@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const STATES = ["ready", "working", "review", "done", "blocked"];
|
|
2
|
+
const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*?\})/s;
|
|
3
|
+
function toAdf(text) {
|
|
4
|
+
return {
|
|
5
|
+
type: "doc",
|
|
6
|
+
version: 1,
|
|
7
|
+
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Walks an ADF document's `text` nodes and joins them — the minimal inverse of `toAdf`, not a full ADF renderer. */
|
|
11
|
+
function adfToText(adf) {
|
|
12
|
+
if (!adf || typeof adf !== "object")
|
|
13
|
+
return "";
|
|
14
|
+
const node = adf;
|
|
15
|
+
if (node.type === "text" && typeof node.text === "string")
|
|
16
|
+
return node.text;
|
|
17
|
+
if (Array.isArray(node.content))
|
|
18
|
+
return node.content.map(adfToText).join("");
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
export class JiraProvider {
|
|
22
|
+
baseUrl;
|
|
23
|
+
projectKey;
|
|
24
|
+
labelPrefix;
|
|
25
|
+
email;
|
|
26
|
+
apiToken;
|
|
27
|
+
constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
|
|
28
|
+
projectKey, labelPrefix, email, apiToken) {
|
|
29
|
+
this.baseUrl = baseUrl;
|
|
30
|
+
this.projectKey = projectKey;
|
|
31
|
+
this.labelPrefix = labelPrefix;
|
|
32
|
+
this.email = email;
|
|
33
|
+
this.apiToken = apiToken;
|
|
34
|
+
}
|
|
35
|
+
authHeader() {
|
|
36
|
+
return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
|
|
37
|
+
}
|
|
38
|
+
async jira(path, init) {
|
|
39
|
+
const debug = Boolean(process.env["SPF_JIRA_DEBUG"]);
|
|
40
|
+
if (debug)
|
|
41
|
+
console.error(`[jira debug] ${init?.method ?? "GET"} ${this.baseUrl}${path} body=${init?.body ?? "(none)"}`);
|
|
42
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
43
|
+
...init,
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: this.authHeader(),
|
|
46
|
+
Accept: "application/json",
|
|
47
|
+
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
|
48
|
+
...init?.headers,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const detail = await response.text().catch(() => "");
|
|
53
|
+
throw new Error(`Jira ${init?.method ?? "GET"} ${path} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
54
|
+
}
|
|
55
|
+
if (response.status === 204)
|
|
56
|
+
return undefined;
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
if (debug)
|
|
59
|
+
console.error(`[jira debug] -> ${response.status} ${text.slice(0, 2000)}`);
|
|
60
|
+
return text ? JSON.parse(text) : undefined;
|
|
61
|
+
}
|
|
62
|
+
label(state) {
|
|
63
|
+
return `${this.labelPrefix}:${state}`;
|
|
64
|
+
}
|
|
65
|
+
/** Jira labels are freeform strings, not a seedable registry — report what's used, create nothing. */
|
|
66
|
+
async ensureLabels() {
|
|
67
|
+
return { created: [], updated: [], unchanged: STATES.map((s) => this.label(s)) };
|
|
68
|
+
}
|
|
69
|
+
toIssue(raw) {
|
|
70
|
+
return {
|
|
71
|
+
id: raw.key,
|
|
72
|
+
title: raw.fields.summary,
|
|
73
|
+
body: raw.fields.description ? adfToText(raw.fields.description) : "",
|
|
74
|
+
labels: raw.fields.labels,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* POST with the JQL in the JSON body, NOT a GET with `jql` as a query
|
|
79
|
+
* param: this endpoint's own real-world behavior (confirmed by multiple
|
|
80
|
+
* independent bug reports against it, not just this project's own
|
|
81
|
+
* testing) is to silently ignore query-string parameters and return an
|
|
82
|
+
* empty `issues` array with a 200 OK — no error, nothing to catch. A
|
|
83
|
+
* `spf watch` that builds this as a GET query string would run cleanly
|
|
84
|
+
* forever without ever claiming a single issue.
|
|
85
|
+
*/
|
|
86
|
+
async searchByLabel(label) {
|
|
87
|
+
const jql = `project = ${JSON.stringify(this.projectKey)} AND labels = ${JSON.stringify(label)}`;
|
|
88
|
+
const result = await this.jira("/rest/api/3/search/jql", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
body: JSON.stringify({ jql, maxResults: 100, fields: ["summary", "description", "labels"] }),
|
|
91
|
+
});
|
|
92
|
+
return result.issues.map((i) => this.toIssue(i));
|
|
93
|
+
}
|
|
94
|
+
async listEligible() {
|
|
95
|
+
return this.searchByLabel(this.label("ready"));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* `opts.includeAll` is ignored: nothing but this provider's own
|
|
99
|
+
* `transition()` ever changes an issue's labels or resolution here, so
|
|
100
|
+
* there's no side channel (like GitHub's `Closes #n` auto-close) that
|
|
101
|
+
* could make a `review`-labeled issue vanish from an unfiltered query.
|
|
102
|
+
*/
|
|
103
|
+
async listInState(state) {
|
|
104
|
+
return this.searchByLabel(this.label(state));
|
|
105
|
+
}
|
|
106
|
+
async claim(issue) {
|
|
107
|
+
const next = issue.labels.filter((l) => l !== this.label("ready"));
|
|
108
|
+
next.push(this.label("working"));
|
|
109
|
+
await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
|
|
110
|
+
const fresh = await this.jira(`/rest/api/3/issue/${issue.id}?fields=summary,description,labels`);
|
|
111
|
+
const labels = fresh.fields.labels;
|
|
112
|
+
const claimed = labels.includes(this.label("working")) && !labels.includes(this.label("ready"));
|
|
113
|
+
if (!claimed) {
|
|
114
|
+
const revert = labels.filter((l) => l !== this.label("working"));
|
|
115
|
+
revert.push(this.label("ready"));
|
|
116
|
+
await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: revert } }) }).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
return claimed;
|
|
119
|
+
}
|
|
120
|
+
async transition(issue, to, detail) {
|
|
121
|
+
const next = issue.labels.filter((l) => !STATES.some((s) => this.label(s) === l));
|
|
122
|
+
next.push(this.label(to));
|
|
123
|
+
await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
|
|
124
|
+
if (detail)
|
|
125
|
+
await this.comment(issue, detail);
|
|
126
|
+
}
|
|
127
|
+
async comment(issue, body) {
|
|
128
|
+
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
|
|
129
|
+
}
|
|
130
|
+
async findMarkerComment(issueId) {
|
|
131
|
+
const result = await this.jira(`/rest/api/3/issue/${issueId}/comment?maxResults=100`);
|
|
132
|
+
let found = null;
|
|
133
|
+
for (const c of result.comments) {
|
|
134
|
+
const match = MARKER_RE.exec(adfToText(c.body));
|
|
135
|
+
if (!match)
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
found = { id: c.id, marker: JSON.parse(match[1]) };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// malformed marker JSON — tolerate it and keep looking, like github_provider.ts does
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
async readMarker(issue) {
|
|
147
|
+
const found = await this.findMarkerComment(issue.id);
|
|
148
|
+
return found?.marker ?? null;
|
|
149
|
+
}
|
|
150
|
+
async writeMarker(issue, marker) {
|
|
151
|
+
// Unlike GitHub's HTML-comment trick, Jira's ADF has no way to actually
|
|
152
|
+
// hide this from a viewer — it's a plainly visible comment, just one
|
|
153
|
+
// that starts with a recognizable, regex-matchable tag.
|
|
154
|
+
const body = toAdf(`[spf-watch-marker] ${JSON.stringify(marker)}`);
|
|
155
|
+
const existing = await this.findMarkerComment(issue.id);
|
|
156
|
+
if (existing) {
|
|
157
|
+
await this.jira(`/rest/api/3/issue/${issue.id}/comment/${existing.id}`, { method: "PUT", body: JSON.stringify({ body }) });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body }) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -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;
|
|
@@ -16,6 +17,18 @@ export interface WatchDeps {
|
|
|
16
17
|
baseBranch: string;
|
|
17
18
|
concurrency: number;
|
|
18
19
|
worktreesDir: string;
|
|
20
|
+
/**
|
|
21
|
+
* Symlink (or otherwise wire up) `<worktreePath>/.spf/data` to the MAIN
|
|
22
|
+
* repo's own persistent data_dir, called once per worktree right after
|
|
23
|
+
* it's created. Without this, a chain run's session/trace data resolves
|
|
24
|
+
* relative to `cwd` (the worktree — see ChainContext's doc comment) and
|
|
25
|
+
* lands in a fresh, throwaway `.spf/data` that `cleanupWorktree` deletes
|
|
26
|
+
* along with the rest of the worktree once the issue finishes: no trace
|
|
27
|
+
* in `spf ui`, and no trace anywhere at all after cleanup. Injected (not
|
|
28
|
+
* called directly) so watch.ts's own tests never touch the real
|
|
29
|
+
* filesystem for it.
|
|
30
|
+
*/
|
|
31
|
+
linkDataDir: (worktreePath: string) => void;
|
|
19
32
|
dryRun: boolean;
|
|
20
33
|
runChain: (opts: {
|
|
21
34
|
prompt: string;
|
|
@@ -25,7 +38,7 @@ export interface WatchDeps {
|
|
|
25
38
|
log: (message: string) => void;
|
|
26
39
|
}
|
|
27
40
|
export interface WatchRunState {
|
|
28
|
-
inflight: Set<
|
|
41
|
+
inflight: Set<string>;
|
|
29
42
|
}
|
|
30
43
|
export declare function createWatchState(): WatchRunState;
|
|
31
44
|
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,40 +127,56 @@ 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 {
|
|
132
|
+
// Both worktreePath and branch are fully deterministic from issue.id —
|
|
133
|
+
// the only way either could already exist is a previous spf watch
|
|
134
|
+
// attempt for THIS issue that never reached its own cleanup (killed
|
|
135
|
+
// mid-run, crashed, machine restart). `git worktree add -b` refuses
|
|
136
|
+
// outright if the branch already exists ("fatal: a branch named '...'
|
|
137
|
+
// already exists"), which without this would permanently block the
|
|
138
|
+
// issue from ever being claimed again — it'd fail this same way on
|
|
139
|
+
// every single retry. Safe to clear unconditionally: worktreeRemove/
|
|
140
|
+
// deleteLocalBranch are both no-ops if there's nothing to remove.
|
|
141
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
129
142
|
deps.git.fetch("origin", deps.baseBranch);
|
|
130
143
|
deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
|
|
144
|
+
deps.linkDataDir(worktreePath);
|
|
131
145
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
|
|
132
146
|
const prompt = `${issue.title}\n\n${issue.body}`.trim();
|
|
133
147
|
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
|
|
134
148
|
if (!result.accepted) {
|
|
135
|
-
deps.log(`watch:
|
|
149
|
+
deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
|
|
136
150
|
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
151
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
138
152
|
return;
|
|
139
153
|
}
|
|
140
154
|
const wtGit = deps.worktreeGit(worktreePath);
|
|
141
155
|
if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
|
|
142
|
-
deps.log(`watch:
|
|
156
|
+
deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
|
|
143
157
|
await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
|
|
144
158
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
145
159
|
return;
|
|
146
160
|
}
|
|
147
161
|
wtGit.push("origin", branch);
|
|
148
|
-
|
|
162
|
+
// No cross-linking magic keyword here on purpose (a code host paired
|
|
163
|
+
// with a different tracker has no "Closes #n" convention to hook into
|
|
164
|
+
// — see provider.ts) — the issue id in the title/body is plain text
|
|
165
|
+
// for humans, and, on a Jira+Bitbucket pairing, exactly what Jira's own
|
|
166
|
+
// Bitbucket integration scans for to link the PR automatically.
|
|
167
|
+
const pr = await deps.codeHost.openPr({
|
|
149
168
|
branch,
|
|
150
|
-
title: `${issue.title} (
|
|
151
|
-
body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}
|
|
169
|
+
title: `${issue.title} (${issue.id})`,
|
|
170
|
+
body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.`,
|
|
152
171
|
base: deps.baseBranch,
|
|
153
172
|
});
|
|
154
173
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
|
|
155
174
|
await deps.provider.transition(issue, "review");
|
|
156
|
-
deps.log(`watch:
|
|
175
|
+
deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
|
|
157
176
|
}
|
|
158
177
|
catch (error) {
|
|
159
178
|
const message = error.message;
|
|
160
|
-
deps.log(`watch:
|
|
179
|
+
deps.log(`watch: ${issue.id}: error: ${message}`);
|
|
161
180
|
await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
|
|
162
181
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
163
182
|
}
|
|
@@ -170,20 +189,20 @@ export async function claimNewWork(deps, state) {
|
|
|
170
189
|
for (const issue of eligible) {
|
|
171
190
|
if (state.inflight.size >= deps.concurrency)
|
|
172
191
|
break;
|
|
173
|
-
if (state.inflight.has(issue.
|
|
192
|
+
if (state.inflight.has(issue.id))
|
|
174
193
|
continue;
|
|
175
194
|
if (deps.dryRun) {
|
|
176
|
-
deps.log(`watch: [dry-run] would claim
|
|
195
|
+
deps.log(`watch: [dry-run] would claim ${issue.id} (${issue.title}) and run chain "${deps.chain}"`);
|
|
177
196
|
continue;
|
|
178
197
|
}
|
|
179
198
|
const claimed = await deps.provider.claim(issue);
|
|
180
199
|
if (!claimed) {
|
|
181
|
-
deps.log(`watch:
|
|
200
|
+
deps.log(`watch: ${issue.id} lost the claim race this tick — skipping`);
|
|
182
201
|
continue;
|
|
183
202
|
}
|
|
184
|
-
deps.log(`watch: claimed
|
|
185
|
-
state.inflight.add(issue.
|
|
186
|
-
runIssue(deps, issue).finally(() => state.inflight.delete(issue.
|
|
203
|
+
deps.log(`watch: claimed ${issue.id}: ${issue.title}`);
|
|
204
|
+
state.inflight.add(issue.id);
|
|
205
|
+
runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
|
|
187
206
|
}
|
|
188
207
|
}
|
|
189
208
|
/** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */
|