@gr8ful/spf 0.1.5 → 0.1.7

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.
@@ -0,0 +1,163 @@
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 debug = Boolean(process.env["SPF_JIRA_DEBUG"]);
40
+ if (debug)
41
+ console.error(`[jira debug] ${init?.method ?? "GET"} ${this.baseUrl}${path} body=${init?.body ?? "(none)"}`);
42
+ const response = await fetch(`${this.baseUrl}${path}`, {
43
+ ...init,
44
+ headers: {
45
+ Authorization: this.authHeader(),
46
+ Accept: "application/json",
47
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
48
+ ...init?.headers,
49
+ },
50
+ });
51
+ if (!response.ok) {
52
+ const detail = await response.text().catch(() => "");
53
+ throw new Error(`Jira ${init?.method ?? "GET"} ${path} -> ${response.status}: ${detail.slice(0, 500)}`);
54
+ }
55
+ if (response.status === 204)
56
+ return undefined;
57
+ const text = await response.text();
58
+ if (debug)
59
+ console.error(`[jira debug] -> ${response.status} ${text.slice(0, 2000)}`);
60
+ return text ? JSON.parse(text) : undefined;
61
+ }
62
+ label(state) {
63
+ return `${this.labelPrefix}:${state}`;
64
+ }
65
+ /** Jira labels are freeform strings, not a seedable registry — report what's used, create nothing. */
66
+ async ensureLabels() {
67
+ return { created: [], updated: [], unchanged: STATES.map((s) => this.label(s)) };
68
+ }
69
+ toIssue(raw) {
70
+ return {
71
+ id: raw.key,
72
+ title: raw.fields.summary,
73
+ body: raw.fields.description ? adfToText(raw.fields.description) : "",
74
+ labels: raw.fields.labels,
75
+ };
76
+ }
77
+ /**
78
+ * POST with the JQL in the JSON body, NOT a GET with `jql` as a query
79
+ * param: this endpoint's own real-world behavior (confirmed by multiple
80
+ * independent bug reports against it, not just this project's own
81
+ * testing) is to silently ignore query-string parameters and return an
82
+ * empty `issues` array with a 200 OK — no error, nothing to catch. A
83
+ * `spf watch` that builds this as a GET query string would run cleanly
84
+ * forever without ever claiming a single issue.
85
+ */
86
+ async searchByLabel(label) {
87
+ const jql = `project = ${JSON.stringify(this.projectKey)} AND labels = ${JSON.stringify(label)}`;
88
+ const result = await this.jira("/rest/api/3/search/jql", {
89
+ method: "POST",
90
+ body: JSON.stringify({ jql, maxResults: 100, fields: ["summary", "description", "labels"] }),
91
+ });
92
+ return result.issues.map((i) => this.toIssue(i));
93
+ }
94
+ async listEligible() {
95
+ return this.searchByLabel(this.label("ready"));
96
+ }
97
+ /**
98
+ * `opts.includeAll` is ignored: nothing but this provider's own
99
+ * `transition()` ever changes an issue's labels or resolution here, so
100
+ * there's no side channel (like GitHub's `Closes #n` auto-close) that
101
+ * could make a `review`-labeled issue vanish from an unfiltered query.
102
+ */
103
+ async listInState(state) {
104
+ return this.searchByLabel(this.label(state));
105
+ }
106
+ async claim(issue) {
107
+ const next = issue.labels.filter((l) => l !== this.label("ready"));
108
+ next.push(this.label("working"));
109
+ await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
110
+ const fresh = await this.jira(`/rest/api/3/issue/${issue.id}?fields=summary,description,labels`);
111
+ const labels = fresh.fields.labels;
112
+ const claimed = labels.includes(this.label("working")) && !labels.includes(this.label("ready"));
113
+ if (!claimed) {
114
+ const revert = labels.filter((l) => l !== this.label("working"));
115
+ revert.push(this.label("ready"));
116
+ await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: revert } }) }).catch(() => undefined);
117
+ }
118
+ return claimed;
119
+ }
120
+ async transition(issue, to, detail) {
121
+ const next = issue.labels.filter((l) => !STATES.some((s) => this.label(s) === l));
122
+ next.push(this.label(to));
123
+ await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
124
+ if (detail)
125
+ await this.comment(issue, detail);
126
+ }
127
+ async comment(issue, body) {
128
+ await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
129
+ }
130
+ async findMarkerComment(issueId) {
131
+ const result = await this.jira(`/rest/api/3/issue/${issueId}/comment?maxResults=100`);
132
+ let found = null;
133
+ for (const c of result.comments) {
134
+ const match = MARKER_RE.exec(adfToText(c.body));
135
+ if (!match)
136
+ continue;
137
+ try {
138
+ found = { id: c.id, marker: JSON.parse(match[1]) };
139
+ }
140
+ catch {
141
+ // malformed marker JSON — tolerate it and keep looking, like github_provider.ts does
142
+ }
143
+ }
144
+ return found;
145
+ }
146
+ async readMarker(issue) {
147
+ const found = await this.findMarkerComment(issue.id);
148
+ return found?.marker ?? null;
149
+ }
150
+ async writeMarker(issue, marker) {
151
+ // Unlike GitHub's HTML-comment trick, Jira's ADF has no way to actually
152
+ // hide this from a viewer — it's a plainly visible comment, just one
153
+ // that starts with a recognizable, regex-matchable tag.
154
+ const body = toAdf(`[spf-watch-marker] ${JSON.stringify(marker)}`);
155
+ const existing = await this.findMarkerComment(issue.id);
156
+ if (existing) {
157
+ await this.jira(`/rest/api/3/issue/${issue.id}/comment/${existing.id}`, { method: "PUT", body: JSON.stringify({ body }) });
158
+ }
159
+ else {
160
+ await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body }) });
161
+ }
162
+ }
163
+ }
@@ -1,10 +1,18 @@
1
1
  /**
2
- * The issue-tracker seam `spf watch` drives — the abstraction the user's own
2
+ * The two seams `spf watch` drives — the abstraction the user's own
3
3
  * reference implementation (a GitHub-issues SDLC poller) never had: its
4
4
  * GitHub client is a concrete class referenced by type everywhere, so
5
- * adding a second tracker would mean reworking the poll loop itself. Here,
6
- * nothing outside `github_provider.ts` (or a future `jira_provider.ts`)
7
- * knows it's talking to GitHub.
5
+ * adding a second tracker would mean reworking the poll loop itself.
6
+ *
7
+ * `IssueProvider` (tracker: list/claim/transition/comment/markers) and
8
+ * `CodeHostProvider` (PR lifecycle: open/status) are deliberately separate
9
+ * interfaces, not one bundled seam — a tracker and a code host are
10
+ * independent choices in practice (Jira issues against a Bitbucket repo is
11
+ * a real setup, not a hypothetical one). `github_provider.ts`'s single
12
+ * class implements both, since GitHub natively is both; `jira_provider.ts`
13
+ * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
+ * `CodeHostProvider` — any tracker x host combination is just config
15
+ * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
8
16
  *
9
17
  * The label-as-state-machine design is deliberate, copied from that same
10
18
  * reference: `transition()` is the ONE mutator, so every state change is
@@ -13,7 +21,8 @@
13
21
  */
14
22
  export type WatchState = "ready" | "working" | "review" | "done" | "blocked";
15
23
  export interface Issue {
16
- number: number;
24
+ /** Opaque tracker identifier: a GitHub issue number stringified ("42"), a Jira key ("PROJ-123"). */
25
+ id: string;
17
26
  title: string;
18
27
  body: string;
19
28
  labels: string[];
@@ -51,8 +60,8 @@ export interface IssueProvider {
51
60
  * Idempotently seed whatever this tracker needs for the state machine to
52
61
  * work at all — GitHub: the five `<prefix>:*` labels, with a color and
53
62
  * description, created if missing and corrected if drifted. A tracker
54
- * with no such concept (a future one might model state entirely as a
55
- * workflow field) can make this a no-op — `spf watch init` just reports
63
+ * with no such concept (Jira labels are freeform strings, not seedable
64
+ * objects) can make this a no-op — `spf watch init` just reports
56
65
  * whatever comes back, empty results included.
57
66
  */
58
67
  ensureLabels(): Promise<EnsureLabelsResult>;
@@ -60,10 +69,13 @@ export interface IssueProvider {
60
69
  listEligible(): Promise<Issue[]>;
61
70
  /**
62
71
  * Issues currently in `state`. `includeAll` queries closed issues too —
63
- * required for `review`, since GitHub (or any tracker with a `Closes #n`
64
- * convention) can auto-close an issue the instant its PR merges, often
65
- * before the next poll tick runs; an open-only query would let it vanish
66
- * from tracking forever.
72
+ * required for `review` on a tracker where closing an issue is a side
73
+ * effect the tracker itself performs (GitHub auto-closes on a merged
74
+ * `Closes #n` PR, often before the next poll tick runs); an open-only
75
+ * query would let it vanish from tracking forever. A tracker where
76
+ * nothing but `transition()` ever changes an issue's resolution (Jira,
77
+ * under this design) can ignore the flag — there's no side channel to
78
+ * miss.
67
79
  */
68
80
  listInState(state: WatchState, opts?: {
69
81
  includeAll?: boolean;
@@ -78,13 +90,24 @@ export interface IssueProvider {
78
90
  /** The one state-mutating call. `detail`, if given, is also posted as a comment. */
79
91
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
80
92
  comment(issue: Issue, body: string): Promise<void>;
81
- openPr(issue: Issue, opts: {
93
+ readMarker(issue: Issue): Promise<WatchMarker | null>;
94
+ writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
95
+ }
96
+ /**
97
+ * The PR-lifecycle seam, independent of `IssueProvider` — see the module
98
+ * comment above. `openPr` takes no issue reference: cross-linking a PR to
99
+ * its issue is the caller's job (put the issue's `id`/title in `title`/
100
+ * `body`), not this seam's, since a code host paired with a different
101
+ * tracker has no native "closes" convention to hook into anyway. `spf
102
+ * watch`'s own polling (`finishReviews`), not the host's auto-close
103
+ * behavior, is what drives `done`/`blocked` — see `watch.ts`.
104
+ */
105
+ export interface CodeHostProvider {
106
+ openPr(opts: {
82
107
  branch: string;
83
108
  title: string;
84
109
  body: string;
85
110
  base: string;
86
111
  }): Promise<PrRef>;
87
112
  prStatus(pr: PrRef): Promise<PrStatus>;
88
- readMarker(issue: Issue): Promise<WatchMarker | null>;
89
- writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
90
113
  }
@@ -1,10 +1,18 @@
1
1
  /**
2
- * The issue-tracker seam `spf watch` drives — the abstraction the user's own
2
+ * The two seams `spf watch` drives — the abstraction the user's own
3
3
  * reference implementation (a GitHub-issues SDLC poller) never had: its
4
4
  * GitHub client is a concrete class referenced by type everywhere, so
5
- * adding a second tracker would mean reworking the poll loop itself. Here,
6
- * nothing outside `github_provider.ts` (or a future `jira_provider.ts`)
7
- * knows it's talking to GitHub.
5
+ * adding a second tracker would mean reworking the poll loop itself.
6
+ *
7
+ * `IssueProvider` (tracker: list/claim/transition/comment/markers) and
8
+ * `CodeHostProvider` (PR lifecycle: open/status) are deliberately separate
9
+ * interfaces, not one bundled seam — a tracker and a code host are
10
+ * independent choices in practice (Jira issues against a Bitbucket repo is
11
+ * a real setup, not a hypothetical one). `github_provider.ts`'s single
12
+ * class implements both, since GitHub natively is both; `jira_provider.ts`
13
+ * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
+ * `CodeHostProvider` — any tracker x host combination is just config
15
+ * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
8
16
  *
9
17
  * The label-as-state-machine design is deliberate, copied from that same
10
18
  * reference: `transition()` is the ONE mutator, so every state change is
@@ -22,6 +22,8 @@ export declare const ASSETS_DIR: string;
22
22
  export declare const WEB_DIR: string;
23
23
  export declare const BUILTIN_CONFIG_PATH: string;
24
24
  export declare const BUILTIN_PROMPTS_DIR: string;
25
+ /** `spf init --template <name>` reads `<name>.spf.config.yaml` from here. */
26
+ export declare const TEMPLATES_DIR: string;
25
27
  export interface RepoAnchor {
26
28
  /** The invocation cwd this anchor was resolved from (already absolute). */
27
29
  cwd: string;
@@ -25,6 +25,8 @@ export const ASSETS_DIR = path.join(PACKAGE_ROOT, "assets");
25
25
  export const WEB_DIR = path.join(PACKAGE_ROOT, "web");
26
26
  export const BUILTIN_CONFIG_PATH = path.join(ASSETS_DIR, "defaults", "spf.config.yaml");
27
27
  export const BUILTIN_PROMPTS_DIR = path.join(ASSETS_DIR, "prompts");
28
+ /** `spf init --template <name>` reads `<name>.spf.config.yaml` from here. */
29
+ export const TEMPLATES_DIR = path.join(ASSETS_DIR, "templates");
28
30
  /** Walk up from `cwd` to (and including) `repoRoot` looking for a `.spf/` directory. */
29
31
  function findSfDir(cwd, repoRoot) {
30
32
  let dir = cwd;
@@ -1,5 +1,5 @@
1
1
  import type { GitHandle } from "./git_helper.ts";
2
- import type { Issue, IssueProvider } from "./issues/provider.ts";
2
+ import type { CodeHostProvider, Issue, IssueProvider } from "./issues/provider.ts";
3
3
  export interface ChainRunResult {
4
4
  accepted: boolean;
5
5
  adwId: string;
@@ -8,6 +8,7 @@ export interface ChainRunResult {
8
8
  }
9
9
  export interface WatchDeps {
10
10
  provider: IssueProvider;
11
+ codeHost: CodeHostProvider;
11
12
  git: GitHandle;
12
13
  /** Bound to a specific worktree path (diffFiles/push run there) — inject `git_helper.makeGit` for real use, a fake for tests. */
13
14
  worktreeGit: (worktreePath: string) => GitHandle;
@@ -16,6 +17,18 @@ export interface WatchDeps {
16
17
  baseBranch: string;
17
18
  concurrency: number;
18
19
  worktreesDir: string;
20
+ /**
21
+ * Symlink (or otherwise wire up) `<worktreePath>/.spf/data` to the MAIN
22
+ * repo's own persistent data_dir, called once per worktree right after
23
+ * it's created. Without this, a chain run's session/trace data resolves
24
+ * relative to `cwd` (the worktree — see ChainContext's doc comment) and
25
+ * lands in a fresh, throwaway `.spf/data` that `cleanupWorktree` deletes
26
+ * along with the rest of the worktree once the issue finishes: no trace
27
+ * in `spf ui`, and no trace anywhere at all after cleanup. Injected (not
28
+ * called directly) so watch.ts's own tests never touch the real
29
+ * filesystem for it.
30
+ */
31
+ linkDataDir: (worktreePath: string) => void;
19
32
  dryRun: boolean;
20
33
  runChain: (opts: {
21
34
  prompt: string;
@@ -25,7 +38,7 @@ export interface WatchDeps {
25
38
  log: (message: string) => void;
26
39
  }
27
40
  export interface WatchRunState {
28
- inflight: Set<number>;
41
+ inflight: Set<string>;
29
42
  }
30
43
  export declare function createWatchState(): WatchRunState;
31
44
  export declare function branchNameFor(issue: Issue): string;
@@ -40,10 +40,13 @@ export function branchNameFor(issue) {
40
40
  .slice(0, 5)
41
41
  .join("-")
42
42
  .replace(/[^a-z0-9-]/g, "");
43
- return `spf-watch/${issue.number}-${slug || "issue"}`.slice(0, 200);
43
+ // The issue's own id in the branch name isn't just labeling: Jira's
44
+ // Bitbucket integration auto-links a PR to the issue when its key
45
+ // appears anywhere in the branch name, no explicit API call needed.
46
+ return `spf-watch/${issue.id}-${slug || "issue"}`.slice(0, 200);
44
47
  }
45
48
  function worktreePathFor(deps, issue) {
46
- return path.join(deps.worktreesDir, `issue-${issue.number}`);
49
+ return path.join(deps.worktreesDir, `issue-${issue.id}`);
47
50
  }
48
51
  function cleanupWorktree(deps, marker) {
49
52
  if (!marker)
@@ -67,13 +70,13 @@ function cleanupWorktree(deps, marker) {
67
70
  export async function reconcileOrphans(deps, state) {
68
71
  const working = await deps.provider.listInState("working");
69
72
  for (const issue of working) {
70
- if (state.inflight.has(issue.number))
73
+ if (state.inflight.has(issue.id))
71
74
  continue;
72
75
  const marker = await deps.provider.readMarker(issue);
73
76
  if (marker?.pr) {
74
- const status = await deps.provider.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
77
+ const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
75
78
  if (status.state === "open" || status.merged) {
76
- deps.log(`watch: #${issue.number} orphaned with an open/merged PR #${marker.pr} — resuming as review`);
79
+ deps.log(`watch: ${issue.id} orphaned with an open/merged PR #${marker.pr} — resuming as review`);
77
80
  if (!deps.dryRun)
78
81
  await deps.provider.transition(issue, "review");
79
82
  continue;
@@ -81,14 +84,14 @@ export async function reconcileOrphans(deps, state) {
81
84
  }
82
85
  const attempt = (marker?.attempt ?? 0) + 1;
83
86
  if (attempt <= MAX_ORPHAN_ATTEMPTS) {
84
- deps.log(`watch: #${issue.number} orphaned, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to ready`);
87
+ deps.log(`watch: ${issue.id} orphaned, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to ready`);
85
88
  if (!deps.dryRun) {
86
89
  await deps.provider.writeMarker(issue, { ...marker, attempt });
87
90
  await deps.provider.transition(issue, "ready");
88
91
  }
89
92
  }
90
93
  else {
91
- deps.log(`watch: #${issue.number} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
94
+ deps.log(`watch: ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
92
95
  if (!deps.dryRun) {
93
96
  await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`);
94
97
  cleanupWorktree(deps, marker);
@@ -103,16 +106,16 @@ export async function finishReviews(deps) {
103
106
  const marker = await deps.provider.readMarker(issue);
104
107
  if (!marker?.pr)
105
108
  continue;
106
- const status = await deps.provider.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
109
+ const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
107
110
  if (status.merged) {
108
- deps.log(`watch: #${issue.number}'s PR #${marker.pr} merged — done`);
111
+ deps.log(`watch: ${issue.id}'s PR #${marker.pr} merged — done`);
109
112
  if (!deps.dryRun) {
110
113
  await deps.provider.transition(issue, "done");
111
114
  cleanupWorktree(deps, marker);
112
115
  }
113
116
  }
114
117
  else if (status.state === "closed") {
115
- deps.log(`watch: #${issue.number}'s PR #${marker.pr} closed without merging — blocked`);
118
+ deps.log(`watch: ${issue.id}'s PR #${marker.pr} closed without merging — blocked`);
116
119
  if (!deps.dryRun) {
117
120
  await deps.provider.transition(issue, "blocked", `PR #${marker.pr} was closed without merging.`);
118
121
  cleanupWorktree(deps, marker);
@@ -124,40 +127,56 @@ export async function finishReviews(deps) {
124
127
  async function runIssue(deps, issue) {
125
128
  const branch = branchNameFor(issue);
126
129
  const worktreePath = worktreePathFor(deps, issue);
127
- const adwId = `issue-${issue.number}`;
130
+ const adwId = `issue-${issue.id}`;
128
131
  try {
132
+ // Both worktreePath and branch are fully deterministic from issue.id —
133
+ // the only way either could already exist is a previous spf watch
134
+ // attempt for THIS issue that never reached its own cleanup (killed
135
+ // mid-run, crashed, machine restart). `git worktree add -b` refuses
136
+ // outright if the branch already exists ("fatal: a branch named '...'
137
+ // already exists"), which without this would permanently block the
138
+ // issue from ever being claimed again — it'd fail this same way on
139
+ // every single retry. Safe to clear unconditionally: worktreeRemove/
140
+ // deleteLocalBranch are both no-ops if there's nothing to remove.
141
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
129
142
  deps.git.fetch("origin", deps.baseBranch);
130
143
  deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
144
+ deps.linkDataDir(worktreePath);
131
145
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
132
146
  const prompt = `${issue.title}\n\n${issue.body}`.trim();
133
147
  const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
134
148
  if (!result.accepted) {
135
- deps.log(`watch: #${issue.number}: chain "${deps.chain}" did not succeed — blocked`);
149
+ deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
136
150
  await deps.provider.transition(issue, "blocked", result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`);
137
151
  cleanupWorktree(deps, { worktree: worktreePath, branch });
138
152
  return;
139
153
  }
140
154
  const wtGit = deps.worktreeGit(worktreePath);
141
155
  if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
142
- deps.log(`watch: #${issue.number}: chain succeeded but committed nothing — blocked`);
156
+ deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
143
157
  await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
144
158
  cleanupWorktree(deps, { worktree: worktreePath, branch });
145
159
  return;
146
160
  }
147
161
  wtGit.push("origin", branch);
148
- const pr = await deps.provider.openPr(issue, {
162
+ // No cross-linking magic keyword here on purpose (a code host paired
163
+ // with a different tracker has no "Closes #n" convention to hook into
164
+ // — see provider.ts) — the issue id in the title/body is plain text
165
+ // for humans, and, on a Jira+Bitbucket pairing, exactly what Jira's own
166
+ // Bitbucket integration scans for to link the PR automatically.
167
+ const pr = await deps.codeHost.openPr({
149
168
  branch,
150
- title: `${issue.title} (#${issue.number})`,
151
- body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`.`,
169
+ title: `${issue.title} (${issue.id})`,
170
+ body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.`,
152
171
  base: deps.baseBranch,
153
172
  });
154
173
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
155
174
  await deps.provider.transition(issue, "review");
156
- deps.log(`watch: #${issue.number}: opened PR #${pr.number} — review`);
175
+ deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
157
176
  }
158
177
  catch (error) {
159
178
  const message = error.message;
160
- deps.log(`watch: #${issue.number}: error: ${message}`);
179
+ deps.log(`watch: ${issue.id}: error: ${message}`);
161
180
  await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
162
181
  cleanupWorktree(deps, { worktree: worktreePath, branch });
163
182
  }
@@ -170,20 +189,20 @@ export async function claimNewWork(deps, state) {
170
189
  for (const issue of eligible) {
171
190
  if (state.inflight.size >= deps.concurrency)
172
191
  break;
173
- if (state.inflight.has(issue.number))
192
+ if (state.inflight.has(issue.id))
174
193
  continue;
175
194
  if (deps.dryRun) {
176
- deps.log(`watch: [dry-run] would claim #${issue.number} (${issue.title}) and run chain "${deps.chain}"`);
195
+ deps.log(`watch: [dry-run] would claim ${issue.id} (${issue.title}) and run chain "${deps.chain}"`);
177
196
  continue;
178
197
  }
179
198
  const claimed = await deps.provider.claim(issue);
180
199
  if (!claimed) {
181
- deps.log(`watch: #${issue.number} lost the claim race this tick — skipping`);
200
+ deps.log(`watch: ${issue.id} lost the claim race this tick — skipping`);
182
201
  continue;
183
202
  }
184
- deps.log(`watch: claimed #${issue.number}: ${issue.title}`);
185
- state.inflight.add(issue.number);
186
- runIssue(deps, issue).finally(() => state.inflight.delete(issue.number));
203
+ deps.log(`watch: claimed ${issue.id}: ${issue.title}`);
204
+ state.inflight.add(issue.id);
205
+ runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
187
206
  }
188
207
  }
189
208
  /** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */