Add agentpr and watchpr CLI tools
ci/woodpecker/pr/build Pipeline failed
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline failed

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:
2026-08-12 21:37:09 +10:00
parent 1c42974b74
commit d78644d173
23 changed files with 1828 additions and 1 deletions
+179
View File
@@ -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")
}
}
+148
View File
@@ -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
}
+59
View File
@@ -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
}
+79
View File
@@ -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)
}
}
}
+86
View File
@@ -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)
}
+83
View File
@@ -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
}
+89
View File
@@ -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, ""
}
+116
View File
@@ -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)
}
}