@gr8ful/spf 0.13.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.
- package/README.md +52 -4
- package/assets/skill/references/config.md +4 -0
- package/dist/cli/commands/doctor.js +21 -0
- package/dist/cli/commands/watch.js +64 -3
- package/dist/cli/interview.js +18 -1
- package/dist/core/data_types.d.ts +233 -3
- package/dist/core/data_types.js +86 -3
- package/dist/core/issues/github_provider.d.ts +66 -2
- package/dist/core/issues/github_provider.js +161 -2
- package/dist/core/issues/jira_provider.d.ts +50 -9
- package/dist/core/issues/jira_provider.js +62 -2
- package/dist/core/notify/notifier.d.ts +3 -2
- package/dist/core/notify/notifier.js +32 -3
- package/dist/core/refine.js +2 -2
- package/dist/core/utils.d.ts +5 -5
- package/dist/core/utils.js +14 -7
- package/package.json +1 -1
- package/web/assets/index-CRujNW-1.js +11 -0
- package/web/assets/index-Cto6nuQL.css +1 -0
- package/web/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
- package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
- package/web/index.html +33 -2
- package/web/logo.svg +4 -4
- package/web/assets/index-C7nF068F.css +0 -1
- package/web/assets/index-mzSArcnQ.js +0 -11
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
package/dist/core/data_types.js
CHANGED
|
@@ -661,19 +661,94 @@ export const JiraIssueTypeMapSchema = v.object({
|
|
|
661
661
|
*/
|
|
662
662
|
spec: v.optional(v.string(), "Story"),
|
|
663
663
|
});
|
|
664
|
+
/**
|
|
665
|
+
* Optional, per-project `WatchState` -> Jira workflow-status-name map.
|
|
666
|
+
* Unlike `JiraIssueTypeMapSchema`, this has no sane universal default:
|
|
667
|
+
* "To Do" vs "Open" vs "Backlog" (and everything in between) is entirely
|
|
668
|
+
* per-project workflow configuration in Jira, so every field defaults to
|
|
669
|
+
* unset. A `WatchState` with no entry here keeps today's behavior exactly —
|
|
670
|
+
* `jira_provider.ts`'s `transition()`/`claim()` update the label only and
|
|
671
|
+
* never attempt a status change for it.
|
|
672
|
+
*
|
|
673
|
+
* Deliberately covers only the states a human's Jira board status would
|
|
674
|
+
* plausibly want to reflect (the build lane, `spec-ready` through
|
|
675
|
+
* `blocked`) — the refine-lane states (`refining`, `split-proposed`, ...)
|
|
676
|
+
* are internal bookkeeping for `core/refine.ts`, not board-visible work.
|
|
677
|
+
*
|
|
678
|
+
* `jira_provider.ts`'s `syncStatus()` is the reader; `validateStatusMap()`
|
|
679
|
+
* is what `spf watch init` and `spf watch`'s own startup check should call
|
|
680
|
+
* to confirm each configured name is a real status on the project before an
|
|
681
|
+
* unattended run relies on it — same shape as `issue_types`/
|
|
682
|
+
* `validateIssueTypes()`.
|
|
683
|
+
*/
|
|
684
|
+
export const JiraStatusMapSchema = v.object({
|
|
685
|
+
"spec-ready": v.optional(v.string()),
|
|
686
|
+
ready: v.optional(v.string()),
|
|
687
|
+
working: v.optional(v.string()),
|
|
688
|
+
review: v.optional(v.string()),
|
|
689
|
+
done: v.optional(v.string()),
|
|
690
|
+
blocked: v.optional(v.string()),
|
|
691
|
+
});
|
|
664
692
|
/**
|
|
665
693
|
* Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` +
|
|
666
694
|
* `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. Whole-
|
|
667
695
|
* object replace on config-file-layer merge, like `refine`/
|
|
668
696
|
* `observability.otel` (see `agents.ts`'s `mergeRawConfig`) — an override
|
|
669
|
-
* file that touches `watch.jira` at all must repeat `issue_types
|
|
670
|
-
* wants to keep a customized mapping, same caveat
|
|
671
|
-
* `base_url`/`project_key` today.
|
|
697
|
+
* file that touches `watch.jira` at all must repeat `issue_types`/
|
|
698
|
+
* `status_map` too if it wants to keep a customized mapping, same caveat
|
|
699
|
+
* that already applies to `base_url`/`project_key` today.
|
|
672
700
|
*/
|
|
673
701
|
export const WatchJiraConfigSchema = v.object({
|
|
674
702
|
base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
|
|
675
703
|
project_key: v.optional(v.string(), ""), // e.g. "PROJ"
|
|
676
704
|
issue_types: v.optional(JiraIssueTypeMapSchema, () => v.parse(JiraIssueTypeMapSchema, {})),
|
|
705
|
+
status_map: v.optional(JiraStatusMapSchema, () => v.parse(JiraStatusMapSchema, {})),
|
|
706
|
+
});
|
|
707
|
+
/**
|
|
708
|
+
* Optional, per-repo `WatchState` -> GitHub Projects v2 "Status" option-name
|
|
709
|
+
* map — the GitHub-side twin of `JiraIssueTypeMap`'s sibling on the Jira
|
|
710
|
+
* provider (see `jira_provider.ts`'s `status_map`). No sane universal
|
|
711
|
+
* default: a board's Status column options ("Todo"/"In Progress"/"Done", or
|
|
712
|
+
* anything else) are per-project configuration, so every field defaults to
|
|
713
|
+
* unset. A `WatchState` with no entry keeps today's behavior exactly —
|
|
714
|
+
* `github_provider.ts`'s `transition()`/`claim()` update the label only and
|
|
715
|
+
* never touch Projects v2 for it.
|
|
716
|
+
*
|
|
717
|
+
* Same six build-lane states as Jira's map — the refine-lane's own
|
|
718
|
+
* bookkeeping states (`refining`, `split-proposed`, ...) aren't board-visible
|
|
719
|
+
* work, same reasoning as `jira_provider.ts`'s `JiraStatusMapSchema`.
|
|
720
|
+
*
|
|
721
|
+
* `github_provider.ts`'s `syncStatus()` is the reader; `validateStatusMap()`
|
|
722
|
+
* is what `spf watch init` and `spf watch`'s own startup check call to
|
|
723
|
+
* confirm each configured name is a real Status option on the configured
|
|
724
|
+
* project before an unattended run relies on it.
|
|
725
|
+
*/
|
|
726
|
+
export const GithubStatusMapSchema = v.object({
|
|
727
|
+
"spec-ready": v.optional(v.string()),
|
|
728
|
+
ready: v.optional(v.string()),
|
|
729
|
+
working: v.optional(v.string()),
|
|
730
|
+
review: v.optional(v.string()),
|
|
731
|
+
done: v.optional(v.string()),
|
|
732
|
+
blocked: v.optional(v.string()),
|
|
733
|
+
});
|
|
734
|
+
/**
|
|
735
|
+
* Only consulted when `issue_provider: github` (or `code_host: github`) AND
|
|
736
|
+
* `status_map` actually has an entry configured — `project_number: 0` (the
|
|
737
|
+
* default) disables status sync outright regardless of `status_map`, since
|
|
738
|
+
* there's no sane "guess the board" default: a repo can have zero, one, or
|
|
739
|
+
* many Projects v2 boards, and none of them is canonical. Scoped to the
|
|
740
|
+
* repo's OWNER (`watch.repo`'s "owner/name", the owner half) — GitHub
|
|
741
|
+
* Projects v2 numbers are per-owner, not per-repo, so `project_number: 3`
|
|
742
|
+
* means owner's project #3, which may or may not have this repo's issues on
|
|
743
|
+
* it yet (`github_provider.ts`'s `syncStatus()` adds the issue to the
|
|
744
|
+
* project itself the first time it needs to). Whole-object replace on
|
|
745
|
+
* config-file-layer merge, like `jira`/`refine` above — an override file
|
|
746
|
+
* that touches `watch.github` at all must repeat `status_map` too if it
|
|
747
|
+
* wants to keep a customized mapping.
|
|
748
|
+
*/
|
|
749
|
+
export const WatchGithubConfigSchema = v.object({
|
|
750
|
+
project_number: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0)), 0),
|
|
751
|
+
status_map: v.optional(GithubStatusMapSchema, () => v.parse(GithubStatusMapSchema, {})),
|
|
677
752
|
});
|
|
678
753
|
/**
|
|
679
754
|
* The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
|
|
@@ -798,6 +873,7 @@ export const WatchConfigSchema = v.object({
|
|
|
798
873
|
*/
|
|
799
874
|
chain_options: v.optional(v.record(v.string(), v.string()), () => ({})),
|
|
800
875
|
jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
|
|
876
|
+
github: v.optional(WatchGithubConfigSchema, () => v.parse(WatchGithubConfigSchema, {})),
|
|
801
877
|
refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
|
|
802
878
|
fanout: v.optional(WatchFanoutConfigSchema, () => v.parse(WatchFanoutConfigSchema, {})),
|
|
803
879
|
});
|
|
@@ -834,6 +910,13 @@ export const NotificationsConfigSchema = v.object({
|
|
|
834
910
|
events: v.optional(NotifyScopeSchema, "off"),
|
|
835
911
|
timeout_ms: v.optional(v.number(), 5_000),
|
|
836
912
|
channels: v.optional(v.array(NotifyChannelSchema), () => []),
|
|
913
|
+
// Prefixed onto every outbound title (e.g. "[api] watch: ..." ) and added
|
|
914
|
+
// as a `repo` field, so one Slack/Teams/webhook endpoint shared across
|
|
915
|
+
// several `spf watch` instances (one per repo) can tell them apart.
|
|
916
|
+
// Empty means "derive from watch.repo" (see `resolveNotifier`) — set this
|
|
917
|
+
// explicitly only when that repo string isn't a good enough label, e.g.
|
|
918
|
+
// it's blank, or two watched repos share a basename.
|
|
919
|
+
project: v.optional(v.string(), ""),
|
|
837
920
|
});
|
|
838
921
|
/**
|
|
839
922
|
* `simple_sdlc`'s human-signoff gate — see the ACCEPTED ADVERSARIAL
|
|
@@ -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
|
|
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(
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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(
|
|
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
|
}
|
|
@@ -18,13 +18,14 @@ export declare class Notifier {
|
|
|
18
18
|
private readonly timeoutMs;
|
|
19
19
|
private readonly dryRun;
|
|
20
20
|
private readonly log;
|
|
21
|
+
private readonly project;
|
|
21
22
|
private pending;
|
|
22
23
|
constructor(channels: Array<{
|
|
23
24
|
channel: NotificationChannel;
|
|
24
25
|
scope: NotifyScope;
|
|
25
|
-
}>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void);
|
|
26
|
+
}>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void, project?: string);
|
|
26
27
|
/** Sync, fire-and-forget — every call site is sync and must stay that way. */
|
|
27
|
-
send(
|
|
28
|
+
send(rawEvent: NotifyEvent): void;
|
|
28
29
|
/** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
|
|
29
30
|
flush(): Promise<void>;
|
|
30
31
|
/**
|