@gr8ful/spf 0.3.0 → 0.5.0
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 +177 -9
- package/assets/defaults/spf.config.yaml +22 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +104 -3
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +15 -2
- package/dist/chains/context.d.ts +39 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +75 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +237 -18
- package/dist/chains/steps.js +477 -58
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +324 -10
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +108 -4
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +43 -5
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +214 -16
- package/dist/cli/index.js +63 -6
- package/dist/cli/interview.js +81 -9
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +182 -4
- package/dist/core/data_types.js +141 -2
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/issues/github_provider.d.ts +35 -9
- package/dist/core/issues/github_provider.js +76 -28
- package/dist/core/issues/jira_provider.d.ts +14 -1
- package/dist/core/issues/jira_provider.js +9 -7
- package/dist/core/issues/provider.d.ts +77 -15
- package/dist/core/issues/provider.js +7 -4
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +152 -0
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +67 -1
- package/dist/core/watch.js +217 -13
- package/dist/test/chains.test.js +9 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +71 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +131 -3
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +189 -0
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +297 -6
- package/package.json +5 -5
|
@@ -16,8 +16,37 @@ export declare function isRepoAt(cwd: string): boolean;
|
|
|
16
16
|
* not inside one — ADWs run fine in a non-git dir; only a commit phase
|
|
17
17
|
* requires a repo. Always absolute, so it is safe to hand to a subprocess
|
|
18
18
|
* regardless of where the ADW was launched from.
|
|
19
|
+
*
|
|
20
|
+
* `isRepoAt` only proves `git rev-parse --git-dir` succeeds, which is also
|
|
21
|
+
* true inside a bare repo and inside a `.git/` directory itself — neither
|
|
22
|
+
* has a work tree, so `--show-toplevel` fails there even though `isRepoAt`
|
|
23
|
+
* said yes. That failure is not a bug to propagate: there is still an
|
|
24
|
+
* honest answer (`cwd` itself), so it falls back rather than throwing —
|
|
25
|
+
* this function's whole contract is "never throws, always returns some root."
|
|
19
26
|
*/
|
|
20
27
|
export declare function findRepoRoot(cwd: string): string;
|
|
28
|
+
export interface CommitterIdentity {
|
|
29
|
+
name: string;
|
|
30
|
+
email: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `git config user.name`/`user.email` at `repoRoot` — `undefined` (never a
|
|
34
|
+
* fallback literal) when either is unset, so a caller can tell "no identity
|
|
35
|
+
* configured" from "identity is the empty string."
|
|
36
|
+
*
|
|
37
|
+
* Deliberately NOT `utils.engineerName()`: that helper tries the
|
|
38
|
+
* `ENGINEER_NAME` env var (spoofable by an operator), then `git config
|
|
39
|
+
* user.name`, then `$USER`/`$USERNAME`, and finally falls back to the
|
|
40
|
+
* literal string `"engineer"` when nothing is set — fine for a phase's
|
|
41
|
+
* display `owner`, but a `Signed-off-by:` trailer is a git attestation, and
|
|
42
|
+
* this is the one thing on this branch that ends up in one (see
|
|
43
|
+
* `chains/simple_sdlc.ts`'s `decideSignoff` and `chains/steps.ts`'s
|
|
44
|
+
* `commitEnvelope`). A trailer needs the identity git itself would use for
|
|
45
|
+
* the commit — `undefined` here means the caller records the sign-off
|
|
46
|
+
* decision in the trace anyway and skips the trailer with a logged note,
|
|
47
|
+
* rather than inventing a name for it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function committerIdentity(repoRoot: string): CommitterIdentity | undefined;
|
|
21
50
|
export interface GitHandle {
|
|
22
51
|
currentBranch(): string;
|
|
23
52
|
createBranch(name: string): string;
|
package/dist/core/git_helper.js
CHANGED
|
@@ -28,13 +28,53 @@ export function isRepoAt(cwd) {
|
|
|
28
28
|
* not inside one — ADWs run fine in a non-git dir; only a commit phase
|
|
29
29
|
* requires a repo. Always absolute, so it is safe to hand to a subprocess
|
|
30
30
|
* regardless of where the ADW was launched from.
|
|
31
|
+
*
|
|
32
|
+
* `isRepoAt` only proves `git rev-parse --git-dir` succeeds, which is also
|
|
33
|
+
* true inside a bare repo and inside a `.git/` directory itself — neither
|
|
34
|
+
* has a work tree, so `--show-toplevel` fails there even though `isRepoAt`
|
|
35
|
+
* said yes. That failure is not a bug to propagate: there is still an
|
|
36
|
+
* honest answer (`cwd` itself), so it falls back rather than throwing —
|
|
37
|
+
* this function's whole contract is "never throws, always returns some root."
|
|
31
38
|
*/
|
|
32
39
|
export function findRepoRoot(cwd) {
|
|
33
40
|
if (isRepoAt(cwd)) {
|
|
34
|
-
|
|
41
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8" });
|
|
42
|
+
if (result.status === 0)
|
|
43
|
+
return path.resolve(result.stdout.trim());
|
|
35
44
|
}
|
|
36
45
|
return path.resolve(cwd);
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* `git config user.name`/`user.email` at `repoRoot` — `undefined` (never a
|
|
49
|
+
* fallback literal) when either is unset, so a caller can tell "no identity
|
|
50
|
+
* configured" from "identity is the empty string."
|
|
51
|
+
*
|
|
52
|
+
* Deliberately NOT `utils.engineerName()`: that helper tries the
|
|
53
|
+
* `ENGINEER_NAME` env var (spoofable by an operator), then `git config
|
|
54
|
+
* user.name`, then `$USER`/`$USERNAME`, and finally falls back to the
|
|
55
|
+
* literal string `"engineer"` when nothing is set — fine for a phase's
|
|
56
|
+
* display `owner`, but a `Signed-off-by:` trailer is a git attestation, and
|
|
57
|
+
* this is the one thing on this branch that ends up in one (see
|
|
58
|
+
* `chains/simple_sdlc.ts`'s `decideSignoff` and `chains/steps.ts`'s
|
|
59
|
+
* `commitEnvelope`). A trailer needs the identity git itself would use for
|
|
60
|
+
* the commit — `undefined` here means the caller records the sign-off
|
|
61
|
+
* decision in the trace anyway and skips the trailer with a logged note,
|
|
62
|
+
* rather than inventing a name for it.
|
|
63
|
+
*/
|
|
64
|
+
export function committerIdentity(repoRoot) {
|
|
65
|
+
const name = gitConfigValue(repoRoot, "user.name");
|
|
66
|
+
const email = gitConfigValue(repoRoot, "user.email");
|
|
67
|
+
if (!name || !email)
|
|
68
|
+
return undefined;
|
|
69
|
+
return { name, email };
|
|
70
|
+
}
|
|
71
|
+
/** `""` on anything short of a clean, non-empty value — unset, unreadable, or blank all read the same to a caller that only wants "do we have one?" */
|
|
72
|
+
function gitConfigValue(repoRoot, key) {
|
|
73
|
+
const result = spawnSync("git", ["config", "--get", key], { cwd: repoRoot, encoding: "utf-8" });
|
|
74
|
+
if (result.status !== 0)
|
|
75
|
+
return "";
|
|
76
|
+
return result.stdout.trim();
|
|
77
|
+
}
|
|
38
78
|
/** Every operation this returns is bound to `repoRoot` — never `process.cwd()`. */
|
|
39
79
|
export function makeGit(repoRoot) {
|
|
40
80
|
const run = (args) => git(args, repoRoot);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub REST implementation of
|
|
3
|
-
* — one class, since GitHub natively is
|
|
4
|
-
*
|
|
2
|
+
* GitHub REST implementation of `IssueProvider`, `CodeHostProvider`, AND
|
|
3
|
+
* `IssueAuthoringProvider` — one class, since GitHub natively is an issue
|
|
4
|
+
* tracker, a code host, and (via sub-issues) an issue-hierarchy API all at
|
|
5
|
+
* once — via Node 22's native `fetch()`: deliberately not `octokit`, whose
|
|
5
6
|
* full meta-package resolves to ~82MB of installed dependencies (`@octokit/app`,
|
|
6
|
-
* `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which
|
|
7
|
-
*
|
|
7
|
+
* `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which is
|
|
8
|
+
* a couple dozen REST calls, none of them exotic. `spf`'s own package stays
|
|
9
|
+
* dependency-free either way.
|
|
8
10
|
*
|
|
9
11
|
* Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
|
|
10
12
|
* construction — matching the reference implementation's pattern and this
|
|
@@ -13,8 +15,11 @@
|
|
|
13
15
|
* makes (a repo with >100 open `<prefix>:ready` issues at once is not this
|
|
14
16
|
* version's problem to solve).
|
|
15
17
|
*/
|
|
16
|
-
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
17
|
-
|
|
18
|
+
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
19
|
+
/** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
|
|
20
|
+
export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
|
|
21
|
+
export type IssueKind = (typeof ISSUE_KINDS)[number];
|
|
22
|
+
export declare class GitHubProvider implements IssueProvider, CodeHostProvider, IssueAuthoringProvider {
|
|
18
23
|
private readonly repo;
|
|
19
24
|
private readonly labelPrefix;
|
|
20
25
|
private readonly token;
|
|
@@ -22,14 +27,17 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
22
27
|
labelPrefix: string, token: string);
|
|
23
28
|
private gh;
|
|
24
29
|
private label;
|
|
30
|
+
private typeLabel;
|
|
25
31
|
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
26
32
|
private getLabel;
|
|
27
33
|
/**
|
|
28
|
-
* Idempotent by inspection, not by "create and catch a 422": GET
|
|
34
|
+
* Idempotent by inspection, not by "create and catch a 422": GET the
|
|
29
35
|
* label first, then create/update/leave alone depending on what's
|
|
30
36
|
* actually there. One fewer request in the common "already correct"
|
|
31
37
|
* case, and no brittle matching against GitHub's error-message text.
|
|
38
|
+
* Shared by `ensureLabels()`'s state-label and type-label passes.
|
|
32
39
|
*/
|
|
40
|
+
private ensureOneLabel;
|
|
33
41
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
34
42
|
private toIssue;
|
|
35
43
|
private listByLabel;
|
|
@@ -37,7 +45,10 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
37
45
|
listInState(state: WatchState, opts?: {
|
|
38
46
|
includeAll?: boolean;
|
|
39
47
|
}): Promise<Issue[]>;
|
|
40
|
-
claim(issue: Issue
|
|
48
|
+
claim(issue: Issue, opts?: {
|
|
49
|
+
from?: WatchState;
|
|
50
|
+
to?: WatchState;
|
|
51
|
+
}): Promise<boolean>;
|
|
41
52
|
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
42
53
|
comment(issue: Issue, body: string): Promise<void>;
|
|
43
54
|
openPr(opts: {
|
|
@@ -47,6 +58,21 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
47
58
|
base: string;
|
|
48
59
|
}): Promise<PrRef>;
|
|
49
60
|
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
61
|
+
/** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
|
|
62
|
+
createIssue(input: {
|
|
63
|
+
title: string;
|
|
64
|
+
body: string;
|
|
65
|
+
labels: string[];
|
|
66
|
+
}): Promise<Issue>;
|
|
67
|
+
/**
|
|
68
|
+
* `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
|
|
69
|
+
* native sub-issue link. Confirmed against GitHub's own REST docs: the
|
|
70
|
+
* body param is `sub_issue_id`, the CHILD's database id, not its issue
|
|
71
|
+
* number — hence `linkChild` requiring `child.internal_id` rather than
|
|
72
|
+
* `child.id`. GitHub's documented limits (not enforced client-side here):
|
|
73
|
+
* 100 sub-issues per parent, 8 levels of nesting.
|
|
74
|
+
*/
|
|
75
|
+
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
50
76
|
private findMarkerComment;
|
|
51
77
|
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
52
78
|
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const API = "https://api.github.com";
|
|
2
|
-
const STATES = ["ready", "working", "review", "done", "blocked"];
|
|
2
|
+
const STATES = ["ready", "working", "review", "done", "blocked", "spec-ready", "refining", "refined"];
|
|
3
3
|
const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*?\})\s*-->/s;
|
|
4
|
+
/** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
|
|
5
|
+
export const ISSUE_KINDS = ["epic", "feature", "story", "bug", "task"];
|
|
4
6
|
// GitHub label colors are 6 hex digits, no leading '#'.
|
|
5
7
|
const LABEL_META = {
|
|
6
8
|
ready: { color: "0e8a16", description: "spf watch will claim this issue on its next poll" },
|
|
@@ -8,6 +10,16 @@ const LABEL_META = {
|
|
|
8
10
|
review: { color: "1d76db", description: "spf watch opened a PR for this issue — awaiting merge" },
|
|
9
11
|
done: { color: "5319e7", description: "spf watch's PR for this issue merged" },
|
|
10
12
|
blocked: { color: "d93f0b", description: "spf watch gave up — needs a human" },
|
|
13
|
+
"spec-ready": { color: "0e8a16", description: "spf watch's refine lane will claim this spec on its next poll" },
|
|
14
|
+
refining: { color: "fbca04", description: "spf watch has claimed this spec and is decomposing it into issues" },
|
|
15
|
+
refined: { color: "c2e0c6", description: "generated by spf watch's refine lane — promote to spf:ready when it's worth building" },
|
|
16
|
+
};
|
|
17
|
+
const TYPE_LABEL_META = {
|
|
18
|
+
epic: { color: "5319e7", description: "a container generated by spf watch's refine lane — not directly workable" },
|
|
19
|
+
feature: { color: "1d76db", description: "a container generated by spf watch's refine lane — not directly workable" },
|
|
20
|
+
story: { color: "bfd4f2", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
|
|
21
|
+
bug: { color: "e99695", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
|
|
22
|
+
task: { color: "d4c5f9", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
|
|
11
23
|
};
|
|
12
24
|
export class GitHubProvider {
|
|
13
25
|
repo;
|
|
@@ -41,6 +53,9 @@ export class GitHubProvider {
|
|
|
41
53
|
label(state) {
|
|
42
54
|
return `${this.labelPrefix}:${state}`;
|
|
43
55
|
}
|
|
56
|
+
typeLabel(kind) {
|
|
57
|
+
return `${this.labelPrefix}:type:${kind}`;
|
|
58
|
+
}
|
|
44
59
|
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
45
60
|
async getLabel(name) {
|
|
46
61
|
const response = await fetch(`${API}/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
|
|
@@ -55,39 +70,45 @@ export class GitHubProvider {
|
|
|
55
70
|
return (await response.json());
|
|
56
71
|
}
|
|
57
72
|
/**
|
|
58
|
-
* Idempotent by inspection, not by "create and catch a 422": GET
|
|
73
|
+
* Idempotent by inspection, not by "create and catch a 422": GET the
|
|
59
74
|
* label first, then create/update/leave alone depending on what's
|
|
60
75
|
* actually there. One fewer request in the common "already correct"
|
|
61
76
|
* case, and no brittle matching against GitHub's error-message text.
|
|
77
|
+
* Shared by `ensureLabels()`'s state-label and type-label passes.
|
|
62
78
|
*/
|
|
79
|
+
async ensureOneLabel(name, color, description, result) {
|
|
80
|
+
const existing = await this.getLabel(name);
|
|
81
|
+
if (!existing) {
|
|
82
|
+
await this.gh(`/repos/${this.repo}/labels`, { method: "POST", body: JSON.stringify({ name, color, description }) });
|
|
83
|
+
result.created.push(name);
|
|
84
|
+
}
|
|
85
|
+
else if (existing.color !== color || (existing.description ?? "") !== description) {
|
|
86
|
+
await this.gh(`/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
|
|
87
|
+
method: "PATCH",
|
|
88
|
+
body: JSON.stringify({ color, description }),
|
|
89
|
+
});
|
|
90
|
+
result.updated.push(name);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
result.unchanged.push(name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
63
96
|
async ensureLabels() {
|
|
64
|
-
const
|
|
65
|
-
const updated = [];
|
|
66
|
-
const unchanged = [];
|
|
97
|
+
const result = { created: [], updated: [], unchanged: [] };
|
|
67
98
|
for (const state of STATES) {
|
|
68
|
-
const name = this.label(state);
|
|
69
99
|
const { color, description } = LABEL_META[state];
|
|
70
|
-
|
|
71
|
-
if (!existing) {
|
|
72
|
-
await this.gh(`/repos/${this.repo}/labels`, { method: "POST", body: JSON.stringify({ name, color, description }) });
|
|
73
|
-
created.push(name);
|
|
74
|
-
}
|
|
75
|
-
else if (existing.color !== color || (existing.description ?? "") !== description) {
|
|
76
|
-
await this.gh(`/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
|
|
77
|
-
method: "PATCH",
|
|
78
|
-
body: JSON.stringify({ color, description }),
|
|
79
|
-
});
|
|
80
|
-
updated.push(name);
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
unchanged.push(name);
|
|
84
|
-
}
|
|
100
|
+
await this.ensureOneLabel(this.label(state), color, description, result);
|
|
85
101
|
}
|
|
86
|
-
|
|
102
|
+
for (const kind of ISSUE_KINDS) {
|
|
103
|
+
const { color, description } = TYPE_LABEL_META[kind];
|
|
104
|
+
await this.ensureOneLabel(this.typeLabel(kind), color, description, result);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
87
107
|
}
|
|
88
108
|
toIssue(raw) {
|
|
89
109
|
return {
|
|
90
110
|
id: String(raw.number),
|
|
111
|
+
internal_id: String(raw.id),
|
|
91
112
|
title: raw.title,
|
|
92
113
|
body: raw.body ?? "",
|
|
93
114
|
labels: raw.labels.map((l) => (typeof l === "string" ? l : l.name)),
|
|
@@ -103,22 +124,24 @@ export class GitHubProvider {
|
|
|
103
124
|
async listInState(state, opts) {
|
|
104
125
|
return this.listByLabel(this.label(state), opts?.includeAll ? "all" : "open");
|
|
105
126
|
}
|
|
106
|
-
async claim(issue) {
|
|
107
|
-
|
|
127
|
+
async claim(issue, opts) {
|
|
128
|
+
const from = this.label(opts?.from ?? "ready");
|
|
129
|
+
const to = this.label(opts?.to ?? "working");
|
|
130
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels/${encodeURIComponent(from)}`, {
|
|
108
131
|
method: "DELETE",
|
|
109
132
|
}).catch(() => undefined); // already gone is fine
|
|
110
133
|
await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels`, {
|
|
111
134
|
method: "POST",
|
|
112
|
-
body: JSON.stringify({ labels: [
|
|
135
|
+
body: JSON.stringify({ labels: [to] }),
|
|
113
136
|
});
|
|
114
137
|
const fresh = await this.gh(`/repos/${this.repo}/issues/${issue.id}`);
|
|
115
138
|
const labels = this.toIssue(fresh).labels;
|
|
116
|
-
const claimed = labels.includes(
|
|
139
|
+
const claimed = labels.includes(to) && !labels.includes(from);
|
|
117
140
|
if (!claimed) {
|
|
118
|
-
// Lost the race (or something else relabeled it) — put
|
|
141
|
+
// Lost the race (or something else relabeled it) — put the source label back so it's not stuck.
|
|
119
142
|
await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels`, {
|
|
120
143
|
method: "POST",
|
|
121
|
-
body: JSON.stringify({ labels: [
|
|
144
|
+
body: JSON.stringify({ labels: [from] }),
|
|
122
145
|
}).catch(() => undefined);
|
|
123
146
|
}
|
|
124
147
|
return claimed;
|
|
@@ -173,6 +196,31 @@ export class GitHubProvider {
|
|
|
173
196
|
}
|
|
174
197
|
return { merged: detail.merged, state: detail.state, ciStatus };
|
|
175
198
|
}
|
|
199
|
+
/** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
|
|
200
|
+
async createIssue(input) {
|
|
201
|
+
const raw = await this.gh(`/repos/${this.repo}/issues`, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
body: JSON.stringify({ title: input.title, body: input.body, labels: input.labels }),
|
|
204
|
+
});
|
|
205
|
+
return this.toIssue(raw);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
|
|
209
|
+
* native sub-issue link. Confirmed against GitHub's own REST docs: the
|
|
210
|
+
* body param is `sub_issue_id`, the CHILD's database id, not its issue
|
|
211
|
+
* number — hence `linkChild` requiring `child.internal_id` rather than
|
|
212
|
+
* `child.id`. GitHub's documented limits (not enforced client-side here):
|
|
213
|
+
* 100 sub-issues per parent, 8 levels of nesting.
|
|
214
|
+
*/
|
|
215
|
+
async linkChild(parent, child) {
|
|
216
|
+
if (!child.internal_id) {
|
|
217
|
+
throw new Error(`linkChild: child issue #${child.id} has no internal_id — only an issue this provider just created/fetched can be linked`);
|
|
218
|
+
}
|
|
219
|
+
await this.gh(`/repos/${this.repo}/issues/${parent.id}/sub_issues`, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: JSON.stringify({ sub_issue_id: Number(child.internal_id) }),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
176
224
|
async findMarkerComment(issueId) {
|
|
177
225
|
const comments = await this.gh(`/repos/${this.repo}/issues/${issueId}/comments?per_page=100`);
|
|
178
226
|
let found = null;
|
|
@@ -29,6 +29,16 @@
|
|
|
29
29
|
* `ensureLabels()` is a no-op that reports the labels this run will use:
|
|
30
30
|
* Jira labels are freeform strings with no color/description registry to
|
|
31
31
|
* seed, unlike GitHub's.
|
|
32
|
+
*
|
|
33
|
+
* Does NOT implement `IssueAuthoringProvider` — the refine lane's create/
|
|
34
|
+
* link seam. Jira maps cleanly in principle (native issue types plus a
|
|
35
|
+
* `parent` field give a real hierarchy, unlike the label trick this file
|
|
36
|
+
* already leans on for state), but that is a real implementation, not a
|
|
37
|
+
* one-line stub, so it is a deliberate follow-on rather than done here.
|
|
38
|
+
* `resolveIssueAuthoringProvider()` (`cli/commands/watch.ts`) returns `null`
|
|
39
|
+
* for this provider, and `watch.refine.enabled: true` with
|
|
40
|
+
* `issue_provider: jira` fails loudly at startup rather than silently
|
|
41
|
+
* running a refine lane that can never publish anything.
|
|
32
42
|
*/
|
|
33
43
|
import type { EnsureLabelsResult, Issue, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
|
|
34
44
|
export declare class JiraProvider implements IssueProvider {
|
|
@@ -63,7 +73,10 @@ export declare class JiraProvider implements IssueProvider {
|
|
|
63
73
|
* could make a `review`-labeled issue vanish from an unfiltered query.
|
|
64
74
|
*/
|
|
65
75
|
listInState(state: WatchState): Promise<Issue[]>;
|
|
66
|
-
claim(issue: Issue
|
|
76
|
+
claim(issue: Issue, opts?: {
|
|
77
|
+
from?: WatchState;
|
|
78
|
+
to?: WatchState;
|
|
79
|
+
}): Promise<boolean>;
|
|
67
80
|
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
68
81
|
comment(issue: Issue, body: string): Promise<void>;
|
|
69
82
|
private findMarkerComment;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const STATES = ["ready", "working", "review", "done", "blocked"];
|
|
1
|
+
const STATES = ["ready", "working", "review", "done", "blocked", "spec-ready", "refining", "refined"];
|
|
2
2
|
const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*?\})/s;
|
|
3
3
|
function toAdf(text) {
|
|
4
4
|
return {
|
|
@@ -103,16 +103,18 @@ export class JiraProvider {
|
|
|
103
103
|
async listInState(state) {
|
|
104
104
|
return this.searchByLabel(this.label(state));
|
|
105
105
|
}
|
|
106
|
-
async claim(issue) {
|
|
107
|
-
const
|
|
108
|
-
|
|
106
|
+
async claim(issue, opts) {
|
|
107
|
+
const from = this.label(opts?.from ?? "ready");
|
|
108
|
+
const to = this.label(opts?.to ?? "working");
|
|
109
|
+
const next = issue.labels.filter((l) => l !== from);
|
|
110
|
+
next.push(to);
|
|
109
111
|
await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
|
|
110
112
|
const fresh = await this.jira(`/rest/api/3/issue/${issue.id}?fields=summary,description,labels`);
|
|
111
113
|
const labels = fresh.fields.labels;
|
|
112
|
-
const claimed = labels.includes(
|
|
114
|
+
const claimed = labels.includes(to) && !labels.includes(from);
|
|
113
115
|
if (!claimed) {
|
|
114
|
-
const revert = labels.filter((l) => l !==
|
|
115
|
-
revert.push(
|
|
116
|
+
const revert = labels.filter((l) => l !== to);
|
|
117
|
+
revert.push(from);
|
|
116
118
|
await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: revert } }) }).catch(() => undefined);
|
|
117
119
|
}
|
|
118
120
|
return claimed;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The seams `spf watch` drives — the abstraction the user's own reference
|
|
3
|
+
* implementation (a GitHub-issues SDLC poller) never had: its GitHub client
|
|
4
|
+
* is a concrete class referenced by type everywhere, so adding a second
|
|
5
|
+
* tracker would mean reworking the poll loop itself.
|
|
6
6
|
*
|
|
7
7
|
* `IssueProvider` (tracker: list/claim/transition/comment/markers) and
|
|
8
8
|
* `CodeHostProvider` (PR lifecycle: open/status) are deliberately separate
|
|
@@ -13,19 +13,45 @@
|
|
|
13
13
|
* implements only `IssueProvider`, `bitbucket_provider.ts` only
|
|
14
14
|
* `CodeHostProvider` — any tracker x host combination is just config
|
|
15
15
|
* (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
|
|
16
|
+
* `IssueAuthoringProvider` (create/link, at the bottom of this file) is a
|
|
17
|
+
* third, again separate — the refine lane's own need, optional per tracker,
|
|
18
|
+
* and orthogonal to which one is the code host.
|
|
16
19
|
*
|
|
17
20
|
* The label-as-state-machine design is deliberate, copied from that same
|
|
18
21
|
* reference: `transition()` is the ONE mutator, so every state change is
|
|
19
22
|
* traceable to one call site, and a provider can layer notifications
|
|
20
23
|
* (Slack, a webhook, whatever) on top of it without the poll loop caring.
|
|
21
24
|
*/
|
|
22
|
-
|
|
25
|
+
/**
|
|
26
|
+
* `spec-ready`/`refining` drive the SECOND lane's state machine (a product
|
|
27
|
+
* spec being decomposed — see `reconcileRefining`/`claimSpecs` in
|
|
28
|
+
* `watch.ts`), independent of the build lane's own `ready..blocked` states.
|
|
29
|
+
* `refined` is not a lane state at all — it never appears on the left of a
|
|
30
|
+
* `transition()` call. It is the terminal label a generated LEAF issue
|
|
31
|
+
* (story/bug/task) gets, marking it awaiting a human's promotion to `ready`.
|
|
32
|
+
* All eight still live in one `WatchState` union (not two separate unions)
|
|
33
|
+
* because `transition()`'s "strip every `<prefix>:<state>` label, then add
|
|
34
|
+
* one" logic (see `github_provider.ts`/`jira_provider.ts`) has to know about
|
|
35
|
+
* every one of them to strip correctly, and `ensureLabels()` seeds all of
|
|
36
|
+
* them from one `STATES` array.
|
|
37
|
+
*/
|
|
38
|
+
export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined";
|
|
23
39
|
export interface Issue {
|
|
24
40
|
/** Opaque tracker identifier: a GitHub issue number stringified ("42"), a Jira key ("PROJ-123"). */
|
|
25
41
|
id: string;
|
|
26
42
|
title: string;
|
|
27
43
|
body: string;
|
|
28
44
|
labels: string[];
|
|
45
|
+
/**
|
|
46
|
+
* The tracker's own internal/database id, distinct from `id` (the
|
|
47
|
+
* human-facing number/key) — only populated where an authoring operation
|
|
48
|
+
* needs it. GitHub's sub-issue API is the reason this exists: `POST
|
|
49
|
+
* /repos/{o}/{r}/issues/{n}/sub_issues` takes `sub_issue_id` as the
|
|
50
|
+
* issue's database id, not its issue number, so `linkChild()` cannot work
|
|
51
|
+
* from `id` alone. `undefined` on any issue this provider didn't just
|
|
52
|
+
* create/fetch with that field available.
|
|
53
|
+
*/
|
|
54
|
+
internal_id?: string;
|
|
29
55
|
}
|
|
30
56
|
export interface PrRef {
|
|
31
57
|
number: number;
|
|
@@ -42,12 +68,18 @@ export interface PrStatus {
|
|
|
42
68
|
* on the issue itself — zero infrastructure, survives a daemon crash,
|
|
43
69
|
* human-readable. `attempt` bounds orphan-retry (see `watch.ts`); `ciFixes`
|
|
44
70
|
* is reserved for a future fix-loop, unused by the lean v1 poll logic.
|
|
71
|
+
* `refined` is the refine lane's own idempotency record: the ids of every
|
|
72
|
+
* issue a completed publish pass created for this spec. A re-claimed spec
|
|
73
|
+
* whose marker already lists them skips creation entirely — `to-tickets`
|
|
74
|
+
* (the skill this lane's prompt is ported from) has no such guard and
|
|
75
|
+
* duplicates every ticket on a re-run; this is what closes that gap.
|
|
45
76
|
*/
|
|
46
77
|
export interface WatchMarker {
|
|
47
78
|
worktree?: string;
|
|
48
79
|
branch?: string;
|
|
49
80
|
pr?: number;
|
|
50
81
|
attempt?: number;
|
|
82
|
+
refined?: string[];
|
|
51
83
|
}
|
|
52
84
|
/** What `ensureLabels()` actually did, per label — for `spf watch init`'s report. */
|
|
53
85
|
export interface EnsureLabelsResult {
|
|
@@ -58,11 +90,12 @@ export interface EnsureLabelsResult {
|
|
|
58
90
|
export interface IssueProvider {
|
|
59
91
|
/**
|
|
60
92
|
* Idempotently seed whatever this tracker needs for the state machine to
|
|
61
|
-
* work at all — GitHub:
|
|
62
|
-
*
|
|
63
|
-
* with
|
|
64
|
-
*
|
|
65
|
-
*
|
|
93
|
+
* work at all — GitHub: every `<prefix>:*` state label plus the
|
|
94
|
+
* `<prefix>:type:*` vocabulary the refine lane's generated issues carry,
|
|
95
|
+
* each with a color and description, created if missing and corrected if
|
|
96
|
+
* drifted. A tracker with no such concept (Jira labels are freeform
|
|
97
|
+
* strings, not seedable objects) can make this a no-op — `spf watch init`
|
|
98
|
+
* just reports whatever comes back, empty results included.
|
|
66
99
|
*/
|
|
67
100
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
68
101
|
/** Issues currently labeled `<prefix>:ready`. */
|
|
@@ -81,12 +114,20 @@ export interface IssueProvider {
|
|
|
81
114
|
includeAll?: boolean;
|
|
82
115
|
}): Promise<Issue[]>;
|
|
83
116
|
/**
|
|
84
|
-
* Move `ready` -> `
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* the
|
|
117
|
+
* Move `opts.from` (default `ready`) -> `opts.to` (default `working`),
|
|
118
|
+
* with a read-back verify (like the reference implementation's
|
|
119
|
+
* `claimIssue`) — not a true atomic claim, but enough to catch the common
|
|
120
|
+
* case; the real safety net against two daemons racing the same issue is
|
|
121
|
+
* `spf watch`'s own single-instance lockfile. Parameterized so the refine
|
|
122
|
+
* lane's `spec-ready -> refining` claim (see `claimSpecs` in `watch.ts`)
|
|
123
|
+
* reuses the identical DELETE-from/POST-to/read-back-verify dance the
|
|
124
|
+
* build lane's `ready -> working` claim already does, rather than a
|
|
125
|
+
* second copy of it per provider.
|
|
88
126
|
*/
|
|
89
|
-
claim(issue: Issue
|
|
127
|
+
claim(issue: Issue, opts?: {
|
|
128
|
+
from?: WatchState;
|
|
129
|
+
to?: WatchState;
|
|
130
|
+
}): Promise<boolean>;
|
|
90
131
|
/** The one state-mutating call. `detail`, if given, is also posted as a comment. */
|
|
91
132
|
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
92
133
|
comment(issue: Issue, body: string): Promise<void>;
|
|
@@ -111,3 +152,24 @@ export interface CodeHostProvider {
|
|
|
111
152
|
}): Promise<PrRef>;
|
|
112
153
|
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
113
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* The third seam: creating issues and linking them into a hierarchy — what
|
|
157
|
+
* the refine lane needs and neither `IssueProvider` nor `CodeHostProvider`
|
|
158
|
+
* provides (a tracker's read/claim/transition surface has no reason to
|
|
159
|
+
* create new work items). Kept separate rather than folded into
|
|
160
|
+
* `IssueProvider` for the same reason `CodeHostProvider` is separate: not
|
|
161
|
+
* every tracker can do this (Jira could, in principle, via its native issue
|
|
162
|
+
* types + `parent` field, but that is a real future implementation, not a
|
|
163
|
+
* one-line stub — see `jira_provider.ts`'s module comment), and a tracker
|
|
164
|
+
* that can't should be a `null` from `resolveIssueAuthoringProvider()`
|
|
165
|
+
* (`cli/commands/watch.ts`), not a method that throws at call time.
|
|
166
|
+
*/
|
|
167
|
+
export interface IssueAuthoringProvider {
|
|
168
|
+
createIssue(input: {
|
|
169
|
+
title: string;
|
|
170
|
+
body: string;
|
|
171
|
+
labels: string[];
|
|
172
|
+
}): Promise<Issue>;
|
|
173
|
+
/** Link `child` under `parent` using the tracker's native hierarchy — GitHub's sub-issues API today. */
|
|
174
|
+
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
175
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The seams `spf watch` drives — the abstraction the user's own reference
|
|
3
|
+
* implementation (a GitHub-issues SDLC poller) never had: its GitHub client
|
|
4
|
+
* is a concrete class referenced by type everywhere, so adding a second
|
|
5
|
+
* tracker would mean reworking the poll loop itself.
|
|
6
6
|
*
|
|
7
7
|
* `IssueProvider` (tracker: list/claim/transition/comment/markers) and
|
|
8
8
|
* `CodeHostProvider` (PR lifecycle: open/status) are deliberately separate
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* implements only `IssueProvider`, `bitbucket_provider.ts` only
|
|
14
14
|
* `CodeHostProvider` — any tracker x host combination is just config
|
|
15
15
|
* (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
|
|
16
|
+
* `IssueAuthoringProvider` (create/link, at the bottom of this file) is a
|
|
17
|
+
* third, again separate — the refine lane's own need, optional per tracker,
|
|
18
|
+
* and orthogonal to which one is the code host.
|
|
16
19
|
*
|
|
17
20
|
* The label-as-state-machine design is deliberate, copied from that same
|
|
18
21
|
* reference: `transition()` is the ONE mutator, so every state change is
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* predicate a `Notifier` applies — no separate per-kind severity table to
|
|
12
12
|
* keep in sync with this list.
|
|
13
13
|
*/
|
|
14
|
-
export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked";
|
|
14
|
+
export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked" | "spec_refined";
|
|
15
15
|
export interface NotifyEvent {
|
|
16
16
|
kind: NotifyKind;
|
|
17
17
|
/** "error" sends under both `events: errors` and `events: all`; "info" only under `all`. */
|