@gr8ful/spf 0.14.0 → 0.15.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.
@@ -8,7 +8,8 @@
8
8
  * a couple dozen REST calls, none of them exotic. `spf`'s own package stays
9
9
  * dependency-free either way.
10
10
  *
11
- * Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
11
+ * Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope plus `project` if
12
+ * `watch.github.status_map` is configured, see below), read once at
12
13
  * construction — matching the reference implementation's pattern and this
13
14
  * project's existing env-var-for-credentials philosophy. `listByLabel`
14
15
  * paginates up to `MAX_LIST_PAGES` (500 issues per label query) — no longer
@@ -17,7 +18,21 @@
17
18
  * page 1 would silently lose to a new low-priority one), not just a missed
18
19
  * issue. A repo past even that cap gets a loud warning, never a silent
19
20
  * truncation — see `listByLabel`'s own doc comment.
21
+ *
22
+ * State is modeled as labels (`<prefix>:ready`, etc.) — labels are spf's
23
+ * ACTUAL state machine and always get written, unconditionally. Native
24
+ * GitHub Projects v2 board status is a separate, OPTIONAL, best-effort layer
25
+ * on top (`syncStatus()`), driven entirely by `watch.github.status_map` —
26
+ * empty by default, so an existing config's behavior is unchanged. It's
27
+ * opt-in, and GraphQL-only (Projects v2 has no REST API), because not every
28
+ * repo has a board wired up, and a board's Status option names are per-
29
+ * project configuration `spf` can't assume; a misconfigured or unreachable
30
+ * entry degrades to a logged warning, never a thrown error — same rule
31
+ * `jira_provider.ts`'s own `syncStatus()` follows, for the same reason: a
32
+ * status-sync miss must never block the label update `spf watch` actually
33
+ * depends on.
20
34
  */
35
+ import type { GithubStatusMap } from "../data_types.ts";
21
36
  import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
22
37
  /**
23
38
  * The refine lane's leaf/container taxonomy — see `data_types.ts`'s
@@ -32,9 +47,23 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
32
47
  private readonly repo;
33
48
  private readonly labelPrefix;
34
49
  private readonly token;
50
+ private readonly projectNumber;
51
+ private readonly statusMap;
52
+ /** Resolved lazily by `resolveProjectStatusField()` — cached only on SUCCESS, so a transient GraphQL hiccup gets retried the next call rather than disabling status sync for this instance's entire (potentially daemon-long) lifetime. */
53
+ private projectMeta?;
35
54
  constructor(repo: string, // "owner/name"
36
- labelPrefix: string, token: string);
55
+ labelPrefix: string, token: string, projectNumber?: number, // 0 = status sync disabled, regardless of statusMap
56
+ statusMap?: GithubStatusMap);
37
57
  private gh;
58
+ /**
59
+ * Projects v2 has no REST surface at all — this is the one place this
60
+ * file talks GraphQL instead of REST. A GraphQL "not found" (bad login,
61
+ * bad project number, missing `project` scope) comes back as a 200 with a
62
+ * null data field plus an `errors` array, not a non-2xx — callers read
63
+ * `data` being falsy as "couldn't resolve," same as a 404 elsewhere in
64
+ * this file.
65
+ */
66
+ private ghGraphql;
38
67
  private label;
39
68
  private typeLabel;
40
69
  /** 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. */
@@ -82,6 +111,41 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
82
111
  to?: WatchState;
83
112
  }): Promise<boolean>;
84
113
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
114
+ /**
115
+ * Best-effort native Projects v2 status sync — a no-op unless
116
+ * `project_number` AND `status_map` both configure something for `to`.
117
+ * Every failure mode (unconfigured, project/field not found, no matching
118
+ * option, a rejected mutation) is logged and swallowed, never thrown —
119
+ * see this file's module comment on why a status-sync miss must never
120
+ * break the label update callers depend on.
121
+ */
122
+ private syncStatus;
123
+ /**
124
+ * Resolves (and caches — see `projectMeta`'s own doc comment) `watch.github.project_number`'s
125
+ * "Status" single-select field against the repo OWNER's Projects v2 board
126
+ * (Projects v2 numbers are per-owner, not per-repo — see
127
+ * `WatchGithubConfigSchema`'s doc comment). Tries `organization(login:)`
128
+ * first, then `user(login:)`: an owner is exactly one of the two, and
129
+ * GraphQL returns that field as `null` (not a hard error) when it's the
130
+ * wrong kind, so falling through is safe.
131
+ */
132
+ private resolveProjectStatusField;
133
+ /** The item-id half of `syncStatus()`: an issue already on the project has one; otherwise this adds it, since a `status_map` entry is an implicit "yes, put this on the board" — the same way a Jira issue is already assumed to be on its project. `null` (logged) on any lookup/add failure. */
134
+ private resolveProjectItemId;
135
+ /**
136
+ * Read-only validation of the configured `status_map` against the real
137
+ * project's Status options — what `spf watch init` and `spf watch`'s own
138
+ * startup check call to catch a misnamed option before an unattended run
139
+ * silently no-ops its status sync every time, the same role
140
+ * `validateIssueTypes()`/`validateStatusMap()` play on the Jira side.
141
+ * Empty when `status_map` has no entries configured at all — nothing to
142
+ * report, not a mismatch.
143
+ */
144
+ validateStatusMap(): Promise<Array<{
145
+ state: string;
146
+ githubStatus: string;
147
+ exists: boolean;
148
+ }>>;
85
149
  comment(issue: Issue, body: string): Promise<void>;
86
150
  openPr(opts: {
87
151
  branch: string;
@@ -86,11 +86,18 @@ export class GitHubProvider {
86
86
  repo;
87
87
  labelPrefix;
88
88
  token;
89
+ projectNumber;
90
+ statusMap;
91
+ /** Resolved lazily by `resolveProjectStatusField()` — cached only on SUCCESS, so a transient GraphQL hiccup gets retried the next call rather than disabling status sync for this instance's entire (potentially daemon-long) lifetime. */
92
+ projectMeta;
89
93
  constructor(repo, // "owner/name"
90
- labelPrefix, token) {
94
+ labelPrefix, token, projectNumber = 0, // 0 = status sync disabled, regardless of statusMap
95
+ statusMap = {}) {
91
96
  this.repo = repo;
92
97
  this.labelPrefix = labelPrefix;
93
98
  this.token = token;
99
+ this.projectNumber = projectNumber;
100
+ this.statusMap = statusMap;
94
101
  }
95
102
  async gh(path, init) {
96
103
  const response = await fetch(`${API}${path}`, {
@@ -111,6 +118,34 @@ export class GitHubProvider {
111
118
  return undefined;
112
119
  return (await response.json());
113
120
  }
121
+ /**
122
+ * Projects v2 has no REST surface at all — this is the one place this
123
+ * file talks GraphQL instead of REST. A GraphQL "not found" (bad login,
124
+ * bad project number, missing `project` scope) comes back as a 200 with a
125
+ * null data field plus an `errors` array, not a non-2xx — callers read
126
+ * `data` being falsy as "couldn't resolve," same as a 404 elsewhere in
127
+ * this file.
128
+ */
129
+ async ghGraphql(query, variables) {
130
+ const response = await fetch(`${API}/graphql`, {
131
+ method: "POST",
132
+ headers: {
133
+ Authorization: `Bearer ${this.token}`,
134
+ Accept: "application/vnd.github+json",
135
+ "Content-Type": "application/json",
136
+ },
137
+ body: JSON.stringify({ query, variables }),
138
+ });
139
+ if (!response.ok) {
140
+ const detail = await response.text().catch(() => "");
141
+ throw new Error(`GitHub GraphQL -> ${response.status}: ${detail.slice(0, 500)}`);
142
+ }
143
+ const json = (await response.json());
144
+ if (!json.data) {
145
+ throw new Error(`GitHub GraphQL returned no data${json.errors ? `: ${json.errors.map((e) => e.message).join("; ")}` : ""}`);
146
+ }
147
+ return json.data;
148
+ }
114
149
  label(state) {
115
150
  return `${this.labelPrefix}:${state}`;
116
151
  }
@@ -242,8 +277,9 @@ export class GitHubProvider {
242
277
  return raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i));
243
278
  }
244
279
  async claim(issue, opts) {
280
+ const toState = opts?.to ?? "working";
245
281
  const from = this.label(opts?.from ?? "ready");
246
- const to = this.label(opts?.to ?? "working");
282
+ const to = this.label(toState);
247
283
  await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels/${encodeURIComponent(from)}`, {
248
284
  method: "DELETE",
249
285
  }).catch(() => undefined); // already gone is fine
@@ -261,6 +297,9 @@ export class GitHubProvider {
261
297
  body: JSON.stringify({ labels: [from] }),
262
298
  }).catch(() => undefined);
263
299
  }
300
+ else {
301
+ await this.syncStatus(issue, toState);
302
+ }
264
303
  return claimed;
265
304
  }
266
305
  async transition(issue, to, detail) {
@@ -274,9 +313,129 @@ export class GitHubProvider {
274
313
  method: "POST",
275
314
  body: JSON.stringify({ labels: [this.label(to)] }),
276
315
  });
316
+ await this.syncStatus(issue, to);
277
317
  if (detail)
278
318
  await this.comment(issue, detail);
279
319
  }
320
+ /**
321
+ * Best-effort native Projects v2 status sync — a no-op unless
322
+ * `project_number` AND `status_map` both configure something for `to`.
323
+ * Every failure mode (unconfigured, project/field not found, no matching
324
+ * option, a rejected mutation) is logged and swallowed, never thrown —
325
+ * see this file's module comment on why a status-sync miss must never
326
+ * break the label update callers depend on.
327
+ */
328
+ async syncStatus(issue, to) {
329
+ const statusName = this.statusMap[to];
330
+ if (!statusName)
331
+ return;
332
+ const meta = await this.resolveProjectStatusField();
333
+ if (!meta)
334
+ return; // already logged inside resolveProjectStatusField, or simply unconfigured (project_number: 0)
335
+ const optionId = meta.options.get(statusName);
336
+ if (!optionId) {
337
+ console.error(`spf watch: issue #${issue.id} — GitHub Projects #${this.projectNumber} has no Status option named ${JSON.stringify(statusName)} (watch.github.status_map.${to}) — skipping status sync, label already updated`);
338
+ return;
339
+ }
340
+ try {
341
+ const itemId = await this.resolveProjectItemId(issue, meta.projectId);
342
+ if (!itemId)
343
+ return; // already logged inside resolveProjectItemId
344
+ await this.ghGraphql(`mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
345
+ updateProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: {singleSelectOptionId: $optionId}}) {
346
+ clientMutationId
347
+ }
348
+ }`, { projectId: meta.projectId, itemId, fieldId: meta.statusFieldId, optionId });
349
+ }
350
+ catch (err) {
351
+ console.error(`spf watch: issue #${issue.id} — GitHub Projects status sync to ${JSON.stringify(statusName)} failed; label already updated — ${err instanceof Error ? err.message : String(err)}`);
352
+ }
353
+ }
354
+ /**
355
+ * Resolves (and caches — see `projectMeta`'s own doc comment) `watch.github.project_number`'s
356
+ * "Status" single-select field against the repo OWNER's Projects v2 board
357
+ * (Projects v2 numbers are per-owner, not per-repo — see
358
+ * `WatchGithubConfigSchema`'s doc comment). Tries `organization(login:)`
359
+ * first, then `user(login:)`: an owner is exactly one of the two, and
360
+ * GraphQL returns that field as `null` (not a hard error) when it's the
361
+ * wrong kind, so falling through is safe.
362
+ */
363
+ async resolveProjectStatusField() {
364
+ if (!this.projectNumber)
365
+ return null;
366
+ if (this.projectMeta)
367
+ return this.projectMeta;
368
+ const owner = this.repo.split("/")[0];
369
+ let data;
370
+ try {
371
+ data = await this.ghGraphql(`query($login: String!, $number: Int!) {
372
+ organization(login: $login) { projectV2(number: $number) { id fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } }
373
+ user(login: $login) { projectV2(number: $number) { id fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } }
374
+ }`, { login: owner, number: this.projectNumber });
375
+ }
376
+ catch (err) {
377
+ console.error(`spf watch: couldn't resolve GitHub Projects v2 #${this.projectNumber} for ${owner} — status sync skipped this run — ${err instanceof Error ? err.message : String(err)}`);
378
+ return null;
379
+ }
380
+ const project = data.organization?.projectV2 ?? data.user?.projectV2;
381
+ if (!project) {
382
+ console.error(`spf watch: GitHub Projects v2 #${this.projectNumber} not found for ${owner} (or GITHUB_TOKEN lacks "project" scope) — status sync skipped this run`);
383
+ return null;
384
+ }
385
+ const statusField = project.fields.nodes.find((f) => f !== null && f.name === "Status");
386
+ if (!statusField) {
387
+ console.error(`spf watch: GitHub Projects v2 #${this.projectNumber} has no "Status" single-select field — status sync skipped this run`);
388
+ return null;
389
+ }
390
+ this.projectMeta = { projectId: project.id, statusFieldId: statusField.id, options: new Map(statusField.options.map((o) => [o.name, o.id])) };
391
+ return this.projectMeta;
392
+ }
393
+ /** The item-id half of `syncStatus()`: an issue already on the project has one; otherwise this adds it, since a `status_map` entry is an implicit "yes, put this on the board" — the same way a Jira issue is already assumed to be on its project. `null` (logged) on any lookup/add failure. */
394
+ async resolveProjectItemId(issue, projectId) {
395
+ const [owner, name] = this.repo.split("/");
396
+ let data;
397
+ try {
398
+ data = await this.ghGraphql(`query($owner: String!, $name: String!, $number: Int!) {
399
+ repository(owner: $owner, name: $name) {
400
+ issue(number: $number) { id projectItems(first: 20) { nodes { id project { id } } } }
401
+ }
402
+ }`, { owner, name, number: Number(issue.id) });
403
+ }
404
+ catch (err) {
405
+ console.error(`spf watch: issue #${issue.id} — couldn't look up its GitHub Projects item; status sync skipped, label already updated — ${err instanceof Error ? err.message : String(err)}`);
406
+ return null;
407
+ }
408
+ const ghIssue = data.repository?.issue;
409
+ if (!ghIssue)
410
+ return null; // deleted between the label update and here — nothing left to sync
411
+ const existing = ghIssue.projectItems.nodes.find((n) => n.project.id === projectId);
412
+ if (existing)
413
+ return existing.id;
414
+ try {
415
+ const added = await this.ghGraphql(`mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } }`, { projectId, contentId: ghIssue.id });
416
+ return added.addProjectV2ItemById.item.id;
417
+ }
418
+ catch (err) {
419
+ console.error(`spf watch: issue #${issue.id} — couldn't add it to GitHub Projects #${this.projectNumber}; status sync skipped, label already updated — ${err instanceof Error ? err.message : String(err)}`);
420
+ return null;
421
+ }
422
+ }
423
+ /**
424
+ * Read-only validation of the configured `status_map` against the real
425
+ * project's Status options — what `spf watch init` and `spf watch`'s own
426
+ * startup check call to catch a misnamed option before an unattended run
427
+ * silently no-ops its status sync every time, the same role
428
+ * `validateIssueTypes()`/`validateStatusMap()` play on the Jira side.
429
+ * Empty when `status_map` has no entries configured at all — nothing to
430
+ * report, not a mismatch.
431
+ */
432
+ async validateStatusMap() {
433
+ const entries = Object.entries(this.statusMap).filter((entry) => Boolean(entry[1]));
434
+ if (entries.length === 0)
435
+ return [];
436
+ const meta = await this.resolveProjectStatusField();
437
+ return entries.map(([state, githubStatus]) => ({ state, githubStatus, exists: meta ? meta.options.has(githubStatus) : false }));
438
+ }
280
439
  async comment(issue, body) {
281
440
  await this.gh(`/repos/${this.repo}/issues/${issue.id}/comments`, {
282
441
  method: "POST",
@@ -18,13 +18,22 @@
18
18
  * one paragraph of plain text, nothing richer.
19
19
  *
20
20
  * State is modeled as Jira labels (`<prefix>:ready`, etc.), mirroring
21
- * `github_provider.ts` exactly, rather than native workflow status
22
- * transitions the latter would need per-project transition-id mapping
23
- * (workflows vary by project/scheme in Jira), while labels work
24
- * identically everywhere with zero per-project setup. One caveat, verified
25
- * against Atlassian's own docs: colons ARE a legal label character and JQL
26
- * matches on them fine, they just don't show up in Jira's label
27
- * autocomplete UI cosmetic only, not a functional issue.
21
+ * `github_provider.ts` exactly labels are spf's ACTUAL state machine and
22
+ * always get written, unconditionally. One caveat, verified against
23
+ * Atlassian's own docs: colons ARE a legal label character and JQL matches
24
+ * on them fine, they just don't show up in Jira's label autocomplete UI —
25
+ * cosmetic only, not a functional issue.
26
+ *
27
+ * Native workflow status is a separate, OPTIONAL, best-effort layer on top
28
+ * (`syncStatus()`), driven entirely by the configured `statusMap` — empty
29
+ * by default, so an existing config's behavior is unchanged. It's optional
30
+ * rather than baked into every `transition()` call unconditionally because
31
+ * Jira workflows vary by project/scheme (status names, which transitions
32
+ * are reachable from where) in a way labels never do; a project that wants
33
+ * its board's Status column to move when spf changes a label opts in with
34
+ * `watch.jira.status_map`, and a misconfigured or unreachable entry there
35
+ * degrades to a logged warning, never a thrown error — a status-sync miss
36
+ * must never block the label update `spf watch` actually depends on.
28
37
  *
29
38
  * `ensureLabels()` is a no-op that reports the labels this run will use:
30
39
  * Jira labels are freeform strings with no color/description registry to
@@ -58,7 +67,7 @@
58
67
  * Jira API error at publish time — a genuine platform difference, not
59
68
  * something this file tries to paper over.
60
69
  */
61
- import type { JiraIssueTypeMap } from "../data_types.ts";
70
+ import type { JiraIssueTypeMap, JiraStatusMap } from "../data_types.ts";
62
71
  import type { EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
63
72
  export declare class JiraProvider implements IssueProvider, IssueAuthoringProvider {
64
73
  private readonly baseUrl;
@@ -67,8 +76,9 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
67
76
  private readonly email;
68
77
  private readonly apiToken;
69
78
  private readonly issueTypes;
79
+ private readonly statusMap;
70
80
  constructor(baseUrl: string, // e.g. "https://your-domain.atlassian.net", no trailing slash
71
- projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap);
81
+ projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap, statusMap?: JiraStatusMap);
72
82
  private authHeader;
73
83
  private jira;
74
84
  private label;
@@ -145,6 +155,37 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
145
155
  to?: WatchState;
146
156
  }): Promise<boolean>;
147
157
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
158
+ /**
159
+ * Best-effort native workflow-status sync — a no-op unless `statusMap`
160
+ * configures a name for `to`. Looked up per call, not cached: the
161
+ * available transitions are FROM-status-dependent, so the same target
162
+ * status can need a different transition id depending where the issue
163
+ * currently sits, and this same issue's status keeps moving across calls
164
+ * as it advances through the build lane. Every failure mode here
165
+ * (unconfigured, unreachable, or a rejected transition) is logged and
166
+ * swallowed, never thrown — see this file's module comment on why a
167
+ * status-sync miss must never break the label update callers depend on.
168
+ */
169
+ private syncStatus;
170
+ /**
171
+ * Read-only validation of the configured `status_map` against this
172
+ * project's real statuses — what `spf watch init` and `spf watch`'s own
173
+ * startup check should call to catch a misnamed status before an
174
+ * unattended run silently no-ops its status sync every time, the same
175
+ * role `validateIssueTypes()` plays for `issue_types`.
176
+ *
177
+ * Uses `/rest/api/3/project/{key}/statuses`, which groups statuses by
178
+ * issue type — Jira workflows can differ per issue type within one
179
+ * project. A configured name is "exists" if ANY issue type in the project
180
+ * has it: good enough to catch a typo, not a guarantee every issue type
181
+ * this map is used against can actually reach it (that's what
182
+ * `syncStatus()`'s own per-call transition lookup is for).
183
+ */
184
+ validateStatusMap(): Promise<Array<{
185
+ state: string;
186
+ jiraStatus: string;
187
+ exists: boolean;
188
+ }>>;
148
189
  comment(issue: Issue, body: string): Promise<void>;
149
190
  /** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
150
191
  private fetchComments;
@@ -52,14 +52,16 @@ export class JiraProvider {
52
52
  email;
53
53
  apiToken;
54
54
  issueTypes;
55
+ statusMap;
55
56
  constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
56
- projectKey, labelPrefix, email, apiToken, issueTypes) {
57
+ projectKey, labelPrefix, email, apiToken, issueTypes, statusMap = {}) {
57
58
  this.baseUrl = baseUrl;
58
59
  this.projectKey = projectKey;
59
60
  this.labelPrefix = labelPrefix;
60
61
  this.email = email;
61
62
  this.apiToken = apiToken;
62
63
  this.issueTypes = issueTypes;
64
+ this.statusMap = statusMap;
63
65
  }
64
66
  authHeader() {
65
67
  return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
@@ -213,8 +215,9 @@ export class JiraProvider {
213
215
  return Object.entries(this.issueTypes).map(([kind, jiraType]) => ({ kind, jiraType, exists: available.has(jiraType) }));
214
216
  }
215
217
  async claim(issue, opts) {
218
+ const toState = opts?.to ?? "working";
216
219
  const from = this.label(opts?.from ?? "ready");
217
- const to = this.label(opts?.to ?? "working");
220
+ const to = this.label(toState);
218
221
  const next = issue.labels.filter((l) => l !== from);
219
222
  next.push(to);
220
223
  await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
@@ -226,15 +229,72 @@ export class JiraProvider {
226
229
  revert.push(from);
227
230
  await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: revert } }) }).catch(() => undefined);
228
231
  }
232
+ else {
233
+ await this.syncStatus(issue, toState);
234
+ }
229
235
  return claimed;
230
236
  }
231
237
  async transition(issue, to, detail) {
232
238
  const next = issue.labels.filter((l) => !STATES.some((s) => this.label(s) === l));
233
239
  next.push(this.label(to));
234
240
  await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
241
+ await this.syncStatus(issue, to);
235
242
  if (detail)
236
243
  await this.comment(issue, detail);
237
244
  }
245
+ /**
246
+ * Best-effort native workflow-status sync — a no-op unless `statusMap`
247
+ * configures a name for `to`. Looked up per call, not cached: the
248
+ * available transitions are FROM-status-dependent, so the same target
249
+ * status can need a different transition id depending where the issue
250
+ * currently sits, and this same issue's status keeps moving across calls
251
+ * as it advances through the build lane. Every failure mode here
252
+ * (unconfigured, unreachable, or a rejected transition) is logged and
253
+ * swallowed, never thrown — see this file's module comment on why a
254
+ * status-sync miss must never break the label update callers depend on.
255
+ */
256
+ async syncStatus(issue, to) {
257
+ const statusName = this.statusMap[to];
258
+ if (!statusName)
259
+ return;
260
+ let transitions;
261
+ try {
262
+ ({ transitions } = await this.jira(`/rest/api/3/issue/${issue.id}/transitions`));
263
+ }
264
+ catch (err) {
265
+ console.error(`spf watch: ${issue.id} — couldn't fetch available Jira transitions to sync status ${JSON.stringify(statusName)}; label already updated — ${err instanceof Error ? err.message : String(err)}`);
266
+ return;
267
+ }
268
+ const match = transitions.find((t) => t.to.name === statusName);
269
+ if (!match) {
270
+ console.error(`spf watch: ${issue.id} has no available transition to Jira status ${JSON.stringify(statusName)} (watch.jira.status_map.${to}) from its current status — skipping status sync, label already updated`);
271
+ return;
272
+ }
273
+ await this.jira(`/rest/api/3/issue/${issue.id}/transitions`, { method: "POST", body: JSON.stringify({ transition: { id: match.id } }) }).catch((err) => {
274
+ console.error(`spf watch: ${issue.id} — Jira transition to ${JSON.stringify(statusName)} failed; label already updated — ${err instanceof Error ? err.message : String(err)}`);
275
+ });
276
+ }
277
+ /**
278
+ * Read-only validation of the configured `status_map` against this
279
+ * project's real statuses — what `spf watch init` and `spf watch`'s own
280
+ * startup check should call to catch a misnamed status before an
281
+ * unattended run silently no-ops its status sync every time, the same
282
+ * role `validateIssueTypes()` plays for `issue_types`.
283
+ *
284
+ * Uses `/rest/api/3/project/{key}/statuses`, which groups statuses by
285
+ * issue type — Jira workflows can differ per issue type within one
286
+ * project. A configured name is "exists" if ANY issue type in the project
287
+ * has it: good enough to catch a typo, not a guarantee every issue type
288
+ * this map is used against can actually reach it (that's what
289
+ * `syncStatus()`'s own per-call transition lookup is for).
290
+ */
291
+ async validateStatusMap() {
292
+ const result = await this.jira(`/rest/api/3/project/${encodeURIComponent(this.projectKey)}/statuses`);
293
+ const available = new Set(result.flatMap((t) => t.statuses.map((s) => s.name)));
294
+ return Object.entries(this.statusMap)
295
+ .filter((entry) => Boolean(entry[1]))
296
+ .map(([state, jiraStatus]) => ({ state, jiraStatus, exists: available.has(jiraStatus) }));
297
+ }
238
298
  async comment(issue, body) {
239
299
  await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
240
300
  }
@@ -41,7 +41,7 @@ export function resolveAuthoringProvider(cfg) {
41
41
  if (!email || !token) {
42
42
  throw new Error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — the refine lane needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens)');
43
43
  }
44
- return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types);
44
+ return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types, cfg.watch.jira.status_map);
45
45
  }
46
46
  if (cfg.watch.issue_provider !== "github") {
47
47
  throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — the refine lane needs "github" or "jira"`);
@@ -61,7 +61,7 @@ export function resolveAuthoringProvider(cfg) {
61
61
  if (!token) {
62
62
  throw new Error('GITHUB_TOKEN is not set — the refine lane needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo)');
63
63
  }
64
- return new GitHubProvider(repo, cfg.watch.label_prefix, token);
64
+ return new GitHubProvider(repo, cfg.watch.label_prefix, token, cfg.watch.github.project_number, cfg.watch.github.status_map);
65
65
  }
66
66
  function typeLabel(labelPrefix, kind) {
67
67
  return `${labelPrefix}:type:${kind}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",