@gr8ful/spf 0.1.5 → 0.1.6
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 +72 -12
- package/assets/skill/references/config.md +3 -2
- package/assets/templates/ts-cc.spf.config.yaml +43 -0
- package/assets/templates/ts-flue-openrouter.spf.config.yaml +38 -0
- package/assets/templates/ts.spf.config.yaml +51 -0
- package/dist/cli/commands/doctor.js +20 -4
- package/dist/cli/commands/init.js +39 -10
- package/dist/cli/commands/watch.js +63 -20
- package/dist/cli/index.js +1 -1
- package/dist/core/agent_cc.d.ts +8 -0
- package/dist/core/agent_cc.js +12 -1
- package/dist/core/console.d.ts +1 -0
- package/dist/core/console.js +1 -1
- package/dist/core/data_types.d.ts +45 -10
- package/dist/core/data_types.js +22 -8
- package/dist/core/issues/bitbucket_provider.d.ts +35 -0
- package/dist/core/issues/bitbucket_provider.js +70 -0
- package/dist/core/issues/github_provider.d.ts +7 -6
- package/dist/core/issues/github_provider.js +15 -20
- package/dist/core/issues/jira_provider.d.ts +63 -0
- package/dist/core/issues/jira_provider.js +145 -0
- package/dist/core/issues/provider.d.ts +37 -14
- package/dist/core/issues/provider.js +12 -4
- package/dist/core/paths.d.ts +2 -0
- package/dist/core/paths.js +2 -0
- package/dist/core/watch.d.ts +3 -2
- package/dist/core/watch.js +32 -24
- package/dist/test/watch.test.js +82 -66
- package/package.json +1 -1
|
@@ -356,27 +356,49 @@ export declare const ObservabilityConfigSchema: v.ObjectSchema<{
|
|
|
356
356
|
}, undefined>;
|
|
357
357
|
export type ObservabilityConfig = v.InferOutput<typeof ObservabilityConfigSchema>;
|
|
358
358
|
/**
|
|
359
|
-
* `spf watch`'s configuration. `
|
|
360
|
-
* `
|
|
361
|
-
*
|
|
362
|
-
*
|
|
359
|
+
* `spf watch`'s configuration. `issue_provider` (the tracker) and
|
|
360
|
+
* `code_host` (where PRs open) are independent enums, not one combined
|
|
361
|
+
* `provider` — a tracker and a code host are independent choices in
|
|
362
|
+
* practice (Jira issues against a Bitbucket repo, e.g.), and `watch.ts`'s
|
|
363
|
+
* poll loop is written against `IssueProvider`/`CodeHostProvider`
|
|
364
|
+
* separately (see `core/issues/provider.ts`) precisely so any combination
|
|
365
|
+
* is just config, never a rewrite of the loop. Adding a tracker or host
|
|
366
|
+
* later is a new module (`core/issues/*_provider.ts`) plus one more enum
|
|
367
|
+
* entry here.
|
|
363
368
|
*
|
|
364
369
|
* `repo` has no sensible default and is validated as required at `spf
|
|
365
370
|
* watch` startup, not here — an empty string parses fine (this schema has
|
|
366
371
|
* no opinion on whether watch is even configured), matching the same
|
|
367
372
|
* "fails loudly before anything spawns, not eagerly at parse time" pattern
|
|
368
|
-
* `quality:` already uses.
|
|
373
|
+
* `quality:` already uses. Its shape depends on `code_host`: "owner/name"
|
|
374
|
+
* for github, "workspace/repo_slug" for bitbucket.
|
|
369
375
|
*/
|
|
370
|
-
export declare const
|
|
371
|
-
export type
|
|
376
|
+
export declare const WatchIssueProviderSchema: v.PicklistSchema<["github", "jira"], undefined>;
|
|
377
|
+
export type WatchIssueProviderKind = v.InferOutput<typeof WatchIssueProviderSchema>;
|
|
378
|
+
export declare const WatchCodeHostSchema: v.PicklistSchema<["github", "bitbucket"], undefined>;
|
|
379
|
+
export type WatchCodeHostKind = v.InferOutput<typeof WatchCodeHostSchema>;
|
|
380
|
+
/** Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` + `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. */
|
|
381
|
+
export declare const WatchJiraConfigSchema: v.ObjectSchema<{
|
|
382
|
+
readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
383
|
+
readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
384
|
+
}, undefined>;
|
|
385
|
+
export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
|
|
372
386
|
export declare const WatchConfigSchema: v.ObjectSchema<{
|
|
373
|
-
readonly
|
|
387
|
+
readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
|
|
388
|
+
readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
|
|
374
389
|
readonly repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
375
390
|
readonly label_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
|
|
376
391
|
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "plan-build-test">;
|
|
377
392
|
readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
|
|
378
393
|
readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 60000>;
|
|
379
394
|
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
|
|
395
|
+
readonly jira: v.OptionalSchema<v.ObjectSchema<{
|
|
396
|
+
readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
397
|
+
readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
398
|
+
}, undefined>, () => {
|
|
399
|
+
base_url: string;
|
|
400
|
+
project_key: string;
|
|
401
|
+
}>;
|
|
380
402
|
}, undefined>;
|
|
381
403
|
export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
|
|
382
404
|
export declare const SFConfigSchema: v.ObjectSchema<{
|
|
@@ -443,21 +465,34 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
443
465
|
};
|
|
444
466
|
}>;
|
|
445
467
|
readonly watch: v.OptionalSchema<v.ObjectSchema<{
|
|
446
|
-
readonly
|
|
468
|
+
readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
|
|
469
|
+
readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
|
|
447
470
|
readonly repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
448
471
|
readonly label_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
|
|
449
472
|
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "plan-build-test">;
|
|
450
473
|
readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
|
|
451
474
|
readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 60000>;
|
|
452
475
|
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 2>;
|
|
476
|
+
readonly jira: v.OptionalSchema<v.ObjectSchema<{
|
|
477
|
+
readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
478
|
+
readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
479
|
+
}, undefined>, () => {
|
|
480
|
+
base_url: string;
|
|
481
|
+
project_key: string;
|
|
482
|
+
}>;
|
|
453
483
|
}, undefined>, () => {
|
|
454
|
-
|
|
484
|
+
issue_provider: "github" | "jira";
|
|
485
|
+
code_host: "bitbucket" | "github";
|
|
455
486
|
repo: string;
|
|
456
487
|
label_prefix: string;
|
|
457
488
|
chain: string;
|
|
458
489
|
base_branch: string;
|
|
459
490
|
poll_ms: number;
|
|
460
491
|
concurrency: number;
|
|
492
|
+
jira: {
|
|
493
|
+
base_url: string;
|
|
494
|
+
project_key: string;
|
|
495
|
+
};
|
|
461
496
|
}>;
|
|
462
497
|
}, undefined>;
|
|
463
498
|
export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
|
package/dist/core/data_types.js
CHANGED
|
@@ -275,26 +275,40 @@ export const ObservabilityConfigSchema = v.object({
|
|
|
275
275
|
poll_ms: v.optional(v.number(), 500),
|
|
276
276
|
});
|
|
277
277
|
/**
|
|
278
|
-
* `spf watch`'s configuration. `
|
|
279
|
-
* `
|
|
280
|
-
*
|
|
281
|
-
*
|
|
278
|
+
* `spf watch`'s configuration. `issue_provider` (the tracker) and
|
|
279
|
+
* `code_host` (where PRs open) are independent enums, not one combined
|
|
280
|
+
* `provider` — a tracker and a code host are independent choices in
|
|
281
|
+
* practice (Jira issues against a Bitbucket repo, e.g.), and `watch.ts`'s
|
|
282
|
+
* poll loop is written against `IssueProvider`/`CodeHostProvider`
|
|
283
|
+
* separately (see `core/issues/provider.ts`) precisely so any combination
|
|
284
|
+
* is just config, never a rewrite of the loop. Adding a tracker or host
|
|
285
|
+
* later is a new module (`core/issues/*_provider.ts`) plus one more enum
|
|
286
|
+
* entry here.
|
|
282
287
|
*
|
|
283
288
|
* `repo` has no sensible default and is validated as required at `spf
|
|
284
289
|
* watch` startup, not here — an empty string parses fine (this schema has
|
|
285
290
|
* no opinion on whether watch is even configured), matching the same
|
|
286
291
|
* "fails loudly before anything spawns, not eagerly at parse time" pattern
|
|
287
|
-
* `quality:` already uses.
|
|
292
|
+
* `quality:` already uses. Its shape depends on `code_host`: "owner/name"
|
|
293
|
+
* for github, "workspace/repo_slug" for bitbucket.
|
|
288
294
|
*/
|
|
289
|
-
export const
|
|
295
|
+
export const WatchIssueProviderSchema = v.picklist(["github", "jira"]);
|
|
296
|
+
export const WatchCodeHostSchema = v.picklist(["github", "bitbucket"]);
|
|
297
|
+
/** Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` + `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. */
|
|
298
|
+
export const WatchJiraConfigSchema = v.object({
|
|
299
|
+
base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
|
|
300
|
+
project_key: v.optional(v.string(), ""), // e.g. "PROJ"
|
|
301
|
+
});
|
|
290
302
|
export const WatchConfigSchema = v.object({
|
|
291
|
-
|
|
292
|
-
|
|
303
|
+
issue_provider: v.optional(WatchIssueProviderSchema, "github"),
|
|
304
|
+
code_host: v.optional(WatchCodeHostSchema, "github"),
|
|
305
|
+
repo: v.optional(v.string(), ""),
|
|
293
306
|
label_prefix: v.optional(v.string(), "spf"),
|
|
294
307
|
chain: v.optional(v.string(), "plan-build-test"),
|
|
295
308
|
base_branch: v.optional(v.string(), "main"),
|
|
296
309
|
poll_ms: v.optional(v.number(), 60_000),
|
|
297
310
|
concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
|
|
311
|
+
jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
|
|
298
312
|
});
|
|
299
313
|
export const SFConfigSchema = v.object({
|
|
300
314
|
defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bitbucket Cloud REST API v2.0 implementation of `CodeHostProvider` —
|
|
3
|
+
* `spf watch`'s PR seam, not its tracker seam (see `provider.ts`'s module
|
|
4
|
+
* comment): this class never touches issues/labels, so it's paired with an
|
|
5
|
+
* `IssueProvider` (`github_provider.ts` or `jira_provider.ts`) at the CLI
|
|
6
|
+
* layer.
|
|
7
|
+
*
|
|
8
|
+
* Auth is HTTP Basic with an Atlassian account email + API token
|
|
9
|
+
* (`BITBUCKET_EMAIL` / `BITBUCKET_API_TOKEN`) — verified directly against
|
|
10
|
+
* Atlassian's current docs before writing this, not assumed from training
|
|
11
|
+
* data: Bitbucket Cloud app passwords are being fully removed (brownout
|
|
12
|
+
* window closes July 28, 2026), so this project only supports the
|
|
13
|
+
* replacement — API tokens, same auth shape as `jira_provider.ts`.
|
|
14
|
+
*
|
|
15
|
+
* `repo` is `"workspace/repo_slug"` (Bitbucket's own two-part identifier),
|
|
16
|
+
* the same config field GitHub uses for `"owner/name"` — the shape just
|
|
17
|
+
* means something different per `code_host`.
|
|
18
|
+
*/
|
|
19
|
+
import type { CodeHostProvider, PrRef, PrStatus } from "./provider.ts";
|
|
20
|
+
export declare class BitbucketProvider implements CodeHostProvider {
|
|
21
|
+
private readonly email;
|
|
22
|
+
private readonly apiToken;
|
|
23
|
+
private readonly workspace;
|
|
24
|
+
private readonly repoSlug;
|
|
25
|
+
constructor(repo: string, // "workspace/repo_slug"
|
|
26
|
+
email: string, apiToken: string);
|
|
27
|
+
private bb;
|
|
28
|
+
openPr(opts: {
|
|
29
|
+
branch: string;
|
|
30
|
+
title: string;
|
|
31
|
+
body: string;
|
|
32
|
+
base: string;
|
|
33
|
+
}): Promise<PrRef>;
|
|
34
|
+
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const API = "https://api.bitbucket.org/2.0";
|
|
2
|
+
export class BitbucketProvider {
|
|
3
|
+
email;
|
|
4
|
+
apiToken;
|
|
5
|
+
workspace;
|
|
6
|
+
repoSlug;
|
|
7
|
+
constructor(repo, // "workspace/repo_slug"
|
|
8
|
+
email, apiToken) {
|
|
9
|
+
this.email = email;
|
|
10
|
+
this.apiToken = apiToken;
|
|
11
|
+
const [workspace, repoSlug] = repo.split("/");
|
|
12
|
+
if (!workspace || !repoSlug)
|
|
13
|
+
throw new Error(`watch.repo ${JSON.stringify(repo)} is not "workspace/repo_slug"`);
|
|
14
|
+
this.workspace = workspace;
|
|
15
|
+
this.repoSlug = repoSlug;
|
|
16
|
+
}
|
|
17
|
+
async bb(path, init) {
|
|
18
|
+
const response = await fetch(`${API}${path}`, {
|
|
19
|
+
...init,
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`,
|
|
22
|
+
Accept: "application/json",
|
|
23
|
+
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
|
24
|
+
...init?.headers,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
const detail = await response.text().catch(() => "");
|
|
29
|
+
throw new Error(`Bitbucket ${init?.method ?? "GET"} ${path} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
30
|
+
}
|
|
31
|
+
if (response.status === 204)
|
|
32
|
+
return undefined;
|
|
33
|
+
return (await response.json());
|
|
34
|
+
}
|
|
35
|
+
async openPr(opts) {
|
|
36
|
+
const pr = await this.bb(`/repositories/${this.workspace}/${this.repoSlug}/pullrequests`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
title: opts.title,
|
|
40
|
+
description: opts.body,
|
|
41
|
+
source: { branch: { name: opts.branch } },
|
|
42
|
+
destination: { branch: { name: opts.base } },
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
return { number: pr.id, branch: opts.branch, url: pr.links.html.href };
|
|
46
|
+
}
|
|
47
|
+
async prStatus(pr) {
|
|
48
|
+
const detail = await this.bb(`/repositories/${this.workspace}/${this.repoSlug}/pullrequests/${pr.number}`);
|
|
49
|
+
let ciStatus = "pending";
|
|
50
|
+
try {
|
|
51
|
+
const statuses = await this.bb(`/repositories/${this.workspace}/${this.repoSlug}/commit/${detail.source.commit.hash}/statuses?pagelen=100`);
|
|
52
|
+
if (statuses.values.length === 0) {
|
|
53
|
+
ciStatus = "pending"; // no checks configured — never blocks a lean v1's own polling
|
|
54
|
+
}
|
|
55
|
+
else if (statuses.values.some((s) => s.state === "INPROGRESS")) {
|
|
56
|
+
ciStatus = "pending";
|
|
57
|
+
}
|
|
58
|
+
else if (statuses.values.some((s) => s.state === "FAILED" || s.state === "STOPPED")) {
|
|
59
|
+
ciStatus = "failure";
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
ciStatus = "success";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
ciStatus = "pending"; // status lookup failing shouldn't block merge/close detection
|
|
67
|
+
}
|
|
68
|
+
return { merged: detail.state === "MERGED", state: detail.state === "OPEN" ? "open" : "closed", ciStatus };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub REST implementation of `IssueProvider
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* GitHub REST implementation of both `IssueProvider` and `CodeHostProvider`
|
|
3
|
+
* — one class, since GitHub natively is both an issue tracker and a code
|
|
4
|
+
* host — via Node 22's native `fetch()`: deliberately not `octokit`, whose
|
|
5
|
+
* full meta-package resolves to ~82MB of installed dependencies (`@octokit/app`,
|
|
5
6
|
* `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which
|
|
6
7
|
* is six REST calls. `spf`'s own package stays dependency-free either way.
|
|
7
8
|
*
|
|
@@ -12,8 +13,8 @@
|
|
|
12
13
|
* makes (a repo with >100 open `<prefix>:ready` issues at once is not this
|
|
13
14
|
* version's problem to solve).
|
|
14
15
|
*/
|
|
15
|
-
import type { EnsureLabelsResult, Issue, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
16
|
-
export declare class GitHubProvider implements IssueProvider {
|
|
16
|
+
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
17
|
+
export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
17
18
|
private readonly repo;
|
|
18
19
|
private readonly labelPrefix;
|
|
19
20
|
private readonly token;
|
|
@@ -39,7 +40,7 @@ export declare class GitHubProvider implements IssueProvider {
|
|
|
39
40
|
claim(issue: Issue): Promise<boolean>;
|
|
40
41
|
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
41
42
|
comment(issue: Issue, body: string): Promise<void>;
|
|
42
|
-
openPr(
|
|
43
|
+
openPr(opts: {
|
|
43
44
|
branch: string;
|
|
44
45
|
title: string;
|
|
45
46
|
body: string;
|
|
@@ -87,7 +87,7 @@ export class GitHubProvider {
|
|
|
87
87
|
}
|
|
88
88
|
toIssue(raw) {
|
|
89
89
|
return {
|
|
90
|
-
|
|
90
|
+
id: String(raw.number),
|
|
91
91
|
title: raw.title,
|
|
92
92
|
body: raw.body ?? "",
|
|
93
93
|
labels: raw.labels.map((l) => (typeof l === "string" ? l : l.name)),
|
|
@@ -104,19 +104,19 @@ export class GitHubProvider {
|
|
|
104
104
|
return this.listByLabel(this.label(state), opts?.includeAll ? "all" : "open");
|
|
105
105
|
}
|
|
106
106
|
async claim(issue) {
|
|
107
|
-
await this.gh(`/repos/${this.repo}/issues/${issue.
|
|
107
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels/${encodeURIComponent(this.label("ready"))}`, {
|
|
108
108
|
method: "DELETE",
|
|
109
109
|
}).catch(() => undefined); // already gone is fine
|
|
110
|
-
await this.gh(`/repos/${this.repo}/issues/${issue.
|
|
110
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels`, {
|
|
111
111
|
method: "POST",
|
|
112
112
|
body: JSON.stringify({ labels: [this.label("working")] }),
|
|
113
113
|
});
|
|
114
|
-
const fresh = await this.gh(`/repos/${this.repo}/issues/${issue.
|
|
114
|
+
const fresh = await this.gh(`/repos/${this.repo}/issues/${issue.id}`);
|
|
115
115
|
const labels = this.toIssue(fresh).labels;
|
|
116
116
|
const claimed = labels.includes(this.label("working")) && !labels.includes(this.label("ready"));
|
|
117
117
|
if (!claimed) {
|
|
118
118
|
// Lost the race (or something else relabeled it) — put ready back so it's not stuck.
|
|
119
|
-
await this.gh(`/repos/${this.repo}/issues/${issue.
|
|
119
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels`, {
|
|
120
120
|
method: "POST",
|
|
121
121
|
body: JSON.stringify({ labels: [this.label("ready")] }),
|
|
122
122
|
}).catch(() => undefined);
|
|
@@ -127,10 +127,10 @@ export class GitHubProvider {
|
|
|
127
127
|
for (const state of STATES) {
|
|
128
128
|
const label = this.label(state);
|
|
129
129
|
if (issue.labels.includes(label)) {
|
|
130
|
-
await this.gh(`/repos/${this.repo}/issues/${issue.
|
|
130
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels/${encodeURIComponent(label)}`, { method: "DELETE" }).catch(() => undefined);
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
-
await this.gh(`/repos/${this.repo}/issues/${issue.
|
|
133
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels`, {
|
|
134
134
|
method: "POST",
|
|
135
135
|
body: JSON.stringify({ labels: [this.label(to)] }),
|
|
136
136
|
});
|
|
@@ -138,20 +138,15 @@ export class GitHubProvider {
|
|
|
138
138
|
await this.comment(issue, detail);
|
|
139
139
|
}
|
|
140
140
|
async comment(issue, body) {
|
|
141
|
-
await this.gh(`/repos/${this.repo}/issues/${issue.
|
|
141
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}/comments`, {
|
|
142
142
|
method: "POST",
|
|
143
143
|
body: JSON.stringify({ body }),
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
|
-
async openPr(
|
|
147
|
-
// Belt-and-suspenders: ensure the PR body closes the issue even if the
|
|
148
|
-
// caller's body forgot to say so — this is how `finishReviews`-style
|
|
149
|
-
// merge polling knows an issue's work landed at all.
|
|
150
|
-
const closesTag = `Closes #${issue.number}`;
|
|
151
|
-
const body = opts.body.includes(closesTag) ? opts.body : `${opts.body}\n\n${closesTag}`;
|
|
146
|
+
async openPr(opts) {
|
|
152
147
|
const pr = await this.gh(`/repos/${this.repo}/pulls`, {
|
|
153
148
|
method: "POST",
|
|
154
|
-
body: JSON.stringify({ title: opts.title, body, head: opts.branch, base: opts.base }),
|
|
149
|
+
body: JSON.stringify({ title: opts.title, body: opts.body, head: opts.branch, base: opts.base }),
|
|
155
150
|
});
|
|
156
151
|
return { number: pr.number, branch: opts.branch, url: pr.html_url };
|
|
157
152
|
}
|
|
@@ -178,8 +173,8 @@ export class GitHubProvider {
|
|
|
178
173
|
}
|
|
179
174
|
return { merged: detail.merged, state: detail.state, ciStatus };
|
|
180
175
|
}
|
|
181
|
-
async findMarkerComment(
|
|
182
|
-
const comments = await this.gh(`/repos/${this.repo}/issues/${
|
|
176
|
+
async findMarkerComment(issueId) {
|
|
177
|
+
const comments = await this.gh(`/repos/${this.repo}/issues/${issueId}/comments?per_page=100`);
|
|
183
178
|
let found = null;
|
|
184
179
|
for (const c of comments) {
|
|
185
180
|
const match = MARKER_RE.exec(c.body || "");
|
|
@@ -195,17 +190,17 @@ export class GitHubProvider {
|
|
|
195
190
|
return found;
|
|
196
191
|
}
|
|
197
192
|
async readMarker(issue) {
|
|
198
|
-
const found = await this.findMarkerComment(issue.
|
|
193
|
+
const found = await this.findMarkerComment(issue.id);
|
|
199
194
|
return found?.marker ?? null;
|
|
200
195
|
}
|
|
201
196
|
async writeMarker(issue, marker) {
|
|
202
197
|
const body = `<!-- spf-watch: ${JSON.stringify(marker)} -->`;
|
|
203
|
-
const existing = await this.findMarkerComment(issue.
|
|
198
|
+
const existing = await this.findMarkerComment(issue.id);
|
|
204
199
|
if (existing) {
|
|
205
200
|
await this.gh(`/repos/${this.repo}/issues/comments/${existing.id}`, { method: "PATCH", body: JSON.stringify({ body }) });
|
|
206
201
|
}
|
|
207
202
|
else {
|
|
208
|
-
await this.gh(`/repos/${this.repo}/issues/${issue.
|
|
203
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.id}/comments`, { method: "POST", body: JSON.stringify({ body }) });
|
|
209
204
|
}
|
|
210
205
|
}
|
|
211
206
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jira Cloud REST API v3 implementation of `IssueProvider` — `spf watch`'s
|
|
3
|
+
* tracker seam, not its PR seam (see `provider.ts`'s module comment): this
|
|
4
|
+
* class never opens a PR, so it's paired with a `CodeHostProvider`
|
|
5
|
+
* (`github_provider.ts` or `bitbucket_provider.ts`) at the CLI layer.
|
|
6
|
+
*
|
|
7
|
+
* Auth is HTTP Basic with an Atlassian account email + API token
|
|
8
|
+
* (`JIRA_EMAIL` / `JIRA_API_TOKEN`), base64-encoded — verified against
|
|
9
|
+
* developer.atlassian.com's current auth docs, not assumed.
|
|
10
|
+
*
|
|
11
|
+
* Two API-v3 specifics that would otherwise silently break this:
|
|
12
|
+
* - `/rest/api/3/search` (GET, JQL as a query param) was fully removed
|
|
13
|
+
* from Jira Cloud in October 2025. The only working search endpoint
|
|
14
|
+
* now is `/rest/api/3/search/jql`.
|
|
15
|
+
* - Comment/description bodies in API v3 are Atlassian Document Format
|
|
16
|
+
* (ADF) JSON, not plain strings — `{"body": "text"}` is rejected
|
|
17
|
+
* outright. `toAdf`/`adfToText` are the minimal round-trip this needs:
|
|
18
|
+
* one paragraph of plain text, nothing richer.
|
|
19
|
+
*
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* `ensureLabels()` is a no-op that reports the labels this run will use:
|
|
30
|
+
* Jira labels are freeform strings with no color/description registry to
|
|
31
|
+
* seed, unlike GitHub's.
|
|
32
|
+
*/
|
|
33
|
+
import type { EnsureLabelsResult, Issue, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
|
|
34
|
+
export declare class JiraProvider implements IssueProvider {
|
|
35
|
+
private readonly baseUrl;
|
|
36
|
+
private readonly projectKey;
|
|
37
|
+
private readonly labelPrefix;
|
|
38
|
+
private readonly email;
|
|
39
|
+
private readonly apiToken;
|
|
40
|
+
constructor(baseUrl: string, // e.g. "https://your-domain.atlassian.net", no trailing slash
|
|
41
|
+
projectKey: string, labelPrefix: string, email: string, apiToken: string);
|
|
42
|
+
private authHeader;
|
|
43
|
+
private jira;
|
|
44
|
+
private label;
|
|
45
|
+
/** Jira labels are freeform strings, not a seedable registry — report what's used, create nothing. */
|
|
46
|
+
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
47
|
+
private toIssue;
|
|
48
|
+
private searchByLabel;
|
|
49
|
+
listEligible(): Promise<Issue[]>;
|
|
50
|
+
/**
|
|
51
|
+
* `opts.includeAll` is ignored: nothing but this provider's own
|
|
52
|
+
* `transition()` ever changes an issue's labels or resolution here, so
|
|
53
|
+
* there's no side channel (like GitHub's `Closes #n` auto-close) that
|
|
54
|
+
* could make a `review`-labeled issue vanish from an unfiltered query.
|
|
55
|
+
*/
|
|
56
|
+
listInState(state: WatchState): Promise<Issue[]>;
|
|
57
|
+
claim(issue: Issue): Promise<boolean>;
|
|
58
|
+
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
59
|
+
comment(issue: Issue, body: string): Promise<void>;
|
|
60
|
+
private findMarkerComment;
|
|
61
|
+
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
62
|
+
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|
|
63
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const STATES = ["ready", "working", "review", "done", "blocked"];
|
|
2
|
+
const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*?\})/s;
|
|
3
|
+
function toAdf(text) {
|
|
4
|
+
return {
|
|
5
|
+
type: "doc",
|
|
6
|
+
version: 1,
|
|
7
|
+
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Walks an ADF document's `text` nodes and joins them — the minimal inverse of `toAdf`, not a full ADF renderer. */
|
|
11
|
+
function adfToText(adf) {
|
|
12
|
+
if (!adf || typeof adf !== "object")
|
|
13
|
+
return "";
|
|
14
|
+
const node = adf;
|
|
15
|
+
if (node.type === "text" && typeof node.text === "string")
|
|
16
|
+
return node.text;
|
|
17
|
+
if (Array.isArray(node.content))
|
|
18
|
+
return node.content.map(adfToText).join("");
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
export class JiraProvider {
|
|
22
|
+
baseUrl;
|
|
23
|
+
projectKey;
|
|
24
|
+
labelPrefix;
|
|
25
|
+
email;
|
|
26
|
+
apiToken;
|
|
27
|
+
constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
|
|
28
|
+
projectKey, labelPrefix, email, apiToken) {
|
|
29
|
+
this.baseUrl = baseUrl;
|
|
30
|
+
this.projectKey = projectKey;
|
|
31
|
+
this.labelPrefix = labelPrefix;
|
|
32
|
+
this.email = email;
|
|
33
|
+
this.apiToken = apiToken;
|
|
34
|
+
}
|
|
35
|
+
authHeader() {
|
|
36
|
+
return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
|
|
37
|
+
}
|
|
38
|
+
async jira(path, init) {
|
|
39
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
40
|
+
...init,
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: this.authHeader(),
|
|
43
|
+
Accept: "application/json",
|
|
44
|
+
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
|
45
|
+
...init?.headers,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
const detail = await response.text().catch(() => "");
|
|
50
|
+
throw new Error(`Jira ${init?.method ?? "GET"} ${path} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
51
|
+
}
|
|
52
|
+
if (response.status === 204)
|
|
53
|
+
return undefined;
|
|
54
|
+
return (await response.json());
|
|
55
|
+
}
|
|
56
|
+
label(state) {
|
|
57
|
+
return `${this.labelPrefix}:${state}`;
|
|
58
|
+
}
|
|
59
|
+
/** Jira labels are freeform strings, not a seedable registry — report what's used, create nothing. */
|
|
60
|
+
async ensureLabels() {
|
|
61
|
+
return { created: [], updated: [], unchanged: STATES.map((s) => this.label(s)) };
|
|
62
|
+
}
|
|
63
|
+
toIssue(raw) {
|
|
64
|
+
return {
|
|
65
|
+
id: raw.key,
|
|
66
|
+
title: raw.fields.summary,
|
|
67
|
+
body: raw.fields.description ? adfToText(raw.fields.description) : "",
|
|
68
|
+
labels: raw.fields.labels,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async searchByLabel(label) {
|
|
72
|
+
const jql = `project = ${JSON.stringify(this.projectKey)} AND labels = ${JSON.stringify(label)}`;
|
|
73
|
+
const result = await this.jira(`/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&maxResults=100&fields=summary,description,labels`);
|
|
74
|
+
return result.issues.map((i) => this.toIssue(i));
|
|
75
|
+
}
|
|
76
|
+
async listEligible() {
|
|
77
|
+
return this.searchByLabel(this.label("ready"));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `opts.includeAll` is ignored: nothing but this provider's own
|
|
81
|
+
* `transition()` ever changes an issue's labels or resolution here, so
|
|
82
|
+
* there's no side channel (like GitHub's `Closes #n` auto-close) that
|
|
83
|
+
* could make a `review`-labeled issue vanish from an unfiltered query.
|
|
84
|
+
*/
|
|
85
|
+
async listInState(state) {
|
|
86
|
+
return this.searchByLabel(this.label(state));
|
|
87
|
+
}
|
|
88
|
+
async claim(issue) {
|
|
89
|
+
const next = issue.labels.filter((l) => l !== this.label("ready"));
|
|
90
|
+
next.push(this.label("working"));
|
|
91
|
+
await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
|
|
92
|
+
const fresh = await this.jira(`/rest/api/3/issue/${issue.id}?fields=summary,description,labels`);
|
|
93
|
+
const labels = fresh.fields.labels;
|
|
94
|
+
const claimed = labels.includes(this.label("working")) && !labels.includes(this.label("ready"));
|
|
95
|
+
if (!claimed) {
|
|
96
|
+
const revert = labels.filter((l) => l !== this.label("working"));
|
|
97
|
+
revert.push(this.label("ready"));
|
|
98
|
+
await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: revert } }) }).catch(() => undefined);
|
|
99
|
+
}
|
|
100
|
+
return claimed;
|
|
101
|
+
}
|
|
102
|
+
async transition(issue, to, detail) {
|
|
103
|
+
const next = issue.labels.filter((l) => !STATES.some((s) => this.label(s) === l));
|
|
104
|
+
next.push(this.label(to));
|
|
105
|
+
await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
|
|
106
|
+
if (detail)
|
|
107
|
+
await this.comment(issue, detail);
|
|
108
|
+
}
|
|
109
|
+
async comment(issue, body) {
|
|
110
|
+
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
|
|
111
|
+
}
|
|
112
|
+
async findMarkerComment(issueId) {
|
|
113
|
+
const result = await this.jira(`/rest/api/3/issue/${issueId}/comment?maxResults=100`);
|
|
114
|
+
let found = null;
|
|
115
|
+
for (const c of result.comments) {
|
|
116
|
+
const match = MARKER_RE.exec(adfToText(c.body));
|
|
117
|
+
if (!match)
|
|
118
|
+
continue;
|
|
119
|
+
try {
|
|
120
|
+
found = { id: c.id, marker: JSON.parse(match[1]) };
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// malformed marker JSON — tolerate it and keep looking, like github_provider.ts does
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
async readMarker(issue) {
|
|
129
|
+
const found = await this.findMarkerComment(issue.id);
|
|
130
|
+
return found?.marker ?? null;
|
|
131
|
+
}
|
|
132
|
+
async writeMarker(issue, marker) {
|
|
133
|
+
// Unlike GitHub's HTML-comment trick, Jira's ADF has no way to actually
|
|
134
|
+
// hide this from a viewer — it's a plainly visible comment, just one
|
|
135
|
+
// that starts with a recognizable, regex-matchable tag.
|
|
136
|
+
const body = toAdf(`[spf-watch-marker] ${JSON.stringify(marker)}`);
|
|
137
|
+
const existing = await this.findMarkerComment(issue.id);
|
|
138
|
+
if (existing) {
|
|
139
|
+
await this.jira(`/rest/api/3/issue/${issue.id}/comment/${existing.id}`, { method: "PUT", body: JSON.stringify({ body }) });
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body }) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|