05594113a2
agentpr/watchpr already propagated command errors to a non-zero exit, but that behaviour had no regression coverage and the root command was not constructible outside main(). watchpr also fired a spurious conflict alert because Gitea computes mergeability asynchronously and can briefly report mergeable=false right after a push. The docs additionally printed the AppRole role_id literal UUID. - Extract newRootCmd() in both cmd/agentpr and cmd/watchpr so main() only runs Execute and exits non-zero on error; add tests asserting Execute returns an error for a bad PR ref / malformed --repo / no args. - Debounce mergeability loss in MeaningfulChange: only alert when mergeable=false persists across two consecutive polls (both prev and cur false, still open); update the table test for one-poll-false (benign), false-persisting (alert), and recovered false->true (benign). - Refer to AGENT_APPROLE_ROLE_ID by env var in README.md/AGENTS.md without printing the literal role_id; keep the code default and env override.
94 lines
3.2 KiB
Go
94 lines
3.2 KiB
Go
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) for two consecutive polls
|
|
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 + ")"
|
|
}
|
|
// Gitea computes mergeability asynchronously, so a PR can briefly report
|
|
// mergeable=false right after a push. Require the loss to persist across two
|
|
// consecutive polls (both prev and cur false, still open) before treating it
|
|
// as a real conflict; a single false poll is debounced.
|
|
if !prev.Mergeable && !cur.Mergeable && cur.State == "open" {
|
|
return true, "PR lost mergeability (conflict)"
|
|
}
|
|
return false, ""
|
|
}
|