@gr8ful/spf 0.1.4 → 0.1.6

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,10 +1,18 @@
1
1
  /**
2
- * The issue-tracker seam `spf watch` drives — the abstraction the user's own
2
+ * The two seams `spf watch` drives — the abstraction the user's own
3
3
  * reference implementation (a GitHub-issues SDLC poller) never had: its
4
4
  * GitHub client is a concrete class referenced by type everywhere, so
5
- * adding a second tracker would mean reworking the poll loop itself. Here,
6
- * nothing outside `github_provider.ts` (or a future `jira_provider.ts`)
7
- * knows it's talking to GitHub.
5
+ * adding a second tracker would mean reworking the poll loop itself.
6
+ *
7
+ * `IssueProvider` (tracker: list/claim/transition/comment/markers) and
8
+ * `CodeHostProvider` (PR lifecycle: open/status) are deliberately separate
9
+ * interfaces, not one bundled seam — a tracker and a code host are
10
+ * independent choices in practice (Jira issues against a Bitbucket repo is
11
+ * a real setup, not a hypothetical one). `github_provider.ts`'s single
12
+ * class implements both, since GitHub natively is both; `jira_provider.ts`
13
+ * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
+ * `CodeHostProvider` — any tracker x host combination is just config
15
+ * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
8
16
  *
9
17
  * The label-as-state-machine design is deliberate, copied from that same
10
18
  * reference: `transition()` is the ONE mutator, so every state change is
@@ -13,7 +21,8 @@
13
21
  */
14
22
  export type WatchState = "ready" | "working" | "review" | "done" | "blocked";
15
23
  export interface Issue {
16
- number: number;
24
+ /** Opaque tracker identifier: a GitHub issue number stringified ("42"), a Jira key ("PROJ-123"). */
25
+ id: string;
17
26
  title: string;
18
27
  body: string;
19
28
  labels: string[];
@@ -51,8 +60,8 @@ export interface IssueProvider {
51
60
  * Idempotently seed whatever this tracker needs for the state machine to
52
61
  * work at all — GitHub: the five `<prefix>:*` labels, with a color and
53
62
  * description, created if missing and corrected if drifted. A tracker
54
- * with no such concept (a future one might model state entirely as a
55
- * workflow field) can make this a no-op — `spf watch init` just reports
63
+ * with no such concept (Jira labels are freeform strings, not seedable
64
+ * objects) can make this a no-op — `spf watch init` just reports
56
65
  * whatever comes back, empty results included.
57
66
  */
58
67
  ensureLabels(): Promise<EnsureLabelsResult>;
@@ -60,10 +69,13 @@ export interface IssueProvider {
60
69
  listEligible(): Promise<Issue[]>;
61
70
  /**
62
71
  * Issues currently in `state`. `includeAll` queries closed issues too —
63
- * required for `review`, since GitHub (or any tracker with a `Closes #n`
64
- * convention) can auto-close an issue the instant its PR merges, often
65
- * before the next poll tick runs; an open-only query would let it vanish
66
- * from tracking forever.
72
+ * required for `review` on a tracker where closing an issue is a side
73
+ * effect the tracker itself performs (GitHub auto-closes on a merged
74
+ * `Closes #n` PR, often before the next poll tick runs); an open-only
75
+ * query would let it vanish from tracking forever. A tracker where
76
+ * nothing but `transition()` ever changes an issue's resolution (Jira,
77
+ * under this design) can ignore the flag — there's no side channel to
78
+ * miss.
67
79
  */
68
80
  listInState(state: WatchState, opts?: {
69
81
  includeAll?: boolean;
@@ -78,13 +90,24 @@ export interface IssueProvider {
78
90
  /** The one state-mutating call. `detail`, if given, is also posted as a comment. */
79
91
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
80
92
  comment(issue: Issue, body: string): Promise<void>;
81
- openPr(issue: Issue, opts: {
93
+ readMarker(issue: Issue): Promise<WatchMarker | null>;
94
+ writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
95
+ }
96
+ /**
97
+ * The PR-lifecycle seam, independent of `IssueProvider` — see the module
98
+ * comment above. `openPr` takes no issue reference: cross-linking a PR to
99
+ * its issue is the caller's job (put the issue's `id`/title in `title`/
100
+ * `body`), not this seam's, since a code host paired with a different
101
+ * tracker has no native "closes" convention to hook into anyway. `spf
102
+ * watch`'s own polling (`finishReviews`), not the host's auto-close
103
+ * behavior, is what drives `done`/`blocked` — see `watch.ts`.
104
+ */
105
+ export interface CodeHostProvider {
106
+ openPr(opts: {
82
107
  branch: string;
83
108
  title: string;
84
109
  body: string;
85
110
  base: string;
86
111
  }): Promise<PrRef>;
87
112
  prStatus(pr: PrRef): Promise<PrStatus>;
88
- readMarker(issue: Issue): Promise<WatchMarker | null>;
89
- writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
90
113
  }
@@ -1,10 +1,18 @@
1
1
  /**
2
- * The issue-tracker seam `spf watch` drives — the abstraction the user's own
2
+ * The two seams `spf watch` drives — the abstraction the user's own
3
3
  * reference implementation (a GitHub-issues SDLC poller) never had: its
4
4
  * GitHub client is a concrete class referenced by type everywhere, so
5
- * adding a second tracker would mean reworking the poll loop itself. Here,
6
- * nothing outside `github_provider.ts` (or a future `jira_provider.ts`)
7
- * knows it's talking to GitHub.
5
+ * adding a second tracker would mean reworking the poll loop itself.
6
+ *
7
+ * `IssueProvider` (tracker: list/claim/transition/comment/markers) and
8
+ * `CodeHostProvider` (PR lifecycle: open/status) are deliberately separate
9
+ * interfaces, not one bundled seam — a tracker and a code host are
10
+ * independent choices in practice (Jira issues against a Bitbucket repo is
11
+ * a real setup, not a hypothetical one). `github_provider.ts`'s single
12
+ * class implements both, since GitHub natively is both; `jira_provider.ts`
13
+ * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
+ * `CodeHostProvider` — any tracker x host combination is just config
15
+ * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
8
16
  *
9
17
  * The label-as-state-machine design is deliberate, copied from that same
10
18
  * reference: `transition()` is the ONE mutator, so every state change is
@@ -22,6 +22,8 @@ export declare const ASSETS_DIR: string;
22
22
  export declare const WEB_DIR: string;
23
23
  export declare const BUILTIN_CONFIG_PATH: string;
24
24
  export declare const BUILTIN_PROMPTS_DIR: string;
25
+ /** `spf init --template <name>` reads `<name>.spf.config.yaml` from here. */
26
+ export declare const TEMPLATES_DIR: string;
25
27
  export interface RepoAnchor {
26
28
  /** The invocation cwd this anchor was resolved from (already absolute). */
27
29
  cwd: string;
@@ -25,6 +25,8 @@ export const ASSETS_DIR = path.join(PACKAGE_ROOT, "assets");
25
25
  export const WEB_DIR = path.join(PACKAGE_ROOT, "web");
26
26
  export const BUILTIN_CONFIG_PATH = path.join(ASSETS_DIR, "defaults", "spf.config.yaml");
27
27
  export const BUILTIN_PROMPTS_DIR = path.join(ASSETS_DIR, "prompts");
28
+ /** `spf init --template <name>` reads `<name>.spf.config.yaml` from here. */
29
+ export const TEMPLATES_DIR = path.join(ASSETS_DIR, "templates");
28
30
  /** Walk up from `cwd` to (and including) `repoRoot` looking for a `.spf/` directory. */
29
31
  function findSfDir(cwd, repoRoot) {
30
32
  let dir = cwd;
@@ -1,5 +1,5 @@
1
1
  import type { GitHandle } from "./git_helper.ts";
2
- import type { Issue, IssueProvider } from "./issues/provider.ts";
2
+ import type { CodeHostProvider, Issue, IssueProvider } from "./issues/provider.ts";
3
3
  export interface ChainRunResult {
4
4
  accepted: boolean;
5
5
  adwId: string;
@@ -8,6 +8,7 @@ export interface ChainRunResult {
8
8
  }
9
9
  export interface WatchDeps {
10
10
  provider: IssueProvider;
11
+ codeHost: CodeHostProvider;
11
12
  git: GitHandle;
12
13
  /** Bound to a specific worktree path (diffFiles/push run there) — inject `git_helper.makeGit` for real use, a fake for tests. */
13
14
  worktreeGit: (worktreePath: string) => GitHandle;
@@ -25,7 +26,7 @@ export interface WatchDeps {
25
26
  log: (message: string) => void;
26
27
  }
27
28
  export interface WatchRunState {
28
- inflight: Set<number>;
29
+ inflight: Set<string>;
29
30
  }
30
31
  export declare function createWatchState(): WatchRunState;
31
32
  export declare function branchNameFor(issue: Issue): string;
@@ -40,10 +40,13 @@ export function branchNameFor(issue) {
40
40
  .slice(0, 5)
41
41
  .join("-")
42
42
  .replace(/[^a-z0-9-]/g, "");
43
- return `spf-watch/${issue.number}-${slug || "issue"}`.slice(0, 200);
43
+ // The issue's own id in the branch name isn't just labeling: Jira's
44
+ // Bitbucket integration auto-links a PR to the issue when its key
45
+ // appears anywhere in the branch name, no explicit API call needed.
46
+ return `spf-watch/${issue.id}-${slug || "issue"}`.slice(0, 200);
44
47
  }
45
48
  function worktreePathFor(deps, issue) {
46
- return path.join(deps.worktreesDir, `issue-${issue.number}`);
49
+ return path.join(deps.worktreesDir, `issue-${issue.id}`);
47
50
  }
48
51
  function cleanupWorktree(deps, marker) {
49
52
  if (!marker)
@@ -67,13 +70,13 @@ function cleanupWorktree(deps, marker) {
67
70
  export async function reconcileOrphans(deps, state) {
68
71
  const working = await deps.provider.listInState("working");
69
72
  for (const issue of working) {
70
- if (state.inflight.has(issue.number))
73
+ if (state.inflight.has(issue.id))
71
74
  continue;
72
75
  const marker = await deps.provider.readMarker(issue);
73
76
  if (marker?.pr) {
74
- const status = await deps.provider.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
77
+ const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
75
78
  if (status.state === "open" || status.merged) {
76
- deps.log(`watch: #${issue.number} orphaned with an open/merged PR #${marker.pr} — resuming as review`);
79
+ deps.log(`watch: ${issue.id} orphaned with an open/merged PR #${marker.pr} — resuming as review`);
77
80
  if (!deps.dryRun)
78
81
  await deps.provider.transition(issue, "review");
79
82
  continue;
@@ -81,14 +84,14 @@ export async function reconcileOrphans(deps, state) {
81
84
  }
82
85
  const attempt = (marker?.attempt ?? 0) + 1;
83
86
  if (attempt <= MAX_ORPHAN_ATTEMPTS) {
84
- deps.log(`watch: #${issue.number} orphaned, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to ready`);
87
+ deps.log(`watch: ${issue.id} orphaned, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to ready`);
85
88
  if (!deps.dryRun) {
86
89
  await deps.provider.writeMarker(issue, { ...marker, attempt });
87
90
  await deps.provider.transition(issue, "ready");
88
91
  }
89
92
  }
90
93
  else {
91
- deps.log(`watch: #${issue.number} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
94
+ deps.log(`watch: ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
92
95
  if (!deps.dryRun) {
93
96
  await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`);
94
97
  cleanupWorktree(deps, marker);
@@ -103,16 +106,16 @@ export async function finishReviews(deps) {
103
106
  const marker = await deps.provider.readMarker(issue);
104
107
  if (!marker?.pr)
105
108
  continue;
106
- const status = await deps.provider.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
109
+ const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
107
110
  if (status.merged) {
108
- deps.log(`watch: #${issue.number}'s PR #${marker.pr} merged — done`);
111
+ deps.log(`watch: ${issue.id}'s PR #${marker.pr} merged — done`);
109
112
  if (!deps.dryRun) {
110
113
  await deps.provider.transition(issue, "done");
111
114
  cleanupWorktree(deps, marker);
112
115
  }
113
116
  }
114
117
  else if (status.state === "closed") {
115
- deps.log(`watch: #${issue.number}'s PR #${marker.pr} closed without merging — blocked`);
118
+ deps.log(`watch: ${issue.id}'s PR #${marker.pr} closed without merging — blocked`);
116
119
  if (!deps.dryRun) {
117
120
  await deps.provider.transition(issue, "blocked", `PR #${marker.pr} was closed without merging.`);
118
121
  cleanupWorktree(deps, marker);
@@ -124,7 +127,7 @@ export async function finishReviews(deps) {
124
127
  async function runIssue(deps, issue) {
125
128
  const branch = branchNameFor(issue);
126
129
  const worktreePath = worktreePathFor(deps, issue);
127
- const adwId = `issue-${issue.number}`;
130
+ const adwId = `issue-${issue.id}`;
128
131
  try {
129
132
  deps.git.fetch("origin", deps.baseBranch);
130
133
  deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
@@ -132,32 +135,37 @@ async function runIssue(deps, issue) {
132
135
  const prompt = `${issue.title}\n\n${issue.body}`.trim();
133
136
  const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
134
137
  if (!result.accepted) {
135
- deps.log(`watch: #${issue.number}: chain "${deps.chain}" did not succeed — blocked`);
138
+ deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
136
139
  await deps.provider.transition(issue, "blocked", result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`);
137
140
  cleanupWorktree(deps, { worktree: worktreePath, branch });
138
141
  return;
139
142
  }
140
143
  const wtGit = deps.worktreeGit(worktreePath);
141
144
  if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
142
- deps.log(`watch: #${issue.number}: chain succeeded but committed nothing — blocked`);
145
+ deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
143
146
  await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
144
147
  cleanupWorktree(deps, { worktree: worktreePath, branch });
145
148
  return;
146
149
  }
147
150
  wtGit.push("origin", branch);
148
- const pr = await deps.provider.openPr(issue, {
151
+ // No cross-linking magic keyword here on purpose (a code host paired
152
+ // with a different tracker has no "Closes #n" convention to hook into
153
+ // — see provider.ts) — the issue id in the title/body is plain text
154
+ // for humans, and, on a Jira+Bitbucket pairing, exactly what Jira's own
155
+ // Bitbucket integration scans for to link the PR automatically.
156
+ const pr = await deps.codeHost.openPr({
149
157
  branch,
150
- title: `${issue.title} (#${issue.number})`,
151
- body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`.`,
158
+ title: `${issue.title} (${issue.id})`,
159
+ body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.`,
152
160
  base: deps.baseBranch,
153
161
  });
154
162
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
155
163
  await deps.provider.transition(issue, "review");
156
- deps.log(`watch: #${issue.number}: opened PR #${pr.number} — review`);
164
+ deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
157
165
  }
158
166
  catch (error) {
159
167
  const message = error.message;
160
- deps.log(`watch: #${issue.number}: error: ${message}`);
168
+ deps.log(`watch: ${issue.id}: error: ${message}`);
161
169
  await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
162
170
  cleanupWorktree(deps, { worktree: worktreePath, branch });
163
171
  }
@@ -170,20 +178,20 @@ export async function claimNewWork(deps, state) {
170
178
  for (const issue of eligible) {
171
179
  if (state.inflight.size >= deps.concurrency)
172
180
  break;
173
- if (state.inflight.has(issue.number))
181
+ if (state.inflight.has(issue.id))
174
182
  continue;
175
183
  if (deps.dryRun) {
176
- deps.log(`watch: [dry-run] would claim #${issue.number} (${issue.title}) and run chain "${deps.chain}"`);
184
+ deps.log(`watch: [dry-run] would claim ${issue.id} (${issue.title}) and run chain "${deps.chain}"`);
177
185
  continue;
178
186
  }
179
187
  const claimed = await deps.provider.claim(issue);
180
188
  if (!claimed) {
181
- deps.log(`watch: #${issue.number} lost the claim race this tick — skipping`);
189
+ deps.log(`watch: ${issue.id} lost the claim race this tick — skipping`);
182
190
  continue;
183
191
  }
184
- deps.log(`watch: claimed #${issue.number}: ${issue.title}`);
185
- state.inflight.add(issue.number);
186
- runIssue(deps, issue).finally(() => state.inflight.delete(issue.number));
192
+ deps.log(`watch: claimed ${issue.id}: ${issue.title}`);
193
+ state.inflight.add(issue.id);
194
+ runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
187
195
  }
188
196
  }
189
197
  /** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */
@@ -4,14 +4,11 @@ import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcile
4
4
  /** In-memory fake — exactly the seam `provider.ts` exists for. */
5
5
  class FakeProvider {
6
6
  entries = new Map();
7
- prs = new Map();
8
7
  ensureLabelsCalls = 0;
9
8
  transitions = [];
10
- openedPrs = [];
11
9
  claimCalls = [];
12
- nextPrNumber = 1000;
13
- addIssue(number, title, state = "ready", marker = null) {
14
- this.entries.set(number, { issue: { number, title, body: "", labels: [`spf:${state}`] }, state, marker });
10
+ addIssue(id, title, state = "ready", marker = null) {
11
+ this.entries.set(id, { issue: { id, title, body: "", labels: [`spf:${state}`] }, state, marker });
15
12
  }
16
13
  async ensureLabels() {
17
14
  this.ensureLabelsCalls++;
@@ -24,33 +21,39 @@ class FakeProvider {
24
21
  return [...this.entries.values()].filter((e) => e.state === state).map((e) => e.issue);
25
22
  }
26
23
  async claim(issue) {
27
- this.claimCalls.push(issue.number);
28
- const entry = this.entries.get(issue.number);
24
+ this.claimCalls.push(issue.id);
25
+ const entry = this.entries.get(issue.id);
29
26
  if (entry.state !== "ready")
30
27
  return false;
31
28
  entry.state = "working";
32
29
  return true;
33
30
  }
34
31
  async transition(issue, to, detail) {
35
- this.entries.get(issue.number).state = to;
36
- this.transitions.push({ number: issue.number, to, detail });
32
+ this.entries.get(issue.id).state = to;
33
+ this.transitions.push({ id: issue.id, to, detail });
37
34
  }
38
35
  async comment() { }
39
- async openPr(issue, opts) {
36
+ async readMarker(issue) {
37
+ return this.entries.get(issue.id)?.marker ?? null;
38
+ }
39
+ async writeMarker(issue, marker) {
40
+ this.entries.get(issue.id).marker = marker;
41
+ }
42
+ }
43
+ /** In-memory fake `CodeHostProvider` — separate from `FakeProvider`, mirroring the real split. */
44
+ class FakeCodeHost {
45
+ prs = new Map();
46
+ openedPrs = [];
47
+ nextPrNumber = 1000;
48
+ async openPr(opts) {
40
49
  const number = this.nextPrNumber++;
41
- this.openedPrs.push({ issueNumber: issue.number, branch: opts.branch });
50
+ this.openedPrs.push({ title: opts.title, branch: opts.branch });
42
51
  this.prs.set(number, { merged: false, state: "open", ciStatus: "pending" });
43
52
  return { number, branch: opts.branch, url: `https://example.invalid/pr/${number}` };
44
53
  }
45
54
  async prStatus(pr) {
46
55
  return this.prs.get(pr.number) ?? { merged: false, state: "open", ciStatus: "pending" };
47
56
  }
48
- async readMarker(issue) {
49
- return this.entries.get(issue.number)?.marker ?? null;
50
- }
51
- async writeMarker(issue, marker) {
52
- this.entries.get(issue.number).marker = marker;
53
- }
54
57
  }
55
58
  function fakeGit(overrides = {}) {
56
59
  return {
@@ -77,9 +80,10 @@ function fakeGit(overrides = {}) {
77
80
  ...overrides,
78
81
  };
79
82
  }
80
- function makeDeps(provider, overrides = {}) {
83
+ function makeDeps(provider, codeHost, overrides = {}) {
81
84
  return {
82
85
  provider,
86
+ codeHost,
83
87
  git: fakeGit(),
84
88
  worktreeGit: () => fakeGit({ diffFiles: () => ["src/index.ts"] }), // a real commit landed, by default
85
89
  labelPrefix: "spf",
@@ -102,63 +106,69 @@ async function waitUntil(predicate, timeoutMs = 2000) {
102
106
  }
103
107
  }
104
108
  test("branchNameFor: sanitizes a title into a safe branch name", () => {
105
- assert.equal(branchNameFor({ number: 42, title: "Add a /health endpoint!!", body: "", labels: [] }), "spf-watch/42-add-a-health-endpoint");
106
- assert.equal(branchNameFor({ number: 7, title: "", body: "", labels: [] }), "spf-watch/7-issue");
109
+ assert.equal(branchNameFor({ id: "42", title: "Add a /health endpoint!!", body: "", labels: [] }), "spf-watch/42-add-a-health-endpoint");
110
+ assert.equal(branchNameFor({ id: "7", title: "", body: "", labels: [] }), "spf-watch/7-issue");
111
+ assert.equal(branchNameFor({ id: "PROJ-123", title: "Fix the thing", body: "", labels: [] }), "spf-watch/PROJ-123-fix-the-thing");
107
112
  });
108
113
  test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves to review", async () => {
109
114
  const provider = new FakeProvider();
110
- provider.addIssue(1, "Add a /health endpoint");
115
+ provider.addIssue("1", "Add a /health endpoint");
116
+ const codeHost = new FakeCodeHost();
111
117
  const state = createWatchState();
112
- const deps = makeDeps(provider);
118
+ const deps = makeDeps(provider, codeHost);
113
119
  await claimNewWork(deps, state);
114
120
  await waitUntil(() => state.inflight.size === 0);
115
- assert.deepEqual(provider.claimCalls, [1]);
116
- assert.equal(provider.openedPrs.length, 1);
117
- assert.equal(provider.openedPrs[0].issueNumber, 1);
121
+ assert.deepEqual(provider.claimCalls, ["1"]);
122
+ assert.equal(codeHost.openedPrs.length, 1);
123
+ assert.match(codeHost.openedPrs[0].title, /^Add a \/health endpoint \(1\)/);
118
124
  assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
119
- assert.equal(provider.entries.get(1).marker?.pr, 1000);
125
+ assert.equal(provider.entries.get("1").marker?.pr, 1000);
120
126
  });
121
127
  test("claimNewWork: a rejected chain run blocks the issue with the failure detail", async () => {
122
128
  const provider = new FakeProvider();
123
- provider.addIssue(2, "Flaky feature");
129
+ provider.addIssue("2", "Flaky feature");
130
+ const codeHost = new FakeCodeHost();
124
131
  const state = createWatchState();
125
- const deps = makeDeps(provider, {
132
+ const deps = makeDeps(provider, codeHost, {
126
133
  runChain: async () => ({ accepted: false, adwId: "issue-2", detail: "build-test failed at phase build" }),
127
134
  });
128
135
  await claimNewWork(deps, state);
129
136
  await waitUntil(() => state.inflight.size === 0);
130
- assert.deepEqual(provider.transitions, [{ number: 2, to: "blocked", detail: "build-test failed at phase build" }]);
131
- assert.equal(provider.openedPrs.length, 0);
137
+ assert.deepEqual(provider.transitions, [{ id: "2", to: "blocked", detail: "build-test failed at phase build" }]);
138
+ assert.equal(codeHost.openedPrs.length, 0);
132
139
  });
133
140
  test("claimNewWork: an accepted run with nothing committed also blocks, without opening a PR", async () => {
134
141
  const provider = new FakeProvider();
135
- provider.addIssue(3, "No-op request");
142
+ provider.addIssue("3", "No-op request");
143
+ const codeHost = new FakeCodeHost();
136
144
  const state = createWatchState();
137
- const deps = makeDeps(provider, { worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
145
+ const deps = makeDeps(provider, codeHost, { worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
138
146
  await claimNewWork(deps, state);
139
147
  await waitUntil(() => state.inflight.size === 0);
140
148
  assert.equal(provider.transitions[0]?.to, "blocked");
141
149
  assert.match(provider.transitions[0]?.detail ?? "", /no committed changes/);
142
- assert.equal(provider.openedPrs.length, 0);
150
+ assert.equal(codeHost.openedPrs.length, 0);
143
151
  });
144
152
  test("claimNewWork: never claims more than the concurrency budget in one tick", async () => {
145
153
  const provider = new FakeProvider();
146
- provider.addIssue(10, "one");
147
- provider.addIssue(11, "two");
148
- provider.addIssue(12, "three");
154
+ provider.addIssue("10", "one");
155
+ provider.addIssue("11", "two");
156
+ provider.addIssue("12", "three");
157
+ const codeHost = new FakeCodeHost();
149
158
  const state = createWatchState();
150
159
  // A runChain that never resolves keeps every claimed issue "inflight" for this assertion.
151
- const deps = makeDeps(provider, { concurrency: 2, runChain: () => new Promise(() => { }) });
160
+ const deps = makeDeps(provider, codeHost, { concurrency: 2, runChain: () => new Promise(() => { }) });
152
161
  await claimNewWork(deps, state);
153
162
  assert.equal(state.inflight.size, 2);
154
163
  assert.equal(provider.claimCalls.length, 2);
155
164
  });
156
165
  test("claimNewWork: dry-run claims nothing and calls neither claim() nor runChain", async () => {
157
166
  const provider = new FakeProvider();
158
- provider.addIssue(20, "dry run me");
167
+ provider.addIssue("20", "dry run me");
168
+ const codeHost = new FakeCodeHost();
159
169
  const state = createWatchState();
160
170
  let runChainCalled = false;
161
- const deps = makeDeps(provider, {
171
+ const deps = makeDeps(provider, codeHost, {
162
172
  dryRun: true,
163
173
  runChain: async () => {
164
174
  runChainCalled = true;
@@ -168,60 +178,66 @@ test("claimNewWork: dry-run claims nothing and calls neither claim() nor runChai
168
178
  await claimNewWork(deps, state);
169
179
  assert.equal(provider.claimCalls.length, 0);
170
180
  assert.equal(runChainCalled, false);
171
- assert.equal(provider.entries.get(20).state, "ready");
181
+ assert.equal(provider.entries.get("20").state, "ready");
172
182
  });
173
183
  test("reconcileOrphans: a working issue with an open PR in its marker resumes as review", async () => {
174
184
  const provider = new FakeProvider();
175
- provider.addIssue(30, "orphaned mid-review", "working", { pr: 500, branch: "spf-watch/30-x" });
176
- provider.prs.set(500, { merged: false, state: "open", ciStatus: "pending" });
185
+ provider.addIssue("30", "orphaned mid-review", "working", { pr: 500, branch: "spf-watch/30-x" });
186
+ const codeHost = new FakeCodeHost();
187
+ codeHost.prs.set(500, { merged: false, state: "open", ciStatus: "pending" });
177
188
  const state = createWatchState();
178
- await reconcileOrphans(makeDeps(provider), state);
179
- assert.deepEqual(provider.transitions, [{ number: 30, to: "review", detail: undefined }]);
189
+ await reconcileOrphans(makeDeps(provider, codeHost), state);
190
+ assert.deepEqual(provider.transitions, [{ id: "30", to: "review", detail: undefined }]);
180
191
  });
181
192
  test("reconcileOrphans: a working issue with no marker retries up to the cap, then blocks", async () => {
182
193
  const provider = new FakeProvider();
183
- provider.addIssue(31, "orphaned, no marker", "working", null);
194
+ provider.addIssue("31", "orphaned, no marker", "working", null);
195
+ const codeHost = new FakeCodeHost();
184
196
  const state = createWatchState();
185
- const deps = makeDeps(provider);
197
+ const deps = makeDeps(provider, codeHost);
186
198
  await reconcileOrphans(deps, state); // attempt 1 -> ready
187
- assert.equal(provider.entries.get(31).state, "ready");
188
- assert.equal(provider.entries.get(31).marker?.attempt, 1);
189
- provider.entries.get(31).state = "working"; // simulate it getting re-claimed and orphaned again
199
+ assert.equal(provider.entries.get("31").state, "ready");
200
+ assert.equal(provider.entries.get("31").marker?.attempt, 1);
201
+ provider.entries.get("31").state = "working"; // simulate it getting re-claimed and orphaned again
190
202
  await reconcileOrphans(deps, state); // attempt 2 -> ready
191
- assert.equal(provider.entries.get(31).state, "ready");
192
- provider.entries.get(31).state = "working";
203
+ assert.equal(provider.entries.get("31").state, "ready");
204
+ provider.entries.get("31").state = "working";
193
205
  await reconcileOrphans(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
194
- assert.equal(provider.entries.get(31).state, "blocked");
206
+ assert.equal(provider.entries.get("31").state, "blocked");
195
207
  assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned attempts/);
196
208
  });
197
209
  test("reconcileOrphans: skips issues this process is already tracking as in-flight", async () => {
198
210
  const provider = new FakeProvider();
199
- provider.addIssue(32, "actually still running", "working", null);
211
+ provider.addIssue("32", "actually still running", "working", null);
212
+ const codeHost = new FakeCodeHost();
200
213
  const state = createWatchState();
201
- state.inflight.add(32);
202
- await reconcileOrphans(makeDeps(provider), state);
214
+ state.inflight.add("32");
215
+ await reconcileOrphans(makeDeps(provider, codeHost), state);
203
216
  assert.equal(provider.transitions.length, 0, "an in-flight issue must not be treated as orphaned");
204
217
  });
205
218
  test("finishReviews: a merged PR moves the issue to done", async () => {
206
219
  const provider = new FakeProvider();
207
- provider.addIssue(40, "shipped", "review", { pr: 600, branch: "spf-watch/40-x" });
208
- provider.prs.set(600, { merged: true, state: "closed", ciStatus: "success" });
209
- await finishReviews(makeDeps(provider));
210
- assert.deepEqual(provider.transitions, [{ number: 40, to: "done", detail: undefined }]);
220
+ provider.addIssue("40", "shipped", "review", { pr: 600, branch: "spf-watch/40-x" });
221
+ const codeHost = new FakeCodeHost();
222
+ codeHost.prs.set(600, { merged: true, state: "closed", ciStatus: "success" });
223
+ await finishReviews(makeDeps(provider, codeHost));
224
+ assert.deepEqual(provider.transitions, [{ id: "40", to: "done", detail: undefined }]);
211
225
  });
212
226
  test("finishReviews: a closed-without-merging PR blocks the issue", async () => {
213
227
  const provider = new FakeProvider();
214
- provider.addIssue(41, "rejected", "review", { pr: 601, branch: "spf-watch/41-x" });
215
- provider.prs.set(601, { merged: false, state: "closed", ciStatus: "failure" });
216
- await finishReviews(makeDeps(provider));
228
+ provider.addIssue("41", "rejected", "review", { pr: 601, branch: "spf-watch/41-x" });
229
+ const codeHost = new FakeCodeHost();
230
+ codeHost.prs.set(601, { merged: false, state: "closed", ciStatus: "failure" });
231
+ await finishReviews(makeDeps(provider, codeHost));
217
232
  assert.equal(provider.transitions[0]?.to, "blocked");
218
233
  assert.match(provider.transitions[0]?.detail ?? "", /closed without merging/);
219
234
  });
220
235
  test("finishReviews: a still-open PR leaves the issue in review", async () => {
221
236
  const provider = new FakeProvider();
222
- provider.addIssue(42, "still cooking", "review", { pr: 602, branch: "spf-watch/42-x" });
223
- provider.prs.set(602, { merged: false, state: "open", ciStatus: "pending" });
224
- await finishReviews(makeDeps(provider));
237
+ provider.addIssue("42", "still cooking", "review", { pr: 602, branch: "spf-watch/42-x" });
238
+ const codeHost = new FakeCodeHost();
239
+ codeHost.prs.set(602, { merged: false, state: "open", ciStatus: "pending" });
240
+ await finishReviews(makeDeps(provider, codeHost));
225
241
  assert.equal(provider.transitions.length, 0);
226
- assert.equal(provider.entries.get(42).state, "review");
242
+ assert.equal(provider.entries.get("42").state, "review");
227
243
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",