@clipboard-health/groundcrew 4.52.7 → 4.52.8

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.
package/README.md CHANGED
@@ -123,6 +123,9 @@ crew cleanup [--force] <TASK> # tear down every worktre
123
123
  crew cleanup [--force] --all # tear down every idle worktree (no live workspace)
124
124
  crew upgrade [<version>] # reinstall crew globally through npm
125
125
  crew completions <bash|zsh|fish> # print a shell completion script
126
+ crew stage label-add <pr-url> <label> # add a configured pr-stage-sync gating label
127
+ crew stage label-remove <pr-url> <label> # remove a configured pr-stage-sync gating label
128
+ crew stage refresh # run one pr-stage-sync pass now
126
129
  ```
127
130
 
128
131
  See [command details](./docs/commands.md) for status output, doctor behavior, and the stop/resume workflow.
@@ -224,4 +224,18 @@ export default {
224
224
  // // evidence with it. Default: `${XDG_STATE_HOME:-~/.local/state}/groundcrew/groundcrew.log`.
225
225
  // file: "~/Library/Logs/groundcrew/groundcrew.log",
226
226
  // },
227
+ //
228
+ // // Paints each task's cmux sidebar with its pull request's review stage
229
+ // // (crew_stage), gating labels (crew_labels), and ticket id (crew_ticket)
230
+ // // once per watch tick. Opt-in and cmux-only; other workspaceKind backends
231
+ // // no-op. The two gating labels below are created on the PR's repo on
232
+ // // first use via `crew stage label-add`/`label-remove`; a PR sits at
233
+ // // `my_review` until `selfReviewed` is applied and `needs_testing` until
234
+ // // `tested` is applied, ahead of CI and review-decision state.
235
+ // cmux: {
236
+ // prStages: {
237
+ // enabled: true,
238
+ // labels: { selfReviewed: "self-reviewed", tested: "tested" },
239
+ // },
240
+ // },
227
241
  } satisfies Config;
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAuPA;;;GAGG;AACH,wBAAgB,mBAAmB,IAAI,SAAS,MAAM,EAAE,CAIvD;AAiDD,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCvD"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AA6PA;;;GAGG;AACH,wBAAgB,mBAAmB,IAAI,SAAS,MAAM,EAAE,CAIvD;AAiDD,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCvD"}
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { orchestrate } from "./commands/orchestrator.js";
9
9
  import { resumeWorkspaceCli } from "./commands/resumeWorkspace.js";
10
10
  import { setupWorkspaceCli } from "./commands/setupWorkspace.js";
11
11
  import { sourceCli } from "./commands/source.js";
12
+ import { stageCli } from "./commands/stage.js";
12
13
  import { statusCli } from "./commands/status.js";
13
14
  import { taskCli } from "./commands/task.js";
14
15
  import { createDefaultUpgradeCliOptions, upgradeCli } from "./commands/upgrade.js";
@@ -192,6 +193,11 @@ const SUBCOMMANDS = {
192
193
  usage: "<bash|zsh|fish>",
193
194
  invoke: completionsCli,
194
195
  },
196
+ stage: {
197
+ summary: "Manage pr-stage-sync's gating labels, or run a sync pass now",
198
+ usage: "<label-add|label-remove|refresh> [...]",
199
+ invoke: stageCli,
200
+ },
195
201
  };
196
202
  function isVisibleCommand(command) {
197
203
  return command.hidden !== true && command.deprecated !== true;
@@ -1 +1 @@
1
- {"version":3,"file":"completions.d.ts","sourceRoot":"","sources":["../../src/commands/completions.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,+EAA+E;AAC/E,KAAK,aAAa,GACd;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAC7D;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;CAAE,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEhC,UAAU,gBAAgB;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,QAAQ,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC/C,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACpD,kEAAkE;IAClE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAED,eAAO,MAAM,gBAAgB,YAAI,MAAM,EAAE,KAAK,EAAE,MAAM,CAAU,CAAC;AAEjE,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AA2B/D;;;GAGG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,iBAAiB,EAqJvD,CAAC;AAqXF,2DAA2D;AAC3D,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAEtE;AAQD,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlE"}
1
+ {"version":3,"file":"completions.d.ts","sourceRoot":"","sources":["../../src/commands/completions.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,+EAA+E;AAC/E,KAAK,aAAa,GACd;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAC7D;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;CAAE,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEhC,UAAU,gBAAgB;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,QAAQ,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC/C,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACpD,kEAAkE;IAClE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAED,eAAO,MAAM,gBAAgB,YAAI,MAAM,EAAE,KAAK,EAAE,MAAM,CAAU,CAAC;AAEjE,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AA2B/D;;;GAGG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,iBAAiB,EAsKvD,CAAC;AAqXF,2DAA2D;AAC3D,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAEtE;AAQD,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlE"}
@@ -186,6 +186,23 @@ export const COMPLETION_SPEC = [
186
186
  summary: "Print a shell completion script",
187
187
  argValues: SUPPORTED_SHELLS,
188
188
  },
189
+ {
190
+ name: "stage",
191
+ summary: "Manage pr-stage-sync's gating labels, or run a sync pass now",
192
+ subcommands: [
193
+ {
194
+ name: "label-add",
195
+ summary: "Add a configured gating label to a pull request",
196
+ options: [],
197
+ },
198
+ {
199
+ name: "label-remove",
200
+ summary: "Remove a configured gating label from a pull request",
201
+ options: [],
202
+ },
203
+ { name: "refresh", summary: "Run one pr-stage-sync pass now" },
204
+ ],
205
+ },
189
206
  ];
190
207
  /** Global flags valid only as the first token. */
191
208
  const TOP_LEVEL_GLOBALS = [
@@ -1 +1 @@
1
- {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../src/commands/orchestrator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAoEH,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;CACjB;AAiBD,wBAAsB,WAAW,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiE7E"}
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../src/commands/orchestrator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAqEH,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;CACjB;AAiBD,wBAAsB,WAAW,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoE7E"}
@@ -14,6 +14,7 @@ import { errorMessage, log, sleep, writeOutput } from "../lib/util.js";
14
14
  import { worktrees } from "../lib/worktrees.js";
15
15
  import { createCleaner } from "./cleaner.js";
16
16
  import { createDispatcher } from "./dispatcher.js";
17
+ import { createPrStageSync, createPrStageSyncDeps } from "./prStageSync.js";
17
18
  import { createReviewer } from "./reviewer.js";
18
19
  const RATE_LIMIT_DELAY_MS = 60_000;
19
20
  const RETRY_BASE_DELAY_MS = 1000;
@@ -98,6 +99,7 @@ export async function orchestrate(options) {
98
99
  findPullRequests: findPullRequestsForBranch,
99
100
  });
100
101
  const dispatcher = createDispatcher({ config, board });
102
+ const prStageSync = createPrStageSync(createPrStageSyncDeps(config));
101
103
  // Folded into the dispatcher's idle log lines in watch mode so each idle
102
104
  // tick prints one combined line instead of "<reason>" + "Next poll in Xs".
103
105
  const idleSuffix = options.watch
@@ -121,6 +123,7 @@ export async function orchestrate(options) {
121
123
  });
122
124
  await reviewer.runOnce(tickArguments);
123
125
  await cleaner.runOnce(tickArguments);
126
+ await prStageSync.runOnce(tickArguments);
124
127
  };
125
128
  await (options.watch ? runWatchLoop(tick, config) : tick());
126
129
  }
@@ -0,0 +1,74 @@
1
+ /**
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
4
+ * and gating labels too. Replaces the standalone `crew-pr-stages` bash
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.
10
+ *
11
+ * `crew_ticket` is written for every matched cmux task workspace regardless
12
+ * of the `cmux.prStages.enabled` opt-in, since a custom sidebar may read it
13
+ * independent of PR-stage sync; the opt-in only gates the `gh`-backed stage
14
+ * and label writes (and the `crew_poller_heartbeat` that signals they're
15
+ * running). `cmuxAdapter.ts` also writes it once at workspace-creation time,
16
+ * using the same `deriveTicket` rule, so a brand-new workspace shows a
17
+ * ticket pill before the first tick without disagreeing with it — this
18
+ * module keeps the value current afterward.
19
+ *
20
+ * Runs after the other tick steps in `orchestrator.ts`. Every failure is
21
+ * caught and logged here — a flaky `gh` call or a single misbehaving
22
+ * workspace must never break the watch loop or starve the other steps.
23
+ */
24
+ import { type CmuxWorkspaceSummary } from "../lib/cmuxAdapter.ts";
25
+ import { type CmuxStatusWrite } from "../lib/cmuxStatusFields.ts";
26
+ import type { ResolvedConfig } from "../lib/config.ts";
27
+ import { type PullRequestDetail } from "../lib/pullRequestDetails.ts";
28
+ import { type PullRequestSummary } from "../lib/pullRequests.ts";
29
+ import type { WorktreeEntry } from "../lib/worktrees.ts";
30
+ export type FindPullRequests = (arguments_: {
31
+ cwd: string;
32
+ branchName: string;
33
+ signal?: AbortSignal;
34
+ }) => Promise<readonly PullRequestSummary[]>;
35
+ export type FetchPullRequestDetails = (arguments_: {
36
+ urls: readonly string[];
37
+ signal?: AbortSignal;
38
+ }) => Promise<Map<string, PullRequestDetail | undefined>>;
39
+ export type ListCmuxWorkspaces = (signal?: AbortSignal) => Promise<CmuxWorkspaceSummary[] | undefined>;
40
+ export type ReadCmuxStatus = (workspaceId: string, signal?: AbortSignal) => Promise<Map<string, string>>;
41
+ export type WriteCmuxStatus = (workspaceId: string, write: CmuxStatusWrite, signal?: AbortSignal) => Promise<void>;
42
+ export type IsCmuxAdapterActive = (signal?: AbortSignal) => Promise<boolean>;
43
+ export interface PrStageSyncDeps {
44
+ config: ResolvedConfig;
45
+ findPullRequests: FindPullRequests;
46
+ fetchPullRequestDetails: FetchPullRequestDetails;
47
+ listCmuxWorkspaces: ListCmuxWorkspaces;
48
+ readCmuxStatus: ReadCmuxStatus;
49
+ writeCmuxStatus: WriteCmuxStatus;
50
+ isCmuxAdapterActive: IsCmuxAdapterActive;
51
+ }
52
+ export interface PrStageSyncTickArgs {
53
+ worktreeEntries: readonly WorktreeEntry[];
54
+ /** Orchestrator `--dry-run`; only `runOnce` honors it — `syncOnce` (`crew stage refresh`) always writes. */
55
+ dryRun?: boolean;
56
+ signal?: AbortSignal;
57
+ }
58
+ export interface PrStageSync {
59
+ /**
60
+ * The orchestrator tick step. A dry run skips everything. Otherwise
61
+ * `crew_ticket` is always written for matched cmux task workspaces; stage,
62
+ * labels, and the heartbeat are additionally synced only when
63
+ * `config.cmux.prStages.enabled` is true.
64
+ */
65
+ runOnce: (arguments_: PrStageSyncTickArgs) => Promise<void>;
66
+ /** Ungated (still requires the cmux adapter): `crew stage refresh` and the label CLI's trailing sync. */
67
+ syncOnce: (arguments_: PrStageSyncTickArgs) => Promise<void>;
68
+ }
69
+ /** Whether the resolved workspace backend for this config is cmux — every other adapter no-ops. */
70
+ export declare function isCmuxAdapterActive(config: ResolvedConfig, signal?: AbortSignal): Promise<boolean>;
71
+ export declare function createPrStageSync(deps: PrStageSyncDeps): PrStageSync;
72
+ /** Wires the real cmux/gh-backed implementations behind `PrStageSyncDeps`. */
73
+ export declare function createPrStageSyncDeps(config: ResolvedConfig): PrStageSyncDeps;
74
+ //# sourceMappingURL=prStageSync.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,397 @@
1
+ /**
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
4
+ * and gating labels too. Replaces the standalone `crew-pr-stages` bash
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.
10
+ *
11
+ * `crew_ticket` is written for every matched cmux task workspace regardless
12
+ * of the `cmux.prStages.enabled` opt-in, since a custom sidebar may read it
13
+ * independent of PR-stage sync; the opt-in only gates the `gh`-backed stage
14
+ * and label writes (and the `crew_poller_heartbeat` that signals they're
15
+ * running). `cmuxAdapter.ts` also writes it once at workspace-creation time,
16
+ * using the same `deriveTicket` rule, so a brand-new workspace shows a
17
+ * ticket pill before the first tick without disagreeing with it — this
18
+ * module keeps the value current afterward.
19
+ *
20
+ * Runs after the other tick steps in `orchestrator.ts`. Every failure is
21
+ * caught and logged here — a flaky `gh` call or a single misbehaving
22
+ * workspace must never break the watch loop or starve the other steps.
23
+ */
24
+ import { listCmuxWorkspaceSummaries } from "../lib/cmuxAdapter.js";
25
+ import { readCmuxStatus, TICKET_KEY, TICKET_PRIORITY, writeCmuxStatus, } from "../lib/cmuxStatusFields.js";
26
+ import { detectHostCapabilities } from "../lib/host.js";
27
+ import { fetchPullRequestDetails } from "../lib/pullRequestDetails.js";
28
+ import { findPullRequestsForBranchOrThrow } from "../lib/pullRequests.js";
29
+ import { deriveTicket, derivePrStage, managedLabelsField, } from "../lib/prStageRules.js";
30
+ import { debug, errorMessage, logEvent, readEnvironmentVariable } from "../lib/util.js";
31
+ import { resolveWorkspaceKind } from "../lib/workspaces.js";
32
+ import { effectiveBranchName } from "../lib/worktreeRunState.js";
33
+ const FLOW = "pr-stage-sync";
34
+ const STAGE_KEY = "crew_stage";
35
+ const STAGE_PRIORITY = -10;
36
+ const HEARTBEAT_KEY = "crew_poller_heartbeat";
37
+ const HEARTBEAT_PRIORITY = -12;
38
+ const LABELS_KEY = "crew_labels";
39
+ const LABELS_PRIORITY = -13;
40
+ const CMUX_WORKSPACE_ID_ENV = "CMUX_WORKSPACE_ID";
41
+ /** Whether the resolved workspace backend for this config is cmux — every other adapter no-ops. */
42
+ export async function isCmuxAdapterActive(config, signal) {
43
+ try {
44
+ const { resolved } = resolveWorkspaceKind({
45
+ config,
46
+ host: await detectHostCapabilities(signal),
47
+ });
48
+ return resolved === "cmux";
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Only cmux workspaces groundcrew created for a task are in scope: resolving
56
+ * a PR "from the task's worktree branch" presupposes a task. A cmux pane the
57
+ * user opened by hand has neither — it is left exactly as untouched as
58
+ * before this feature existed, rather than guessed at. The workspace's
59
+ * reported cwd is the precise match (handles multi-repo tasks); the task-id
60
+ * marker is the fallback for a workspace cmux hasn't reported a cwd for yet.
61
+ */
62
+ function matchWorkspacesToTasks(workspaces, worktreeEntries) {
63
+ const matches = [];
64
+ for (const workspace of workspaces) {
65
+ const entry = worktreeEntries.find((candidate) => candidate.dir === workspace.currentDirectory) ??
66
+ worktreeEntries.find((candidate) => candidate.task === workspace.taskId);
67
+ if (entry !== undefined) {
68
+ matches.push({ workspace, entry });
69
+ }
70
+ }
71
+ return matches;
72
+ }
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
+ function newCounters() {
81
+ return { written: 0, cleared: 0, skipped: 0 };
82
+ }
83
+ async function applyField(arguments_) {
84
+ const { workspaceId, key, priority, current, desired, signal, writeStatus, counters } = arguments_;
85
+ if (desired === current) {
86
+ counters.skipped += 1;
87
+ return;
88
+ }
89
+ await writeStatus(workspaceId, { key, priority, value: desired }, signal);
90
+ if (desired.length === 0) {
91
+ counters.cleared += 1;
92
+ }
93
+ else {
94
+ counters.written += 1;
95
+ }
96
+ }
97
+ /**
98
+ * The ticket field is derived from the workspace's own title/cwd, so it
99
+ * needs no PR lookup and is written for every matched cmux task workspace —
100
+ * including when `cmux.prStages.enabled` is false, so the sidebar's
101
+ * `crew_ticket` read works for everyone regardless of the PR-stage opt-in.
102
+ */
103
+ async function writeTicketField(arguments_) {
104
+ const { workspace, current, deps, signal, counters } = arguments_;
105
+ await applyField({
106
+ workspaceId: workspace.id,
107
+ key: TICKET_KEY,
108
+ priority: TICKET_PRIORITY,
109
+ current: current.get(TICKET_KEY) ?? "",
110
+ desired: deriveTicket({
111
+ title: workspace.title,
112
+ ...(workspace.currentDirectory === undefined
113
+ ? {}
114
+ : { currentDirectory: workspace.currentDirectory }),
115
+ }) ?? "",
116
+ signal,
117
+ writeStatus: deps.writeCmuxStatus,
118
+ counters,
119
+ });
120
+ }
121
+ /**
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.
127
+ */
128
+ async function syncWorkspace(arguments_) {
129
+ const { match, labelNames, pullRequest, pullRequestLookupFailed, detail, deps, signal, counters, } = arguments_;
130
+ const { workspace } = match;
131
+ const current = await deps.readCmuxStatus(workspace.id, signal);
132
+ await writeTicketField({ workspace, current, deps, signal, counters });
133
+ if (pullRequestLookupFailed) {
134
+ counters.skipped += 2;
135
+ return;
136
+ }
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
+ });
158
+ return;
159
+ }
160
+ if (detail === undefined) {
161
+ counters.skipped += 2;
162
+ return;
163
+ }
164
+ await applyField({
165
+ workspaceId: workspace.id,
166
+ key: STAGE_KEY,
167
+ priority: STAGE_PRIORITY,
168
+ current: current.get(STAGE_KEY) ?? "",
169
+ desired: derivePrStage(detail, labelNames),
170
+ signal,
171
+ writeStatus: deps.writeCmuxStatus,
172
+ counters,
173
+ });
174
+ await applyField({
175
+ workspaceId: workspace.id,
176
+ key: LABELS_KEY,
177
+ priority: LABELS_PRIORITY,
178
+ current: current.get(LABELS_KEY) ?? "",
179
+ desired: managedLabelsField(detail.labels, labelNames),
180
+ signal,
181
+ writeStatus: deps.writeCmuxStatus,
182
+ counters,
183
+ });
184
+ }
185
+ async function resolvePullRequestsByWorkspace(arguments_) {
186
+ const { matches, config, findPullRequests, signal } = arguments_;
187
+ const byWorkspace = new Map();
188
+ const failedWorkspaceIds = new Set();
189
+ 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 });
192
+ try {
193
+ // oxlint-disable-next-line no-await-in-loop -- see above
194
+ const pullRequests = await findPullRequests({
195
+ cwd: match.entry.dir,
196
+ branchName,
197
+ ...(signal === undefined ? {} : { signal }),
198
+ });
199
+ byWorkspace.set(match.workspace.id, selectTrackedPullRequest(pullRequests));
200
+ }
201
+ catch (error) {
202
+ debug(`pr-stage-sync: PR lookup failed for ${match.entry.task}: ${errorMessage(error)}`);
203
+ failedWorkspaceIds.add(match.workspace.id);
204
+ }
205
+ }
206
+ return { byWorkspace, failedWorkspaceIds };
207
+ }
208
+ /**
209
+ * Prefers the first matched task workspace, since the sidebar's staleness
210
+ * check (`heartbeatFresh`) scans every workspace for any recent heartbeat —
211
+ * it doesn't matter which task workspace carries it from pass to pass. This
212
+ * matters because `crew stage refresh`/`label-add`/`label-remove` run this
213
+ * same pass from a cmux Action's own ephemeral workspace, which cmux closes
214
+ * right after the command exits; a heartbeat written there would vanish with
215
+ * it, making a successful on-demand refresh look stale on the very next
216
+ * render. Falls back to the watch loop's own cmux workspace
217
+ * (`$CMUX_WORKSPACE_ID`) only when no task is currently tracked, so a
218
+ * fully-working sync with zero matched tasks still reports a heartbeat
219
+ * somewhere instead of silently writing none at all.
220
+ */
221
+ async function writeHeartbeat(arguments_) {
222
+ const targetWorkspaceId = arguments_.matches[0]?.workspace.id ?? readEnvironmentVariable(CMUX_WORKSPACE_ID_ENV);
223
+ if (targetWorkspaceId === undefined) {
224
+ return;
225
+ }
226
+ const epochSeconds = String(Math.floor(Date.now() / 1000));
227
+ await arguments_.writeStatus(targetWorkspaceId, { key: HEARTBEAT_KEY, priority: HEARTBEAT_PRIORITY, value: epochSeconds }, arguments_.signal);
228
+ }
229
+ const TICKETS_ONLY_FLOW = "pr-stage-sync.tickets-only";
230
+ export function createPrStageSync(deps) {
231
+ /**
232
+ * Runs when `cmux.prStages.enabled` is false: writes only `crew_ticket` for
233
+ * each matched task workspace, with no `gh` lookups, no stage/label
234
+ * writes, and no `crew_poller_heartbeat` — the absence of any heartbeat is
235
+ * exactly what tells the sidebar the PR-stage feature is off, so it must
236
+ * never be written from this path.
237
+ */
238
+ async function syncTicketsOnly(arguments_) {
239
+ const { worktreeEntries, signal } = arguments_;
240
+ if (!(await deps.isCmuxAdapterActive(signal))) {
241
+ return;
242
+ }
243
+ debug("pr-stage-sync: starting (tickets-only, cmux.prStages disabled)");
244
+ const counters = newCounters();
245
+ let outcome = "updated";
246
+ let reason;
247
+ try {
248
+ const workspaces = await deps.listCmuxWorkspaces(signal);
249
+ if (workspaces === undefined) {
250
+ logEvent(TICKETS_ONLY_FLOW, { outcome: "error", reason: "workspace_list_failed" });
251
+ return;
252
+ }
253
+ const matches = matchWorkspacesToTasks(workspaces, worktreeEntries);
254
+ for (const match of matches) {
255
+ try {
256
+ // oxlint-disable-next-line no-await-in-loop -- sequential per workspace, mirrors syncOnce
257
+ const current = await deps.readCmuxStatus(match.workspace.id, signal);
258
+ // oxlint-disable-next-line no-await-in-loop -- sequential per workspace, mirrors syncOnce
259
+ await writeTicketField({ workspace: match.workspace, current, deps, signal, counters });
260
+ }
261
+ catch (error) {
262
+ outcome = "error";
263
+ reason = "status_write_failed";
264
+ debug(`pr-stage-sync: ticket write failed for ${match.entry.task}: ${errorMessage(error)}`);
265
+ }
266
+ }
267
+ }
268
+ catch (error) {
269
+ outcome = "error";
270
+ reason = errorMessage(error);
271
+ }
272
+ logEvent(TICKETS_ONLY_FLOW, {
273
+ outcome,
274
+ written: counters.written,
275
+ cleared: counters.cleared,
276
+ skipped: counters.skipped,
277
+ ...(reason === undefined ? {} : { reason }),
278
+ });
279
+ }
280
+ async function syncOnce(arguments_) {
281
+ const { worktreeEntries, signal } = arguments_;
282
+ const { config } = deps;
283
+ if (!(await deps.isCmuxAdapterActive(signal))) {
284
+ return;
285
+ }
286
+ debug("pr-stage-sync: starting");
287
+ const counters = newCounters();
288
+ let outcome = "updated";
289
+ let reason;
290
+ try {
291
+ const workspaces = await deps.listCmuxWorkspaces(signal);
292
+ if (workspaces === undefined) {
293
+ logEvent(FLOW, { outcome: "error", reason: "workspace_list_failed" });
294
+ return;
295
+ }
296
+ const matches = matchWorkspacesToTasks(workspaces, worktreeEntries);
297
+ const { byWorkspace: pullRequestByWorkspace, failedWorkspaceIds } = await resolvePullRequestsByWorkspace({
298
+ matches,
299
+ config,
300
+ findPullRequests: deps.findPullRequests,
301
+ signal,
302
+ });
303
+ if (failedWorkspaceIds.size > 0) {
304
+ outcome = "error";
305
+ reason = "pull_request_lookup_failed";
306
+ }
307
+ const urls = [
308
+ ...new Set([...pullRequestByWorkspace.values()].flatMap((pr) => (pr === undefined ? [] : [pr.url]))),
309
+ ];
310
+ let details = new Map();
311
+ if (urls.length > 0) {
312
+ try {
313
+ details = await deps.fetchPullRequestDetails({
314
+ urls,
315
+ ...(signal === undefined ? {} : { signal }),
316
+ });
317
+ }
318
+ catch (error) {
319
+ outcome = "error";
320
+ reason = "pull_request_detail_lookup_failed";
321
+ debug(`pr-stage-sync: PR detail lookup failed: ${errorMessage(error)}`);
322
+ }
323
+ }
324
+ for (const match of matches) {
325
+ const pullRequest = pullRequestByWorkspace.get(match.workspace.id);
326
+ const detail = pullRequest === undefined ? undefined : details.get(pullRequest.url);
327
+ try {
328
+ // oxlint-disable-next-line no-await-in-loop -- sequential per workspace so one workspace's status calls never interleave with another's
329
+ await syncWorkspace({
330
+ match,
331
+ labelNames: config.cmux.prStages.labels,
332
+ pullRequest,
333
+ pullRequestLookupFailed: failedWorkspaceIds.has(match.workspace.id),
334
+ detail,
335
+ deps,
336
+ signal,
337
+ counters,
338
+ });
339
+ }
340
+ catch (error) {
341
+ outcome = "error";
342
+ reason = "status_write_failed";
343
+ debug(`pr-stage-sync: sync failed for ${match.entry.task}: ${errorMessage(error)}`);
344
+ }
345
+ }
346
+ try {
347
+ await writeHeartbeat({ matches, writeStatus: deps.writeCmuxStatus, signal });
348
+ }
349
+ catch (error) {
350
+ // A heartbeat failure is reported only when nothing else already
351
+ // flagged this pass as an error, so it never masks a more specific
352
+ // reason (like a per-workspace status_write_failed) from an earlier
353
+ // step in the same pass.
354
+ if (outcome !== "error") {
355
+ outcome = "error";
356
+ reason = "heartbeat_write_failed";
357
+ }
358
+ debug(`pr-stage-sync: heartbeat write failed: ${errorMessage(error)}`);
359
+ }
360
+ }
361
+ catch (error) {
362
+ outcome = "error";
363
+ reason = errorMessage(error);
364
+ }
365
+ logEvent(FLOW, {
366
+ outcome,
367
+ written: counters.written,
368
+ cleared: counters.cleared,
369
+ skipped: counters.skipped,
370
+ ...(reason === undefined ? {} : { reason }),
371
+ });
372
+ }
373
+ async function runOnce(arguments_) {
374
+ if (arguments_.dryRun === true) {
375
+ logEvent(FLOW, { outcome: "skipped", reason: "dry_run" });
376
+ return;
377
+ }
378
+ if (!deps.config.cmux.prStages.enabled) {
379
+ await syncTicketsOnly(arguments_);
380
+ return;
381
+ }
382
+ await syncOnce(arguments_);
383
+ }
384
+ return { runOnce, syncOnce };
385
+ }
386
+ /** Wires the real cmux/gh-backed implementations behind `PrStageSyncDeps`. */
387
+ export function createPrStageSyncDeps(config) {
388
+ return {
389
+ config,
390
+ findPullRequests: findPullRequestsForBranchOrThrow,
391
+ fetchPullRequestDetails,
392
+ listCmuxWorkspaces: listCmuxWorkspaceSummaries,
393
+ readCmuxStatus,
394
+ writeCmuxStatus,
395
+ isCmuxAdapterActive: async (signal) => await isCmuxAdapterActive(config, signal),
396
+ };
397
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `crew stage` — operator commands for `pr-stage-sync`'s two gating labels
3
+ * (`label-add` / `label-remove`) and an on-demand sync pass (`refresh`).
4
+ * Labels are restricted to the two names configured under
5
+ * `cmux.prStages.labels`; each is created in the PR's repo on first use with
6
+ * a fixed color so the two gates stay visually distinct in GitHub's label UI.
7
+ */
8
+ export declare function stageCli(argv: string[]): Promise<void>;
9
+ //# sourceMappingURL=stage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stage.d.ts","sourceRoot":"","sources":["../../src/commands/stage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAuJH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAe5D"}