@gr8ful/spf 0.6.0 → 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.
@@ -10,8 +10,11 @@ const STATES = [
10
10
  "refined",
11
11
  "needs-feedback",
12
12
  "continue-refinement",
13
+ "spec-in-progress",
13
14
  ];
14
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;
15
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. */
16
19
  export const ISSUE_KINDS = ["epic", "feature", "story", "bug", "task"];
17
20
  // GitHub label colors are 6 hex digits, no leading '#'.
@@ -26,6 +29,7 @@ const LABEL_META = {
26
29
  refined: { color: "c2e0c6", description: "generated by spf watch's refine lane — promote to spf:ready when it's worth building" },
27
30
  "needs-feedback": { color: "d93f0b", description: "spf's refiner needs a human answer before it can finish decomposing this spec" },
28
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" },
29
33
  };
30
34
  const TYPE_LABEL_META = {
31
35
  epic: { color: "5319e7", description: "a container generated by spf watch's refine lane — not directly workable" },
@@ -34,6 +38,24 @@ const TYPE_LABEL_META = {
34
38
  bug: { color: "e99695", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
35
39
  task: { color: "d4c5f9", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
36
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
+ };
37
59
  export class GitHubProvider {
38
60
  repo;
39
61
  labelPrefix;
@@ -69,6 +91,10 @@ export class GitHubProvider {
69
91
  typeLabel(kind) {
70
92
  return `${this.labelPrefix}:type:${kind}`;
71
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
+ }
72
98
  /** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
73
99
  async getLabel(name) {
74
100
  const response = await fetch(`${API}/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
@@ -116,6 +142,13 @@ export class GitHubProvider {
116
142
  const { color, description } = TYPE_LABEL_META[kind];
117
143
  await this.ensureOneLabel(this.typeLabel(kind), color, description, result);
118
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
+ }
119
152
  return result;
120
153
  }
121
154
  toIssue(raw) {
@@ -127,9 +160,36 @@ export class GitHubProvider {
127
160
  labels: raw.labels.map((l) => (typeof l === "string" ? l : l.name)),
128
161
  };
129
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
+ */
130
180
  async listByLabel(label, state) {
131
- const raw = await this.gh(`/repos/${this.repo}/issues?labels=${encodeURIComponent(label)}&state=${state}&per_page=100`);
132
- return raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i));
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;
133
193
  }
134
194
  async listEligible() {
135
195
  return this.listByLabel(this.label("ready"), "open");
@@ -137,6 +197,24 @@ export class GitHubProvider {
137
197
  async listInState(state, opts) {
138
198
  return this.listByLabel(this.label(state), opts?.includeAll ? "all" : "open");
139
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
+ }
140
218
  async claim(issue, opts) {
141
219
  const from = this.label(opts?.from ?? "ready");
142
220
  const to = this.label(opts?.to ?? "working");
@@ -209,7 +287,13 @@ export class GitHubProvider {
209
287
  }
210
288
  return { merged: detail.merged, state: detail.state, ciStatus };
211
289
  }
212
- /** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
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
+ */
213
297
  async createIssue(input) {
214
298
  const raw = await this.gh(`/repos/${this.repo}/issues`, {
215
299
  method: "POST",
@@ -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
- * 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.
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 { EnsureLabelsResult, Issue, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
44
- export declare class JiraProvider implements IssueProvider {
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,6 +9,7 @@ const STATES = [
9
9
  "refined",
10
10
  "needs-feedback",
11
11
  "continue-refinement",
12
+ "spec-in-progress",
12
13
  ];
13
14
  const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*?\})/s;
14
15
  function toAdf(text) {
@@ -35,13 +36,15 @@ export class JiraProvider {
35
36
  labelPrefix;
36
37
  email;
37
38
  apiToken;
39
+ issueTypes;
38
40
  constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
39
- projectKey, labelPrefix, email, apiToken) {
41
+ projectKey, labelPrefix, email, apiToken, issueTypes) {
40
42
  this.baseUrl = baseUrl;
41
43
  this.projectKey = projectKey;
42
44
  this.labelPrefix = labelPrefix;
43
45
  this.email = email;
44
46
  this.apiToken = apiToken;
47
+ this.issueTypes = issueTypes;
45
48
  }
46
49
  authHeader() {
47
50
  return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
@@ -114,6 +117,86 @@ export class JiraProvider {
114
117
  async listInState(state) {
115
118
  return this.searchByLabel(this.label(state));
116
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
+ }
117
200
  async claim(issue, opts) {
118
201
  const from = this.label(opts?.from ?? "ready");
119
202
  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 both, since GitHub natively is both; `jira_provider.ts`
13
- * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
- * `CodeHostProvider` any tracker x host combination is just config
15
- * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
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
- * and orthogonal to which one is the code host.
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
- * All ten still live in one `WatchState` union (not several separate unions)
46
- * because `transition()`'s "strip every `<prefix>:<state>` label, then add
47
- * one" logic (see `github_provider.ts`/`jira_provider.ts`) has to know about
48
- * every one of them to strip correctly, and `ensureLabels()` seeds all of
49
- * them from one `STATES` array.
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 (Jira could, in principle, via its native issue
213
- * types + `parent` field, but that is a real future implementation, not a
214
- * one-line stubsee `jira_provider.ts`'s module comment), and a tracker
215
- * that can't should be a `null` from `resolveIssueAuthoringProvider()`
216
- * (`cli/commands/watch.ts`), not a method that throws at call time.
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 today. */
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 both, since GitHub natively is both; `jira_provider.ts`
13
- * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
- * `CodeHostProvider` any tracker x host combination is just config
15
- * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
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
- * and orthogonal to which one is the code host.
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
- export {};
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`. */