@gr8ful/spf 0.11.0 → 0.11.1

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.
@@ -1,21 +1,3 @@
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
1
  import type { CodeHostProvider, PrRef, PrStatus } from "./provider.ts";
20
2
  export declare class BitbucketProvider implements CodeHostProvider {
21
3
  private readonly email;
@@ -1,3 +1,22 @@
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 { fetchRetryTransient } from "../utils.js";
1
20
  const API = "https://api.bitbucket.org/2.0";
2
21
  export class BitbucketProvider {
3
22
  email;
@@ -15,7 +34,7 @@ export class BitbucketProvider {
15
34
  this.repoSlug = repoSlug;
16
35
  }
17
36
  async bb(path, init) {
18
- const response = await fetch(`${API}${path}`, {
37
+ const response = await fetchRetryTransient(`${API}${path}`, {
19
38
  ...init,
20
39
  headers: {
21
40
  Authorization: `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`,
@@ -1,3 +1,4 @@
1
+ import { fetchRetryTransient } from "../utils.js";
1
2
  const STATES = [
2
3
  "ready",
3
4
  "working",
@@ -65,7 +66,7 @@ export class JiraProvider {
65
66
  const debug = Boolean(process.env["SPF_JIRA_DEBUG"]);
66
67
  if (debug)
67
68
  console.error(`[jira debug] ${init?.method ?? "GET"} ${this.baseUrl}${path} body=${init?.body ?? "(none)"}`);
68
- const response = await fetch(`${this.baseUrl}${path}`, {
69
+ const response = await fetchRetryTransient(`${this.baseUrl}${path}`, {
69
70
  ...init,
70
71
  headers: {
71
72
  Authorization: this.authHeader(),
@@ -139,7 +140,7 @@ export class JiraProvider {
139
140
  * never discovers it from the issue body itself.
140
141
  */
141
142
  async getIssue(id) {
142
- const response = await fetch(`${this.baseUrl}/rest/api/3/issue/${id}?fields=summary,description,labels`, {
143
+ const response = await fetchRetryTransient(`${this.baseUrl}/rest/api/3/issue/${id}?fields=summary,description,labels`, {
143
144
  headers: { Authorization: this.authHeader(), Accept: "application/json" },
144
145
  });
145
146
  if (response.status === 404)
@@ -19,6 +19,18 @@ import path from "node:path";
19
19
  */
20
20
  export declare function operatorEnv(): Record<string, string>;
21
21
  export declare function newId(length?: number): string;
22
+ /**
23
+ * `fetch`, but a transport-level blip on the FIRST attempt gets one silent
24
+ * retry before it's allowed to throw. Node's global `fetch` (undici) pools
25
+ * keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
26
+ * Bitbucket) close idle ones from their end, which surfaces here as
27
+ * `ECONNRESET` the next time a long-lived poller (`spf watch`) reuses one —
28
+ * a stale-socket race, not a real problem with the request. Only retries
29
+ * error codes that mean "the transport failed," never an HTTP error status
30
+ * (a 4xx/5xx response is not a thrown error here, and must keep surfacing
31
+ * on the first attempt so callers see it immediately).
32
+ */
33
+ export declare function fetchRetryTransient(input: string, init?: RequestInit): Promise<Response>;
22
34
  /** Matches Python's `datetime.now(timezone.utc).isoformat(timespec="milliseconds")`. */
23
35
  export declare function nowIso(): string;
24
36
  export declare function ensureDir(dirPath: string): string;
@@ -31,6 +31,30 @@ export function operatorEnv() {
31
31
  export function newId(length = 8) {
32
32
  return randomBytes(Math.floor(length / 2)).toString("hex");
33
33
  }
34
+ /** Transport-level blips worth one silent retry — see `fetchRetryTransient` below. */
35
+ const TRANSIENT_FETCH_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "EPIPE", "ECONNREFUSED", "EAI_AGAIN"]);
36
+ /**
37
+ * `fetch`, but a transport-level blip on the FIRST attempt gets one silent
38
+ * retry before it's allowed to throw. Node's global `fetch` (undici) pools
39
+ * keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
40
+ * Bitbucket) close idle ones from their end, which surfaces here as
41
+ * `ECONNRESET` the next time a long-lived poller (`spf watch`) reuses one —
42
+ * a stale-socket race, not a real problem with the request. Only retries
43
+ * error codes that mean "the transport failed," never an HTTP error status
44
+ * (a 4xx/5xx response is not a thrown error here, and must keep surfacing
45
+ * on the first attempt so callers see it immediately).
46
+ */
47
+ export async function fetchRetryTransient(input, init) {
48
+ try {
49
+ return await fetch(input, init);
50
+ }
51
+ catch (error) {
52
+ const code = error.cause?.code;
53
+ if (!code || !TRANSIENT_FETCH_CODES.has(code))
54
+ throw error;
55
+ return await fetch(input, init);
56
+ }
57
+ }
34
58
  /** Matches Python's `datetime.now(timezone.utc).isoformat(timespec="milliseconds")`. */
35
59
  export function nowIso() {
36
60
  const iso = new Date().toISOString(); // e.g. 2024-01-01T12:00:00.123Z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",