@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.
- package/README.md +72 -12
- package/assets/skill/references/config.md +3 -2
- package/assets/templates/ts-cc.spf.config.yaml +43 -0
- package/assets/templates/ts-flue-openrouter.spf.config.yaml +38 -0
- package/assets/templates/ts.spf.config.yaml +51 -0
- package/dist/cli/commands/doctor.js +20 -4
- package/dist/cli/commands/init.js +39 -10
- package/dist/cli/commands/watch.js +63 -20
- 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 +63 -0
- package/dist/core/issues/jira_provider.js +145 -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 +3 -2
- package/dist/core/watch.js +32 -24
- package/dist/test/watch.test.js +82 -66
- package/package.json +1 -1
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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 (
|
|
55
|
-
*
|
|
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
|
|
64
|
-
*
|
|
65
|
-
* before the next poll tick runs; an open-only
|
|
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
|
-
|
|
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
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/paths.js
CHANGED
|
@@ -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;
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -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<
|
|
29
|
+
inflight: Set<string>;
|
|
29
30
|
}
|
|
30
31
|
export declare function createWatchState(): WatchRunState;
|
|
31
32
|
export declare function branchNameFor(issue: Issue): string;
|
package/dist/core/watch.js
CHANGED
|
@@ -40,10 +40,13 @@ export function branchNameFor(issue) {
|
|
|
40
40
|
.slice(0, 5)
|
|
41
41
|
.join("-")
|
|
42
42
|
.replace(/[^a-z0-9-]/g, "");
|
|
43
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
109
|
+
const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
|
|
107
110
|
if (status.merged) {
|
|
108
|
-
deps.log(`watch:
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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} (
|
|
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:
|
|
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:
|
|
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.
|
|
181
|
+
if (state.inflight.has(issue.id))
|
|
174
182
|
continue;
|
|
175
183
|
if (deps.dryRun) {
|
|
176
|
-
deps.log(`watch: [dry-run] would claim
|
|
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:
|
|
189
|
+
deps.log(`watch: ${issue.id} lost the claim race this tick — skipping`);
|
|
182
190
|
continue;
|
|
183
191
|
}
|
|
184
|
-
deps.log(`watch: claimed
|
|
185
|
-
state.inflight.add(issue.
|
|
186
|
-
runIssue(deps, issue).finally(() => state.inflight.delete(issue.
|
|
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. */
|
package/dist/test/watch.test.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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.
|
|
28
|
-
const entry = this.entries.get(issue.
|
|
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.
|
|
36
|
-
this.transitions.push({
|
|
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
|
|
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({
|
|
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({
|
|
106
|
-
assert.equal(branchNameFor({
|
|
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(
|
|
117
|
-
assert.
|
|
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, [{
|
|
131
|
-
assert.equal(
|
|
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(
|
|
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
|
-
|
|
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, [{
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
});
|