Files
kandev/apps/backend/internal/github/workflow_attention_test.go
T
2026-09-10 23:39:56 +01:00

330 lines
14 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
type workflowAttentionTestClient struct {
*MockClient
runsErr error
jobsErr error
}
func (c *workflowAttentionTestClient) ListWorkflowRuns(ctx context.Context, owner, repo, headSHA string) ([]WorkflowRun, error) {
if c.runsErr != nil {
return nil, c.runsErr
}
return c.MockClient.ListWorkflowRuns(ctx, owner, repo, headSHA)
}
func (c *workflowAttentionTestClient) ListWorkflowRunJobs(ctx context.Context, owner, repo string, runID int64, attempt int) ([]WorkflowJob, error) {
if c.jobsErr != nil {
return nil, c.jobsErr
}
return c.MockClient.ListWorkflowRunJobs(ctx, owner, repo, runID, attempt)
}
func TestWorkflowAttention_JoblessForkApproval(t *testing.T) {
client := &workflowAttentionTestClient{MockClient: NewMockClient()}
client.AddPR(&PR{
RepoOwner: "acme",
RepoName: "widget",
Number: 143,
State: "open",
HeadSHA: "head-sha",
HeadBranch: "feature/approval",
HeadRepoOwner: "contributor",
HeadRepoName: "widget-fork",
})
client.ReplaceWorkflowRuns("acme", "widget", "head-sha", []WorkflowRun{
{
ID: 34494307522,
RunAttempt: 1,
WorkflowID: 77,
Name: "Run tests",
Event: "pull_request",
Status: "completed",
Conclusion: "action_required",
HeadSHA: "head-sha",
HeadBranch: "feature/approval",
HeadRepoOwner: "contributor",
HeadRepoName: "widget-fork",
CreatedAt: time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC),
PullRequests: nil,
HTMLURL: "https://github.com/acme/widget/actions/runs/34494307522",
},
})
client.ReplaceWorkflowRunJobs("acme", "widget", 34494307522, 1, nil)
status, err := client.GetPRStatus(context.Background(), "acme", "widget", 143)
if err != nil {
t.Fatalf("GetPRStatus() error = %v", err)
}
if status.WorkflowAttention == nil {
t.Fatal("WorkflowAttention = nil, want approval observation")
}
if got := status.WorkflowAttention.State; got != WorkflowAttentionApprovalRequired {
t.Fatalf("WorkflowAttention.State = %q, want %q", got, WorkflowAttentionApprovalRequired)
}
if status.ChecksTotal != 0 || status.ChecksPassing != 0 {
t.Fatalf("check counts = (%d, %d), want (0, 0)", status.ChecksTotal, status.ChecksPassing)
}
if len(status.WorkflowAttention.Runs) != 1 {
t.Fatalf("workflow attention runs = %d, want 1", len(status.WorkflowAttention.Runs))
}
if got := status.WorkflowAttention.Runs[0].Name; got != "Run tests" {
t.Fatalf("workflow attention run name = %q, want %q", got, "Run tests")
}
}
func TestWorkflowAttention_LatestRunAndAssociationFiltering(t *testing.T) {
client := &workflowAttentionTestClient{MockClient: NewMockClient()}
pr := &PR{
Number: 7,
State: "open",
HeadSHA: "head-sha",
HeadBranch: "feature/approval",
HeadRepoOwner: "contributor",
HeadRepoName: "widget-fork",
RepoOwner: "acme",
RepoName: "widget",
}
client.AddPR(pr)
old := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
newer := old.Add(time.Hour)
client.ReplaceWorkflowRuns("acme", "widget", pr.HeadSHA, []WorkflowRun{
{ID: 100, RunAttempt: 1, WorkflowID: 9, Name: "CI", Event: "pull_request", Status: "completed", Conclusion: "action_required", HeadSHA: pr.HeadSHA, HeadBranch: pr.HeadBranch, HeadRepoOwner: "contributor", HeadRepoName: "widget-fork", CreatedAt: old, UpdatedAt: old},
{ID: 101, RunAttempt: 1, WorkflowID: 9, Name: "CI", Event: "pull_request", Status: "completed", Conclusion: "success", HeadSHA: pr.HeadSHA, HeadBranch: pr.HeadBranch, HeadRepoOwner: "contributor", HeadRepoName: "widget-fork", CreatedAt: newer, UpdatedAt: newer},
{ID: 102, RunAttempt: 1, WorkflowID: 10, Name: "Other PR", Event: "pull_request", Status: "completed", Conclusion: "action_required", HeadSHA: pr.HeadSHA, HeadBranch: pr.HeadBranch, HeadRepoOwner: "contributor", HeadRepoName: "widget-fork", PullRequests: []WorkflowRunPullRequest{{Number: 99, HeadSHA: pr.HeadSHA, HeadBranch: pr.HeadBranch, HeadRepoOwner: "contributor", HeadRepoName: "widget-fork"}}, CreatedAt: newer, UpdatedAt: newer},
})
attention, err := collectWorkflowAttention(context.Background(), client, "acme", "widget", pr)
if err != nil {
t.Fatalf("collectWorkflowAttention() error = %v", err)
}
if attention.State != WorkflowAttentionNone || len(attention.Runs) != 0 {
t.Fatalf("attention = %#v, want authoritative none after newer success and association filtering", attention)
}
}
func TestWorkflowAttention_RESTAssociationUsesRepositoryURLIdentity(t *testing.T) {
runs, err := decodeGHWorkflowRuns(`{"id":7,"run_attempt":1,"workflow_id":9,"name":"Run tests","event":"pull_request","status":"completed","conclusion":"action_required","head_sha":"head-sha","head_branch":"feature/approval","head_repository":{"id":123,"full_name":"contributor/widget-fork","name":"widget-fork","owner":{"login":"contributor"}},"pull_requests":[{"number":143,"head":{"ref":"feature/approval","sha":"head-sha","repo":{"id":123,"name":"widget-fork","url":"https://api.github.com/repos/contributor/widget-fork"}}}]}`)
if err != nil {
t.Fatalf("decodeGHWorkflowRuns() error = %v", err)
}
if len(runs) != 1 {
t.Fatalf("decoded runs = %d, want 1", len(runs))
}
run := convertRawWorkflowRun(runs[0])
matchingPR := &PR{
Number: 143,
HeadSHA: "head-sha",
HeadBranch: "feature/approval",
HeadRepoID: 123,
HeadRepoOwner: "contributor",
HeadRepoName: "widget-fork",
}
if !workflowRunMatchesPR(run, matchingPR) {
t.Fatalf("workflowRunMatchesPR() = false for the REST association shape: %#v", run)
}
mismatchingPR := *matchingPR
mismatchingPR.HeadRepoID = 456
if workflowRunMatchesPR(run, &mismatchingPR) {
t.Fatal("workflowRunMatchesPR() = true for a mismatching head repository")
}
}
func TestWorkflowAttention_NewAssociatedSuccessSupersedesOlderUnassociatedApproval(t *testing.T) {
runs, err := decodeGHWorkflowRuns(`{"id":100,"run_attempt":1,"workflow_id":9,"name":"CI","event":"pull_request","status":"completed","conclusion":"action_required","head_sha":"head-sha","head_branch":"feature/approval","head_repository":{"id":123,"full_name":"contributor/widget-fork","name":"widget-fork","owner":{"login":"contributor"}},"created_at":"2026-09-10T12:00:00Z","updated_at":"2026-09-10T12:00:00Z","pull_requests":[]}
{"id":101,"run_attempt":1,"workflow_id":9,"name":"CI","event":"pull_request","status":"completed","conclusion":"success","head_sha":"head-sha","head_branch":"feature/approval","head_repository":{"id":123,"full_name":"contributor/widget-fork","name":"widget-fork","owner":{"login":"contributor"}},"created_at":"2026-09-10T13:00:00Z","updated_at":"2026-09-10T13:00:00Z","pull_requests":[{"number":143,"head":{"ref":"feature/approval","sha":"head-sha","repo":{"id":123,"name":"widget-fork","url":"https://api.github.com/repos/contributor/widget-fork"}}}]}`)
if err != nil {
t.Fatalf("decodeGHWorkflowRuns() error = %v", err)
}
converted := make([]WorkflowRun, 0, len(runs))
for _, raw := range runs {
converted = append(converted, convertRawWorkflowRun(raw))
}
pr := &PR{
Number: 143,
State: "open",
HeadSHA: "head-sha",
HeadBranch: "feature/approval",
HeadRepoID: 123,
HeadRepoOwner: "contributor",
HeadRepoName: "widget-fork",
RepoOwner: "acme",
RepoName: "widget",
}
selected := selectCurrentWorkflowRuns(converted, pr)
if len(selected) != 1 || selected[0].ID != 101 {
t.Fatalf("selected workflow runs = %#v, want only newer associated success", selected)
}
}
func TestWorkflowAttention_NewExecutionWinsOverLateUpdate(t *testing.T) {
pr := &PR{
Number: 7,
State: "open",
HeadSHA: "head-sha",
HeadBranch: "feature/approval",
HeadRepoOwner: "contributor",
HeadRepoName: "widget-fork",
RepoOwner: "acme",
RepoName: "widget",
}
created := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
selected := selectCurrentWorkflowRuns([]WorkflowRun{
{
ID: 100,
RunAttempt: 1,
WorkflowID: 9,
Name: "CI",
Event: "pull_request",
Status: "completed",
Conclusion: "action_required",
HeadSHA: pr.HeadSHA,
HeadBranch: pr.HeadBranch,
HeadRepoOwner: pr.HeadRepoOwner,
HeadRepoName: pr.HeadRepoName,
CreatedAt: created,
UpdatedAt: created.Add(2 * time.Hour),
},
{
ID: 101,
RunAttempt: 1,
WorkflowID: 9,
Name: "CI",
Event: "pull_request",
Status: "completed",
Conclusion: "success",
HeadSHA: pr.HeadSHA,
HeadBranch: pr.HeadBranch,
HeadRepoOwner: pr.HeadRepoOwner,
HeadRepoName: pr.HeadRepoName,
CreatedAt: created.Add(time.Hour),
UpdatedAt: created.Add(time.Hour),
},
}, pr)
if len(selected) != 1 || selected[0].ID != 101 {
t.Fatalf("selected workflow runs = %#v, want only newer execution 101", selected)
}
}
func TestWorkflowAttention_ActionRequiredWithJobsIsGeneric(t *testing.T) {
client := &workflowAttentionTestClient{MockClient: NewMockClient()}
pr := &PR{
Number: 1, State: "open", HeadSHA: "head-sha", HeadBranch: "feature",
HeadRepoOwner: "contributor", HeadRepoName: "widget-fork", RepoOwner: "acme", RepoName: "widget",
}
client.AddPR(pr)
client.ReplaceWorkflowRuns("acme", "widget", pr.HeadSHA, []WorkflowRun{{
ID: 44, RunAttempt: 2, WorkflowID: 12, Name: "CI", Event: "pull_request", Status: "completed", Conclusion: "action_required",
HeadSHA: pr.HeadSHA, HeadBranch: pr.HeadBranch, HeadRepoOwner: "contributor", HeadRepoName: "widget-fork",
}})
client.ReplaceWorkflowRunJobs("acme", "widget", 44, 2, []WorkflowJob{{ID: 55, Name: "test", Status: "completed", Conclusion: "success"}})
attention, err := collectWorkflowAttention(context.Background(), client, "acme", "widget", pr)
if err != nil {
t.Fatalf("collectWorkflowAttention() error = %v", err)
}
if attention.State != WorkflowAttentionActionRequired || len(attention.Runs) != 1 || attention.Runs[0].Reason != workflowAttentionActionRequiredReason {
t.Fatalf("attention = %#v, want generic action_required", attention)
}
}
func TestWorkflowAttention_ClassifierIgnoresTerminalAndHeadlessPRs(t *testing.T) {
runs := []WorkflowRun{{
ID: 44, RunAttempt: 1, WorkflowID: 12, Name: "CI", Event: "pull_request", Status: "completed", Conclusion: "action_required",
HeadSHA: "head-sha", HeadBranch: "feature", HeadRepoOwner: "contributor", HeadRepoName: "widget-fork",
}}
readJobs := func(context.Context, int64, int) ([]WorkflowJob, error) {
t.Fatal("readJobs called for a PR that cannot have active workflow attention")
return nil, nil
}
terminal := classifyWorkflowAttentionWithJobs(context.Background(), "acme", "widget", &PR{
State: "merged", HeadSHA: "head-sha",
}, runs, readJobs)
if terminal.State != WorkflowAttentionNone || terminal.HeadSHA != "head-sha" {
t.Fatalf("terminal attention = %#v, want authoritative none", terminal)
}
headless := classifyWorkflowAttentionWithJobs(context.Background(), "acme", "widget", &PR{
State: "open",
}, runs, readJobs)
if headless.State != WorkflowAttentionUnknown || headless.HeadSHA != "" {
t.Fatalf("headless attention = %#v, want unknown", headless)
}
}
func TestWorkflowAttention_DeniedReadIsUnknown(t *testing.T) {
client := &workflowAttentionTestClient{MockClient: NewMockClient(), runsErr: errors.New("forbidden")}
pr := &PR{State: "open", HeadSHA: "head-sha", RepoOwner: "acme", RepoName: "widget"}
attention, err := collectWorkflowAttention(context.Background(), client, "acme", "widget", pr)
if !errors.Is(err, client.runsErr) {
t.Fatalf("collectWorkflowAttention() error = %v, want forbidden error", err)
}
if attention.State != WorkflowAttentionUnknown || attention.HeadSHA != pr.HeadSHA {
t.Fatalf("attention = %#v, want unknown same-head observation", attention)
}
}
func TestWorkflowAttention_UnknownPreservesSameHeadAndClearsOnHeadChange(t *testing.T) {
previous := &TaskPR{
HeadSHA: "old-head",
WorkflowAttention: &WorkflowAttention{
State: WorkflowAttentionApprovalRequired,
HeadSHA: "old-head",
ObservedAt: time.Now().UTC().Add(-time.Minute),
Runs: []WorkflowAttentionRun{{RunID: 1, Name: "CI", Reason: workflowAttentionApprovalRequiredReason}},
},
}
unknown := &PRStatus{
PR: &PR{HeadSHA: "old-head"},
WorkflowAttention: workflowAttentionUnknown("old-head"),
WorkflowAttentionPopulated: true,
}
got := resolveTaskPRWorkflowAttention(previous, unknown, "old-head")
if got == nil || got.State != WorkflowAttentionApprovalRequired || !got.Stale {
t.Fatalf("same-head unknown result = %#v, want stale preserved approval", got)
}
if !got.ObservedAt.Equal(previous.WorkflowAttention.ObservedAt) {
t.Fatalf("same-head unavailable observed_at = %v, want last successful observation %v", got.ObservedAt, previous.WorkflowAttention.ObservedAt)
}
newHead := resolveTaskPRWorkflowAttention(previous, &PRStatus{
PR: &PR{HeadSHA: "new-head"},
WorkflowAttention: workflowAttentionUnknown("new-head"),
WorkflowAttentionPopulated: true,
}, "new-head")
if newHead == nil || newHead.State != WorkflowAttentionUnknown || newHead.HeadSHA != "new-head" || HasActiveWorkflowAttention(&TaskPR{HeadSHA: "new-head", WorkflowAttention: newHead}) {
t.Fatalf("new-head result = %#v, want unknown without old active claim", newHead)
}
}
func TestWorkflowAttentionJSONKeepsRunArraysNonNil(t *testing.T) {
encoded, err := json.Marshal(workflowAttentionNone("head-sha"))
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if got := string(encoded); !strings.Contains(got, `"runs":[]`) {
t.Fatalf("workflow attention JSON = %s, want an empty run array", got)
}
tp := &TaskPR{WorkflowAttentionJSON: `{"state":"unknown","head_sha":"head-sha"}`}
if err := hydrateTaskPRWorkflowAttention(tp); err != nil {
t.Fatalf("hydrateTaskPRWorkflowAttention: %v", err)
}
if tp.WorkflowAttention == nil || tp.WorkflowAttention.Runs == nil {
t.Fatalf("hydrated runs = %#v, want non-nil empty array", tp.WorkflowAttention.Runs)
}
}