@gr8ful/spf 0.6.0 → 0.8.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 +122 -27
- package/assets/prompts/refiner/system.md +11 -1
- package/assets/prompts/refiner/user.md +9 -3
- package/assets/skill/references/config.md +51 -13
- package/assets/templates/ts.spf.config.yaml +6 -2
- package/dist/chains/context.d.ts +26 -0
- package/dist/chains/simple_sdlc.js +9 -0
- package/dist/chains/steps.d.ts +0 -27
- package/dist/chains/steps.js +21 -2
- package/dist/cli/ask.d.ts +13 -0
- package/dist/cli/ask.js +15 -1
- package/dist/cli/commands/doctor.js +47 -9
- package/dist/cli/commands/fanout.js +49 -5
- package/dist/cli/commands/init.js +11 -3
- package/dist/cli/commands/list.d.ts +1 -1
- package/dist/cli/commands/list.js +31 -12
- package/dist/cli/commands/phases.d.ts +1 -1
- package/dist/cli/commands/phases.js +18 -4
- package/dist/cli/commands/run.js +30 -2
- package/dist/cli/commands/sessions.d.ts +1 -1
- package/dist/cli/commands/sessions.js +11 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +93 -13
- package/dist/cli/index.js +4 -4
- package/dist/cli/interview.js +9 -5
- package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
- package/dist/cli/ui/fanout_dashboard.js +102 -0
- package/dist/cli/ui/ink_asker.d.ts +13 -0
- package/dist/cli/ui/ink_asker.js +247 -0
- package/dist/cli/ui/reports.d.ts +30 -0
- package/dist/cli/ui/reports.js +61 -0
- package/dist/cli/ui/run_dashboard.d.ts +15 -0
- package/dist/cli/ui/run_dashboard.js +131 -0
- package/dist/cli/ui/watch_dashboard.d.ts +22 -0
- package/dist/cli/ui/watch_dashboard.js +78 -0
- package/dist/core/console.d.ts +40 -1
- package/dist/core/console.js +25 -3
- package/dist/core/data_types.d.ts +108 -5
- package/dist/core/data_types.js +50 -5
- package/dist/core/fanout.d.ts +9 -0
- package/dist/core/fanout.js +6 -2
- package/dist/core/gates.js +24 -1
- package/dist/core/issues/github_provider.d.ts +39 -5
- package/dist/core/issues/github_provider.js +103 -4
- package/dist/core/issues/jira_provider.d.ts +79 -12
- package/dist/core/issues/jira_provider.js +97 -2
- package/dist/core/issues/provider.d.ts +73 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +5 -1
- package/dist/core/runner.js +2 -1
- package/dist/core/session.d.ts +7 -1
- package/dist/core/session.js +5 -1
- package/dist/core/watch.d.ts +86 -3
- package/dist/core/watch.js +353 -29
- package/package.json +6 -1
|
@@ -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,6 +93,53 @@ 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;
|
|
@@ -9,8 +9,21 @@ const STATES = [
|
|
|
9
9
|
"refined",
|
|
10
10
|
"needs-feedback",
|
|
11
11
|
"continue-refinement",
|
|
12
|
+
"spec-in-progress",
|
|
12
13
|
];
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* GREEDY capture, not lazy — same fix and same reasoning as
|
|
16
|
+
* `github_provider.ts`'s own `MARKER_RE`: `WatchMarker.feedback` nests its
|
|
17
|
+
* own object, so a lazy `\{.*?\}` stops at `feedback`'s own closing brace
|
|
18
|
+
* instead of the marker's outer one, producing unparseable JSON the moment
|
|
19
|
+
* a spec escalates even once — `readMarker` then returns `null` forever,
|
|
20
|
+
* `writeMarker` can never find the existing marker to edit in place (a new
|
|
21
|
+
* one gets posted every tick instead), and `buildSpecPrompt` never sees a
|
|
22
|
+
* valid `asked_at`, so a human's answer never gets flagged as one. Greedy
|
|
23
|
+
* backtracks from the end of the comment body to the true last `}` — safe
|
|
24
|
+
* here because nothing follows the JSON in this format at all.
|
|
25
|
+
*/
|
|
26
|
+
const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*\})/s;
|
|
14
27
|
function toAdf(text) {
|
|
15
28
|
return {
|
|
16
29
|
type: "doc",
|
|
@@ -35,13 +48,15 @@ export class JiraProvider {
|
|
|
35
48
|
labelPrefix;
|
|
36
49
|
email;
|
|
37
50
|
apiToken;
|
|
51
|
+
issueTypes;
|
|
38
52
|
constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
|
|
39
|
-
projectKey, labelPrefix, email, apiToken) {
|
|
53
|
+
projectKey, labelPrefix, email, apiToken, issueTypes) {
|
|
40
54
|
this.baseUrl = baseUrl;
|
|
41
55
|
this.projectKey = projectKey;
|
|
42
56
|
this.labelPrefix = labelPrefix;
|
|
43
57
|
this.email = email;
|
|
44
58
|
this.apiToken = apiToken;
|
|
59
|
+
this.issueTypes = issueTypes;
|
|
45
60
|
}
|
|
46
61
|
authHeader() {
|
|
47
62
|
return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
|
|
@@ -114,6 +129,86 @@ export class JiraProvider {
|
|
|
114
129
|
async listInState(state) {
|
|
115
130
|
return this.searchByLabel(this.label(state));
|
|
116
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* `null` on a real 404 (deleted, or a key that never existed) — any other
|
|
134
|
+
* non-2xx still throws, same as `jira()`. What `claimNewWork`'s frontier
|
|
135
|
+
* check (`watch.ts`) uses to look up a `blocked_by` id's current labels.
|
|
136
|
+
* Unlike `github_provider.ts`'s hidden `spf-refine:` body marker, this
|
|
137
|
+
* file has no equivalent for a Jira issue's OWN parent/blockers — a
|
|
138
|
+
* caller here only ever sees what `blocked_by` it already has in hand,
|
|
139
|
+
* never discovers it from the issue body itself.
|
|
140
|
+
*/
|
|
141
|
+
async getIssue(id) {
|
|
142
|
+
const response = await fetch(`${this.baseUrl}/rest/api/3/issue/${id}?fields=summary,description,labels`, {
|
|
143
|
+
headers: { Authorization: this.authHeader(), Accept: "application/json" },
|
|
144
|
+
});
|
|
145
|
+
if (response.status === 404)
|
|
146
|
+
return null;
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
const detail = await response.text().catch(() => "");
|
|
149
|
+
throw new Error(`Jira GET /rest/api/3/issue/${id} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
150
|
+
}
|
|
151
|
+
return this.toIssue((await response.json()));
|
|
152
|
+
}
|
|
153
|
+
/** `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. */
|
|
154
|
+
async createIssue(input) {
|
|
155
|
+
const response = await this.jira("/rest/api/3/issue", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
fields: {
|
|
159
|
+
project: { key: this.projectKey },
|
|
160
|
+
summary: input.title,
|
|
161
|
+
description: toAdf(input.body),
|
|
162
|
+
issuetype: { name: this.issueTypes[input.kind] },
|
|
163
|
+
labels: input.labels,
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
return { id: response.key, title: input.title, body: input.body, labels: input.labels };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The modern mechanism only — Jira's `parent` field, not the legacy
|
|
171
|
+
* "Epic Link" custom field. Works on team-managed projects and on
|
|
172
|
+
* company-managed projects with Jira's current issue-hierarchy setting;
|
|
173
|
+
* a project not configured for it surfaces Jira's own API error here,
|
|
174
|
+
* unmodified — see this file's module comment on the accepted
|
|
175
|
+
* Epic-under-Epic limitation this implies.
|
|
176
|
+
*/
|
|
177
|
+
async linkChild(parent, child) {
|
|
178
|
+
await this.jira(`/rest/api/3/issue/${child.id}`, { method: "PUT", body: JSON.stringify({ fields: { parent: { key: parent.id } } }) });
|
|
179
|
+
}
|
|
180
|
+
/** 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. */
|
|
181
|
+
async listChildren(parent) {
|
|
182
|
+
const jql = `parent = ${JSON.stringify(parent.id)}`;
|
|
183
|
+
const result = await this.jira("/rest/api/3/search/jql", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
body: JSON.stringify({ jql, maxResults: 100, fields: ["summary", "description", "labels"] }),
|
|
186
|
+
});
|
|
187
|
+
return result.issues.map((i) => this.toIssue(i));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Read-only validation of the configured `issueTypes` map against this
|
|
191
|
+
* project's real issue types — what `spf watch init` and `spf watch`'s
|
|
192
|
+
* own refine-lane startup check (`cli/commands/watch.ts`) both call to
|
|
193
|
+
* catch a bad mapping before anything unattended runs on it, rather than
|
|
194
|
+
* discovering it the first time a spec tries to publish. Not part of
|
|
195
|
+
* `IssueAuthoringProvider` — GitHub has no equivalent concept, since it
|
|
196
|
+
* has no native issue-type field to get wrong.
|
|
197
|
+
*
|
|
198
|
+
* Fetches a single page (Jira's own default: 50) — a project with more
|
|
199
|
+
* issue types than that is exotic enough to warrant a loud warning
|
|
200
|
+
* rather than a silent multi-page fetch loop for an edge case this
|
|
201
|
+
* unlikely.
|
|
202
|
+
*/
|
|
203
|
+
async validateIssueTypes() {
|
|
204
|
+
const result = await this.jira(`/rest/api/3/issue/createmeta/${encodeURIComponent(this.projectKey)}/issuetypes`);
|
|
205
|
+
if (result.isLast === false) {
|
|
206
|
+
console.error(`spf watch: ${this.projectKey} has more issue types than one page reports — validateIssueTypes() may be missing some; ` +
|
|
207
|
+
`re-run with a narrower watch.jira.issue_types check if a false mismatch shows up`);
|
|
208
|
+
}
|
|
209
|
+
const available = new Set(result.issueTypes.map((t) => t.name));
|
|
210
|
+
return Object.entries(this.issueTypes).map(([kind, jiraType]) => ({ kind, jiraType, exists: available.has(jiraType) }));
|
|
211
|
+
}
|
|
117
212
|
async claim(issue, opts) {
|
|
118
213
|
const from = this.label(opts?.from ?? "ready");
|
|
119
214
|
const to = this.label(opts?.to ?? "working");
|
|
@@ -9,19 +9,25 @@
|
|
|
9
9
|
* interfaces, not one bundled seam — a tracker and a code host are
|
|
10
10
|
* independent choices in practice (Jira issues against a Bitbucket repo is
|
|
11
11
|
* a real setup, not a hypothetical one). `github_provider.ts`'s single
|
|
12
|
-
* class implements
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
12
|
+
* class implements all three (GitHub natively is a tracker, a code host,
|
|
13
|
+
* AND an authoring API); `jira_provider.ts` implements `IssueProvider` and
|
|
14
|
+
* `IssueAuthoringProvider` (Jira is a tracker and can author, but never
|
|
15
|
+
* opens PRs); `bitbucket_provider.ts` only `CodeHostProvider` — any tracker
|
|
16
|
+
* x host combination is just config (`watch.issue_provider` x
|
|
17
|
+
* `watch.code_host`), never a poll-loop change.
|
|
16
18
|
* `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
|
-
*
|
|
19
|
+
* third, again separate — the refine lane's own need, optional per tracker
|
|
20
|
+
* (not every tracker's write API can author + link a hierarchy), and
|
|
21
|
+
* orthogonal to which one is the code host. `isAuthoringProvider()` (below)
|
|
22
|
+
* is how the rest of the codebase asks "can this provider author?" without
|
|
23
|
+
* caring which concrete class answers yes.
|
|
19
24
|
*
|
|
20
25
|
* The label-as-state-machine design is deliberate, copied from that same
|
|
21
26
|
* reference: `transition()` is the ONE mutator, so every state change is
|
|
22
27
|
* traceable to one call site, and a provider can layer notifications
|
|
23
28
|
* (Slack, a webhook, whatever) on top of it without the poll loop caring.
|
|
24
29
|
*/
|
|
30
|
+
import type { RefinedIssue } from "../data_types.ts";
|
|
25
31
|
/**
|
|
26
32
|
* `spec-ready`/`refining` drive the SECOND lane's state machine (a product
|
|
27
33
|
* spec being decomposed — see `reconcileRefining`/`claimSpecs` in
|
|
@@ -42,13 +48,23 @@
|
|
|
42
48
|
* the issue id) with the comment thread folded into the prompt. This can
|
|
43
49
|
* loop any number of rounds; there is no cap.
|
|
44
50
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* every
|
|
49
|
-
*
|
|
51
|
+
* `spec-in-progress` is where a spec lands once it's been decomposed and
|
|
52
|
+
* published — deliberately NOT `done` yet: a product manager watching this
|
|
53
|
+
* spec's status must not see "done" until every issue the refiner produced
|
|
54
|
+
* (every story/bug/task, and every feature/epic container once its own
|
|
55
|
+
* children finish — see `rollUp` in `watch.ts`) is itself `<prefix>:done`.
|
|
56
|
+
* `announceRefined` (`watch.ts`) makes the move `refining -> spec-in-progress`
|
|
57
|
+
* once publish succeeds; `finishTrackedSpecs` (`watch.ts`) polls every
|
|
58
|
+
* `spec-in-progress` spec each tick and moves it the rest of the way,
|
|
59
|
+
* `-> done`, once `WatchMarker.refined` is entirely `<prefix>:done`.
|
|
60
|
+
*
|
|
61
|
+
* All eleven still live in one `WatchState` union (not several separate
|
|
62
|
+
* unions) because `transition()`'s "strip every `<prefix>:<state>` label,
|
|
63
|
+
* then add one" logic (see `github_provider.ts`/`jira_provider.ts`) has to
|
|
64
|
+
* know about every one of them to strip correctly, and `ensureLabels()`
|
|
65
|
+
* seeds all of them from one `STATES` array.
|
|
50
66
|
*/
|
|
51
|
-
export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined" | "needs-feedback" | "continue-refinement";
|
|
67
|
+
export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined" | "needs-feedback" | "continue-refinement" | "spec-in-progress";
|
|
52
68
|
export interface Issue {
|
|
53
69
|
/** Opaque tracker identifier: a GitHub issue number stringified ("42"), a Jira key ("PROJ-123"). */
|
|
54
70
|
id: string;
|
|
@@ -94,7 +110,10 @@ export interface PrStatus {
|
|
|
94
110
|
* issue a completed publish pass created for this spec. A re-claimed spec
|
|
95
111
|
* whose marker already lists them skips creation entirely — `to-tickets`
|
|
96
112
|
* (the skill this lane's prompt is ported from) has no such guard and
|
|
97
|
-
* duplicates every ticket on a re-run; this is what closes that gap.
|
|
113
|
+
* duplicates every ticket on a re-run; this is what closes that gap. It does
|
|
114
|
+
* double duty once the spec reaches `spec-in-progress`: `finishTrackedSpecs`
|
|
115
|
+
* (`watch.ts`) reads this same list back to check whether every one of them
|
|
116
|
+
* is `<prefix>:done` yet — the gate on the spec's OWN move to `done`.
|
|
98
117
|
*
|
|
99
118
|
* `feedback` is the refine lane's human-in-the-loop cursor: `rounds` counts
|
|
100
119
|
* how many times this spec has been escalated (so a resumed run's summary
|
|
@@ -134,6 +153,15 @@ export interface IssueProvider {
|
|
|
134
153
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
135
154
|
/** Issues currently labeled `<prefix>:ready`. */
|
|
136
155
|
listEligible(): Promise<Issue[]>;
|
|
156
|
+
/**
|
|
157
|
+
* One issue by its tracker-facing id, or `null` if it no longer exists
|
|
158
|
+
* (deleted, or — on a tracker where a closed item 404s a plain fetch —
|
|
159
|
+
* closed). The frontier check needs this on every tracker (`claimNewWork`
|
|
160
|
+
* in `watch.ts` calls it once per distinct `blocked_by` id per tick, to
|
|
161
|
+
* decide whether a leaf's blockers all carry `<prefix>:done`), so unlike
|
|
162
|
+
* `IssueAuthoringProvider`'s methods below, this is required, not optional.
|
|
163
|
+
*/
|
|
164
|
+
getIssue(id: string): Promise<Issue | null>;
|
|
137
165
|
/**
|
|
138
166
|
* Issues currently in `state`. `includeAll` queries closed issues too —
|
|
139
167
|
* required for `review` on a tracker where closing an issue is a side
|
|
@@ -209,18 +237,44 @@ export interface CodeHostProvider {
|
|
|
209
237
|
* provides (a tracker's read/claim/transition surface has no reason to
|
|
210
238
|
* create new work items). Kept separate rather than folded into
|
|
211
239
|
* `IssueProvider` for the same reason `CodeHostProvider` is separate: not
|
|
212
|
-
* every tracker can do this
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
240
|
+
* every tracker can do this — GitHub and Jira both implement it today
|
|
241
|
+
* (GitHub via sub-issues, Jira via native issue types + the `parent`
|
|
242
|
+
* field), Bitbucket does not — and a tracker that can't should be
|
|
243
|
+
* recognized as such via `isAuthoringProvider()` (below), not a method
|
|
244
|
+
* that throws at call time.
|
|
217
245
|
*/
|
|
218
246
|
export interface IssueAuthoringProvider {
|
|
219
247
|
createIssue(input: {
|
|
220
248
|
title: string;
|
|
221
249
|
body: string;
|
|
222
250
|
labels: string[];
|
|
251
|
+
kind: RefinedIssue["kind"];
|
|
223
252
|
}): Promise<Issue>;
|
|
224
|
-
/** Link `child` under `parent` using the tracker's native hierarchy — GitHub's sub-issues API
|
|
253
|
+
/** Link `child` under `parent` using the tracker's native hierarchy — GitHub's sub-issues API, Jira's `parent` field. */
|
|
225
254
|
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
255
|
+
/**
|
|
256
|
+
* Read back what `linkChild` wrote — every issue currently linked under
|
|
257
|
+
* `parent`. What makes container roll-up possible at all (`rollUp` in
|
|
258
|
+
* `watch.ts`: a container is `done` once every one of these carries
|
|
259
|
+
* `<prefix>:done`); lives here rather than on `IssueProvider` for the same
|
|
260
|
+
* reason `linkChild` does — a tracker's plain list/claim/transition surface
|
|
261
|
+
* has no reason to know about a hierarchy it may not even have. A tracker
|
|
262
|
+
* without this (Bitbucket-as-issue-tracker isn't a real combination this
|
|
263
|
+
* codebase supports, so in practice: any provider that isn't `IssueAuthoringProvider`
|
|
264
|
+
* at all) makes roll-up a logged no-op, not a startup failure the way
|
|
265
|
+
* `watch.refine.enabled` without ANY authoring support is
|
|
266
|
+
* (`cli/commands/watch.ts`) — the build lane still functions without
|
|
267
|
+
* roll-up, refine cannot function without authoring at all.
|
|
268
|
+
*/
|
|
269
|
+
listChildren(parent: Issue): Promise<Issue[]>;
|
|
226
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Structural, not nominal: checks for the three methods rather than
|
|
273
|
+
* `instanceof SomeConcreteClass` — so a new authoring-capable provider is
|
|
274
|
+
* recognized automatically everywhere this is used (today: `cli/commands/
|
|
275
|
+
* watch.ts`'s container-roll-up wiring) without an edit to an `instanceof`
|
|
276
|
+
* chain. Every current implementer (`GitHubProvider`, `JiraProvider`)
|
|
277
|
+
* satisfies `IssueProvider` too, so the intersection type is sound in
|
|
278
|
+
* practice, not just at the type level.
|
|
279
|
+
*/
|
|
280
|
+
export declare function isAuthoringProvider(provider: IssueProvider): provider is IssueProvider & IssueAuthoringProvider;
|
|
@@ -9,17 +9,34 @@
|
|
|
9
9
|
* interfaces, not one bundled seam — a tracker and a code host are
|
|
10
10
|
* independent choices in practice (Jira issues against a Bitbucket repo is
|
|
11
11
|
* a real setup, not a hypothetical one). `github_provider.ts`'s single
|
|
12
|
-
* class implements
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
12
|
+
* class implements all three (GitHub natively is a tracker, a code host,
|
|
13
|
+
* AND an authoring API); `jira_provider.ts` implements `IssueProvider` and
|
|
14
|
+
* `IssueAuthoringProvider` (Jira is a tracker and can author, but never
|
|
15
|
+
* opens PRs); `bitbucket_provider.ts` only `CodeHostProvider` — any tracker
|
|
16
|
+
* x host combination is just config (`watch.issue_provider` x
|
|
17
|
+
* `watch.code_host`), never a poll-loop change.
|
|
16
18
|
* `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
|
-
*
|
|
19
|
+
* third, again separate — the refine lane's own need, optional per tracker
|
|
20
|
+
* (not every tracker's write API can author + link a hierarchy), and
|
|
21
|
+
* orthogonal to which one is the code host. `isAuthoringProvider()` (below)
|
|
22
|
+
* is how the rest of the codebase asks "can this provider author?" without
|
|
23
|
+
* caring which concrete class answers yes.
|
|
19
24
|
*
|
|
20
25
|
* The label-as-state-machine design is deliberate, copied from that same
|
|
21
26
|
* reference: `transition()` is the ONE mutator, so every state change is
|
|
22
27
|
* traceable to one call site, and a provider can layer notifications
|
|
23
28
|
* (Slack, a webhook, whatever) on top of it without the poll loop caring.
|
|
24
29
|
*/
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Structural, not nominal: checks for the three methods rather than
|
|
32
|
+
* `instanceof SomeConcreteClass` — so a new authoring-capable provider is
|
|
33
|
+
* recognized automatically everywhere this is used (today: `cli/commands/
|
|
34
|
+
* watch.ts`'s container-roll-up wiring) without an edit to an `instanceof`
|
|
35
|
+
* chain. Every current implementer (`GitHubProvider`, `JiraProvider`)
|
|
36
|
+
* satisfies `IssueProvider` too, so the intersection type is sound in
|
|
37
|
+
* practice, not just at the type level.
|
|
38
|
+
*/
|
|
39
|
+
export function isAuthoringProvider(provider) {
|
|
40
|
+
const candidate = provider;
|
|
41
|
+
return typeof candidate.createIssue === "function" && typeof candidate.linkChild === "function" && typeof candidate.listChildren === "function";
|
|
42
|
+
}
|
|
@@ -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" | "spec_refined" | "spec_needs_feedback";
|
|
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" | "spec_needs_feedback" | "feature_done" | "spec_done";
|
|
15
15
|
export interface NotifyEvent {
|
|
16
16
|
kind: NotifyKind;
|
|
17
17
|
/** "error" sends under both `events: errors` and `events: all`; "info" only under `all`. */
|
package/dist/core/refine.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Issue, IssueAuthoringProvider } from "./issues/provider.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type RefinedIssue, type RefinedPriority, type SFConfig } from "./data_types.ts";
|
|
3
3
|
export interface PublishedIssue {
|
|
4
4
|
/** The `RefinedIssue.key` this came from — a run-local id, never a tracker id. */
|
|
5
5
|
key: string;
|
|
@@ -9,25 +9,62 @@ export interface PublishedIssue {
|
|
|
9
9
|
isLeaf: boolean;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* `IssueAuthoringProvider` has a real implementation
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* `IssueAuthoringProvider` has a real implementation on `GitHubProvider` and
|
|
13
|
+
* `JiraProvider` — any other `issue_provider` value fails here, defensively
|
|
14
|
+
* (the config schema's picklist already rejects it earlier). Throws rather
|
|
15
|
+
* than returning `null` so a `code` phase calling this
|
|
15
16
|
* (`steps.publishIssues()`) fails the phase with a clear, specific reason —
|
|
16
17
|
* the same "fail loudly, never silently do nothing" contract
|
|
17
18
|
* `agents.validate()` uses for an unconfigured quality suite.
|
|
19
|
+
*
|
|
20
|
+
* Duplicates `cli/commands/watch.ts`'s own `resolveIssueProvider`
|
|
21
|
+
* construction logic for each provider — a pre-existing pattern for GitHub
|
|
22
|
+
* (this function has always rebuilt its own `GitHubProvider` rather than
|
|
23
|
+
* sharing one with the CLI layer's build-lane provider), mirrored for Jira
|
|
24
|
+
* rather than refactored away, to stay within this change's scope.
|
|
18
25
|
*/
|
|
19
26
|
export declare function resolveAuthoringProvider(cfg: SFConfig): IssueAuthoringProvider;
|
|
27
|
+
export interface RefineMarker {
|
|
28
|
+
/** The parent's real issue id, or `null` for a top-level node. */
|
|
29
|
+
parent: string | null;
|
|
30
|
+
/** Real issue ids — resolved from `blocked_by` `key`s at publish time, see `renderBody`. */
|
|
31
|
+
blocked_by: string[];
|
|
32
|
+
priority: RefinedPriority;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pure and exported so it's directly unit-testable without a provider —
|
|
36
|
+
* `core/watch.ts`'s `claimNewWork` and `rollUp` are the real callers, reading
|
|
37
|
+
* it straight out of the `Issue.body` a `listEligible`/`getIssue` call
|
|
38
|
+
* already returned. Never throws: a body with no marker (any issue not
|
|
39
|
+
* created by this lane, or one whose marker a human stripped while editing)
|
|
40
|
+
* degrades to `NO_REFINE_MARKER`, same as malformed JSON inside one.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseRefineMarker(body: string): RefineMarker;
|
|
20
43
|
export interface PublishOptions {
|
|
21
44
|
labelPrefix: string;
|
|
22
45
|
/** The originating spec issue's id, for every created issue's `## Parent` back-reference. `null`/omitted for a manual run with no source issue. */
|
|
23
46
|
specIssueId?: string | null;
|
|
47
|
+
/**
|
|
48
|
+
* The spec's own priority (its `spf:priority:pN` label, read by
|
|
49
|
+
* `core/watch.ts`'s `runSpec`, or `spf refine`'s `--priority` flag for a
|
|
50
|
+
* bare manual run) — a CEILING, never a floor. Every node is clamped down
|
|
51
|
+
* to this if it outranks it (`clampPriority`), so a p3 "someday" spec
|
|
52
|
+
* cannot spawn p0 work that jumps the build lane's queue, regardless of
|
|
53
|
+
* what the refiner's own per-node judgment (or the gate's monotonicity
|
|
54
|
+
* check, which only sees the tree, never the spec) would otherwise allow.
|
|
55
|
+
* `null`/omitted — a bare run with nothing to inherit from — is a no-op:
|
|
56
|
+
* `clampPriority`'s own default.
|
|
57
|
+
*/
|
|
58
|
+
priorityCeiling?: RefinedPriority | null;
|
|
24
59
|
}
|
|
25
60
|
/**
|
|
26
61
|
* Create every node in `issues`, in dependency order, with its
|
|
27
|
-
* `<prefix>:type:<kind>`
|
|
28
|
-
* see `WatchState`'s doc comment in
|
|
29
|
-
* via the tracker's native
|
|
30
|
-
*
|
|
62
|
+
* `<prefix>:type:<kind>` and `<prefix>:priority:<pN>` labels (plus
|
|
63
|
+
* `<prefix>:refined` on leaves only — see `WatchState`'s doc comment in
|
|
64
|
+
* `provider.ts`), link each to its parent via the tracker's native
|
|
65
|
+
* hierarchy, and render real `#n` references into `## Blocked by` plus the
|
|
66
|
+
* hidden `spf-refine:` marker `parseRefineMarker` reads back. Returns what
|
|
67
|
+
* it created, in creation order.
|
|
31
68
|
*
|
|
32
69
|
* Not transactional: if a create or link call throws partway through, the
|
|
33
70
|
* nodes already published stay published, orphaned from whatever hadn't run
|