Add agentpr and watchpr CLI tools
agentpr manages PRs/comments/whoami as unkin-agent (Vault AppRole -> gitea creds -> Gitea API), fixing tea's post-as-Ben default. watchpr polls PRs and alerts only on merge/close, human comment, CI failure, or lost mergeability. - cobra multi-binary layout mirroring node-lookup (cmd/ + internal/) - Makefile (build, patch|minor|major, completions, rpm), nfpm RPM with both binaries + bash/zsh/fish completions, woodpecker CI publishing to rpm-internal - unit tests for parsing, meaningful-change detection, and the Vault+Gitea client
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeVault serves the AppRole login and gitea creds endpoints.
|
||||
func fakeVault(t *testing.T, wantRoleID, giteaToken string) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("approle login method = %s, want POST", r.Method)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["role_id"] != wantRoleID {
|
||||
t.Errorf("role_id = %q, want %q", body["role_id"], wantRoleID)
|
||||
}
|
||||
if _, ok := body["secret_id"]; ok {
|
||||
t.Errorf("secret_id must not be sent")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
|
||||
})
|
||||
mux.HandleFunc("/v1/"+GiteaCredsPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Vault-Token"); got != "s.vaulttoken" {
|
||||
t.Errorf("X-Vault-Token = %q, want s.vaulttoken", got)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"data":{"token":"`+giteaToken+`"}}`)
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestFetchGiteaToken(t *testing.T) {
|
||||
srv := fakeVault(t, "role-xyz", "gitea-abc")
|
||||
defer srv.Close()
|
||||
|
||||
tok, err := fetchGiteaToken(srv.URL, "role-xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchGiteaToken: %v", err)
|
||||
}
|
||||
if tok != "gitea-abc" {
|
||||
t.Errorf("token = %q, want gitea-abc", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchGiteaTokenLoginError(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := fetchGiteaToken(srv.URL, "role-xyz"); err == nil {
|
||||
t.Fatal("expected error on 403 login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePRRequestBody(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
var gotBody CreatePROptions
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
_, _ = io.WriteString(w, `{"number":7,"state":"open","html_url":"https://git.unkin.net/unkin/repo/pulls/7","head":{"sha":"deadbeef"}}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "gitea-abc", HTTP: srv.Client()}
|
||||
pr, err := c.CreatePR("unkin/repo", CreatePROptions{Base: "main", Head: "feature", Title: "T", Body: "B"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePR: %v", err)
|
||||
}
|
||||
if gotPath != "/api/v1/repos/unkin/repo/pulls" {
|
||||
t.Errorf("path = %q", gotPath)
|
||||
}
|
||||
if gotAuth != "token gitea-abc" {
|
||||
t.Errorf("auth header = %q, want 'token gitea-abc'", gotAuth)
|
||||
}
|
||||
if gotBody.Base != "main" || gotBody.Head != "feature" || gotBody.Title != "T" || gotBody.Body != "B" {
|
||||
t.Errorf("request body = %+v", gotBody)
|
||||
}
|
||||
if pr.Number != 7 || pr.HTMLURL == "" {
|
||||
t.Errorf("parsed PR = %+v", pr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateComment(t *testing.T) {
|
||||
var gotBody map[string]string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
_, _ = io.WriteString(w, `{"id":99,"user":{"login":"unkin-agent"},"body":"hi"}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
||||
cm, err := c.CreateComment("unkin/repo", 7, "hi")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateComment: %v", err)
|
||||
}
|
||||
if gotBody["body"] != "hi" {
|
||||
t.Errorf("comment body = %q", gotBody["body"])
|
||||
}
|
||||
if cm.ID != 99 {
|
||||
t.Errorf("comment id = %d, want 99", cm.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"login":"unkin-agent","id":42}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
||||
u, err := c.Whoami()
|
||||
if err != nil {
|
||||
t.Fatalf("Whoami: %v", err)
|
||||
}
|
||||
if u.Login != "unkin-agent" {
|
||||
t.Errorf("login = %q, want unkin-agent", u.Login)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchState(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"number":7,"state":"open","merged":false,"mergeable":true,"title":"feat","html_url":"u","head":{"sha":"cafebabecafebabe"}}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabecafebabe/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"state":"success"}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `[{"id":1,"user":{"login":"unkin-agent"}},{"id":2,"user":{"login":"ben"}}]`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
||||
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
|
||||
st, err := FetchState(c, ref, "unkin-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchState: %v", err)
|
||||
}
|
||||
if st.State != "open" || st.CIStatus != "success" || st.HeadSHA != "cafebabecafebabe" {
|
||||
t.Errorf("state = %+v", st)
|
||||
}
|
||||
if st.NonAgentComments != 1 {
|
||||
t.Errorf("NonAgentComments = %d, want 1 (agent comment excluded)", st.NonAgentComments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaAPIError(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = io.WriteString(w, `{"message":"head and base are the same"}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
||||
if _, err := c.CreatePR("unkin/repo", CreatePROptions{Base: "main", Head: "main", Title: "x"}); err == nil {
|
||||
t.Fatal("expected error on 422")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GiteaClient talks to the Gitea REST API as the agent user.
|
||||
type GiteaClient struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewGiteaClient builds a client from the configured base URL and a Vault-minted
|
||||
// token.
|
||||
func NewGiteaClient(token string) *GiteaClient {
|
||||
return &GiteaClient{BaseURL: GiteaURL(), Token: token, HTTP: httpClient}
|
||||
}
|
||||
|
||||
func (c *GiteaClient) do(method, path string, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(b)
|
||||
}
|
||||
url := strings.TrimRight(c.BaseURL, "/") + path
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+c.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("gitea %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if out != nil && len(data) > 0 {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("gitea %s %s: decoding response: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// User is the subset of the Gitea user object we care about.
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// Whoami returns the authenticated user (GET /api/v1/user).
|
||||
func (c *GiteaClient) Whoami() (User, error) {
|
||||
var u User
|
||||
err := c.do(http.MethodGet, "/api/v1/user", nil, &u)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// PullRequest is the subset of Gitea's PR object we track.
|
||||
type PullRequest struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
Title string `json:"title"`
|
||||
Merged bool `json:"merged"`
|
||||
Mergeable bool `json:"mergeable"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Head struct {
|
||||
Sha string `json:"sha"`
|
||||
} `json:"head"`
|
||||
}
|
||||
|
||||
// CreatePROptions are the fields for opening a PR.
|
||||
type CreatePROptions struct {
|
||||
Base string `json:"base"`
|
||||
Head string `json:"head"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// CreatePR opens a pull request (POST /api/v1/repos/{owner}/{repo}/pulls).
|
||||
func (c *GiteaClient) CreatePR(repoPath string, opts CreatePROptions) (PullRequest, error) {
|
||||
var pr PullRequest
|
||||
err := c.do(http.MethodPost, "/api/v1/repos/"+repoPath+"/pulls", opts, &pr)
|
||||
return pr, err
|
||||
}
|
||||
|
||||
// GetPR fetches a single pull request.
|
||||
func (c *GiteaClient) GetPR(repoPath string, number int) (PullRequest, error) {
|
||||
var pr PullRequest
|
||||
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/pulls/%d", repoPath, number), nil, &pr)
|
||||
return pr, err
|
||||
}
|
||||
|
||||
// Comment is the subset of an issue comment we track.
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
User User `json:"user"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// CreateComment posts a comment on the PR's issue thread
|
||||
// (POST /api/v1/repos/{owner}/{repo}/issues/{n}/comments).
|
||||
func (c *GiteaClient) CreateComment(repoPath string, number int, body string) (Comment, error) {
|
||||
var cm Comment
|
||||
payload := map[string]string{"body": body}
|
||||
err := c.do(http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/issues/%d/comments", repoPath, number), payload, &cm)
|
||||
return cm, err
|
||||
}
|
||||
|
||||
// ListComments lists the PR's issue comments.
|
||||
func (c *GiteaClient) ListComments(repoPath string, number int) ([]Comment, error) {
|
||||
var out []Comment
|
||||
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/issues/%d/comments", repoPath, number), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CombinedStatus is the combined commit status for a ref.
|
||||
type CombinedStatus struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// CommitStatus returns the combined CI status for a commit SHA
|
||||
// (GET /api/v1/repos/{owner}/{repo}/commits/{sha}/status). An empty ref yields
|
||||
// an empty state without an API call.
|
||||
func (c *GiteaClient) CommitStatus(repoPath, sha string) (string, error) {
|
||||
if sha == "" {
|
||||
return "", nil
|
||||
}
|
||||
var cs CombinedStatus
|
||||
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/commits/%s/status", repoPath, sha), nil, &cs)
|
||||
return cs.State, err
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PRRef identifies a single pull request by repository and number.
|
||||
type PRRef struct {
|
||||
Owner string
|
||||
Repo string
|
||||
Number int
|
||||
}
|
||||
|
||||
// String renders the ref in canonical owner/repo#N form.
|
||||
func (r PRRef) String() string {
|
||||
return fmt.Sprintf("%s/%s#%d", r.Owner, r.Repo, r.Number)
|
||||
}
|
||||
|
||||
// RepoPath returns the "owner/repo" portion used in Gitea API URLs.
|
||||
func (r PRRef) RepoPath() string {
|
||||
return r.Owner + "/" + r.Repo
|
||||
}
|
||||
|
||||
// ParsePRRef parses "owner/repo#N" or "owner/repo:N" into a PRRef.
|
||||
func ParsePRRef(s string) (PRRef, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
sep := strings.IndexAny(s, "#:")
|
||||
if sep < 0 {
|
||||
return PRRef{}, fmt.Errorf("invalid PR reference %q: expected owner/repo#N or owner/repo:N", s)
|
||||
}
|
||||
repoPart := s[:sep]
|
||||
numPart := s[sep+1:]
|
||||
|
||||
owner, repo, ok := strings.Cut(repoPart, "/")
|
||||
if !ok || owner == "" || repo == "" {
|
||||
return PRRef{}, fmt.Errorf("invalid PR reference %q: repo must be owner/repo", s)
|
||||
}
|
||||
if strings.Contains(repo, "/") {
|
||||
return PRRef{}, fmt.Errorf("invalid PR reference %q: repo must be owner/repo", s)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(numPart)
|
||||
if err != nil || n <= 0 {
|
||||
return PRRef{}, fmt.Errorf("invalid PR reference %q: PR number must be a positive integer", s)
|
||||
}
|
||||
return PRRef{Owner: owner, Repo: repo, Number: n}, nil
|
||||
}
|
||||
|
||||
// ParseRepo validates and splits an "owner/repo" string.
|
||||
func ParseRepo(s string) (owner, repo string, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
owner, repo, ok := strings.Cut(s, "/")
|
||||
if !ok || owner == "" || repo == "" || strings.Contains(repo, "/") {
|
||||
return "", "", fmt.Errorf("invalid repo %q: expected owner/repo", s)
|
||||
}
|
||||
return owner, repo, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePRRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
wantErr bool
|
||||
owner string
|
||||
repo string
|
||||
num int
|
||||
}{
|
||||
{"unkin/argocd-apps#42", false, "unkin", "argocd-apps", 42},
|
||||
{"unkin/argocd-apps:42", false, "unkin", "argocd-apps", 42},
|
||||
{" unkin/repo#1 ", false, "unkin", "repo", 1},
|
||||
{"unkin/repo#0", true, "", "", 0},
|
||||
{"unkin/repo#-3", true, "", "", 0},
|
||||
{"unkin/repo#abc", true, "", "", 0},
|
||||
{"unkin/repo", true, "", "", 0},
|
||||
{"unkinrepo#3", true, "", "", 0},
|
||||
{"unkin/a/b#3", true, "", "", 0},
|
||||
{"/repo#3", true, "", "", 0},
|
||||
{"unkin/#3", true, "", "", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := ParsePRRef(tt.in)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ParsePRRef(%q): expected error, got %+v", tt.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParsePRRef(%q): unexpected error: %v", tt.in, err)
|
||||
continue
|
||||
}
|
||||
if got.Owner != tt.owner || got.Repo != tt.repo || got.Number != tt.num {
|
||||
t.Errorf("ParsePRRef(%q) = %+v, want %s/%s#%d", tt.in, got, tt.owner, tt.repo, tt.num)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRRefString(t *testing.T) {
|
||||
r := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
|
||||
if got := r.String(); got != "unkin/repo#7" {
|
||||
t.Errorf("String() = %q, want unkin/repo#7", got)
|
||||
}
|
||||
if got := r.RepoPath(); got != "unkin/repo" {
|
||||
t.Errorf("RepoPath() = %q, want unkin/repo", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepo(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
wantErr bool
|
||||
owner string
|
||||
repo string
|
||||
}{
|
||||
{"unkin/repo", false, "unkin", "repo"},
|
||||
{" unkin/repo ", false, "unkin", "repo"},
|
||||
{"repo", true, "", ""},
|
||||
{"unkin/a/b", true, "", ""},
|
||||
{"/repo", true, "", ""},
|
||||
{"unkin/", true, "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
o, r, err := ParseRepo(tt.in)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ParseRepo(%q): expected error", tt.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || o != tt.owner || r != tt.repo {
|
||||
t.Errorf("ParseRepo(%q) = (%q,%q,%v), want (%q,%q,nil)", tt.in, o, r, err, tt.owner, tt.repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package agent holds the plumbing shared by the agent-tools CLIs (agentpr and
|
||||
// watchpr): obtaining a Gitea token via Vault AppRole, talking to the Gitea
|
||||
// API, parsing PR references, and deciding when a watched PR changed
|
||||
// meaningfully. Both tools acquire their Gitea token the same way, so that
|
||||
// logic lives here once.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultVaultAddr is the OpenBao/Vault address used when VAULT_ADDR is unset.
|
||||
DefaultVaultAddr = "https://vault.service.consul:8200"
|
||||
// DefaultRoleID is the agent AppRole role_id used when AGENT_APPROLE_ROLE_ID
|
||||
// is unset. Login uses role_id only (no secret_id).
|
||||
DefaultRoleID = "ababbcd3-9c77-5c6a-be2d-287fce9214a6"
|
||||
// GiteaCredsPath is the Vault path that mints a scoped Gitea token.
|
||||
GiteaCredsPath = "gitea/creds/unkin-agent"
|
||||
// DefaultGiteaURL is the Gitea base URL used when GITEA_URL is unset.
|
||||
DefaultGiteaURL = "https://git.unkin.net"
|
||||
// DefaultAgentLogin is the Gitea login of the agent whose own comments are
|
||||
// ignored by watchpr. Overridable via AGENT_LOGIN.
|
||||
DefaultAgentLogin = "unkin-agent"
|
||||
)
|
||||
|
||||
// VaultAddr returns the configured Vault address (env VAULT_ADDR or the default).
|
||||
func VaultAddr() string {
|
||||
if v := os.Getenv("VAULT_ADDR"); v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultVaultAddr
|
||||
}
|
||||
|
||||
// RoleID returns the configured AppRole role_id (env AGENT_APPROLE_ROLE_ID or
|
||||
// the default).
|
||||
func RoleID() string {
|
||||
if v := os.Getenv("AGENT_APPROLE_ROLE_ID"); v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultRoleID
|
||||
}
|
||||
|
||||
// GiteaURL returns the configured Gitea base URL (env GITEA_URL or the default).
|
||||
func GiteaURL() string {
|
||||
if v := os.Getenv("GITEA_URL"); v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultGiteaURL
|
||||
}
|
||||
|
||||
// AgentLogin returns the login whose comments watchpr ignores (env AGENT_LOGIN
|
||||
// or the default).
|
||||
func AgentLogin() string {
|
||||
if v := os.Getenv("AGENT_LOGIN"); v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultAgentLogin
|
||||
}
|
||||
|
||||
var (
|
||||
tokenOnce sync.Once
|
||||
tokenValue string
|
||||
tokenErr error
|
||||
)
|
||||
|
||||
// GiteaToken returns a Gitea token, minting it via Vault AppRole on first call
|
||||
// and caching it in-process for the lifetime of the command.
|
||||
func GiteaToken() (string, error) {
|
||||
tokenOnce.Do(func() {
|
||||
tokenValue, tokenErr = fetchGiteaToken(VaultAddr(), RoleID())
|
||||
})
|
||||
return tokenValue, tokenErr
|
||||
}
|
||||
|
||||
// fetchGiteaToken performs the AppRole login and reads the Gitea creds. It is
|
||||
// separated from GiteaToken so tests can exercise it directly against an
|
||||
// httptest server without touching the process-wide cache.
|
||||
func fetchGiteaToken(vaultAddr, roleID string) (string, error) {
|
||||
clientToken, err := approleLogin(vaultAddr, roleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return readGiteaCreds(vaultAddr, clientToken)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// httpClient is shared by the Vault and Gitea calls. A modest timeout keeps a
|
||||
// hung endpoint from wedging watchpr's poll loop.
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// approleLogin logs in with role_id only (no secret_id) and returns the
|
||||
// resulting client_token.
|
||||
func approleLogin(vaultAddr, roleID string) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"role_id": roleID})
|
||||
url := strings.TrimRight(vaultAddr, "/") + "/v1/auth/approle/login"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("vault approle login: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("vault approle login: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Auth struct {
|
||||
ClientToken string `json:"client_token"`
|
||||
} `json:"auth"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", fmt.Errorf("vault approle login: decoding response: %w", err)
|
||||
}
|
||||
if out.Auth.ClientToken == "" {
|
||||
return "", fmt.Errorf("vault approle login: no client_token in response")
|
||||
}
|
||||
return out.Auth.ClientToken, nil
|
||||
}
|
||||
|
||||
// readGiteaCreds reads the Gitea creds secret and returns the token field.
|
||||
func readGiteaCreds(vaultAddr, clientToken string) (string, error) {
|
||||
url := strings.TrimRight(vaultAddr, "/") + "/v1/" + GiteaCredsPath
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("X-Vault-Token", clientToken)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("vault read %s: %w", GiteaCredsPath, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("vault read %s: HTTP %d: %s", GiteaCredsPath, resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", fmt.Errorf("vault read %s: decoding response: %w", GiteaCredsPath, err)
|
||||
}
|
||||
if out.Data.Token == "" {
|
||||
return "", fmt.Errorf("vault read %s: no token field in secret", GiteaCredsPath)
|
||||
}
|
||||
return out.Data.Token, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package agent
|
||||
|
||||
// PRState is a point-in-time snapshot of the PR attributes watchpr tracks.
|
||||
type PRState struct {
|
||||
Ref PRRef `json:"ref"`
|
||||
State string `json:"state"` // open / closed
|
||||
Merged bool `json:"merged"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
Mergeable bool `json:"mergeable"`
|
||||
CIStatus string `json:"ci_status"` // success / pending / failure / error / ""
|
||||
NonAgentComments int `json:"non_agent_comments"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// FetchState builds a PRState for the given ref. agentLogin's comments are
|
||||
// excluded from the non-agent comment count.
|
||||
func FetchState(c *GiteaClient, ref PRRef, agentLogin string) (PRState, error) {
|
||||
pr, err := c.GetPR(ref.RepoPath(), ref.Number)
|
||||
if err != nil {
|
||||
return PRState{}, err
|
||||
}
|
||||
ci, err := c.CommitStatus(ref.RepoPath(), pr.Head.Sha)
|
||||
if err != nil {
|
||||
return PRState{}, err
|
||||
}
|
||||
comments, err := c.ListComments(ref.RepoPath(), ref.Number)
|
||||
if err != nil {
|
||||
return PRState{}, err
|
||||
}
|
||||
return PRState{
|
||||
Ref: ref,
|
||||
State: pr.State,
|
||||
Merged: pr.Merged,
|
||||
HeadSHA: pr.Head.Sha,
|
||||
Mergeable: pr.Mergeable,
|
||||
CIStatus: ci,
|
||||
NonAgentComments: countNonAgentComments(comments, agentLogin),
|
||||
Title: pr.Title,
|
||||
URL: pr.HTMLURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// countNonAgentComments counts comments authored by anyone other than agentLogin.
|
||||
func countNonAgentComments(comments []Comment, agentLogin string) int {
|
||||
n := 0
|
||||
for _, cm := range comments {
|
||||
if cm.User.Login != agentLogin {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isFailedCI reports whether a combined CI state is a terminal failure.
|
||||
func isFailedCI(state string) bool {
|
||||
return state == "failure" || state == "error"
|
||||
}
|
||||
|
||||
// MeaningfulChange compares a previous state to the current one and reports
|
||||
// whether a change warrants alerting the operator, with a human-readable
|
||||
// reason. Benign transitions (CI pending→success, the agent's own comments, an
|
||||
// unchanged snapshot) return false.
|
||||
//
|
||||
// Alerting conditions:
|
||||
// - the PR merged
|
||||
// - the PR closed without merging
|
||||
// - a new comment from someone other than the agent
|
||||
// - CI transitioned into failure/error
|
||||
// - the PR lost mergeability (a conflict appeared)
|
||||
func MeaningfulChange(prev, cur PRState) (bool, string) {
|
||||
if !prev.Merged && cur.Merged {
|
||||
return true, "PR merged"
|
||||
}
|
||||
// Closed (not merged): only alert on the open→closed edge.
|
||||
if prev.State == "open" && cur.State == "closed" && !cur.Merged {
|
||||
return true, "PR closed without merging"
|
||||
}
|
||||
if cur.NonAgentComments > prev.NonAgentComments {
|
||||
return true, "new comment from a non-agent user"
|
||||
}
|
||||
if isFailedCI(cur.CIStatus) && !isFailedCI(prev.CIStatus) {
|
||||
return true, "CI failed (" + cur.CIStatus + ")"
|
||||
}
|
||||
if prev.Mergeable && !cur.Mergeable && cur.State == "open" {
|
||||
return true, "PR lost mergeability (conflict)"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func base() PRState {
|
||||
return PRState{
|
||||
Ref: PRRef{Owner: "unkin", Repo: "repo", Number: 1},
|
||||
State: "open",
|
||||
Merged: false,
|
||||
HeadSHA: "abc123",
|
||||
Mergeable: true,
|
||||
CIStatus: "pending",
|
||||
NonAgentComments: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeaningfulChange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(s *PRState)
|
||||
wantChange bool
|
||||
}{
|
||||
{
|
||||
name: "no change",
|
||||
mutate: func(s *PRState) {},
|
||||
wantChange: false,
|
||||
},
|
||||
{
|
||||
name: "CI pending to success is benign",
|
||||
mutate: func(s *PRState) { s.CIStatus = "success" },
|
||||
wantChange: false,
|
||||
},
|
||||
{
|
||||
name: "open to merged alerts",
|
||||
mutate: func(s *PRState) { s.Merged = true; s.State = "closed" },
|
||||
wantChange: true,
|
||||
},
|
||||
{
|
||||
name: "open to closed without merge alerts",
|
||||
mutate: func(s *PRState) { s.State = "closed" },
|
||||
wantChange: true,
|
||||
},
|
||||
{
|
||||
name: "new non-agent comment alerts",
|
||||
mutate: func(s *PRState) { s.NonAgentComments = 1 },
|
||||
wantChange: true,
|
||||
},
|
||||
{
|
||||
name: "CI to failure alerts",
|
||||
mutate: func(s *PRState) { s.CIStatus = "failure" },
|
||||
wantChange: true,
|
||||
},
|
||||
{
|
||||
name: "CI to error alerts",
|
||||
mutate: func(s *PRState) { s.CIStatus = "error" },
|
||||
wantChange: true,
|
||||
},
|
||||
{
|
||||
name: "lost mergeability alerts",
|
||||
mutate: func(s *PRState) { s.Mergeable = false },
|
||||
wantChange: true,
|
||||
},
|
||||
{
|
||||
name: "new head sha alone is benign",
|
||||
mutate: func(s *PRState) { s.HeadSHA = "def456" },
|
||||
wantChange: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prev := base()
|
||||
cur := base()
|
||||
tt.mutate(&cur)
|
||||
got, reason := MeaningfulChange(prev, cur)
|
||||
if got != tt.wantChange {
|
||||
t.Errorf("MeaningfulChange() = %v (%q), want %v", got, reason, tt.wantChange)
|
||||
}
|
||||
if got && reason == "" {
|
||||
t.Errorf("change reported without a reason")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A comment that only the agent posts must not alert: the non-agent count is
|
||||
// unchanged, so MeaningfulChange sees nothing.
|
||||
func TestMeaningfulChangeAgentCommentIgnored(t *testing.T) {
|
||||
prev := base()
|
||||
cur := base() // agent commented, but NonAgentComments stayed 0
|
||||
if got, _ := MeaningfulChange(prev, cur); got {
|
||||
t.Errorf("agent-only comment should not alert")
|
||||
}
|
||||
}
|
||||
|
||||
// Once CI is already failing, staying failed must not re-alert.
|
||||
func TestMeaningfulChangeStaysFailed(t *testing.T) {
|
||||
prev := base()
|
||||
prev.CIStatus = "failure"
|
||||
cur := base()
|
||||
cur.CIStatus = "failure"
|
||||
if got, _ := MeaningfulChange(prev, cur); got {
|
||||
t.Errorf("CI staying failed should not re-alert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountNonAgentComments(t *testing.T) {
|
||||
comments := []Comment{
|
||||
{User: User{Login: "unkin-agent"}},
|
||||
{User: User{Login: "ben"}},
|
||||
{User: User{Login: "unkin-agent"}},
|
||||
{User: User{Login: "reviewer"}},
|
||||
}
|
||||
if n := countNonAgentComments(comments, "unkin-agent"); n != 2 {
|
||||
t.Errorf("countNonAgentComments = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user