@gr8ful/spf 0.1.5 → 0.1.7
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 +72 -12
- package/assets/skill/references/config.md +3 -2
- package/assets/templates/ts-cc.spf.config.yaml +59 -0
- package/assets/templates/ts-flue-openrouter.spf.config.yaml +53 -0
- package/assets/templates/ts.spf.config.yaml +66 -0
- package/dist/cli/commands/doctor.js +20 -4
- package/dist/cli/commands/init.js +49 -10
- package/dist/cli/commands/watch.js +86 -21
- package/dist/cli/index.js +1 -1
- package/dist/core/agent_cc.d.ts +8 -0
- package/dist/core/agent_cc.js +12 -1
- package/dist/core/console.d.ts +1 -0
- package/dist/core/console.js +1 -1
- package/dist/core/data_types.d.ts +45 -10
- package/dist/core/data_types.js +22 -8
- package/dist/core/issues/bitbucket_provider.d.ts +35 -0
- package/dist/core/issues/bitbucket_provider.js +70 -0
- package/dist/core/issues/github_provider.d.ts +7 -6
- package/dist/core/issues/github_provider.js +15 -20
- package/dist/core/issues/jira_provider.d.ts +72 -0
- package/dist/core/issues/jira_provider.js +163 -0
- package/dist/core/issues/provider.d.ts +37 -14
- package/dist/core/issues/provider.js +12 -4
- package/dist/core/paths.d.ts +2 -0
- package/dist/core/paths.js +2 -0
- package/dist/core/watch.d.ts +15 -2
- package/dist/core/watch.js +43 -24
- package/dist/test/watch.test.js +122 -66
- package/package.json +1 -1
package/dist/test/watch.test.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans } from "../core/watch.js";
|
|
4
5
|
/** In-memory fake — exactly the seam `provider.ts` exists for. */
|
|
5
6
|
class FakeProvider {
|
|
6
7
|
entries = new Map();
|
|
7
|
-
prs = new Map();
|
|
8
8
|
ensureLabelsCalls = 0;
|
|
9
9
|
transitions = [];
|
|
10
|
-
openedPrs = [];
|
|
11
10
|
claimCalls = [];
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
this.entries.set(number, { issue: { number, title, body: "", labels: [`spf:${state}`] }, state, marker });
|
|
11
|
+
addIssue(id, title, state = "ready", marker = null) {
|
|
12
|
+
this.entries.set(id, { issue: { id, title, body: "", labels: [`spf:${state}`] }, state, marker });
|
|
15
13
|
}
|
|
16
14
|
async ensureLabels() {
|
|
17
15
|
this.ensureLabelsCalls++;
|
|
@@ -24,33 +22,39 @@ class FakeProvider {
|
|
|
24
22
|
return [...this.entries.values()].filter((e) => e.state === state).map((e) => e.issue);
|
|
25
23
|
}
|
|
26
24
|
async claim(issue) {
|
|
27
|
-
this.claimCalls.push(issue.
|
|
28
|
-
const entry = this.entries.get(issue.
|
|
25
|
+
this.claimCalls.push(issue.id);
|
|
26
|
+
const entry = this.entries.get(issue.id);
|
|
29
27
|
if (entry.state !== "ready")
|
|
30
28
|
return false;
|
|
31
29
|
entry.state = "working";
|
|
32
30
|
return true;
|
|
33
31
|
}
|
|
34
32
|
async transition(issue, to, detail) {
|
|
35
|
-
this.entries.get(issue.
|
|
36
|
-
this.transitions.push({
|
|
33
|
+
this.entries.get(issue.id).state = to;
|
|
34
|
+
this.transitions.push({ id: issue.id, to, detail });
|
|
37
35
|
}
|
|
38
36
|
async comment() { }
|
|
39
|
-
async
|
|
37
|
+
async readMarker(issue) {
|
|
38
|
+
return this.entries.get(issue.id)?.marker ?? null;
|
|
39
|
+
}
|
|
40
|
+
async writeMarker(issue, marker) {
|
|
41
|
+
this.entries.get(issue.id).marker = marker;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** In-memory fake `CodeHostProvider` — separate from `FakeProvider`, mirroring the real split. */
|
|
45
|
+
class FakeCodeHost {
|
|
46
|
+
prs = new Map();
|
|
47
|
+
openedPrs = [];
|
|
48
|
+
nextPrNumber = 1000;
|
|
49
|
+
async openPr(opts) {
|
|
40
50
|
const number = this.nextPrNumber++;
|
|
41
|
-
this.openedPrs.push({
|
|
51
|
+
this.openedPrs.push({ title: opts.title, branch: opts.branch });
|
|
42
52
|
this.prs.set(number, { merged: false, state: "open", ciStatus: "pending" });
|
|
43
53
|
return { number, branch: opts.branch, url: `https://example.invalid/pr/${number}` };
|
|
44
54
|
}
|
|
45
55
|
async prStatus(pr) {
|
|
46
56
|
return this.prs.get(pr.number) ?? { merged: false, state: "open", ciStatus: "pending" };
|
|
47
57
|
}
|
|
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
58
|
}
|
|
55
59
|
function fakeGit(overrides = {}) {
|
|
56
60
|
return {
|
|
@@ -77,9 +81,10 @@ function fakeGit(overrides = {}) {
|
|
|
77
81
|
...overrides,
|
|
78
82
|
};
|
|
79
83
|
}
|
|
80
|
-
function makeDeps(provider, overrides = {}) {
|
|
84
|
+
function makeDeps(provider, codeHost, overrides = {}) {
|
|
81
85
|
return {
|
|
82
86
|
provider,
|
|
87
|
+
codeHost,
|
|
83
88
|
git: fakeGit(),
|
|
84
89
|
worktreeGit: () => fakeGit({ diffFiles: () => ["src/index.ts"] }), // a real commit landed, by default
|
|
85
90
|
labelPrefix: "spf",
|
|
@@ -87,6 +92,7 @@ function makeDeps(provider, overrides = {}) {
|
|
|
87
92
|
baseBranch: "main",
|
|
88
93
|
concurrency: 2,
|
|
89
94
|
worktreesDir: "/tmp/spf-watch-test-worktrees",
|
|
95
|
+
linkDataDir: () => { },
|
|
90
96
|
dryRun: false,
|
|
91
97
|
runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
|
|
92
98
|
log: () => { },
|
|
@@ -102,63 +108,107 @@ async function waitUntil(predicate, timeoutMs = 2000) {
|
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
test("branchNameFor: sanitizes a title into a safe branch name", () => {
|
|
105
|
-
assert.equal(branchNameFor({
|
|
106
|
-
assert.equal(branchNameFor({
|
|
111
|
+
assert.equal(branchNameFor({ id: "42", title: "Add a /health endpoint!!", body: "", labels: [] }), "spf-watch/42-add-a-health-endpoint");
|
|
112
|
+
assert.equal(branchNameFor({ id: "7", title: "", body: "", labels: [] }), "spf-watch/7-issue");
|
|
113
|
+
assert.equal(branchNameFor({ id: "PROJ-123", title: "Fix the thing", body: "", labels: [] }), "spf-watch/PROJ-123-fix-the-thing");
|
|
114
|
+
});
|
|
115
|
+
test("claimNewWork: clears a stale worktree/branch from a killed prior attempt before creating a fresh one", async () => {
|
|
116
|
+
const provider = new FakeProvider();
|
|
117
|
+
provider.addIssue("2", "Retry me");
|
|
118
|
+
const codeHost = new FakeCodeHost();
|
|
119
|
+
const state = createWatchState();
|
|
120
|
+
const calls = [];
|
|
121
|
+
// Simulates a `spf watch` process killed mid-run for this exact issue: a
|
|
122
|
+
// real `git.worktreeAdd` would refuse outright with "fatal: a branch
|
|
123
|
+
// named '...' already exists" until the leftover branch is cleared.
|
|
124
|
+
let branchExists = true;
|
|
125
|
+
const deps = makeDeps(provider, codeHost, {
|
|
126
|
+
git: fakeGit({
|
|
127
|
+
worktreeRemove: (p) => calls.push(`remove:${p}`),
|
|
128
|
+
deleteLocalBranch: (n) => {
|
|
129
|
+
calls.push(`delete-branch:${n}`);
|
|
130
|
+
branchExists = false;
|
|
131
|
+
},
|
|
132
|
+
worktreeAdd: (p, b) => {
|
|
133
|
+
if (branchExists)
|
|
134
|
+
throw new Error(`fatal: a branch named '${b}' already exists`);
|
|
135
|
+
calls.push(`add:${p}`);
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
await claimNewWork(deps, state);
|
|
140
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
141
|
+
const branchIdx = calls.findIndex((c) => c.startsWith("delete-branch:"));
|
|
142
|
+
const addIdx = calls.findIndex((c) => c.startsWith("add:"));
|
|
143
|
+
assert.ok(branchIdx !== -1 && addIdx !== -1 && branchIdx < addIdx, `expected the stale branch cleared before worktreeAdd, got: ${calls.join(", ")}`);
|
|
144
|
+
// The retry then succeeds normally, same as any other claim.
|
|
145
|
+
assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
|
|
107
146
|
});
|
|
108
147
|
test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves to review", async () => {
|
|
109
148
|
const provider = new FakeProvider();
|
|
110
|
-
provider.addIssue(1, "Add a /health endpoint");
|
|
149
|
+
provider.addIssue("1", "Add a /health endpoint");
|
|
150
|
+
const codeHost = new FakeCodeHost();
|
|
111
151
|
const state = createWatchState();
|
|
112
|
-
const
|
|
152
|
+
const linkedWorktrees = [];
|
|
153
|
+
const deps = makeDeps(provider, codeHost, { linkDataDir: (worktreePath) => linkedWorktrees.push(worktreePath) });
|
|
113
154
|
await claimNewWork(deps, state);
|
|
114
155
|
await waitUntil(() => state.inflight.size === 0);
|
|
115
|
-
assert.deepEqual(provider.claimCalls, [1]);
|
|
116
|
-
assert.equal(
|
|
117
|
-
assert.
|
|
156
|
+
assert.deepEqual(provider.claimCalls, ["1"]);
|
|
157
|
+
assert.equal(codeHost.openedPrs.length, 1);
|
|
158
|
+
assert.match(codeHost.openedPrs[0].title, /^Add a \/health endpoint \(1\)/);
|
|
118
159
|
assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
|
|
119
|
-
assert.equal(provider.entries.get(1).marker?.pr, 1000);
|
|
160
|
+
assert.equal(provider.entries.get("1").marker?.pr, 1000);
|
|
161
|
+
// linkDataDir must run before the chain does — otherwise the run's own
|
|
162
|
+
// session/trace data resolves into the worktree's throwaway .spf/data
|
|
163
|
+
// instead of the main repo's persistent one (invisible in spf ui, and
|
|
164
|
+
// deleted along with the worktree on cleanup).
|
|
165
|
+
assert.deepEqual(linkedWorktrees, [path.join(deps.worktreesDir, "issue-1")]);
|
|
120
166
|
});
|
|
121
167
|
test("claimNewWork: a rejected chain run blocks the issue with the failure detail", async () => {
|
|
122
168
|
const provider = new FakeProvider();
|
|
123
|
-
provider.addIssue(2, "Flaky feature");
|
|
169
|
+
provider.addIssue("2", "Flaky feature");
|
|
170
|
+
const codeHost = new FakeCodeHost();
|
|
124
171
|
const state = createWatchState();
|
|
125
|
-
const deps = makeDeps(provider, {
|
|
172
|
+
const deps = makeDeps(provider, codeHost, {
|
|
126
173
|
runChain: async () => ({ accepted: false, adwId: "issue-2", detail: "build-test failed at phase build" }),
|
|
127
174
|
});
|
|
128
175
|
await claimNewWork(deps, state);
|
|
129
176
|
await waitUntil(() => state.inflight.size === 0);
|
|
130
|
-
assert.deepEqual(provider.transitions, [{
|
|
131
|
-
assert.equal(
|
|
177
|
+
assert.deepEqual(provider.transitions, [{ id: "2", to: "blocked", detail: "build-test failed at phase build" }]);
|
|
178
|
+
assert.equal(codeHost.openedPrs.length, 0);
|
|
132
179
|
});
|
|
133
180
|
test("claimNewWork: an accepted run with nothing committed also blocks, without opening a PR", async () => {
|
|
134
181
|
const provider = new FakeProvider();
|
|
135
|
-
provider.addIssue(3, "No-op request");
|
|
182
|
+
provider.addIssue("3", "No-op request");
|
|
183
|
+
const codeHost = new FakeCodeHost();
|
|
136
184
|
const state = createWatchState();
|
|
137
|
-
const deps = makeDeps(provider, { worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
|
|
185
|
+
const deps = makeDeps(provider, codeHost, { worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
|
|
138
186
|
await claimNewWork(deps, state);
|
|
139
187
|
await waitUntil(() => state.inflight.size === 0);
|
|
140
188
|
assert.equal(provider.transitions[0]?.to, "blocked");
|
|
141
189
|
assert.match(provider.transitions[0]?.detail ?? "", /no committed changes/);
|
|
142
|
-
assert.equal(
|
|
190
|
+
assert.equal(codeHost.openedPrs.length, 0);
|
|
143
191
|
});
|
|
144
192
|
test("claimNewWork: never claims more than the concurrency budget in one tick", async () => {
|
|
145
193
|
const provider = new FakeProvider();
|
|
146
|
-
provider.addIssue(10, "one");
|
|
147
|
-
provider.addIssue(11, "two");
|
|
148
|
-
provider.addIssue(12, "three");
|
|
194
|
+
provider.addIssue("10", "one");
|
|
195
|
+
provider.addIssue("11", "two");
|
|
196
|
+
provider.addIssue("12", "three");
|
|
197
|
+
const codeHost = new FakeCodeHost();
|
|
149
198
|
const state = createWatchState();
|
|
150
199
|
// A runChain that never resolves keeps every claimed issue "inflight" for this assertion.
|
|
151
|
-
const deps = makeDeps(provider, { concurrency: 2, runChain: () => new Promise(() => { }) });
|
|
200
|
+
const deps = makeDeps(provider, codeHost, { concurrency: 2, runChain: () => new Promise(() => { }) });
|
|
152
201
|
await claimNewWork(deps, state);
|
|
153
202
|
assert.equal(state.inflight.size, 2);
|
|
154
203
|
assert.equal(provider.claimCalls.length, 2);
|
|
155
204
|
});
|
|
156
205
|
test("claimNewWork: dry-run claims nothing and calls neither claim() nor runChain", async () => {
|
|
157
206
|
const provider = new FakeProvider();
|
|
158
|
-
provider.addIssue(20, "dry run me");
|
|
207
|
+
provider.addIssue("20", "dry run me");
|
|
208
|
+
const codeHost = new FakeCodeHost();
|
|
159
209
|
const state = createWatchState();
|
|
160
210
|
let runChainCalled = false;
|
|
161
|
-
const deps = makeDeps(provider, {
|
|
211
|
+
const deps = makeDeps(provider, codeHost, {
|
|
162
212
|
dryRun: true,
|
|
163
213
|
runChain: async () => {
|
|
164
214
|
runChainCalled = true;
|
|
@@ -168,60 +218,66 @@ test("claimNewWork: dry-run claims nothing and calls neither claim() nor runChai
|
|
|
168
218
|
await claimNewWork(deps, state);
|
|
169
219
|
assert.equal(provider.claimCalls.length, 0);
|
|
170
220
|
assert.equal(runChainCalled, false);
|
|
171
|
-
assert.equal(provider.entries.get(20).state, "ready");
|
|
221
|
+
assert.equal(provider.entries.get("20").state, "ready");
|
|
172
222
|
});
|
|
173
223
|
test("reconcileOrphans: a working issue with an open PR in its marker resumes as review", async () => {
|
|
174
224
|
const provider = new FakeProvider();
|
|
175
|
-
provider.addIssue(30, "orphaned mid-review", "working", { pr: 500, branch: "spf-watch/30-x" });
|
|
176
|
-
|
|
225
|
+
provider.addIssue("30", "orphaned mid-review", "working", { pr: 500, branch: "spf-watch/30-x" });
|
|
226
|
+
const codeHost = new FakeCodeHost();
|
|
227
|
+
codeHost.prs.set(500, { merged: false, state: "open", ciStatus: "pending" });
|
|
177
228
|
const state = createWatchState();
|
|
178
|
-
await reconcileOrphans(makeDeps(provider), state);
|
|
179
|
-
assert.deepEqual(provider.transitions, [{
|
|
229
|
+
await reconcileOrphans(makeDeps(provider, codeHost), state);
|
|
230
|
+
assert.deepEqual(provider.transitions, [{ id: "30", to: "review", detail: undefined }]);
|
|
180
231
|
});
|
|
181
232
|
test("reconcileOrphans: a working issue with no marker retries up to the cap, then blocks", async () => {
|
|
182
233
|
const provider = new FakeProvider();
|
|
183
|
-
provider.addIssue(31, "orphaned, no marker", "working", null);
|
|
234
|
+
provider.addIssue("31", "orphaned, no marker", "working", null);
|
|
235
|
+
const codeHost = new FakeCodeHost();
|
|
184
236
|
const state = createWatchState();
|
|
185
|
-
const deps = makeDeps(provider);
|
|
237
|
+
const deps = makeDeps(provider, codeHost);
|
|
186
238
|
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
|
|
239
|
+
assert.equal(provider.entries.get("31").state, "ready");
|
|
240
|
+
assert.equal(provider.entries.get("31").marker?.attempt, 1);
|
|
241
|
+
provider.entries.get("31").state = "working"; // simulate it getting re-claimed and orphaned again
|
|
190
242
|
await reconcileOrphans(deps, state); // attempt 2 -> ready
|
|
191
|
-
assert.equal(provider.entries.get(31).state, "ready");
|
|
192
|
-
provider.entries.get(31).state = "working";
|
|
243
|
+
assert.equal(provider.entries.get("31").state, "ready");
|
|
244
|
+
provider.entries.get("31").state = "working";
|
|
193
245
|
await reconcileOrphans(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
|
|
194
|
-
assert.equal(provider.entries.get(31).state, "blocked");
|
|
246
|
+
assert.equal(provider.entries.get("31").state, "blocked");
|
|
195
247
|
assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned attempts/);
|
|
196
248
|
});
|
|
197
249
|
test("reconcileOrphans: skips issues this process is already tracking as in-flight", async () => {
|
|
198
250
|
const provider = new FakeProvider();
|
|
199
|
-
provider.addIssue(32, "actually still running", "working", null);
|
|
251
|
+
provider.addIssue("32", "actually still running", "working", null);
|
|
252
|
+
const codeHost = new FakeCodeHost();
|
|
200
253
|
const state = createWatchState();
|
|
201
|
-
state.inflight.add(32);
|
|
202
|
-
await reconcileOrphans(makeDeps(provider), state);
|
|
254
|
+
state.inflight.add("32");
|
|
255
|
+
await reconcileOrphans(makeDeps(provider, codeHost), state);
|
|
203
256
|
assert.equal(provider.transitions.length, 0, "an in-flight issue must not be treated as orphaned");
|
|
204
257
|
});
|
|
205
258
|
test("finishReviews: a merged PR moves the issue to done", async () => {
|
|
206
259
|
const provider = new FakeProvider();
|
|
207
|
-
provider.addIssue(40, "shipped", "review", { pr: 600, branch: "spf-watch/40-x" });
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
260
|
+
provider.addIssue("40", "shipped", "review", { pr: 600, branch: "spf-watch/40-x" });
|
|
261
|
+
const codeHost = new FakeCodeHost();
|
|
262
|
+
codeHost.prs.set(600, { merged: true, state: "closed", ciStatus: "success" });
|
|
263
|
+
await finishReviews(makeDeps(provider, codeHost));
|
|
264
|
+
assert.deepEqual(provider.transitions, [{ id: "40", to: "done", detail: undefined }]);
|
|
211
265
|
});
|
|
212
266
|
test("finishReviews: a closed-without-merging PR blocks the issue", async () => {
|
|
213
267
|
const provider = new FakeProvider();
|
|
214
|
-
provider.addIssue(41, "rejected", "review", { pr: 601, branch: "spf-watch/41-x" });
|
|
215
|
-
|
|
216
|
-
|
|
268
|
+
provider.addIssue("41", "rejected", "review", { pr: 601, branch: "spf-watch/41-x" });
|
|
269
|
+
const codeHost = new FakeCodeHost();
|
|
270
|
+
codeHost.prs.set(601, { merged: false, state: "closed", ciStatus: "failure" });
|
|
271
|
+
await finishReviews(makeDeps(provider, codeHost));
|
|
217
272
|
assert.equal(provider.transitions[0]?.to, "blocked");
|
|
218
273
|
assert.match(provider.transitions[0]?.detail ?? "", /closed without merging/);
|
|
219
274
|
});
|
|
220
275
|
test("finishReviews: a still-open PR leaves the issue in review", async () => {
|
|
221
276
|
const provider = new FakeProvider();
|
|
222
|
-
provider.addIssue(42, "still cooking", "review", { pr: 602, branch: "spf-watch/42-x" });
|
|
223
|
-
|
|
224
|
-
|
|
277
|
+
provider.addIssue("42", "still cooking", "review", { pr: 602, branch: "spf-watch/42-x" });
|
|
278
|
+
const codeHost = new FakeCodeHost();
|
|
279
|
+
codeHost.prs.set(602, { merged: false, state: "open", ciStatus: "pending" });
|
|
280
|
+
await finishReviews(makeDeps(provider, codeHost));
|
|
225
281
|
assert.equal(provider.transitions.length, 0);
|
|
226
|
-
assert.equal(provider.entries.get(42).state, "review");
|
|
282
|
+
assert.equal(provider.entries.get("42").state, "review");
|
|
227
283
|
});
|