From e256d4ddf8f2924e29c0f534e4bba520c1e6261e Mon Sep 17 00:00:00 2001 From: Bingran You Date: Thu, 23 Apr 2026 00:39:35 -0700 Subject: [PATCH 1/2] fix(breeze): only monitor explicit mentions and review requests --- skills/breeze/SKILL.md | 6 +-- src/products/breeze/README.md | 8 ++-- src/products/breeze/cli.ts | 9 +++-- src/products/breeze/engine/commands/poll.ts | 10 ++--- .../breeze/engine/daemon/candidate-loop.ts | 2 +- .../breeze/engine/daemon/gh-client.ts | 22 +---------- src/products/breeze/engine/daemon/poller.ts | 12 +++--- .../breeze/engine/runtime/task-kind.ts | 14 +++---- tests/breeze/breeze-daemon-gh-client.test.ts | 38 +++++++++++++------ tests/breeze/breeze-daemon-poller.test.ts | 14 +++---- tests/breeze/breeze-poll.test.ts | 34 ++++++++++++++--- tests/breeze/breeze-task-kind.test.ts | 12 +++--- tests/breeze/breeze-task.test.ts | 2 +- 13 files changed, 103 insertions(+), 80 deletions(-) diff --git a/skills/breeze/SKILL.md b/skills/breeze/SKILL.md index 5139a963..be3a2592 100644 --- a/skills/breeze/SKILL.md +++ b/skills/breeze/SKILL.md @@ -1,6 +1,6 @@ --- name: breeze -description: Operate the `first-tree breeze` CLI — a proposal/inbox agent that turns GitHub notifications into a live Claude Code statusline, a browsable inbox, an activity feed, and scheduled background work. Use whenever you need to run, start, stop, inspect, poll, or debug the breeze daemon; view or respond to GitHub notifications from the terminal; or wire up the breeze statusline hook. +description: Operate the `first-tree breeze` CLI — a proposal/inbox agent that turns explicit GitHub mentions and review requests into a live Claude Code statusline, a browsable inbox, an activity feed, and scheduled background work. Use whenever you need to run, start, stop, inspect, poll, or debug the breeze daemon; view or respond to GitHub-triggered breeze work from the terminal; or wire up the breeze statusline hook. --- # Breeze — Operational Skill @@ -26,7 +26,7 @@ and safe to re-run. ## Core Concepts -- **Inbox** — the local store of GitHub notifications, under `~/.breeze/`. +- **Inbox** — the local store of explicit GitHub mentions and review requests, under `~/.breeze/`. - **Daemon** — a long-running broker process that polls GitHub, keeps the inbox fresh, dispatches work to per-task agent runners, and serves a local HTTP/SSE endpoint on `127.0.0.1:7878` for the dashboard. @@ -47,7 +47,7 @@ and safe to re-run. | `first-tree breeze status` | Print the daemon lock + runtime/status.env | | `first-tree breeze doctor` | One-screen diagnostic of the local install | | `first-tree breeze watch` | Live TUI: status board + activity feed | -| `first-tree breeze poll` | Poll GitHub notifications once (no daemon required) | +| `first-tree breeze poll` | Poll explicit GitHub mentions and review requests once (no daemon required) | ### Advanced (agents, debugging) diff --git a/src/products/breeze/README.md b/src/products/breeze/README.md index 771d103e..5f3bfbf4 100644 --- a/src/products/breeze/README.md +++ b/src/products/breeze/README.md @@ -1,9 +1,9 @@ # `first-tree breeze` -Local daemon that takes over your `gh` login and turns GitHub notifications -(PRs, comments, discussions, issues) into a triaged, optionally auto-handled -inbox. Drives a Claude Code statusline, an SSE dashboard, and scheduled -background work. +Local daemon that takes over your `gh` login and turns explicit GitHub review +requests and direct mentions into a triaged, optionally auto-handled inbox. +Drives a Claude Code statusline, an SSE dashboard, and scheduled background +work. ## What's In This Directory diff --git a/src/products/breeze/cli.ts b/src/products/breeze/cli.ts index dc1c95c6..a65f43ff 100644 --- a/src/products/breeze/cli.ts +++ b/src/products/breeze/cli.ts @@ -21,9 +21,9 @@ import { join } from "node:path"; export const BREEZE_USAGE = `usage: first-tree breeze - Breeze is the proposal/inbox agent. It polls GitHub notifications, - keeps a local inbox under \`~/.breeze/\`, and dispatches work to - per-task agent runners. + Breeze is the proposal/inbox agent. It polls explicit GitHub review + requests and direct mentions, keeps a local inbox under \`~/.breeze/\`, + and dispatches work to per-task agent runners. Primary commands (start here): install Run the first-run setup (creates config.yaml, then @@ -34,7 +34,8 @@ Primary commands (start here): status Print daemon lock + runtime/status.env doctor Diagnose the local install watch Live TUI: status board + activity feed - poll Poll GitHub notifications once (no daemon required) + poll Poll explicit GitHub review requests and mentions + once (no daemon required) Advanced commands (for agents or debugging): run, daemon Run the broker loop in the foreground. diff --git a/src/products/breeze/engine/commands/poll.ts b/src/products/breeze/engine/commands/poll.ts index b54e285f..79bf86c3 100644 --- a/src/products/breeze/engine/commands/poll.ts +++ b/src/products/breeze/engine/commands/poll.ts @@ -41,6 +41,7 @@ import { loadBreezeConfig } from "../runtime/config.js"; import { GhClient, GhExecError } from "../runtime/gh.js"; import { resolveBreezePaths } from "../runtime/paths.js"; import { updateInbox } from "../runtime/store.js"; +import { shouldProcessReason } from "../runtime/task-kind.js"; import { type GhState, type Inbox, @@ -96,11 +97,8 @@ function priorityForReason(reason: string): number { case "review_requested": return 1; case "mention": + case "team_mention": return 2; - case "assign": - return 3; - case "participating": - return 4; default: return 5; } @@ -135,7 +133,8 @@ function htmlUrlFor( /** * Parse raw `gh api /notifications` JSON response (array of notifications, * or concatenated arrays when `--paginate` is used) into unclassified entries. - * Filters out CheckSuite / Commit subjects (fetcher.rs:15). + * Filters out CheckSuite / Commit subjects and any notification reason that + * is not an explicit mention or review request. */ export function parseNotifications( rawJsonPages: readonly string[], @@ -162,6 +161,7 @@ export function parseNotifications( const repo = item.repository?.full_name ?? ""; if (!repo) continue; const reason = item.reason ?? ""; + if (!shouldProcessReason(reason)) continue; const title = item.subject?.title ?? ""; const url = item.subject?.url ?? ""; const lastActor = item.subject?.latest_comment_url ?? url ?? ""; diff --git a/src/products/breeze/engine/daemon/candidate-loop.ts b/src/products/breeze/engine/daemon/candidate-loop.ts index 542c8be6..8e4970bf 100644 --- a/src/products/breeze/engine/daemon/candidate-loop.ts +++ b/src/products/breeze/engine/daemon/candidate-loop.ts @@ -43,7 +43,7 @@ export interface CandidateLoopOptions { pollIntervalSec: number; /** Max items per `gh search` call. Rust default: 10. */ searchLimit: number; - /** Include the search-bucket queries (review_requests / assigned). */ + /** Include the search-bucket query for direct review requests. */ includeSearch: boolean; /** Lookback window for notifications, seconds. Rust default: 24h. */ lookbackSecs: number; diff --git a/src/products/breeze/engine/daemon/gh-client.ts b/src/products/breeze/engine/daemon/gh-client.ts index 09093590..b28332ef 100644 --- a/src/products/breeze/engine/daemon/gh-client.ts +++ b/src/products/breeze/engine/daemon/gh-client.ts @@ -2,8 +2,7 @@ * Phase 4: TS port of `gh.rs`. * * `GhClient` wraps a `GhExecutor` with the GitHub-specific query set - * (notifications, direct review requests, required-review backlog, - * assigned issues/PRs) and the snapshot-hydration path + * (notifications, direct review requests) and the snapshot-hydration path * (`writeTaskSnapshot`). Rate limiting + write-cooldown are handled * entirely by the executor; this module only builds argv. * @@ -424,7 +423,7 @@ export class GhClient { /** * Top-level candidate producer used by the dispatcher. Runs the - * notification poll and (optionally) the three search/list queries, + * notification poll and (optionally) the direct review-request search, * aggregates, de-dups by thread_key, sorts. */ async collectCandidates(options: { @@ -464,23 +463,6 @@ export class GhClient { poll.warnings.push(`review search: ${message.trim()}`); } - try { - const tasks = await this.requiredReviewBacklog(options.limit); - poll.tasks.push(...tasks); - } catch (err) { - const message = errMessage(err); - if (isRateLimitError(message)) poll.searchRateLimited = true; - poll.warnings.push(`review backlog: ${message.trim()}`); - } - - try { - const tasks = await this.assignedItems(options.limit); - poll.tasks.push(...tasks); - } catch (err) { - const message = errMessage(err); - if (isRateLimitError(message)) poll.searchRateLimited = true; - poll.warnings.push(`assignment search: ${message.trim()}`); - } } poll.tasks = poll.tasks.filter( diff --git a/src/products/breeze/engine/daemon/poller.ts b/src/products/breeze/engine/daemon/poller.ts index 2b98a061..62fa25f0 100644 --- a/src/products/breeze/engine/daemon/poller.ts +++ b/src/products/breeze/engine/daemon/poller.ts @@ -174,11 +174,13 @@ export async function pollOnce(deps: PollOnceDeps): Promise { try { const stdout = gh.runChecked("fetch notifications", [ "api", - // `participating=true` restricts to notifications where the user is a - // direct participant (author, assignee, mention, review_requested) and - // respects GitHub's server-side spam filter. `?all=true` was previously - // used here but bypassed the filter, causing breeze to act on - // mention-then-delete spam surfaced to no one in the UI (#251). + // `participating=true` restricts to direct-participation notifications + // and respects GitHub's server-side spam filter. `parseNotifications` + // further narrows those results to explicit mentions and review + // requests so breeze does not act on generic author/assignee/comment + // traffic. `?all=true` was previously used here but bypassed the + // filter, causing breeze to act on mention-then-delete spam surfaced + // to no one in the UI (#251). "/notifications?participating=true", "--paginate", "-H", diff --git a/src/products/breeze/engine/runtime/task-kind.ts b/src/products/breeze/engine/runtime/task-kind.ts index 3a17e491..c3720ef6 100644 --- a/src/products/breeze/engine/runtime/task-kind.ts +++ b/src/products/breeze/engine/runtime/task-kind.ts @@ -3,7 +3,8 @@ * * `TaskKind` classifies a GitHub notification subject + reason into the * breeze task taxonomy. `priority_for` returns the dispatcher priority; - * `should_process_reason` gates which notification reasons get a task. + * `should_process_reason` gates which notification reasons the daemon and + * inbox treat as actionable. * * Pure. No I/O. Safe to import from anywhere. */ @@ -34,19 +35,16 @@ export function taskKindFromString(value: string): TaskKind | undefined { } /** - * Whitelist of notification `reason`s that merit dispatch. Mirrors - * `should_process_reason` in Rust — anything outside this list is - * ignored (ci_activity, subscribed, state_change, etc.). + * Whitelist of explicit GitHub signals that merit dispatch. Breeze only + * acts on direct review requests and direct mentions; other participation + * reasons (author, assign, comment, manual, etc.) stay visible on GitHub + * itself but are not treated as breeze work items. */ export function shouldProcessReason(reason: string): boolean { switch (reason) { case "review_requested": - case "comment": case "mention": case "team_mention": - case "assign": - case "author": - case "manual": return true; default: return false; diff --git a/tests/breeze/breeze-daemon-gh-client.test.ts b/tests/breeze/breeze-daemon-gh-client.test.ts index a657bc21..622f8727 100644 --- a/tests/breeze/breeze-daemon-gh-client.test.ts +++ b/tests/breeze/breeze-daemon-gh-client.test.ts @@ -139,6 +139,27 @@ describe("GhClient.recentNotifications", () => { const tasks = await client.recentNotifications(now, 60); expect(tasks).toEqual([]); }); + + it("drops notification reasons that are not explicit mentions or review requests", async () => { + const { executor, ctl } = makeStubExecutor(); + ctl.setResponses([ + { + stdout: [ + "owner/repo\tPullRequest\tcomment\tHello\thttps://api.github.com/repos/owner/repo/pulls/7\t\t2026-04-15T12:00:00Z", + "owner/repo\tIssue\tauthor\tMine\thttps://api.github.com/repos/owner/repo/issues/3\t\t2026-04-15T12:00:00Z", + "owner/repo\tIssue\tassign\tAssigned\thttps://api.github.com/repos/owner/repo/issues/4\t\t2026-04-15T12:00:00Z", + ].join("\n"), + }, + ]); + const client = new GhClient({ + host: "github.com", + repoFilter: RepoFilter.parseCsv("owner/*"), + executor, + }); + const now = Date.UTC(2026, 3, 15, 12, 0, 0) / 1000; + const tasks = await client.recentNotifications(now, 120); + expect(tasks).toEqual([]); + }); }); describe("GhClient.reviewRequests / assignedItems", () => { @@ -344,7 +365,7 @@ describe("GhClient.collectCandidates", () => { expect(poll.searchRateLimited).toBe(true); }); - it("adds required-review backlog when review-requested search is empty", async () => { + it("does not add review backlog or assigned-search tasks when direct review search is empty", async () => { const { executor, ctl } = makeStubExecutor(); ctl.setResponder((spec) => { if (spec.args[0] === "api") return { stdout: "" }; @@ -374,18 +395,13 @@ describe("GhClient.collectCandidates", () => { nowEpoch: now, lookbackSecs: 3600, }); - expect(poll.tasks).toEqual([ - buildRequiredReviewCandidate({ - repo: "o/r", - number: 11, - title: "Review me later", - webUrl: "https://github.com/o/r/pull/11", - updatedAt: "2026-04-15T12:00:00Z", - }), - ]); + expect(poll.tasks).toEqual([]); expect( ctl.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list"), - ).toBe(true); + ).toBe(false); + expect( + ctl.calls.some((call) => call.args[0] === "search" && call.args[1] === "issues"), + ).toBe(false); }); }); diff --git a/tests/breeze/breeze-daemon-poller.test.ts b/tests/breeze/breeze-daemon-poller.test.ts index 90924e6c..36f3d6f1 100644 --- a/tests/breeze/breeze-daemon-poller.test.ts +++ b/tests/breeze/breeze-daemon-poller.test.ts @@ -113,18 +113,18 @@ describe("pollOnce parity with Rust fetcher", () => { afterEach(() => rmSync(ctx.dir, { recursive: true, force: true })); it("writes an inbox.json whose single entry matches the canonical schema", async () => { - // Canonical shape (redacted) copied from the inbox schema migration doc §1.2: + // Canonical shape for an explicit review request: // { // "id": "23576674030", // "type": "PullRequest", - // "reason": "author", + // "reason": "review_requested", // "repo": "serenakeyitan/paperclip-tree", // "title": "fix(tree): salvage nya1 member node from closed sync PR 282", // "url": "https://api.github.com/.../pulls/290", // "last_actor": "https://api.github.com/.../issues/comments/4258143984", // "updated_at": "2026-04-16T07:24:28Z", // "unread": false, - // "priority": 5, + // "priority": 1, // "number": 290, // "html_url": "https://github.com/.../pull/290", // "gh_state": "OPEN", @@ -142,7 +142,7 @@ describe("pollOnce parity with Rust fetcher", () => { "https://api.github.com/repos/serenakeyitan/paperclip-tree/issues/comments/4258143984", }, repository: { full_name: "serenakeyitan/paperclip-tree" }, - reason: "author", + reason: "review_requested", updated_at: "2026-04-16T07:24:28Z", unread: false, }, @@ -202,7 +202,7 @@ describe("pollOnce parity with Rust fetcher", () => { expect(entry).toMatchObject({ id: "23576674030", type: "PullRequest", - reason: "author", + reason: "review_requested", repo: "serenakeyitan/paperclip-tree", title: "fix(tree): salvage nya1 member node from closed sync PR 282", url: "https://api.github.com/repos/serenakeyitan/paperclip-tree/pulls/290", @@ -210,7 +210,7 @@ describe("pollOnce parity with Rust fetcher", () => { "https://api.github.com/repos/serenakeyitan/paperclip-tree/issues/comments/4258143984", updated_at: "2026-04-16T07:24:28Z", unread: false, - priority: 5, + priority: 1, number: 290, html_url: "https://github.com/serenakeyitan/paperclip-tree/pull/290", gh_state: "OPEN", @@ -264,7 +264,7 @@ describe("pollOnce parity with Rust fetcher", () => { id: "disc-1", subject: { type: "Discussion", title: "chat", url: null }, repository: { full_name: "o/r" }, - reason: "subscribed", + reason: "mention", updated_at: "2026-04-16T10:00:00Z", unread: false, }, diff --git a/tests/breeze/breeze-poll.test.ts b/tests/breeze/breeze-poll.test.ts index 4fa71ac8..9fd8c587 100644 --- a/tests/breeze/breeze-poll.test.ts +++ b/tests/breeze/breeze-poll.test.ts @@ -170,34 +170,56 @@ describe("parseNotifications", () => { expect(entries[0].number).toBe(12); }); - it("builds issue html_url for Issue subjects", () => { + it("builds issue html_url for explicit mention notifications on issues", () => { const raw = `[{ "id": "10", "subject": { "type": "Issue", "title": "bug", "url": "https://api.github.com/repos/o/r/issues/42" }, "repository": { "full_name": "o/r" }, - "reason": "assign", + "reason": "mention", "updated_at": "2026-04-16T10:00:00Z", "unread": false }]`; const entries = parseNotifications([raw], "github.com"); expect(entries[0].html_url).toBe("https://github.com/o/r/issues/42"); expect(entries[0].number).toBe(42); - expect(entries[0].priority).toBe(3); + expect(entries[0].priority).toBe(2); }); - it("falls back to repo base for Discussion (no number)", () => { + it("drops non-explicit notification reasons from the inbox view", () => { + const raw = `[ + { + "id": "10", + "subject": { "type": "Issue", "title": "bug", "url": "https://api.github.com/repos/o/r/issues/42" }, + "repository": { "full_name": "o/r" }, + "reason": "assign", + "updated_at": "2026-04-16T10:00:00Z", + "unread": false + }, + { + "id": "11", + "subject": { "type": "Issue", "title": "mine", "url": "https://api.github.com/repos/o/r/issues/43" }, + "repository": { "full_name": "o/r" }, + "reason": "author", + "updated_at": "2026-04-16T10:00:00Z", + "unread": false + } + ]`; + expect(parseNotifications([raw], "github.com")).toEqual([]); + }); + + it("falls back to repo base for explicit Discussion mentions (no number)", () => { const raw = `[{ "id": "20", "subject": { "type": "Discussion", "title": "chat", "url": "https://github.com/o/r/discussions/5" }, "repository": { "full_name": "o/r" }, - "reason": "subscribed", + "reason": "mention", "updated_at": "2026-04-16T10:00:00Z", "unread": false }]`; const entries = parseNotifications([raw], "github.com"); expect(entries[0].html_url).toBe("https://github.com/o/r"); expect(entries[0].number).toBe(null); - expect(entries[0].priority).toBe(5); + expect(entries[0].priority).toBe(2); }); }); diff --git a/tests/breeze/breeze-task-kind.test.ts b/tests/breeze/breeze-task-kind.test.ts index 881fb98c..3ee04cb5 100644 --- a/tests/breeze/breeze-task-kind.test.ts +++ b/tests/breeze/breeze-task-kind.test.ts @@ -59,20 +59,22 @@ describe("priorityFor", () => { }); describe("shouldProcessReason", () => { - it("accepts the actionable set", () => { + it("accepts only explicit mentions and review requests", () => { for (const r of [ "review_requested", "mention", "team_mention", - "comment", - "assign", - "author", - "manual", ]) { expect(shouldProcessReason(r)).toBe(true); } }); + it("rejects non-explicit participation reasons", () => { + for (const r of ["comment", "assign", "author", "manual"]) { + expect(shouldProcessReason(r)).toBe(false); + } + }); + it("rejects subscribed / ci_activity / empty", () => { expect(shouldProcessReason("subscribed")).toBe(false); expect(shouldProcessReason("ci_activity")).toBe(false); diff --git a/tests/breeze/breeze-task.test.ts b/tests/breeze/breeze-task.test.ts index 4901c866..9c7c00e8 100644 --- a/tests/breeze/breeze-task.test.ts +++ b/tests/breeze/breeze-task.test.ts @@ -88,7 +88,7 @@ describe("buildNotificationCandidate", () => { host: "github.com", repo: "o/r", subjectType: "Issue", - reason: "comment", + reason: "mention", title: "t", apiUrl: "https://api.github.com/repos/o/r/issues/7", latestCommentApiUrl: "", From 0c62ed6010bcf3cb382395750a385f853425df90 Mon Sep 17 00:00:00 2001 From: Bingran You Date: Thu, 23 Apr 2026 00:56:03 -0700 Subject: [PATCH 2/2] refactor(breeze): drop dead backlog candidate helpers --- .../breeze/engine/daemon/gh-client.ts | 154 ------------------ src/products/breeze/engine/runtime/task.ts | 44 +---- tests/breeze/breeze-daemon-gh-client.test.ts | 60 +------ tests/breeze/breeze-task.test.ts | 28 +--- 4 files changed, 5 insertions(+), 281 deletions(-) diff --git a/src/products/breeze/engine/daemon/gh-client.ts b/src/products/breeze/engine/daemon/gh-client.ts index b28332ef..dd5b7bb0 100644 --- a/src/products/breeze/engine/daemon/gh-client.ts +++ b/src/products/breeze/engine/daemon/gh-client.ts @@ -26,9 +26,7 @@ import { type SearchScope, } from "../runtime/repo-filter.js"; import { - buildAssignedCandidate, buildNotificationCandidate, - buildRequiredReviewCandidate, buildReviewRequestCandidate, taskIssueNumber, taskPrNumber, @@ -169,157 +167,6 @@ export class GhClient { return deduplicate(tasks); } - /** `gh search issues --assignee=@me --include-prs`. */ - async assignedItems(limit: number): Promise { - const jq = - '.[] | [(.repository.nameWithOwner // ""), ((.number | tostring) // "0"), (.title // ""), (.url // ""), (.updatedAt // ""), (if .isPullRequest then "1" else "0" end)] | @tsv'; - const tasks: TaskCandidate[] = []; - for (const scope of searchScopesFor(this.repoFilter)) { - const stdout = await this.runChecked( - "search assigned items", - withSearchScope( - [ - "search", - "issues", - "--assignee=@me", - "--state", - "open", - "--include-prs", - "--limit", - String(limit), - "--json", - "number,title,url,updatedAt,repository,isPullRequest", - "--jq", - jq, - ], - scope, - ), - "search", - ); - for (const line of stdout.split("\n")) { - if (line.trim().length === 0) continue; - const fields = parseTsvLine(line); - if (fields.length < 6) continue; - const number = Number.parseInt(fields[1], 10) || 0; - tasks.push( - buildAssignedCandidate({ - repo: fields[0], - number, - title: fields[2], - webUrl: fields[3], - updatedAt: fields[4], - isPullRequest: fields[5] === "1", - }), - ); - } - } - return deduplicate(tasks); - } - - /** - * Recovery path for review backlog. This complements - * `--review-requested=@me`: when breeze was offline, a PR may still be - * awaiting review even though the explicit reviewer-request signal is no - * longer visible. Exact repo scopes use `gh pr list` for fresher data; - * owner/all scopes fall back to `gh search prs --review required`. - */ - async requiredReviewBacklog(limit: number): Promise { - const tasks: TaskCandidate[] = []; - const repoListJq = - '.[] | [((.number | tostring) // "0"), (.title // ""), (.url // ""), (.updatedAt // ""), (if .isDraft then "1" else "0" end), (.reviewDecision // "")] | @tsv'; - for (const repo of this.repoFilter.repos()) { - const stdout = await this.runChecked( - "list required-review backlog", - [ - "pr", - "list", - "--repo", - repo, - "--state", - "open", - "--search", - "review:required sort:updated-desc", - "--limit", - String(limit), - "--json", - "number,title,url,updatedAt,isDraft,reviewDecision", - "--jq", - repoListJq, - ], - "core", - ); - for (const line of stdout.split("\n")) { - if (line.trim().length === 0) continue; - const fields = parseTsvLine(line); - if (fields.length < 6) continue; - const number = Number.parseInt(fields[0], 10) || 0; - if (fields[4] === "1") continue; - if (fields[5] !== "REVIEW_REQUIRED") continue; - tasks.push( - buildRequiredReviewCandidate({ - repo, - number, - title: fields[1], - webUrl: fields[2], - updatedAt: fields[3], - }), - ); - } - } - - const searchScopes: SearchScope[] = this.repoFilter.isEmpty() - ? [{ kind: "all" }] - : this.repoFilter.owners().map((owner) => ({ kind: "owner", owner })); - const searchJq = - '.[] | [(.repository.nameWithOwner // ""), ((.number | tostring) // "0"), (.title // ""), (.url // ""), (.updatedAt // ""), (if .isDraft then "1" else "0" end)] | @tsv'; - for (const scope of searchScopes) { - const stdout = await this.runChecked( - "search required-review backlog", - withSearchScope( - [ - "search", - "prs", - "--review", - "required", - "--state", - "open", - "--sort", - "updated", - "--order", - "desc", - "--limit", - String(limit), - "--json", - "number,title,url,updatedAt,repository,isDraft", - "--jq", - searchJq, - ], - scope, - ), - "search", - ); - for (const line of stdout.split("\n")) { - if (line.trim().length === 0) continue; - const fields = parseTsvLine(line); - if (fields.length < 6) continue; - const repo = fields[0]; - const number = Number.parseInt(fields[1], 10) || 0; - if (fields[5] === "1") continue; - tasks.push( - buildRequiredReviewCandidate({ - repo, - number, - title: fields[2], - webUrl: fields[3], - updatedAt: fields[4], - }), - ); - } - } - - return deduplicate(tasks); - } - /** Last comment on `api_url`'s thread (empty api_url → null). */ async latestCommentActivity(apiUrl: string): Promise { if (apiUrl.trim().length === 0) return null; @@ -462,7 +309,6 @@ export class GhClient { if (isRateLimitError(message)) poll.searchRateLimited = true; poll.warnings.push(`review search: ${message.trim()}`); } - } poll.tasks = poll.tasks.filter( diff --git a/src/products/breeze/engine/runtime/task.ts b/src/products/breeze/engine/runtime/task.ts index 2cc4ef7a..155ffeca 100644 --- a/src/products/breeze/engine/runtime/task.ts +++ b/src/products/breeze/engine/runtime/task.ts @@ -29,7 +29,7 @@ import { } from "./task-util.js"; export interface TaskCandidate { - /** Origin: `notifications` | `review-search` | `review-backlog` | `assigned-search` | `recovered-running`. */ + /** Origin: `notifications` | `review-search` | `recovered-running`. */ source: string; /** Source repo (`owner/repo`). */ repo: string; @@ -178,20 +178,6 @@ export function buildReviewRequestCandidate(args: { }); } -export function buildRequiredReviewCandidate(args: { - repo: string; - number: number; - title: string; - webUrl: string; - updatedAt: string; -}): TaskCandidate { - return buildReviewCandidate({ - ...args, - source: "review-backlog", - reason: "review_required", - }); -} - function buildReviewCandidate(args: { repo: string; number: number; @@ -217,34 +203,6 @@ function buildReviewCandidate(args: { }; } -export function buildAssignedCandidate(args: { - repo: string; - number: number; - title: string; - webUrl: string; - updatedAt: string; - isPullRequest: boolean; -}): TaskCandidate { - const kind: TaskKind = args.isPullRequest - ? "assigned_pull_request" - : "assigned_issue"; - const suffix = args.isPullRequest ? "pulls" : "issues"; - return { - source: "assigned-search", - repo: args.repo, - workspaceRepo: args.repo, - threadKey: `/repos/${args.repo}/${suffix}/${args.number}`, - kind, - reason: "assigned", - title: args.title, - webUrl: args.webUrl, - apiUrl: `https://api.github.com/repos/${args.repo}/${suffix}/${args.number}`, - latestCommentApiUrl: "", - updatedAt: args.updatedAt, - priority: priorityFor(kind, "assigned"), - }; -} - /** Convert a rich TaskCandidate into the dispatcher's minimal shape. */ export function toDispatcherCandidate(task: TaskCandidate): DispatchCandidate { const prNumber = taskPrNumber(task); diff --git a/tests/breeze/breeze-daemon-gh-client.test.ts b/tests/breeze/breeze-daemon-gh-client.test.ts index 622f8727..f79cc62b 100644 --- a/tests/breeze/breeze-daemon-gh-client.test.ts +++ b/tests/breeze/breeze-daemon-gh-client.test.ts @@ -26,7 +26,6 @@ import { import { RepoFilter } from "../../src/products/breeze/engine/runtime/repo-filter.js"; import { buildNotificationCandidate, - buildRequiredReviewCandidate, buildReviewRequestCandidate, } from "../../src/products/breeze/engine/runtime/task.js"; @@ -162,7 +161,7 @@ describe("GhClient.recentNotifications", () => { }); }); -describe("GhClient.reviewRequests / assignedItems", () => { +describe("GhClient.reviewRequests", () => { it("fans out one call per search scope", async () => { const { executor, ctl } = makeStubExecutor(); ctl.setResponder((spec) => { @@ -186,28 +185,6 @@ describe("GhClient.reviewRequests / assignedItems", () => { expect(tasks[0].kind).toBe("review_request"); }); - it("parses the isPullRequest flag for assigned items", async () => { - const { executor, ctl } = makeStubExecutor(); - ctl.setResponses([ - { - stdout: [ - "o/r\t3\tBug\thttps://github.com/o/r/issues/3\t2026-04-15T12:00:00Z\t0", - "o/r\t4\tFeature\thttps://github.com/o/r/pull/4\t2026-04-15T12:00:00Z\t1", - ].join("\n"), - }, - ]); - const client = new GhClient({ - host: "github.com", - repoFilter: RepoFilter.empty(), - executor, - }); - const tasks = await client.assignedItems(10); - expect(tasks.map((t) => t.kind)).toEqual([ - "assigned_issue", - "assigned_pull_request", - ]); - }); - it("falls back to issue comments for search-derived PR candidates", async () => { const { executor, ctl } = makeStubExecutor(); ctl.setResponder((spec) => { @@ -227,7 +204,7 @@ describe("GhClient.reviewRequests / assignedItems", () => { executor, }); const activity = await client.latestVisibleActivity( - buildRequiredReviewCandidate({ + buildReviewRequestCandidate({ repo: "o/r", number: 45, title: "Handle backlog", @@ -264,7 +241,7 @@ describe("GhClient.reviewRequests / assignedItems", () => { executor, }); const activity = await client.latestVisibleActivity( - buildRequiredReviewCandidate({ + buildReviewRequestCandidate({ repo: "o/r", number: 45, title: "Handle backlog", @@ -279,37 +256,6 @@ describe("GhClient.reviewRequests / assignedItems", () => { }); }); - it("recovers required-review backlog from exact repo scopes", async () => { - const { executor, ctl } = makeStubExecutor(); - ctl.setResponses([ - { - stdout: [ - "45\tHandle backlog\thttps://github.com/o/r/pull/45\t2026-04-15T12:00:00Z\t0\tREVIEW_REQUIRED", - "46\tSkip draft\thttps://github.com/o/r/pull/46\t2026-04-15T12:00:00Z\t1\tREVIEW_REQUIRED", - "47\tSkip changes requested\thttps://github.com/o/r/pull/47\t2026-04-15T12:00:00Z\t0\tCHANGES_REQUESTED", - ].join("\n"), - }, - ]); - const client = new GhClient({ - host: "github.com", - repoFilter: RepoFilter.parseCsv("o/r"), - executor, - }); - const tasks = await client.requiredReviewBacklog(10); - expect(tasks).toHaveLength(1); - expect(tasks[0]).toEqual( - buildRequiredReviewCandidate({ - repo: "o/r", - number: 45, - title: "Handle backlog", - webUrl: "https://github.com/o/r/pull/45", - updatedAt: "2026-04-15T12:00:00Z", - }), - ); - expect(ctl.calls[0].args.slice(0, 2)).toEqual(["pr", "list"]); - expect(ctl.calls[0].args).toContain("--repo"); - expect(ctl.calls[0].args).toContain("o/r"); - }); }); describe("GhClient.collectCandidates", () => { diff --git a/tests/breeze/breeze-task.test.ts b/tests/breeze/breeze-task.test.ts index 9c7c00e8..3cdd38c8 100644 --- a/tests/breeze/breeze-task.test.ts +++ b/tests/breeze/breeze-task.test.ts @@ -1,14 +1,12 @@ import { describe, expect, it } from "vitest"; import { - buildAssignedCandidate, buildNotificationCandidate, buildReviewRequestCandidate, candidateFromTaskMetadata, displayUrl, effectiveWorkspaceRepo, stableIdFor, - taskIssueNumber, taskPrNumber, taskUrl, threadRecordFromKv, @@ -98,7 +96,7 @@ describe("buildNotificationCandidate", () => { }); }); -describe("buildReviewRequestCandidate / buildAssignedCandidate", () => { +describe("buildReviewRequestCandidate", () => { it("review request builds with pulls thread key + priority 100", () => { const c = buildReviewRequestCandidate({ repo: "o/r", @@ -111,30 +109,6 @@ describe("buildReviewRequestCandidate / buildAssignedCandidate", () => { expect(c.priority).toBe(100); expect(taskPrNumber(c)).toBe(45); }); - - it("assigned issue vs pr is driven by isPullRequest", () => { - const issue = buildAssignedCandidate({ - repo: "o/r", - number: 3, - title: "t", - webUrl: "u", - updatedAt: "u", - isPullRequest: false, - }); - expect(issue.kind).toBe("assigned_issue"); - expect(taskIssueNumber(issue)).toBe(3); - - const pr = buildAssignedCandidate({ - repo: "o/r", - number: 4, - title: "t", - webUrl: "u", - updatedAt: "u", - isPullRequest: true, - }); - expect(pr.kind).toBe("assigned_pull_request"); - expect(taskPrNumber(pr)).toBe(4); - }); }); describe("candidateFromTaskMetadata", () => {