| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { agentPullState, approvalCandidates, classify, pickRepos, type ClassifyInput, type TodayRepo } from "../../src/agentic/today.js"; |
| 3 | import { checksState, type RepoToday, type TodayPull } from "../../src/agentic/today-repo.js"; |
| 4 | import type { Repo } from "../../src/lib/directory.js"; |
| 5 | |
| 6 | const ME = { id: "u-me", login: "Ayse" }; |
| 7 | const BOT = { id: "a-bot", login: "ayse-bot", kind: "agent" }; |
| 8 | const OTHER_BOT = { id: "a-other", login: "other-bot", kind: "agent" }; |
| 9 | const BOB = { id: "u-bob", login: "bob", kind: "user" }; |
| 10 | |
| 11 | const pull = (p: Partial<TodayPull> & { number: number }): TodayPull => ({ |
| 12 | title: `PR ${p.number}`, |
| 13 | author: BOB, |
| 14 | head: `b${p.number}`, |
| 15 | base: "main", |
| 16 | draft: false, |
| 17 | requestedReviewers: [], |
| 18 | intent: null, |
| 19 | createdAt: "2026-10-13T08:00:00Z", |
| 20 | updatedAt: `2026-10-13T08:${String(p.number).padStart(2, "0")}:00Z`, |
| 21 | approvedBy: [], |
| 22 | approvedHead: false, |
| 23 | changesRequestedBy: [], |
| 24 | reviewerIds: [], |
| 25 | checks: "success", |
| 26 | failingChecks: [], |
| 27 | autoMerge: false, |
| 28 | queued: false, |
| 29 | ...p, |
| 30 | }); |
| 31 | |
| 32 | const repo = (id: string): TodayRepo => ({ id, fullName: `acme/${id}`, url: `/acme/${id}`, defaultBranch: "main", visibility: "private" }); |
| 33 | const data = (d: Partial<RepoToday>): RepoToday => ({ pulls: [], merged: [], releases: [], failedRuns: [], ...d }); |
| 34 | const input = (repos: ClassifyInput["repos"], writable: string[] = repos.map((r) => r.repo.id)): ClassifyInput => ({ me: ME, agentIds: new Set([BOT.id]), writable: new Set(writable), repos }); |
| 35 | |
| 36 | describe("checksState", () => { |
| 37 | it("is none without checks, failure when any failed, pending while any runs", () => { |
| 38 | expect(checksState([]).state).toBe("none"); |
| 39 | expect(checksState([{ name: "ci", status: "completed", conclusion: "success" }, { name: "lint", status: "in_progress", conclusion: null }]).state).toBe("pending"); |
| 40 | const f = checksState([{ name: "ci", status: "completed", conclusion: "failure" }, { name: "lint", status: "in_progress", conclusion: null }]); |
| 41 | expect(f).toEqual({ state: "failure", failing: ["ci"] }); |
| 42 | expect(checksState([{ name: "ci", status: "completed", conclusion: "skipped" }, { name: "x", status: "completed", conclusion: "neutral" }]).state).toBe("success"); |
| 43 | }); |
| 44 | }); |
| 45 | |
| 46 | describe("agentPullState", () => { |
| 47 | it("orders what a person acts on", () => { |
| 48 | expect(agentPullState(pull({ number: 1, draft: true, checks: "failure" }))).toBe("draft"); |
| 49 | expect(agentPullState(pull({ number: 1, autoMerge: true, checks: "failure" }))).toBe("landing"); |
| 50 | expect(agentPullState(pull({ number: 1, checks: "failure", changesRequestedBy: ["bob"] }))).toBe("checks_failing"); |
| 51 | expect(agentPullState(pull({ number: 1, changesRequestedBy: ["bob"], approvedHead: true }))).toBe("changes_requested"); |
| 52 | expect(agentPullState(pull({ number: 1, approvedHead: true }))).toBe("approved"); |
| 53 | expect(agentPullState(pull({ number: 1, checks: "pending" }))).toBe("checks_running"); |
| 54 | expect(agentPullState(pull({ number: 1 }))).toBe("waiting_for_approval"); |
| 55 | }); |
| 56 | }); |
| 57 | |
| 58 | describe("classify", () => { |
| 59 | it("asks the viewer for requested reviews, case-insensitively, but not on drafts", () => { |
| 60 | const t = classify(input([{ repo: repo("web"), data: data({ pulls: [pull({ number: 1, requestedReviewers: ["ayse"] }), pull({ number: 2, requestedReviewers: ["AYSE"], draft: true })] }) }])); |
| 61 | expect(t.needsYou.map((x) => [x.kind, x.pull.number])).toEqual([["review_requested", 1]]); |
| 62 | }); |
| 63 | |
| 64 | it("lists agent pull requests waiting for a person only where the viewer can approve", () => { |
| 65 | const pulls = [pull({ number: 1, author: OTHER_BOT }), pull({ number: 2, author: OTHER_BOT, checks: "pending" }), pull({ number: 3, author: OTHER_BOT, approvedHead: true }), pull({ number: 4, author: OTHER_BOT, checks: "failure" }), pull({ number: 5, author: OTHER_BOT, changesRequestedBy: ["bob"] })]; |
| 66 | const t = classify(input([{ repo: repo("web"), data: data({ pulls }) }, { repo: repo("api"), data: data({ pulls: [pull({ number: 9, author: OTHER_BOT })] }) }], ["web"])); |
| 67 | expect(t.needsYou.map((x) => `${x.repo.id}#${x.pull.number}:${x.kind}`).sort()).toEqual(["web#1:agent_approval", "web#2:agent_approval"]); |
| 68 | }); |
| 69 | |
| 70 | it("only role-checks repositories with agent work waiting for a person", () => { |
| 71 | const repos = [ |
| 72 | { repo: repo("a"), data: data({ pulls: [pull({ number: 1, author: OTHER_BOT })] }) }, |
| 73 | { repo: repo("b"), data: data({ pulls: [pull({ number: 1, author: OTHER_BOT, approvedHead: true }), pull({ number: 2 })] }) }, |
| 74 | { repo: repo("c"), data: data({ pulls: [pull({ number: 1, author: { ...ME, kind: "agent" } })] }) }, |
| 75 | ]; |
| 76 | expect(approvalCandidates(repos, ME)).toEqual(["a"]); |
| 77 | }); |
| 78 | |
| 79 | it("lists deployments waiting for the viewer as a required reviewer, unless they started the run and self-review is off", () => { |
| 80 | const d = (runId: string, reviewerIds: string[], selfBlocked: string[] = [], since = "2026-10-13T08:00:00Z") => ({ runId, runNumber: 1, workflow: "deploy", title: "", ref: "refs/heads/main", environment: "production", reviewerIds, selfBlocked, actor: BOB, since }); |
| 81 | const deployments = [d("r1", ["u-me"]), d("r2", ["u-bob"]), d("r3", ["u-me"], ["u-me"]), d("r4", ["u-me", "u-bob"], [], "2026-10-13T09:00:00Z"), d("r5", ["u-me"], [BOT.id]), d("r6", ["u-me"], [OTHER_BOT.id], "2026-10-13T07:00:00Z")]; |
| 82 | const t = classify({ ...input([{ repo: repo("web"), data: data({ deployments }) }, { repo: repo("old"), data: data({}) }]), ownAgentIds: new Set([BOT.id]) }); |
| 83 | // r3 the viewer started, r5 their own agent did; r6's agent belongs to their organization, not them. |
| 84 | expect(t.deployments.map((x) => x.deployment.runId)).toEqual(["r4", "r1", "r6"]); |
| 85 | }); |
| 86 | |
| 87 | it("flags the viewer's own blocked pull requests", () => { |
| 88 | const mine = { ...ME, kind: "user" }; |
| 89 | const t = classify(input([{ repo: repo("web"), data: data({ pulls: [pull({ number: 1, author: mine, changesRequestedBy: ["bob"] }), pull({ number: 2, author: mine, checks: "failure" }), pull({ number: 3, author: mine })] }) }])); |
| 90 | expect(t.needsYou.map((x) => [x.pull.number, x.kind]).sort()).toEqual([ |
| 91 | [1, "changes_requested"], |
| 92 | [2, "checks_failing"], |
| 93 | ]); |
| 94 | }); |
| 95 | |
| 96 | it("shows the viewer's agents' pull requests with their state, newest first, and lists them once in needs-you", () => { |
| 97 | const t = classify(input([{ repo: repo("web"), data: data({ pulls: [pull({ number: 1, author: BOT }), pull({ number: 2, author: BOT, checks: "failure" }), pull({ number: 3, author: OTHER_BOT })] }) }])); |
| 98 | expect(t.agentsAtWork.map((x) => [x.pull.number, x.state])).toEqual([ |
| 99 | [2, "checks_failing"], |
| 100 | [1, "waiting_for_approval"], |
| 101 | ]); |
| 102 | expect(t.needsYou.map((x) => x.pull.number).sort()).toEqual([1, 3]); |
| 103 | }); |
| 104 | |
| 105 | it("keeps failures that are the viewer's, their agents' or on the default branch", () => { |
| 106 | const run = (id: string, actor: { id: string; login: string; kind: string }, ref: string, event = "push", at = "2026-10-13T07:00:00Z") => ({ id, runNumber: 1, workflow: "CI", title: id, ref, event, actor, conclusion: "failure", prNumber: null, at }); |
| 107 | const t = classify( |
| 108 | input([ |
| 109 | { |
| 110 | repo: repo("web"), |
| 111 | data: data({ failedRuns: [run("mine", { ...ME, kind: "user" }, "refs/heads/x"), run("bot", BOT, "refs/heads/y"), run("main", BOB, "refs/heads/main"), run("pr", BOB, "refs/heads/main", "pull_request"), run("other", BOB, "refs/heads/z")] }), |
| 112 | }, |
| 113 | ]), |
| 114 | ); |
| 115 | expect(t.failures.map((x) => x.run.id).sort()).toEqual(["bot", "main", "mine"]); |
| 116 | }); |
| 117 | }); |
| 118 | |
| 119 | describe("pickRepos", () => { |
| 120 | const r = (id: string, pushed: string | null, archived = 0): Repo => ({ id, owner_id: "o", owner_login: "acme", owner_kind: "org", name: id, description: "", visibility: "private", default_branch: "main", archived, created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z", pushed_at: pushed }); |
| 121 | it("visits active, unarchived repositories, most recent activity first", () => { |
| 122 | const now = new Date("2026-10-13T09:00:00Z"); |
| 123 | const picked = pickRepos([r("old", "2026-01-01T00:00:00Z"), r("pushed", "2026-10-12T00:00:00Z"), r("api", "2026-01-01T00:00:00Z"), r("arch", "2026-10-13T00:00:00Z", 1)], new Map([["api", "2026-10-13T08:00:00Z"]]), now); |
| 124 | expect(picked.repos.map((x) => x.id)).toEqual(["api", "pushed"]); |
| 125 | expect(picked.skipped).toBe(0); |
| 126 | }); |
| 127 | }); |