@clipboard-health/groundcrew 4.52.9 → 4.52.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,23 @@
1
1
  /**
2
2
  * Per-tick sync that paints each tracked task's cmux sidebar with its ticket
3
- * id, and — when `cmux.prStages.enabled` — its pull request's review stage
3
+ * id, and — when `cmux.prStages.enabled` — its pull requests' review stages
4
4
  * and gating labels too. Replaces the standalone `crew-pr-stages` bash
5
5
  * poller: same stage/ticket derivation rules (`../lib/prStageRules.ts`),
6
- * same status-key contract the installed sidebar reads, but PRs are
7
- * resolved from the task's own worktree branch
8
- * (`findPullRequestsForBranchOrThrow`) instead of cmux's own git-branch PR
9
- * detection.
6
+ * same status-key contract the installed sidebar reads, but a task's PRs are
7
+ * discovered from its worktree branch plus every branch stacked off it
8
+ * (`listPullRequestsForRepositoryOrThrow` + `selectTaskPullRequests`)
9
+ * instead of cmux's own single-branch PR detection. That repository-wide
10
+ * list is bounded and author-scoped (`--author @me`, 100 PRs), so a task
11
+ * whose PR falls outside it is recovered by a per-task exact-branch fallback
12
+ * (`findTaskPullRequestsForBranchOrThrow`) — see
13
+ * `resolveTaskPullRequestsByWorkspace` for the two-tier lookup.
14
+ *
15
+ * A task can have several shown PRs (an unsplit branch, or a stack of
16
+ * follow-up PRs chained by base branch). `crew_stage`/`crew_labels` describe
17
+ * only the single most urgent one (`selectDrivingPullRequest`) — the
18
+ * "stage-driving" PR a right-click label toggle acts on — while the
19
+ * `crew_prs` status carries every shown PR so the sidebar can render one row
20
+ * per PR. See `contrib/cmux/README.md` for the full status-key contract.
10
21
  *
11
22
  * `crew_ticket` is written for every matched cmux task workspace regardless
12
23
  * of the `cmux.prStages.enabled` opt-in, since a custom sidebar may read it
@@ -25,13 +36,23 @@ import { type CmuxWorkspaceSummary } from "../lib/cmuxAdapter.ts";
25
36
  import { type CmuxStatusWrite } from "../lib/cmuxStatusFields.ts";
26
37
  import type { ResolvedConfig } from "../lib/config.ts";
27
38
  import { type PullRequestDetail } from "../lib/pullRequestDetails.ts";
28
- import { type PullRequestSummary } from "../lib/pullRequests.ts";
39
+ import { type TaskPullRequest } from "../lib/pullRequests.ts";
29
40
  import type { WorktreeEntry } from "../lib/worktrees.ts";
30
- export type FindPullRequests = (arguments_: {
41
+ /** One `gh pr list` call per repository per tick; matched tasks sharing a repository share its result. */
42
+ export type ListPullRequestsForRepository = (arguments_: {
43
+ cwd: string;
44
+ signal?: AbortSignal;
45
+ }) => Promise<readonly TaskPullRequest[]>;
46
+ /**
47
+ * Per-task fallback used only when a matched task's branch (and its stack)
48
+ * are absent from the repository-wide list — e.g. the task's PR is older
49
+ * than that list's 100-PR window. One call per such task, not per tick.
50
+ */
51
+ export type FindPullRequestsForBranch = (arguments_: {
31
52
  cwd: string;
32
53
  branchName: string;
33
54
  signal?: AbortSignal;
34
- }) => Promise<readonly PullRequestSummary[]>;
55
+ }) => Promise<readonly TaskPullRequest[]>;
35
56
  export type FetchPullRequestDetails = (arguments_: {
36
57
  urls: readonly string[];
37
58
  signal?: AbortSignal;
@@ -42,7 +63,8 @@ export type WriteCmuxStatus = (workspaceId: string, write: CmuxStatusWrite, sign
42
63
  export type IsCmuxAdapterActive = (signal?: AbortSignal) => Promise<boolean>;
43
64
  export interface PrStageSyncDeps {
44
65
  config: ResolvedConfig;
45
- findPullRequests: FindPullRequests;
66
+ listPullRequests: ListPullRequestsForRepository;
67
+ findPullRequestsForBranch: FindPullRequestsForBranch;
46
68
  fetchPullRequestDetails: FetchPullRequestDetails;
47
69
  listCmuxWorkspaces: ListCmuxWorkspaces;
48
70
  readCmuxStatus: ReadCmuxStatus;
@@ -1 +1 @@
1
- {"version":3,"file":"prStageSync.d.ts","sourceRoot":"","sources":["../../src/commands/prStageSync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,KAAK,oBAAoB,EAA8B,MAAM,uBAAuB,CAAC;AAC9F,OAAO,EACL,KAAK,eAAe,EAKrB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,EAA2B,KAAK,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAC/F,OAAO,EAAoC,KAAK,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AASnG,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAczD,MAAM,MAAM,gBAAgB,GAAG,CAAC,UAAU,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,KAAK,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAAC;AAE7C,MAAM,MAAM,uBAAuB,GAAG,CAAC,UAAU,EAAE;IACjD,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,KAAK,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,iBAAiB,GAAG,SAAS,CAAC,CAAC,CAAC;AAE1D,MAAM,MAAM,kBAAkB,GAAG,CAC/B,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC,oBAAoB,EAAE,GAAG,SAAS,CAAC,CAAC;AACjD,MAAM,MAAM,cAAc,GAAG,CAC3B,WAAW,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAClC,MAAM,MAAM,eAAe,GAAG,CAC5B,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,eAAe,EACtB,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC,IAAI,CAAC,CAAC;AACnB,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE7E,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,cAAc,CAAC;IACvB,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,uBAAuB,EAAE,uBAAuB,CAAC;IACjD,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,cAAc,EAAE,cAAc,CAAC;IAC/B,eAAe,EAAE,eAAe,CAAC;IACjC,mBAAmB,EAAE,mBAAmB,CAAC;CAC1C;AAED,MAAM,WAAW,mBAAmB;IAClC,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,4GAA4G;IAC5G,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B;;;;;OAKG;IACH,OAAO,EAAE,CAAC,UAAU,EAAE,mBAAmB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,yGAAyG;IACzG,QAAQ,EAAE,CAAC,UAAU,EAAE,mBAAmB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9D;AAED,mGAAmG;AACnG,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,OAAO,CAAC,CAUlB;AA2QD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,CA0KpE;AAED,8EAA8E;AAC9E,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,cAAc,GAAG,eAAe,CAU7E"}
1
+ {"version":3,"file":"prStageSync.d.ts","sourceRoot":"","sources":["../../src/commands/prStageSync.ts"],"names":[],"mappings":"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"}
@@ -1,12 +1,23 @@
1
1
  /**
2
2
  * Per-tick sync that paints each tracked task's cmux sidebar with its ticket
3
- * id, and — when `cmux.prStages.enabled` — its pull request's review stage
3
+ * id, and — when `cmux.prStages.enabled` — its pull requests' review stages
4
4
  * and gating labels too. Replaces the standalone `crew-pr-stages` bash
5
5
  * poller: same stage/ticket derivation rules (`../lib/prStageRules.ts`),
6
- * same status-key contract the installed sidebar reads, but PRs are
7
- * resolved from the task's own worktree branch
8
- * (`findPullRequestsForBranchOrThrow`) instead of cmux's own git-branch PR
9
- * detection.
6
+ * same status-key contract the installed sidebar reads, but a task's PRs are
7
+ * discovered from its worktree branch plus every branch stacked off it
8
+ * (`listPullRequestsForRepositoryOrThrow` + `selectTaskPullRequests`)
9
+ * instead of cmux's own single-branch PR detection. That repository-wide
10
+ * list is bounded and author-scoped (`--author @me`, 100 PRs), so a task
11
+ * whose PR falls outside it is recovered by a per-task exact-branch fallback
12
+ * (`findTaskPullRequestsForBranchOrThrow`) — see
13
+ * `resolveTaskPullRequestsByWorkspace` for the two-tier lookup.
14
+ *
15
+ * A task can have several shown PRs (an unsplit branch, or a stack of
16
+ * follow-up PRs chained by base branch). `crew_stage`/`crew_labels` describe
17
+ * only the single most urgent one (`selectDrivingPullRequest`) — the
18
+ * "stage-driving" PR a right-click label toggle acts on — while the
19
+ * `crew_prs` status carries every shown PR so the sidebar can render one row
20
+ * per PR. See `contrib/cmux/README.md` for the full status-key contract.
10
21
  *
11
22
  * `crew_ticket` is written for every matched cmux task workspace regardless
12
23
  * of the `cmux.prStages.enabled` opt-in, since a custom sidebar may read it
@@ -22,11 +33,11 @@
22
33
  * workspace must never break the watch loop or starve the other steps.
23
34
  */
24
35
  import { listCmuxWorkspaceSummaries } from "../lib/cmuxAdapter.js";
25
- import { readCmuxStatus, TICKET_KEY, TICKET_PRIORITY, writeCmuxStatus, } from "../lib/cmuxStatusFields.js";
36
+ import { PULL_REQUESTS_KEY, PULL_REQUESTS_PRIORITY, readCmuxStatus, TICKET_KEY, TICKET_PRIORITY, writeCmuxStatus, } from "../lib/cmuxStatusFields.js";
26
37
  import { detectHostCapabilities } from "../lib/host.js";
27
38
  import { fetchPullRequestDetails } from "../lib/pullRequestDetails.js";
28
- import { findPullRequestsForBranchOrThrow } from "../lib/pullRequests.js";
29
- import { deriveTicket, derivePrStage, managedLabelsField, } from "../lib/prStageRules.js";
39
+ import { findTaskPullRequestsForBranchOrThrow, listPullRequestsForRepositoryOrThrow, } from "../lib/pullRequests.js";
40
+ import { deriveTicket, derivePrStage, encodePrStatuses, managedLabelsField, PR_STAGE_URGENCY_ORDER, selectTaskPullRequests, } from "../lib/prStageRules.js";
30
41
  import { debug, errorMessage, logEvent, readEnvironmentVariable } from "../lib/util.js";
31
42
  import { resolveWorkspaceKind } from "../lib/workspaces.js";
32
43
  import { effectiveBranchName } from "../lib/worktreeRunState.js";
@@ -70,13 +81,6 @@ function matchWorkspacesToTasks(workspaces, worktreeEntries) {
70
81
  }
71
82
  return matches;
72
83
  }
73
- /** One PR to track per workspace: prefer a live one, then whichever terminal state, else whatever gh returned first. */
74
- function selectTrackedPullRequest(pullRequests) {
75
- return (pullRequests.find((pr) => pr.state === "open") ??
76
- pullRequests.find((pr) => pr.state === "merged") ??
77
- pullRequests.find((pr) => pr.state === "closed") ??
78
- pullRequests[0]);
79
- }
80
84
  function newCounters() {
81
85
  return { written: 0, cleared: 0, skipped: 0 };
82
86
  }
@@ -118,55 +122,68 @@ async function writeTicketField(arguments_) {
118
122
  counters,
119
123
  });
120
124
  }
125
+ /** The single most urgent PR among a task's shown PRs, per `PR_STAGE_URGENCY_ORDER` — never empty, since `staged` is only built from a non-empty pull-request list. */
126
+ function selectDrivingPullRequest(staged) {
127
+ return staged.reduce((best, candidate) => PR_STAGE_URGENCY_ORDER.indexOf(candidate.stage) < PR_STAGE_URGENCY_ORDER.indexOf(best.stage)
128
+ ? candidate
129
+ : best);
130
+ }
131
+ async function clearPullRequestFields(arguments_) {
132
+ const { workspaceId, current, deps, signal, counters } = arguments_;
133
+ for (const { key, priority } of [
134
+ { key: STAGE_KEY, priority: STAGE_PRIORITY },
135
+ { key: LABELS_KEY, priority: LABELS_PRIORITY },
136
+ { key: PULL_REQUESTS_KEY, priority: PULL_REQUESTS_PRIORITY },
137
+ ]) {
138
+ // oxlint-disable-next-line no-await-in-loop -- three related status writes for one workspace; sequential keeps them from interleaving with another workspace's
139
+ await applyField({
140
+ workspaceId,
141
+ key,
142
+ priority,
143
+ current: current.get(key) ?? "",
144
+ desired: "",
145
+ signal,
146
+ writeStatus: deps.writeCmuxStatus,
147
+ counters,
148
+ });
149
+ }
150
+ }
121
151
  /**
122
- * Stage and labels are cleared only when the branch is confirmed to have no
123
- * PR; when discovery itself failed (network/auth error) or a PR exists but
124
- * its detail couldn't be (re)confirmed this tick (a failed or partial
125
- * GraphQL batch), they are left exactly as they were — never guessed, never
126
- * cleared.
152
+ * Stage, labels, and the PR list are cleared only when the task is confirmed
153
+ * to have no shown PR; when discovery itself failed (network/auth error) or
154
+ * a shown PR's detail couldn't be (re)confirmed this tick (a failed or
155
+ * partial GraphQL batch), all three are left exactly as they were — never
156
+ * guessed, never cleared.
127
157
  */
128
158
  async function syncWorkspace(arguments_) {
129
- const { match, labelNames, pullRequest, pullRequestLookupFailed, detail, deps, signal, counters, } = arguments_;
159
+ const { match, labelNames, pullRequests, pullRequestLookupFailed, detailsByUrl, deps, signal, counters, } = arguments_;
130
160
  const { workspace } = match;
131
161
  const current = await deps.readCmuxStatus(workspace.id, signal);
132
162
  await writeTicketField({ workspace, current, deps, signal, counters });
133
163
  if (pullRequestLookupFailed) {
134
- counters.skipped += 2;
164
+ counters.skipped += 3;
135
165
  return;
136
166
  }
137
- if (pullRequest === undefined) {
138
- await applyField({
139
- workspaceId: workspace.id,
140
- key: STAGE_KEY,
141
- priority: STAGE_PRIORITY,
142
- current: current.get(STAGE_KEY) ?? "",
143
- desired: "",
144
- signal,
145
- writeStatus: deps.writeCmuxStatus,
146
- counters,
147
- });
148
- await applyField({
149
- workspaceId: workspace.id,
150
- key: LABELS_KEY,
151
- priority: LABELS_PRIORITY,
152
- current: current.get(LABELS_KEY) ?? "",
153
- desired: "",
154
- signal,
155
- writeStatus: deps.writeCmuxStatus,
156
- counters,
157
- });
167
+ if (pullRequests.length === 0) {
168
+ await clearPullRequestFields({ workspaceId: workspace.id, current, deps, signal, counters });
158
169
  return;
159
170
  }
160
- if (detail === undefined) {
161
- counters.skipped += 2;
162
- return;
171
+ const staged = [];
172
+ for (const pullRequest of pullRequests) {
173
+ const detail = detailsByUrl.get(pullRequest.url);
174
+ if (detail === undefined) {
175
+ counters.skipped += 3;
176
+ return;
177
+ }
178
+ staged.push({ pullRequest, detail, stage: derivePrStage(detail, labelNames) });
163
179
  }
180
+ const driving = selectDrivingPullRequest(staged);
164
181
  await applyField({
165
182
  workspaceId: workspace.id,
166
183
  key: STAGE_KEY,
167
184
  priority: STAGE_PRIORITY,
168
185
  current: current.get(STAGE_KEY) ?? "",
169
- desired: derivePrStage(detail, labelNames),
186
+ desired: driving.stage,
170
187
  signal,
171
188
  writeStatus: deps.writeCmuxStatus,
172
189
  counters,
@@ -176,30 +193,93 @@ async function syncWorkspace(arguments_) {
176
193
  key: LABELS_KEY,
177
194
  priority: LABELS_PRIORITY,
178
195
  current: current.get(LABELS_KEY) ?? "",
179
- desired: managedLabelsField(detail.labels, labelNames),
196
+ desired: managedLabelsField(driving.detail.labels, labelNames),
197
+ signal,
198
+ writeStatus: deps.writeCmuxStatus,
199
+ counters,
200
+ });
201
+ await applyField({
202
+ workspaceId: workspace.id,
203
+ key: PULL_REQUESTS_KEY,
204
+ priority: PULL_REQUESTS_PRIORITY,
205
+ current: current.get(PULL_REQUESTS_KEY) ?? "",
206
+ desired: encodePrStatuses(staged.map((entry) => ({
207
+ number: entry.pullRequest.number,
208
+ stage: entry.stage,
209
+ url: entry.pullRequest.url,
210
+ }))),
180
211
  signal,
181
212
  writeStatus: deps.writeCmuxStatus,
182
213
  counters,
183
214
  });
184
215
  }
185
- async function resolvePullRequestsByWorkspace(arguments_) {
186
- const { matches, config, findPullRequests, signal } = arguments_;
216
+ /**
217
+ * One `gh pr list` call per distinct repository among the matched
218
+ * workspaces, not one per workspace: every matched task sharing a
219
+ * repository reuses that single repository-wide list, filtered client-side
220
+ * (`selectTaskPullRequests`) down to each task's own branch and its stack.
221
+ *
222
+ * That repository-wide list is bounded (100 PRs, `--author @me`), so a task
223
+ * whose PR is older than the window — or for any other reason absent from
224
+ * it — would otherwise read as "no PR" and get its stage/labels/PR list
225
+ * cleared. When a matched task's filtered result is empty, this falls back
226
+ * to one exact-branch `gh pr list --head` lookup for that task alone
227
+ * (`findPullRequestsForBranch`), the same lookup `pr-stage-sync` used before
228
+ * stack discovery existed. A fallback failure is treated exactly like a
229
+ * repository-list failure — the workspace is marked failed so its fields are
230
+ * left untouched rather than cleared — never silently treated as "no PR".
231
+ */
232
+ async function resolveTaskPullRequestsByWorkspace(arguments_) {
233
+ const { matches, config, listPullRequests, findPullRequestsForBranch, signal } = arguments_;
187
234
  const byWorkspace = new Map();
188
235
  const failedWorkspaceIds = new Set();
236
+ const representativeByRepository = new Map();
237
+ for (const match of matches) {
238
+ if (!representativeByRepository.has(match.entry.repository)) {
239
+ representativeByRepository.set(match.entry.repository, match);
240
+ }
241
+ }
242
+ const pullRequestsByRepository = new Map();
243
+ const failedRepositories = new Set();
244
+ for (const [repository, representative] of representativeByRepository) {
245
+ try {
246
+ // oxlint-disable-next-line no-await-in-loop -- one gh lookup per repository, shared by every matched task in it
247
+ const pullRequests = await listPullRequests({
248
+ cwd: representative.entry.dir,
249
+ ...(signal === undefined ? {} : { signal }),
250
+ });
251
+ pullRequestsByRepository.set(repository, pullRequests);
252
+ }
253
+ catch (error) {
254
+ debug(`pr-stage-sync: PR list failed for repository ${repository}: ${errorMessage(error)}`);
255
+ failedRepositories.add(repository);
256
+ }
257
+ }
189
258
  for (const match of matches) {
190
- // oxlint-disable-next-line no-await-in-loop -- one gh lookup per matched workspace; mirrors reviewer.ts's sequential PR lookups
191
- const branchName = await effectiveBranchName({ config, entry: match.entry });
259
+ if (failedRepositories.has(match.entry.repository)) {
260
+ failedWorkspaceIds.add(match.workspace.id);
261
+ continue;
262
+ }
263
+ // oxlint-disable-next-line no-await-in-loop -- one branch resolution per matched workspace; mirrors the per-repository gh lookup above
264
+ const taskBranch = await effectiveBranchName({ config, entry: match.entry });
265
+ /* v8 ignore next @preserve -- every non-failed repository was populated in the loop above, since every match's repository is added to representativeByRepository first */
266
+ const repositoryPullRequests = pullRequestsByRepository.get(match.entry.repository) ?? [];
267
+ const discovered = selectTaskPullRequests(repositoryPullRequests, taskBranch);
268
+ if (discovered.length > 0) {
269
+ byWorkspace.set(match.workspace.id, discovered);
270
+ continue;
271
+ }
192
272
  try {
193
- // oxlint-disable-next-line no-await-in-loop -- see above
194
- const pullRequests = await findPullRequests({
273
+ // oxlint-disable-next-line no-await-in-loop -- one exact-branch fallback call per task whose PR fell outside the repository-wide window; sequential keeps it from interleaving with another task's status writes
274
+ const fallbackPullRequests = await findPullRequestsForBranch({
195
275
  cwd: match.entry.dir,
196
- branchName,
276
+ branchName: taskBranch,
197
277
  ...(signal === undefined ? {} : { signal }),
198
278
  });
199
- byWorkspace.set(match.workspace.id, selectTrackedPullRequest(pullRequests));
279
+ byWorkspace.set(match.workspace.id, selectTaskPullRequests(fallbackPullRequests, taskBranch));
200
280
  }
201
281
  catch (error) {
202
- debug(`pr-stage-sync: PR lookup failed for ${match.entry.task}: ${errorMessage(error)}`);
282
+ debug(`pr-stage-sync: exact-branch PR fallback failed for ${match.entry.task}: ${errorMessage(error)}`);
203
283
  failedWorkspaceIds.add(match.workspace.id);
204
284
  }
205
285
  }
@@ -294,10 +374,11 @@ export function createPrStageSync(deps) {
294
374
  return;
295
375
  }
296
376
  const matches = matchWorkspacesToTasks(workspaces, worktreeEntries);
297
- const { byWorkspace: pullRequestByWorkspace, failedWorkspaceIds } = await resolvePullRequestsByWorkspace({
377
+ const { byWorkspace: pullRequestsByWorkspace, failedWorkspaceIds } = await resolveTaskPullRequestsByWorkspace({
298
378
  matches,
299
379
  config,
300
- findPullRequests: deps.findPullRequests,
380
+ listPullRequests: deps.listPullRequests,
381
+ findPullRequestsForBranch: deps.findPullRequestsForBranch,
301
382
  signal,
302
383
  });
303
384
  if (failedWorkspaceIds.size > 0) {
@@ -305,7 +386,7 @@ export function createPrStageSync(deps) {
305
386
  reason = "pull_request_lookup_failed";
306
387
  }
307
388
  const urls = [
308
- ...new Set([...pullRequestByWorkspace.values()].flatMap((pr) => (pr === undefined ? [] : [pr.url]))),
389
+ ...new Set([...pullRequestsByWorkspace.values()].flatMap((prs) => prs.map((pr) => pr.url))),
309
390
  ];
310
391
  let details = new Map();
311
392
  if (urls.length > 0) {
@@ -322,16 +403,15 @@ export function createPrStageSync(deps) {
322
403
  }
323
404
  }
324
405
  for (const match of matches) {
325
- const pullRequest = pullRequestByWorkspace.get(match.workspace.id);
326
- const detail = pullRequest === undefined ? undefined : details.get(pullRequest.url);
406
+ const pullRequests = pullRequestsByWorkspace.get(match.workspace.id) ?? [];
327
407
  try {
328
408
  // oxlint-disable-next-line no-await-in-loop -- sequential per workspace so one workspace's status calls never interleave with another's
329
409
  await syncWorkspace({
330
410
  match,
331
411
  labelNames: config.cmux.prStages.labels,
332
- pullRequest,
412
+ pullRequests,
333
413
  pullRequestLookupFailed: failedWorkspaceIds.has(match.workspace.id),
334
- detail,
414
+ detailsByUrl: details,
335
415
  deps,
336
416
  signal,
337
417
  counters,
@@ -387,7 +467,8 @@ export function createPrStageSync(deps) {
387
467
  export function createPrStageSyncDeps(config) {
388
468
  return {
389
469
  config,
390
- findPullRequests: findPullRequestsForBranchOrThrow,
470
+ listPullRequests: listPullRequestsForRepositoryOrThrow,
471
+ findPullRequestsForBranch: findTaskPullRequestsForBranchOrThrow,
391
472
  fetchPullRequestDetails,
392
473
  listCmuxWorkspaces: listCmuxWorkspaceSummaries,
393
474
  readCmuxStatus,
@@ -14,6 +14,15 @@
14
14
  */
15
15
  export declare const TICKET_KEY = "crew_ticket";
16
16
  export declare const TICKET_PRIORITY = -11;
17
+ /**
18
+ * Every shown PR for the task, encoded by `prStageRules.ts#encodePrStatuses`
19
+ * so the cmux sidebar can render one line per PR without re-deriving stage
20
+ * from scratch per row. Written by `prStageSync.ts` alongside `crew_stage`
21
+ * (which carries just the single most urgent stage across these PRs) and
22
+ * `crew_labels` (which describes the same most-urgent, "stage-driving" PR).
23
+ */
24
+ export declare const PULL_REQUESTS_KEY = "crew_prs";
25
+ export declare const PULL_REQUESTS_PRIORITY = -14;
17
26
  export interface CmuxStatusWrite {
18
27
  key: string;
19
28
  priority: number;
@@ -1 +1 @@
1
- {"version":3,"file":"cmuxStatusFields.d.ts","sourceRoot":"","sources":["../../src/lib/cmuxStatusFields.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH;;;;;GAKG;AACH,eAAO,MAAM,UAAU,gBAAgB,CAAC;AACxC,eAAO,MAAM,eAAe,MAAM,CAAC;AAEnC,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAkBxE;AAED,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAO9B;AAED,kEAAkE;AAClE,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,eAAe,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,IAAI,CAAC,CAsBf"}
1
+ {"version":3,"file":"cmuxStatusFields.d.ts","sourceRoot":"","sources":["../../src/lib/cmuxStatusFields.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH;;;;;GAKG;AACH,eAAO,MAAM,UAAU,gBAAgB,CAAC;AACxC,eAAO,MAAM,eAAe,MAAM,CAAC;AAEnC;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,aAAa,CAAC;AAC5C,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAkBxE;AAED,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAO9B;AAED,kEAAkE;AAClE,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,eAAe,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,IAAI,CAAC,CAsBf"}
@@ -15,6 +15,15 @@ import { runWorkspaceCommand } from "./workspaceAdapter.js";
15
15
  */
16
16
  export const TICKET_KEY = "crew_ticket";
17
17
  export const TICKET_PRIORITY = -11;
18
+ /**
19
+ * Every shown PR for the task, encoded by `prStageRules.ts#encodePrStatuses`
20
+ * so the cmux sidebar can render one line per PR without re-deriving stage
21
+ * from scratch per row. Written by `prStageSync.ts` alongside `crew_stage`
22
+ * (which carries just the single most urgent stage across these PRs) and
23
+ * `crew_labels` (which describes the same most-urgent, "stage-driving" PR).
24
+ */
25
+ export const PULL_REQUESTS_KEY = "crew_prs";
26
+ export const PULL_REQUESTS_PRIORITY = -14;
18
27
  /**
19
28
  * Parses `cmux list-status`'s `key=value [attr=val ...]` lines into a
20
29
  * key -> value map. A value is the token immediately after `=`, up to the
@@ -30,6 +30,56 @@ export interface PrStageLabelNames {
30
30
  export declare function derivePrStage(input: PrStageInput, labelNames: PrStageLabelNames): PrStage;
31
31
  /** The managed-label subset of a PR's labels, sorted and comma-joined — the `crew_labels` sidebar value. */
32
32
  export declare function managedLabelsField(labels: readonly string[], labelNames: PrStageLabelNames): string;
33
+ /**
34
+ * Most urgent to least urgent, mirroring the sidebar's section order
35
+ * (`contrib/cmux/groundcrew.swift`) for the stages this module derives —
36
+ * `merged`/`closed` sit last since a shown closed PR only appears when the
37
+ * task has no open or merged PR, and a shown merged PR only appears
38
+ * alongside PRs this urgent or less. Defined once here so `prStageSync.ts`
39
+ * (picking the task's single most urgent stage across its shown PRs) and any
40
+ * other ranking need share the same order instead of re-deriving it.
41
+ */
42
+ export declare const PR_STAGE_URGENCY_ORDER: readonly PrStage[];
43
+ /** The most urgent stage present in `stages`, per `PR_STAGE_URGENCY_ORDER`. */
44
+ export declare function mostUrgentStage(stages: readonly PrStage[]): PrStage | undefined;
45
+ export interface TaskPullRequestRef {
46
+ headRefName: string;
47
+ baseRefName: string;
48
+ }
49
+ /** A task's PRs: its exact branch, plus every branch stacked off it (`<taskBranch>-*`). */
50
+ export declare function isTaskBranch(headRefName: string, taskBranch: string): boolean;
51
+ /**
52
+ * Which of a task's matched pull requests the sidebar shows: every open or
53
+ * merged one, falling back to closed ones only when none are open or
54
+ * merged — a closed PR superseded by a later stack split is noise once the
55
+ * replacement stack exists, but is the only signal left once it's all that
56
+ * remains.
57
+ */
58
+ export declare function selectShownPullRequests<T extends {
59
+ state: string;
60
+ }>(pullRequests: readonly T[]): T[];
61
+ /**
62
+ * Orders a task's shown PRs bottom-of-stack to top when they chain cleanly
63
+ * through baseRefName -> headRefName (each PR's base is either outside the
64
+ * shown set — the bottom — or exactly one other shown PR's head, and no two
65
+ * PRs share a base); falls back to ascending PR number for anything that
66
+ * doesn't form that single linear chain (a fork, a gap, or a cycle).
67
+ */
68
+ export declare function orderPullRequestStack<T extends TaskPullRequestRef & {
69
+ number: number;
70
+ }>(pullRequests: readonly T[]): T[];
71
+ /** Shown PRs for a task, in display order: `selectShownPullRequests` then `orderPullRequestStack`. */
72
+ export declare function selectTaskPullRequests<T extends TaskPullRequestRef & {
73
+ number: number;
74
+ state: string;
75
+ }>(pullRequests: readonly T[], taskBranch: string): T[];
76
+ export interface PrStatusEntry {
77
+ number: number;
78
+ stage: PrStage;
79
+ url: string;
80
+ }
81
+ /** The `crew_prs` sidebar value: `<number>,<stage>,<url>` entries joined by `;`. */
82
+ export declare function encodePrStatuses(entries: readonly PrStatusEntry[]): string;
33
83
  export interface TicketDerivationInput {
34
84
  title?: string | null;
35
85
  currentDirectory?: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"prStageRules.d.ts","sourceRoot":"","sources":["../../src/lib/prStageRules.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,MAAM,yBAAyB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAErE,MAAM,MAAM,yBAAyB,GAAG,UAAU,GAAG,mBAAmB,GAAG,iBAAiB,GAAG,IAAI,CAAC;AAEpG,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,yBAAyB,CAAC;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,cAAc,EAAE,yBAAyB,CAAC;IAC1C,2FAA2F;IAC3F,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,QAAA,MAAM,SAAS,YACb,QAAQ,EACR,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,eAAe,EACf,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,CACL,CAAC;AAEX,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;CAChB;AAKD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,GAAG,OAAO,CA0BzF;AAED,4GAA4G;AAC5G,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,UAAU,EAAE,iBAAiB,GAC5B,MAAM,CAMR;AAmDD,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,KAAK,EAAE,qBAAqB,GAAG,MAAM,GAAG,SAAS,CAE7E"}
1
+ {"version":3,"file":"prStageRules.d.ts","sourceRoot":"","sources":["../../src/lib/prStageRules.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,MAAM,yBAAyB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAErE,MAAM,MAAM,yBAAyB,GAAG,UAAU,GAAG,mBAAmB,GAAG,iBAAiB,GAAG,IAAI,CAAC;AAEpG,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,yBAAyB,CAAC;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,cAAc,EAAE,yBAAyB,CAAC;IAC1C,2FAA2F;IAC3F,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,QAAA,MAAM,SAAS,YACb,QAAQ,EACR,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,eAAe,EACf,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,CACL,CAAC;AAEX,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;CAChB;AAKD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,iBAAiB,GAAG,OAAO,CA0BzF;AAED,4GAA4G;AAC5G,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,UAAU,EAAE,iBAAiB,GAC5B,MAAM,CAMR;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,OAAO,EAUpD,CAAC;AAEF,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,GAAG,SAAS,CAE/E;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAE7E;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,EACjE,YAAY,EAAE,SAAS,CAAC,EAAE,GACzB,CAAC,EAAE,CAML;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,kBAAkB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EACrF,YAAY,EAAE,SAAS,CAAC,EAAE,GACzB,CAAC,EAAE,CAiCL;AAED,sGAAsG;AACtG,wBAAgB,sBAAsB,CACpC,CAAC,SAAS,kBAAkB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAChE,YAAY,EAAE,SAAS,CAAC,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,CAAC,EAAE,CAGrD;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb;AAKD,oFAAoF;AACpF,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,aAAa,EAAE,GAAG,MAAM,CAI1E;AAmDD,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,KAAK,EAAE,qBAAqB,GAAG,MAAM,GAAG,SAAS,CAE7E"}
@@ -59,6 +59,100 @@ export function managedLabelsField(labels, labelNames) {
59
59
  .toSorted((a, b) => a.localeCompare(b))
60
60
  .join(",");
61
61
  }
62
+ /**
63
+ * Most urgent to least urgent, mirroring the sidebar's section order
64
+ * (`contrib/cmux/groundcrew.swift`) for the stages this module derives —
65
+ * `merged`/`closed` sit last since a shown closed PR only appears when the
66
+ * task has no open or merged PR, and a shown merged PR only appears
67
+ * alongside PRs this urgent or less. Defined once here so `prStageSync.ts`
68
+ * (picking the task's single most urgent stage across its shown PRs) and any
69
+ * other ranking need share the same order instead of re-deriving it.
70
+ */
71
+ export const PR_STAGE_URGENCY_ORDER = [
72
+ "my_review",
73
+ "ci_failing",
74
+ "changes_requested",
75
+ "needs_testing",
76
+ "ready_to_merge",
77
+ "ci_running",
78
+ "peer_review",
79
+ "merged",
80
+ "closed",
81
+ ];
82
+ /** The most urgent stage present in `stages`, per `PR_STAGE_URGENCY_ORDER`. */
83
+ export function mostUrgentStage(stages) {
84
+ return PR_STAGE_URGENCY_ORDER.find((stage) => stages.includes(stage));
85
+ }
86
+ /** A task's PRs: its exact branch, plus every branch stacked off it (`<taskBranch>-*`). */
87
+ export function isTaskBranch(headRefName, taskBranch) {
88
+ return headRefName === taskBranch || headRefName.startsWith(`${taskBranch}-`);
89
+ }
90
+ /**
91
+ * Which of a task's matched pull requests the sidebar shows: every open or
92
+ * merged one, falling back to closed ones only when none are open or
93
+ * merged — a closed PR superseded by a later stack split is noise once the
94
+ * replacement stack exists, but is the only signal left once it's all that
95
+ * remains.
96
+ */
97
+ export function selectShownPullRequests(pullRequests) {
98
+ const openOrMerged = pullRequests.filter((pr) => pr.state === "open" || pr.state === "merged");
99
+ if (openOrMerged.length > 0) {
100
+ return openOrMerged;
101
+ }
102
+ return pullRequests.filter((pr) => pr.state === "closed");
103
+ }
104
+ /**
105
+ * Orders a task's shown PRs bottom-of-stack to top when they chain cleanly
106
+ * through baseRefName -> headRefName (each PR's base is either outside the
107
+ * shown set — the bottom — or exactly one other shown PR's head, and no two
108
+ * PRs share a base); falls back to ascending PR number for anything that
109
+ * doesn't form that single linear chain (a fork, a gap, or a cycle).
110
+ */
111
+ export function orderPullRequestStack(pullRequests) {
112
+ if (pullRequests.length <= 1) {
113
+ return [...pullRequests];
114
+ }
115
+ const byHead = new Map(pullRequests.map((pr) => [pr.headRefName, pr]));
116
+ const childByParentHead = new Map();
117
+ const roots = [];
118
+ for (const pr of pullRequests) {
119
+ const parent = byHead.get(pr.baseRefName);
120
+ if (parent === undefined) {
121
+ roots.push(pr);
122
+ continue;
123
+ }
124
+ if (childByParentHead.has(parent.headRefName)) {
125
+ return pullRequests.toSorted((a, b) => a.number - b.number);
126
+ }
127
+ childByParentHead.set(parent.headRefName, pr);
128
+ }
129
+ if (roots.length !== 1) {
130
+ return pullRequests.toSorted((a, b) => a.number - b.number);
131
+ }
132
+ const ordered = [];
133
+ let current = roots[0];
134
+ while (current !== undefined) {
135
+ ordered.push(current);
136
+ current = childByParentHead.get(current.headRefName);
137
+ }
138
+ if (ordered.length !== pullRequests.length) {
139
+ return pullRequests.toSorted((a, b) => a.number - b.number);
140
+ }
141
+ return ordered;
142
+ }
143
+ /** Shown PRs for a task, in display order: `selectShownPullRequests` then `orderPullRequestStack`. */
144
+ export function selectTaskPullRequests(pullRequests, taskBranch) {
145
+ const matched = pullRequests.filter((pr) => isTaskBranch(pr.headRefName, taskBranch));
146
+ return orderPullRequestStack(selectShownPullRequests(matched));
147
+ }
148
+ const PR_ENTRY_SEPARATOR = ";";
149
+ const PR_FIELD_SEPARATOR = ",";
150
+ /** The `crew_prs` sidebar value: `<number>,<stage>,<url>` entries joined by `;`. */
151
+ export function encodePrStatuses(entries) {
152
+ return entries
153
+ .map((entry) => [entry.number, entry.stage, entry.url].join(PR_FIELD_SEPARATOR))
154
+ .join(PR_ENTRY_SEPARATOR);
155
+ }
62
156
  const TICKET_PREFIX_RE = /^[A-Za-z]+$/;
63
157
  const TICKET_NUMBER_RE = /^[0-9]+$/;
64
158
  const TICKET_PREFIX_MAX_LENGTH = 5;
@@ -59,11 +59,60 @@ interface ResolvePullRequestArgs {
59
59
  export declare function resolvePullRequest(arguments_: ResolvePullRequestArgs): Promise<ResolvedPullRequest>;
60
60
  export declare function findPullRequestsForBranch(arguments_: LookupArgs): Promise<readonly PullRequestSummary[]>;
61
61
  /**
62
- * Same lookup as `findPullRequestsForBranch`, but lets failures propagate
63
- * instead of degrading to "no PR info". `pr-stage-sync` needs to tell "the
64
- * branch truly has no PR" apart from "couldn't confirm this tick" so a flaky
65
- * `gh` call never clears a saved stage or label.
62
+ * A repository's full open/merged/closed PR list, with the head/base branch
63
+ * names `pr-stage-sync` needs for stack discovery (a task's PRs are its exact
64
+ * branch plus everything stacked off it) and stack ordering. Unlike
65
+ * `findPullRequestsForBranch`, this isn't filtered server-side by `--head` —
66
+ * `gh` has no "head starts with" filter — so the task-branch match happens
67
+ * client-side (`prStageRules.ts#selectTaskPullRequests`) against one list
68
+ * fetched per repository per tick, not one `gh` call per task.
66
69
  */
67
- export declare function findPullRequestsForBranchOrThrow(arguments_: LookupArgs): Promise<readonly PullRequestSummary[]>;
70
+ export interface TaskPullRequest {
71
+ url: string;
72
+ number: number;
73
+ /** Lowercased lifecycle: "open" | "merged" | "closed". */
74
+ state: string;
75
+ title: string;
76
+ headRefName: string;
77
+ baseRefName: string;
78
+ }
79
+ interface ListPullRequestsForRepositoryArgs {
80
+ /** Any worktree dir for the repository; `gh` resolves the GitHub repo from its git remote. */
81
+ cwd: string;
82
+ signal?: AbortSignal;
83
+ }
84
+ /**
85
+ * Lists every open/merged/closed PR for the repository `cwd` belongs to, in
86
+ * one `gh pr list` call. Lets failures propagate — `pr-stage-sync` needs to
87
+ * tell "the repository truly has no matching PR" apart from "couldn't
88
+ * confirm this tick" so a flaky `gh` call never clears a saved stage.
89
+ *
90
+ * Scoped to `--author @me`: groundcrew runs under the operator's own `gh`
91
+ * identity, which is who every agent-created PR is authored by, so this
92
+ * keeps the 100-PR window to PRs that can plausibly be a tracked task's
93
+ * rather than spending it on the rest of the repository's traffic. A task
94
+ * whose PR still falls outside that window — or isn't under the operator's
95
+ * identity for some other reason — is recovered by the exact-branch fallback
96
+ * in `findTaskPullRequestsForBranchOrThrow`, not by widening this call.
97
+ */
98
+ export declare function listPullRequestsForRepositoryOrThrow(arguments_: ListPullRequestsForRepositoryArgs): Promise<readonly TaskPullRequest[]>;
99
+ interface FindTaskPullRequestsForBranchArgs {
100
+ /** Worktree directory; `gh` resolves the GitHub repo from its git remote. */
101
+ cwd: string;
102
+ /** Exact head branch name to filter PRs by (no prefix/stack matching — that needs the repository-wide list). */
103
+ branchName: string;
104
+ signal?: AbortSignal;
105
+ }
106
+ /**
107
+ * Exact-branch fallback for when a task's PR fell outside the repository-wide
108
+ * list's 100-PR window (`listPullRequestsForRepositoryOrThrow`): the same
109
+ * `gh pr list --head <branch>` lookup `pr-stage-sync` used before stack
110
+ * discovery existed, reshaped to the `TaskPullRequest` fields so its result
111
+ * composes with `prStageRules.ts#selectTaskPullRequests` like the
112
+ * repository-wide list does. Lets failures propagate, same as
113
+ * `listPullRequestsForRepositoryOrThrow` and for the same reason: a flaky
114
+ * `gh` call must read as "couldn't confirm this tick", not "no PR".
115
+ */
116
+ export declare function findTaskPullRequestsForBranchOrThrow(arguments_: FindTaskPullRequestsForBranchArgs): Promise<readonly TaskPullRequest[]>;
68
117
  export {};
69
118
  //# sourceMappingURL=pullRequests.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pullRequests.d.ts","sourceRoot":"","sources":["../../src/lib/pullRequests.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AASD,UAAU,UAAU;IAClB,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,oCAAoC;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAgDD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED,UAAU,sBAAsB;IAC9B,yFAAyF;IACzF,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AA2BD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,sBAAsB,GACjC,OAAO,CAAC,mBAAmB,CAAC,CAsC9B;AA0BD,wBAAsB,yBAAyB,CAC7C,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAWxC;AAED;;;;;GAKG;AACH,wBAAsB,gCAAgC,CACpD,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAExC"}
1
+ {"version":3,"file":"pullRequests.d.ts","sourceRoot":"","sources":["../../src/lib/pullRequests.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AASD,UAAU,UAAU;IAClB,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,oCAAoC;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAgDD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED,UAAU,sBAAsB;IAC9B,yFAAyF;IACzF,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AA2BD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,sBAAsB,GACjC,OAAO,CAAC,mBAAmB,CAAC,CAsC9B;AA0BD,wBAAsB,yBAAyB,CAC7C,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAWxC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;CACrB;AAwDD,UAAU,iCAAiC;IACzC,8FAA8F;IAC9F,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,oCAAoC,CACxD,UAAU,EAAE,iCAAiC,GAC5C,OAAO,CAAC,SAAS,eAAe,EAAE,CAAC,CAoBrC;AAED,UAAU,iCAAiC;IACzC,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,gHAAgH;IAChH,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;;;;GASG;AACH,wBAAsB,oCAAoC,CACxD,UAAU,EAAE,iCAAiC,GAC5C,OAAO,CAAC,SAAS,eAAe,EAAE,CAAC,CAoBrC"}
@@ -144,12 +144,102 @@ export async function findPullRequestsForBranch(arguments_) {
144
144
  return [];
145
145
  }
146
146
  }
147
+ const GH_PR_LIST_TASK_DISCOVERY_LIMIT = 100;
148
+ function isRawTaskPullRequest(value) {
149
+ if (typeof value !== "object" || value === null) {
150
+ return false;
151
+ }
152
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- narrowing untyped JSON.parse output to a record so we can probe its keys
153
+ const record = value;
154
+ return (typeof record["url"] === "string" &&
155
+ typeof record["number"] === "number" &&
156
+ typeof record["state"] === "string" &&
157
+ typeof record["title"] === "string" &&
158
+ typeof record["headRefName"] === "string" &&
159
+ typeof record["baseRefName"] === "string");
160
+ }
161
+ function parseTaskPullRequests(output) {
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(output);
165
+ }
166
+ catch {
167
+ return [];
168
+ }
169
+ if (!Array.isArray(parsed)) {
170
+ return [];
171
+ }
172
+ const summaries = [];
173
+ for (const entry of parsed) {
174
+ if (!isRawTaskPullRequest(entry)) {
175
+ continue;
176
+ }
177
+ summaries.push({
178
+ url: entry.url,
179
+ number: entry.number,
180
+ state: STATE_MAP[entry.state] ?? entry.state.toLowerCase(),
181
+ title: entry.title,
182
+ headRefName: entry.headRefName,
183
+ baseRefName: entry.baseRefName,
184
+ });
185
+ }
186
+ return summaries;
187
+ }
188
+ /**
189
+ * Lists every open/merged/closed PR for the repository `cwd` belongs to, in
190
+ * one `gh pr list` call. Lets failures propagate — `pr-stage-sync` needs to
191
+ * tell "the repository truly has no matching PR" apart from "couldn't
192
+ * confirm this tick" so a flaky `gh` call never clears a saved stage.
193
+ *
194
+ * Scoped to `--author @me`: groundcrew runs under the operator's own `gh`
195
+ * identity, which is who every agent-created PR is authored by, so this
196
+ * keeps the 100-PR window to PRs that can plausibly be a tracked task's
197
+ * rather than spending it on the rest of the repository's traffic. A task
198
+ * whose PR still falls outside that window — or isn't under the operator's
199
+ * identity for some other reason — is recovered by the exact-branch fallback
200
+ * in `findTaskPullRequestsForBranchOrThrow`, not by widening this call.
201
+ */
202
+ export async function listPullRequestsForRepositoryOrThrow(arguments_) {
203
+ const { cwd, signal } = arguments_;
204
+ const options = signal === undefined ? { cwd } : { cwd, signal };
205
+ const output = await runCommandAsync("gh", [
206
+ "pr",
207
+ "list",
208
+ "--state",
209
+ "all",
210
+ "--author",
211
+ "@me",
212
+ "--limit",
213
+ String(GH_PR_LIST_TASK_DISCOVERY_LIMIT),
214
+ "--json",
215
+ "url,number,state,title,headRefName,baseRefName",
216
+ ], options);
217
+ return parseTaskPullRequests(output);
218
+ }
147
219
  /**
148
- * Same lookup as `findPullRequestsForBranch`, but lets failures propagate
149
- * instead of degrading to "no PR info". `pr-stage-sync` needs to tell "the
150
- * branch truly has no PR" apart from "couldn't confirm this tick" so a flaky
151
- * `gh` call never clears a saved stage or label.
220
+ * Exact-branch fallback for when a task's PR fell outside the repository-wide
221
+ * list's 100-PR window (`listPullRequestsForRepositoryOrThrow`): the same
222
+ * `gh pr list --head <branch>` lookup `pr-stage-sync` used before stack
223
+ * discovery existed, reshaped to the `TaskPullRequest` fields so its result
224
+ * composes with `prStageRules.ts#selectTaskPullRequests` like the
225
+ * repository-wide list does. Lets failures propagate, same as
226
+ * `listPullRequestsForRepositoryOrThrow` and for the same reason: a flaky
227
+ * `gh` call must read as "couldn't confirm this tick", not "no PR".
152
228
  */
153
- export async function findPullRequestsForBranchOrThrow(arguments_) {
154
- return await listPullRequestsForBranch(arguments_);
229
+ export async function findTaskPullRequestsForBranchOrThrow(arguments_) {
230
+ const { cwd, branchName, signal } = arguments_;
231
+ const options = signal === undefined ? { cwd } : { cwd, signal };
232
+ const output = await runCommandAsync("gh", [
233
+ "pr",
234
+ "list",
235
+ "--head",
236
+ branchName,
237
+ "--state",
238
+ "all",
239
+ "--limit",
240
+ String(GH_PR_LIST_LIMIT),
241
+ "--json",
242
+ "url,number,state,title,headRefName,baseRefName",
243
+ ], options);
244
+ return parseTaskPullRequests(output);
155
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.52.9",
3
+ "version": "4.52.11",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",
@@ -62,8 +62,8 @@
62
62
  "lint": "oxlint --config config/oxlint.config.ts --deny-warnings --ignore-pattern 'v2/**'",
63
63
  "markdown:lint": "markdownlint-cli2 --config config/markdownlint-cli2.jsonc",
64
64
  "prepack": "node --run build",
65
- "prepare": "husky",
66
- "sync-ai-rules": "node ./node_modules/@clipboard-health/ai-rules/scripts/sync.js common",
65
+ "prepare": "husky && node --run sync-ai-rules",
66
+ "sync-ai-rules": "npm exec --yes --package=@clipboard-health/engineering-practices@latest -- engineering-practices-sync common || true",
67
67
  "syncpack:lint": "syncpack lint --config config/syncpack.config.ts",
68
68
  "test": "vitest run --coverage",
69
69
  "verify": "node scripts/verifyAll.mts"
@@ -77,12 +77,11 @@
77
77
  "zod": "4.6.5"
78
78
  },
79
79
  "devDependencies": {
80
- "@clipboard-health/ai-rules": "2.50.17",
81
80
  "@clipboard-health/oxlint-config": "1.14.3",
82
81
  "@nx/js": "23.1.0",
83
82
  "@tsconfig/node24": "24.0.5",
84
83
  "@tsconfig/strictest": "2.0.8",
85
- "@types/node": "26.6.3",
84
+ "@types/node": "26.6.4",
86
85
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
87
86
  "@vitest/coverage-v8": "5.0.3",
88
87
  "dependency-cruiser": "18.5.0",
@@ -96,7 +95,7 @@
96
95
  "oxlint": "1.78.0",
97
96
  "oxlint-tsgolint": "7.0.2003",
98
97
  "syncpack": "15.3.3",
99
- "vite": "8.3.1",
98
+ "vite": "8.3.2",
100
99
  "vitest": "5.0.3"
101
100
  },
102
101
  "engines": {