@gr8ful/spf 0.5.1 → 0.7.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 +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +48 -2
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +174 -11
- package/dist/core/watch.js +531 -33
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
|
|
12
12
|
* construction — matching the reference implementation's pattern and this
|
|
13
|
-
* project's existing env-var-for-credentials philosophy.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* project's existing env-var-for-credentials philosophy. `listByLabel`
|
|
14
|
+
* paginates up to `MAX_LIST_PAGES` (500 issues per label query) — no longer
|
|
15
|
+
* "not this version's problem to solve," now that priority ordering makes a
|
|
16
|
+
* truncated first page a correctness bug (an old, high-priority issue past
|
|
17
|
+
* page 1 would silently lose to a new low-priority one), not just a missed
|
|
18
|
+
* issue. A repo past even that cap gets a loud warning, never a silent
|
|
19
|
+
* truncation — see `listByLabel`'s own doc comment.
|
|
17
20
|
*/
|
|
18
|
-
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
21
|
+
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueComment, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
22
|
+
import type { RefinedIssue } from "../data_types.ts";
|
|
19
23
|
/** 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
24
|
export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
|
|
21
25
|
export type IssueKind = (typeof ISSUE_KINDS)[number];
|
|
@@ -28,6 +32,8 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
|
|
|
28
32
|
private gh;
|
|
29
33
|
private label;
|
|
30
34
|
private typeLabel;
|
|
35
|
+
/** Mirrors `core/refine.ts`'s own module-level `priorityLabel()` — that one stays provider-agnostic (a plain string, no `this`); this one is `ensureLabels()`'s seeding half. */
|
|
36
|
+
private priorityLabel;
|
|
31
37
|
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
32
38
|
private getLabel;
|
|
33
39
|
/**
|
|
@@ -40,11 +46,32 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
|
|
|
40
46
|
private ensureOneLabel;
|
|
41
47
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
42
48
|
private toIssue;
|
|
49
|
+
/**
|
|
50
|
+
* `sort=created&direction=asc` is stated, not inherited: without it,
|
|
51
|
+
* GitHub's own default (`created`, `desc` — newest first) is what
|
|
52
|
+
* `claimNewWork` used to walk, silently, which is why a >100-issue ready
|
|
53
|
+
* backlog used to be a real risk before pagination existed at all.
|
|
54
|
+
* Oldest-first is also `orderEligible`'s own final tiebreaker (`watch.ts`),
|
|
55
|
+
* so this method's order and that function's are the same order absent a
|
|
56
|
+
* priority/affinity difference — no redundant client-side re-sort needed
|
|
57
|
+
* for the plain case.
|
|
58
|
+
*
|
|
59
|
+
* Paginates up to `MAX_LIST_PAGES` (500 issues) — no longer "this
|
|
60
|
+
* version's problem to solve": a client-side priority sort over a
|
|
61
|
+
* truncated first page would silently misorder or hide real work, which is
|
|
62
|
+
* worse than the old unordered-100-issues behavior it replaces. A repo
|
|
63
|
+
* that still exceeds the cap gets a loud, named warning rather than a
|
|
64
|
+
* silent truncation.
|
|
65
|
+
*/
|
|
43
66
|
private listByLabel;
|
|
44
67
|
listEligible(): Promise<Issue[]>;
|
|
45
68
|
listInState(state: WatchState, opts?: {
|
|
46
69
|
includeAll?: boolean;
|
|
47
70
|
}): Promise<Issue[]>;
|
|
71
|
+
/** `null` on a real 404 — deleted, or (state defaults to open in a plain fetch) an issue GitHub itself considers gone. Any other non-2xx still throws, same as `gh()`. */
|
|
72
|
+
getIssue(id: string): Promise<Issue | null>;
|
|
73
|
+
/** `GET .../sub_issues` — the read-back half of `linkChild`; what makes container roll-up possible (`rollUp` in `watch.ts`). Closed children ARE returned (no `state` filter) — roll-up needs to see a `blocked` child too, to correctly NOT finish the container. */
|
|
74
|
+
listChildren(parent: Issue): Promise<Issue[]>;
|
|
48
75
|
claim(issue: Issue, opts?: {
|
|
49
76
|
from?: WatchState;
|
|
50
77
|
to?: WatchState;
|
|
@@ -58,11 +85,18 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
|
|
|
58
85
|
base: string;
|
|
59
86
|
}): Promise<PrRef>;
|
|
60
87
|
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
61
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s
|
|
90
|
+
* module doc). `input.kind` is unused here: GitHub has no native
|
|
91
|
+
* issue-type field the way Jira does, and `input.labels` already carries
|
|
92
|
+
* `<prefix>:type:<kind>` for GitHub's own bookkeeping — the parameter
|
|
93
|
+
* exists on the shared interface for `JiraProvider`'s sake.
|
|
94
|
+
*/
|
|
62
95
|
createIssue(input: {
|
|
63
96
|
title: string;
|
|
64
97
|
body: string;
|
|
65
98
|
labels: string[];
|
|
99
|
+
kind: RefinedIssue["kind"];
|
|
66
100
|
}): Promise<Issue>;
|
|
67
101
|
/**
|
|
68
102
|
* `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
|
|
@@ -73,7 +107,13 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
|
|
|
73
107
|
* 100 sub-issues per parent, 8 levels of nesting.
|
|
74
108
|
*/
|
|
75
109
|
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
110
|
+
/** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
|
|
111
|
+
private fetchComments;
|
|
76
112
|
private findMarkerComment;
|
|
77
113
|
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
78
114
|
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|
|
115
|
+
/** Oldest-first (GitHub's own comment order), the hidden marker comment filtered out. */
|
|
116
|
+
listComments(issue: Issue): Promise<IssueComment[]>;
|
|
117
|
+
/** `state_reason: "completed"` — the refine lane's own reason for closing a spec once it's fully decomposed; see `finishSpec` in `watch.ts`. */
|
|
118
|
+
closeIssue(issue: Issue): Promise<void>;
|
|
79
119
|
}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
const API = "https://api.github.com";
|
|
2
|
-
const STATES = [
|
|
2
|
+
const STATES = [
|
|
3
|
+
"ready",
|
|
4
|
+
"working",
|
|
5
|
+
"review",
|
|
6
|
+
"done",
|
|
7
|
+
"blocked",
|
|
8
|
+
"spec-ready",
|
|
9
|
+
"refining",
|
|
10
|
+
"refined",
|
|
11
|
+
"needs-feedback",
|
|
12
|
+
"continue-refinement",
|
|
13
|
+
"spec-in-progress",
|
|
14
|
+
];
|
|
3
15
|
const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*?\})\s*-->/s;
|
|
16
|
+
/** `listByLabel`'s pagination bound — see its own doc comment. */
|
|
17
|
+
const MAX_LIST_PAGES = 5;
|
|
4
18
|
/** 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
19
|
export const ISSUE_KINDS = ["epic", "feature", "story", "bug", "task"];
|
|
6
20
|
// GitHub label colors are 6 hex digits, no leading '#'.
|
|
@@ -13,6 +27,9 @@ const LABEL_META = {
|
|
|
13
27
|
"spec-ready": { color: "0e8a16", description: "spf watch's refine lane will claim this spec on its next poll" },
|
|
14
28
|
refining: { color: "fbca04", description: "spf watch has claimed this spec and is decomposing it into issues" },
|
|
15
29
|
refined: { color: "c2e0c6", description: "generated by spf watch's refine lane — promote to spf:ready when it's worth building" },
|
|
30
|
+
"needs-feedback": { color: "d93f0b", description: "spf's refiner needs a human answer before it can finish decomposing this spec" },
|
|
31
|
+
"continue-refinement": { color: "0e8a16", description: "add this once you've answered — spf will resume refining from where it left off" },
|
|
32
|
+
"spec-in-progress": { color: "1d76db", description: "decomposed and published — waiting on every generated issue to reach spf:done" },
|
|
16
33
|
};
|
|
17
34
|
const TYPE_LABEL_META = {
|
|
18
35
|
epic: { color: "5319e7", description: "a container generated by spf watch's refine lane — not directly workable" },
|
|
@@ -21,6 +38,24 @@ const TYPE_LABEL_META = {
|
|
|
21
38
|
bug: { color: "e99695", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
|
|
22
39
|
task: { color: "d4c5f9", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
|
|
23
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* What `claimNewWork` (`watch.ts`) schedules by — see `RefinedPrioritySchema`
|
|
43
|
+
* in `data_types.ts`. Deliberately a LABEL, not this repo's own GitHub
|
|
44
|
+
* Projects v2 "Priority" field (a single-select with its own Urgent/High/
|
|
45
|
+
* Medium/Low options): a Projects v2 value is GraphQL-only, needs a
|
|
46
|
+
* `project` token scope and a project id in config, and has no Jira
|
|
47
|
+
* equivalent — the exact abstraction `jira_provider.ts` exists to protect.
|
|
48
|
+
* If a repo's board also carries a Priority field, the two are independent
|
|
49
|
+
* and nothing reconciles them; `spf watch` obeys only this label. See
|
|
50
|
+
* README.md's `spf watch` section for the reconciliation-by-hand caveat.
|
|
51
|
+
*/
|
|
52
|
+
const PRIORITIES = ["p0", "p1", "p2", "p3"];
|
|
53
|
+
const PRIORITY_LABEL_META = {
|
|
54
|
+
p0: { color: "b60205", description: "drop everything — a broken promise to users, or blocking everything else" },
|
|
55
|
+
p1: { color: "d93f0b", description: "the spec's core value — the slices without which it isn't shipped" },
|
|
56
|
+
p2: { color: "fbca04", description: "the default — real scope, can wait a cycle" },
|
|
57
|
+
p3: { color: "c5def5", description: "worth writing down, not worth scheduling yet" },
|
|
58
|
+
};
|
|
24
59
|
export class GitHubProvider {
|
|
25
60
|
repo;
|
|
26
61
|
labelPrefix;
|
|
@@ -56,6 +91,10 @@ export class GitHubProvider {
|
|
|
56
91
|
typeLabel(kind) {
|
|
57
92
|
return `${this.labelPrefix}:type:${kind}`;
|
|
58
93
|
}
|
|
94
|
+
/** Mirrors `core/refine.ts`'s own module-level `priorityLabel()` — that one stays provider-agnostic (a plain string, no `this`); this one is `ensureLabels()`'s seeding half. */
|
|
95
|
+
priorityLabel(priority) {
|
|
96
|
+
return `${this.labelPrefix}:priority:${priority}`;
|
|
97
|
+
}
|
|
59
98
|
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
60
99
|
async getLabel(name) {
|
|
61
100
|
const response = await fetch(`${API}/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
|
|
@@ -103,6 +142,13 @@ export class GitHubProvider {
|
|
|
103
142
|
const { color, description } = TYPE_LABEL_META[kind];
|
|
104
143
|
await this.ensureOneLabel(this.typeLabel(kind), color, description, result);
|
|
105
144
|
}
|
|
145
|
+
// `<prefix>:priority:p0..p3` — what claimNewWork schedules by (see
|
|
146
|
+
// PRIORITY_LABEL_META's doc comment above on why this is a label, not
|
|
147
|
+
// this repo's own Projects v2 Priority field).
|
|
148
|
+
for (const priority of PRIORITIES) {
|
|
149
|
+
const { color, description } = PRIORITY_LABEL_META[priority];
|
|
150
|
+
await this.ensureOneLabel(this.priorityLabel(priority), color, description, result);
|
|
151
|
+
}
|
|
106
152
|
return result;
|
|
107
153
|
}
|
|
108
154
|
toIssue(raw) {
|
|
@@ -114,9 +160,36 @@ export class GitHubProvider {
|
|
|
114
160
|
labels: raw.labels.map((l) => (typeof l === "string" ? l : l.name)),
|
|
115
161
|
};
|
|
116
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* `sort=created&direction=asc` is stated, not inherited: without it,
|
|
165
|
+
* GitHub's own default (`created`, `desc` — newest first) is what
|
|
166
|
+
* `claimNewWork` used to walk, silently, which is why a >100-issue ready
|
|
167
|
+
* backlog used to be a real risk before pagination existed at all.
|
|
168
|
+
* Oldest-first is also `orderEligible`'s own final tiebreaker (`watch.ts`),
|
|
169
|
+
* so this method's order and that function's are the same order absent a
|
|
170
|
+
* priority/affinity difference — no redundant client-side re-sort needed
|
|
171
|
+
* for the plain case.
|
|
172
|
+
*
|
|
173
|
+
* Paginates up to `MAX_LIST_PAGES` (500 issues) — no longer "this
|
|
174
|
+
* version's problem to solve": a client-side priority sort over a
|
|
175
|
+
* truncated first page would silently misorder or hide real work, which is
|
|
176
|
+
* worse than the old unordered-100-issues behavior it replaces. A repo
|
|
177
|
+
* that still exceeds the cap gets a loud, named warning rather than a
|
|
178
|
+
* silent truncation.
|
|
179
|
+
*/
|
|
117
180
|
async listByLabel(label, state) {
|
|
118
|
-
const
|
|
119
|
-
|
|
181
|
+
const results = [];
|
|
182
|
+
for (let page = 1; page <= MAX_LIST_PAGES; page++) {
|
|
183
|
+
const raw = await this.gh(`/repos/${this.repo}/issues?labels=${encodeURIComponent(label)}&state=${state}&sort=created&direction=asc&per_page=100&page=${page}`);
|
|
184
|
+
results.push(...raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i)));
|
|
185
|
+
if (raw.length < 100)
|
|
186
|
+
return results; // short page — this was the last one
|
|
187
|
+
if (page === MAX_LIST_PAGES) {
|
|
188
|
+
console.error(`spf watch: listByLabel(${JSON.stringify(label)}) hit the ${MAX_LIST_PAGES}-page (${MAX_LIST_PAGES * 100}-issue) cap — ` +
|
|
189
|
+
`older ${JSON.stringify(label)} issues past this cap are invisible this tick`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return results;
|
|
120
193
|
}
|
|
121
194
|
async listEligible() {
|
|
122
195
|
return this.listByLabel(this.label("ready"), "open");
|
|
@@ -124,6 +197,24 @@ export class GitHubProvider {
|
|
|
124
197
|
async listInState(state, opts) {
|
|
125
198
|
return this.listByLabel(this.label(state), opts?.includeAll ? "all" : "open");
|
|
126
199
|
}
|
|
200
|
+
/** `null` on a real 404 — deleted, or (state defaults to open in a plain fetch) an issue GitHub itself considers gone. Any other non-2xx still throws, same as `gh()`. */
|
|
201
|
+
async getIssue(id) {
|
|
202
|
+
const response = await fetch(`${API}/repos/${this.repo}/issues/${id}`, {
|
|
203
|
+
headers: { Authorization: `Bearer ${this.token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
|
204
|
+
});
|
|
205
|
+
if (response.status === 404)
|
|
206
|
+
return null;
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
const detail = await response.text().catch(() => "");
|
|
209
|
+
throw new Error(`GitHub GET /repos/${this.repo}/issues/${id} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
210
|
+
}
|
|
211
|
+
return this.toIssue((await response.json()));
|
|
212
|
+
}
|
|
213
|
+
/** `GET .../sub_issues` — the read-back half of `linkChild`; what makes container roll-up possible (`rollUp` in `watch.ts`). Closed children ARE returned (no `state` filter) — roll-up needs to see a `blocked` child too, to correctly NOT finish the container. */
|
|
214
|
+
async listChildren(parent) {
|
|
215
|
+
const raw = await this.gh(`/repos/${this.repo}/issues/${parent.id}/sub_issues`);
|
|
216
|
+
return raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i));
|
|
217
|
+
}
|
|
127
218
|
async claim(issue, opts) {
|
|
128
219
|
const from = this.label(opts?.from ?? "ready");
|
|
129
220
|
const to = this.label(opts?.to ?? "working");
|
|
@@ -196,7 +287,13 @@ export class GitHubProvider {
|
|
|
196
287
|
}
|
|
197
288
|
return { merged: detail.merged, state: detail.state, ciStatus };
|
|
198
289
|
}
|
|
199
|
-
/**
|
|
290
|
+
/**
|
|
291
|
+
* `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s
|
|
292
|
+
* module doc). `input.kind` is unused here: GitHub has no native
|
|
293
|
+
* issue-type field the way Jira does, and `input.labels` already carries
|
|
294
|
+
* `<prefix>:type:<kind>` for GitHub's own bookkeeping — the parameter
|
|
295
|
+
* exists on the shared interface for `JiraProvider`'s sake.
|
|
296
|
+
*/
|
|
200
297
|
async createIssue(input) {
|
|
201
298
|
const raw = await this.gh(`/repos/${this.repo}/issues`, {
|
|
202
299
|
method: "POST",
|
|
@@ -221,8 +318,12 @@ export class GitHubProvider {
|
|
|
221
318
|
body: JSON.stringify({ sub_issue_id: Number(child.internal_id) }),
|
|
222
319
|
});
|
|
223
320
|
}
|
|
321
|
+
/** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
|
|
322
|
+
async fetchComments(issueId) {
|
|
323
|
+
return this.gh(`/repos/${this.repo}/issues/${issueId}/comments?per_page=100`);
|
|
324
|
+
}
|
|
224
325
|
async findMarkerComment(issueId) {
|
|
225
|
-
const comments = await this.
|
|
326
|
+
const comments = await this.fetchComments(issueId);
|
|
226
327
|
let found = null;
|
|
227
328
|
for (const c of comments) {
|
|
228
329
|
const match = MARKER_RE.exec(c.body || "");
|
|
@@ -251,4 +352,18 @@ export class GitHubProvider {
|
|
|
251
352
|
await this.gh(`/repos/${this.repo}/issues/${issue.id}/comments`, { method: "POST", body: JSON.stringify({ body }) });
|
|
252
353
|
}
|
|
253
354
|
}
|
|
355
|
+
/** Oldest-first (GitHub's own comment order), the hidden marker comment filtered out. */
|
|
356
|
+
async listComments(issue) {
|
|
357
|
+
const comments = await this.fetchComments(issue.id);
|
|
358
|
+
return comments
|
|
359
|
+
.filter((c) => !MARKER_RE.test(c.body || ""))
|
|
360
|
+
.map((c) => ({ id: String(c.id), author: c.user?.login ?? "unknown", created_at: c.created_at, body: c.body || "" }));
|
|
361
|
+
}
|
|
362
|
+
/** `state_reason: "completed"` — the refine lane's own reason for closing a spec once it's fully decomposed; see `finishSpec` in `watch.ts`. */
|
|
363
|
+
async closeIssue(issue) {
|
|
364
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}`, {
|
|
365
|
+
method: "PATCH",
|
|
366
|
+
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
254
369
|
}
|
|
@@ -30,25 +30,45 @@
|
|
|
30
30
|
* Jira labels are freeform strings with no color/description registry to
|
|
31
31
|
* seed, unlike GitHub's.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
33
|
+
* Implements `IssueAuthoringProvider` — the refine lane's create/link seam
|
|
34
|
+
* — via Jira's native issue types plus the `parent` field: `createIssue`
|
|
35
|
+
* maps a `RefinedIssue.kind` to a real Jira issue type name through the
|
|
36
|
+
* configured `issueTypes` map (project setups rename/customize these often
|
|
37
|
+
* enough that hardcoding "Epic"/"Story"/"Bug"/"Task" would break silently
|
|
38
|
+
* on plenty of real projects), and `linkChild` sets the child's `parent`
|
|
39
|
+
* field to the parent's key. This is the MODERN mechanism only — it works
|
|
40
|
+
* on team-managed projects and on company-managed projects with Jira's
|
|
41
|
+
* current issue-hierarchy setting; it does NOT fall back to the legacy
|
|
42
|
+
* "Epic Link" custom field some older company-managed projects still rely
|
|
43
|
+
* on. A project not configured for `parent`-based hierarchy gets Jira's own
|
|
44
|
+
* API error surfaced as-is (this file's `jira()` wrapper never swallows a
|
|
45
|
+
* non-2xx), never silently ignored. `listChildren` reads the hierarchy back
|
|
46
|
+
* via a `parent = "<id>"` JQL search (same POST-body pattern
|
|
47
|
+
* `searchByLabel` already uses below), which is what makes container
|
|
48
|
+
* roll-up (`rollUp` in `watch.ts`) work here too, not just on GitHub.
|
|
49
|
+
* `validateIssueTypes()` (below) is a plain method, not part of any shared
|
|
50
|
+
* interface — GitHub has no equivalent concept — that `spf watch init` and
|
|
51
|
+
* `spf watch`'s own startup check (`cli/commands/watch.ts`) both call to
|
|
52
|
+
* catch a misconfigured `issueTypes` entry before anything unattended runs.
|
|
53
|
+
*
|
|
54
|
+
* One accepted platform limitation: Jira doesn't support Epic-under-Epic
|
|
55
|
+
* nesting the way GitHub's sub-issues API supports up to 8 levels. A
|
|
56
|
+
* refiner tree with a `feature` node parented under another `epic`/
|
|
57
|
+
* `feature` (both mapping to Jira's Epic type by default) surfaces a real
|
|
58
|
+
* Jira API error at publish time — a genuine platform difference, not
|
|
59
|
+
* something this file tries to paper over.
|
|
42
60
|
*/
|
|
43
|
-
import type {
|
|
44
|
-
|
|
61
|
+
import type { RefinedIssue, JiraIssueTypeMap } from "../data_types.ts";
|
|
62
|
+
import type { EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
|
|
63
|
+
export declare class JiraProvider implements IssueProvider, IssueAuthoringProvider {
|
|
45
64
|
private readonly baseUrl;
|
|
46
65
|
private readonly projectKey;
|
|
47
66
|
private readonly labelPrefix;
|
|
48
67
|
private readonly email;
|
|
49
68
|
private readonly apiToken;
|
|
69
|
+
private readonly issueTypes;
|
|
50
70
|
constructor(baseUrl: string, // e.g. "https://your-domain.atlassian.net", no trailing slash
|
|
51
|
-
projectKey: string, labelPrefix: string, email: string, apiToken: string);
|
|
71
|
+
projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap);
|
|
52
72
|
private authHeader;
|
|
53
73
|
private jira;
|
|
54
74
|
private label;
|
|
@@ -73,13 +93,64 @@ export declare class JiraProvider implements IssueProvider {
|
|
|
73
93
|
* could make a `review`-labeled issue vanish from an unfiltered query.
|
|
74
94
|
*/
|
|
75
95
|
listInState(state: WatchState): Promise<Issue[]>;
|
|
96
|
+
/**
|
|
97
|
+
* `null` on a real 404 (deleted, or a key that never existed) — any other
|
|
98
|
+
* non-2xx still throws, same as `jira()`. What `claimNewWork`'s frontier
|
|
99
|
+
* check (`watch.ts`) uses to look up a `blocked_by` id's current labels.
|
|
100
|
+
* Unlike `github_provider.ts`'s hidden `spf-refine:` body marker, this
|
|
101
|
+
* file has no equivalent for a Jira issue's OWN parent/blockers — a
|
|
102
|
+
* caller here only ever sees what `blocked_by` it already has in hand,
|
|
103
|
+
* never discovers it from the issue body itself.
|
|
104
|
+
*/
|
|
105
|
+
getIssue(id: string): Promise<Issue | null>;
|
|
106
|
+
/** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). `POST /rest/api/3/issue`'s response is `{id, key, self}`, not the full read shape `toIssue` expects, so this constructs the returned `Issue` locally rather than re-fetching. */
|
|
107
|
+
createIssue(input: {
|
|
108
|
+
title: string;
|
|
109
|
+
body: string;
|
|
110
|
+
labels: string[];
|
|
111
|
+
kind: RefinedIssue["kind"];
|
|
112
|
+
}): Promise<Issue>;
|
|
113
|
+
/**
|
|
114
|
+
* The modern mechanism only — Jira's `parent` field, not the legacy
|
|
115
|
+
* "Epic Link" custom field. Works on team-managed projects and on
|
|
116
|
+
* company-managed projects with Jira's current issue-hierarchy setting;
|
|
117
|
+
* a project not configured for it surfaces Jira's own API error here,
|
|
118
|
+
* unmodified — see this file's module comment on the accepted
|
|
119
|
+
* Epic-under-Epic limitation this implies.
|
|
120
|
+
*/
|
|
121
|
+
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
122
|
+
/** The read-back half of `linkChild` — same JQL-in-body pattern as `searchByLabel`, since a GET with query params silently returns nothing on this endpoint (see the module comment). What makes container roll-up (`rollUp` in `watch.ts`) work on Jira too. */
|
|
123
|
+
listChildren(parent: Issue): Promise<Issue[]>;
|
|
124
|
+
/**
|
|
125
|
+
* Read-only validation of the configured `issueTypes` map against this
|
|
126
|
+
* project's real issue types — what `spf watch init` and `spf watch`'s
|
|
127
|
+
* own refine-lane startup check (`cli/commands/watch.ts`) both call to
|
|
128
|
+
* catch a bad mapping before anything unattended runs on it, rather than
|
|
129
|
+
* discovering it the first time a spec tries to publish. Not part of
|
|
130
|
+
* `IssueAuthoringProvider` — GitHub has no equivalent concept, since it
|
|
131
|
+
* has no native issue-type field to get wrong.
|
|
132
|
+
*
|
|
133
|
+
* Fetches a single page (Jira's own default: 50) — a project with more
|
|
134
|
+
* issue types than that is exotic enough to warrant a loud warning
|
|
135
|
+
* rather than a silent multi-page fetch loop for an edge case this
|
|
136
|
+
* unlikely.
|
|
137
|
+
*/
|
|
138
|
+
validateIssueTypes(): Promise<Array<{
|
|
139
|
+
kind: string;
|
|
140
|
+
jiraType: string;
|
|
141
|
+
exists: boolean;
|
|
142
|
+
}>>;
|
|
76
143
|
claim(issue: Issue, opts?: {
|
|
77
144
|
from?: WatchState;
|
|
78
145
|
to?: WatchState;
|
|
79
146
|
}): Promise<boolean>;
|
|
80
147
|
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
81
148
|
comment(issue: Issue, body: string): Promise<void>;
|
|
149
|
+
/** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
|
|
150
|
+
private fetchComments;
|
|
82
151
|
private findMarkerComment;
|
|
152
|
+
/** Oldest-first (Jira's own comment order), the hidden `[spf-watch-marker]` comment filtered out. */
|
|
153
|
+
listComments(issue: Issue): Promise<IssueComment[]>;
|
|
83
154
|
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
84
155
|
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|
|
85
156
|
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
const STATES = [
|
|
1
|
+
const STATES = [
|
|
2
|
+
"ready",
|
|
3
|
+
"working",
|
|
4
|
+
"review",
|
|
5
|
+
"done",
|
|
6
|
+
"blocked",
|
|
7
|
+
"spec-ready",
|
|
8
|
+
"refining",
|
|
9
|
+
"refined",
|
|
10
|
+
"needs-feedback",
|
|
11
|
+
"continue-refinement",
|
|
12
|
+
"spec-in-progress",
|
|
13
|
+
];
|
|
2
14
|
const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*?\})/s;
|
|
3
15
|
function toAdf(text) {
|
|
4
16
|
return {
|
|
@@ -24,13 +36,15 @@ export class JiraProvider {
|
|
|
24
36
|
labelPrefix;
|
|
25
37
|
email;
|
|
26
38
|
apiToken;
|
|
39
|
+
issueTypes;
|
|
27
40
|
constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
|
|
28
|
-
projectKey, labelPrefix, email, apiToken) {
|
|
41
|
+
projectKey, labelPrefix, email, apiToken, issueTypes) {
|
|
29
42
|
this.baseUrl = baseUrl;
|
|
30
43
|
this.projectKey = projectKey;
|
|
31
44
|
this.labelPrefix = labelPrefix;
|
|
32
45
|
this.email = email;
|
|
33
46
|
this.apiToken = apiToken;
|
|
47
|
+
this.issueTypes = issueTypes;
|
|
34
48
|
}
|
|
35
49
|
authHeader() {
|
|
36
50
|
return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
|
|
@@ -103,6 +117,86 @@ export class JiraProvider {
|
|
|
103
117
|
async listInState(state) {
|
|
104
118
|
return this.searchByLabel(this.label(state));
|
|
105
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* `null` on a real 404 (deleted, or a key that never existed) — any other
|
|
122
|
+
* non-2xx still throws, same as `jira()`. What `claimNewWork`'s frontier
|
|
123
|
+
* check (`watch.ts`) uses to look up a `blocked_by` id's current labels.
|
|
124
|
+
* Unlike `github_provider.ts`'s hidden `spf-refine:` body marker, this
|
|
125
|
+
* file has no equivalent for a Jira issue's OWN parent/blockers — a
|
|
126
|
+
* caller here only ever sees what `blocked_by` it already has in hand,
|
|
127
|
+
* never discovers it from the issue body itself.
|
|
128
|
+
*/
|
|
129
|
+
async getIssue(id) {
|
|
130
|
+
const response = await fetch(`${this.baseUrl}/rest/api/3/issue/${id}?fields=summary,description,labels`, {
|
|
131
|
+
headers: { Authorization: this.authHeader(), Accept: "application/json" },
|
|
132
|
+
});
|
|
133
|
+
if (response.status === 404)
|
|
134
|
+
return null;
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const detail = await response.text().catch(() => "");
|
|
137
|
+
throw new Error(`Jira GET /rest/api/3/issue/${id} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
138
|
+
}
|
|
139
|
+
return this.toIssue((await response.json()));
|
|
140
|
+
}
|
|
141
|
+
/** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). `POST /rest/api/3/issue`'s response is `{id, key, self}`, not the full read shape `toIssue` expects, so this constructs the returned `Issue` locally rather than re-fetching. */
|
|
142
|
+
async createIssue(input) {
|
|
143
|
+
const response = await this.jira("/rest/api/3/issue", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
fields: {
|
|
147
|
+
project: { key: this.projectKey },
|
|
148
|
+
summary: input.title,
|
|
149
|
+
description: toAdf(input.body),
|
|
150
|
+
issuetype: { name: this.issueTypes[input.kind] },
|
|
151
|
+
labels: input.labels,
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
return { id: response.key, title: input.title, body: input.body, labels: input.labels };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The modern mechanism only — Jira's `parent` field, not the legacy
|
|
159
|
+
* "Epic Link" custom field. Works on team-managed projects and on
|
|
160
|
+
* company-managed projects with Jira's current issue-hierarchy setting;
|
|
161
|
+
* a project not configured for it surfaces Jira's own API error here,
|
|
162
|
+
* unmodified — see this file's module comment on the accepted
|
|
163
|
+
* Epic-under-Epic limitation this implies.
|
|
164
|
+
*/
|
|
165
|
+
async linkChild(parent, child) {
|
|
166
|
+
await this.jira(`/rest/api/3/issue/${child.id}`, { method: "PUT", body: JSON.stringify({ fields: { parent: { key: parent.id } } }) });
|
|
167
|
+
}
|
|
168
|
+
/** The read-back half of `linkChild` — same JQL-in-body pattern as `searchByLabel`, since a GET with query params silently returns nothing on this endpoint (see the module comment). What makes container roll-up (`rollUp` in `watch.ts`) work on Jira too. */
|
|
169
|
+
async listChildren(parent) {
|
|
170
|
+
const jql = `parent = ${JSON.stringify(parent.id)}`;
|
|
171
|
+
const result = await this.jira("/rest/api/3/search/jql", {
|
|
172
|
+
method: "POST",
|
|
173
|
+
body: JSON.stringify({ jql, maxResults: 100, fields: ["summary", "description", "labels"] }),
|
|
174
|
+
});
|
|
175
|
+
return result.issues.map((i) => this.toIssue(i));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Read-only validation of the configured `issueTypes` map against this
|
|
179
|
+
* project's real issue types — what `spf watch init` and `spf watch`'s
|
|
180
|
+
* own refine-lane startup check (`cli/commands/watch.ts`) both call to
|
|
181
|
+
* catch a bad mapping before anything unattended runs on it, rather than
|
|
182
|
+
* discovering it the first time a spec tries to publish. Not part of
|
|
183
|
+
* `IssueAuthoringProvider` — GitHub has no equivalent concept, since it
|
|
184
|
+
* has no native issue-type field to get wrong.
|
|
185
|
+
*
|
|
186
|
+
* Fetches a single page (Jira's own default: 50) — a project with more
|
|
187
|
+
* issue types than that is exotic enough to warrant a loud warning
|
|
188
|
+
* rather than a silent multi-page fetch loop for an edge case this
|
|
189
|
+
* unlikely.
|
|
190
|
+
*/
|
|
191
|
+
async validateIssueTypes() {
|
|
192
|
+
const result = await this.jira(`/rest/api/3/issue/createmeta/${encodeURIComponent(this.projectKey)}/issuetypes`);
|
|
193
|
+
if (result.isLast === false) {
|
|
194
|
+
console.error(`spf watch: ${this.projectKey} has more issue types than one page reports — validateIssueTypes() may be missing some; ` +
|
|
195
|
+
`re-run with a narrower watch.jira.issue_types check if a false mismatch shows up`);
|
|
196
|
+
}
|
|
197
|
+
const available = new Set(result.issueTypes.map((t) => t.name));
|
|
198
|
+
return Object.entries(this.issueTypes).map(([kind, jiraType]) => ({ kind, jiraType, exists: available.has(jiraType) }));
|
|
199
|
+
}
|
|
106
200
|
async claim(issue, opts) {
|
|
107
201
|
const from = this.label(opts?.from ?? "ready");
|
|
108
202
|
const to = this.label(opts?.to ?? "working");
|
|
@@ -129,10 +223,15 @@ export class JiraProvider {
|
|
|
129
223
|
async comment(issue, body) {
|
|
130
224
|
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
|
|
131
225
|
}
|
|
132
|
-
|
|
226
|
+
/** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
|
|
227
|
+
async fetchComments(issueId) {
|
|
133
228
|
const result = await this.jira(`/rest/api/3/issue/${issueId}/comment?maxResults=100`);
|
|
229
|
+
return result.comments;
|
|
230
|
+
}
|
|
231
|
+
async findMarkerComment(issueId) {
|
|
232
|
+
const comments = await this.fetchComments(issueId);
|
|
134
233
|
let found = null;
|
|
135
|
-
for (const c of
|
|
234
|
+
for (const c of comments) {
|
|
136
235
|
const match = MARKER_RE.exec(adfToText(c.body));
|
|
137
236
|
if (!match)
|
|
138
237
|
continue;
|
|
@@ -145,6 +244,13 @@ export class JiraProvider {
|
|
|
145
244
|
}
|
|
146
245
|
return found;
|
|
147
246
|
}
|
|
247
|
+
/** Oldest-first (Jira's own comment order), the hidden `[spf-watch-marker]` comment filtered out. */
|
|
248
|
+
async listComments(issue) {
|
|
249
|
+
const comments = await this.fetchComments(issue.id);
|
|
250
|
+
return comments
|
|
251
|
+
.filter((c) => !MARKER_RE.test(adfToText(c.body)))
|
|
252
|
+
.map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body: adfToText(c.body) }));
|
|
253
|
+
}
|
|
148
254
|
async readMarker(issue) {
|
|
149
255
|
const found = await this.findMarkerComment(issue.id);
|
|
150
256
|
return found?.marker ?? null;
|