@gr8ful/spf 0.1.4 → 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.
@@ -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. `provider` is a small enum today (just
360
- * `"github"`) by design adding Jira/Linear later is a new provider
361
- * module (`core/issues/*_provider.ts`) implementing `IssueProvider` plus
362
- * one more enum entry here, not a rewrite of the poll loop.
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 WatchProviderSchema: v.PicklistSchema<["github"], undefined>;
371
- export type WatchProvider = v.InferOutput<typeof WatchProviderSchema>;
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 provider: v.OptionalSchema<v.PicklistSchema<["github"], undefined>, "github">;
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 provider: v.OptionalSchema<v.PicklistSchema<["github"], undefined>, "github">;
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
- provider: "github";
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>;
@@ -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. `provider` is a small enum today (just
279
- * `"github"`) by design adding Jira/Linear later is a new provider
280
- * module (`core/issues/*_provider.ts`) implementing `IssueProvider` plus
281
- * one more enum entry here, not a rewrite of the poll loop.
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 WatchProviderSchema = v.picklist(["github"]);
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
- provider: v.optional(WatchProviderSchema, "github"),
292
- repo: v.optional(v.string(), ""), // "owner/name"
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`, via Node 22's native
3
- * `fetch()` deliberately not `octokit`: the full `octokit` meta-package
4
- * resolves to ~82MB of installed dependencies (`@octokit/app`,
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(issue: Issue, opts: {
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
- number: raw.number,
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.number}/labels/${encodeURIComponent(this.label("ready"))}`, {
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.number}/labels`, {
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.number}`);
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.number}/labels`, {
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.number}/labels/${encodeURIComponent(label)}`, { method: "DELETE" }).catch(() => undefined);
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.number}/labels`, {
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.number}/comments`, {
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(issue, opts) {
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(issueNumber) {
182
- const comments = await this.gh(`/repos/${this.repo}/issues/${issueNumber}/comments?per_page=100`);
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.number);
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.number);
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.number}/comments`, { method: "POST", body: JSON.stringify({ body }) });
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
+ }