@gethmy/harness 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/git-pr.ts ADDED
@@ -0,0 +1,839 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { Card } from "@harmony/shared";
4
+ import { SAFE_GIT_REF_PATTERN } from "@harmony/shared";
5
+ import type { MergeStrategy, WorktreeConfig } from "./exec-types.js";
6
+ import { log } from "./log.js";
7
+
8
+ // Lazy + memoised: promisify(execFile) has no reason to run at module load.
9
+ // git-pr.ts sits in the motor's public barrel (@gethmy/harness `export *`), so
10
+ // hoisting this to module scope would force EVERY consumer of ANY harness
11
+ // export to pay for constructing a promisified execFile, whether or not they
12
+ // ever call a git-pr function. Deferred to first call instead.
13
+ function createExecFileAsync() {
14
+ return promisify(execFile);
15
+ }
16
+
17
+ let cachedExecFileAsync: ReturnType<typeof createExecFileAsync> | undefined;
18
+
19
+ function execFileAsync() {
20
+ return (cachedExecFileAsync ??= createExecFileAsync());
21
+ }
22
+
23
+ const TAG = "git-pr";
24
+
25
+ // ============ GIT PROVIDER DETECTION ============
26
+
27
+ export type GitProvider =
28
+ | "github"
29
+ | "azure"
30
+ | "gitlab"
31
+ | "bitbucket"
32
+ | "unknown";
33
+
34
+ export function detectGitProvider(cwd?: string): GitProvider {
35
+ try {
36
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
37
+ cwd,
38
+ encoding: "utf-8",
39
+ }).trim();
40
+
41
+ if (url.includes("github.com")) return "github";
42
+ if (url.includes("dev.azure.com") || url.includes("visualstudio.com"))
43
+ return "azure";
44
+ if (url.includes("gitlab.com") || /\bgitlab\b/.test(url)) return "gitlab";
45
+ if (url.includes("bitbucket.org")) return "bitbucket";
46
+ return "unknown";
47
+ } catch {
48
+ return "unknown";
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Validate that the CLI for the detected git provider is installed and authenticated.
54
+ * Returns the provider name or throws with instructions.
55
+ */
56
+ export function validateGitProviderCli(
57
+ provider: GitProvider,
58
+ cwd?: string,
59
+ ): void {
60
+ switch (provider) {
61
+ case "github": {
62
+ try {
63
+ execFileSync("gh", ["auth", "status"], { cwd, stdio: "pipe" });
64
+ } catch {
65
+ throw new Error(
66
+ "GitHub CLI (gh) is not authenticated. Run: gh auth login",
67
+ );
68
+ }
69
+ break;
70
+ }
71
+ case "azure": {
72
+ try {
73
+ execFileSync("az", ["--version"], { cwd, stdio: "pipe" });
74
+ } catch {
75
+ throw new Error(
76
+ "Azure CLI (az) not found. Install it: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli",
77
+ );
78
+ }
79
+ try {
80
+ execFileSync("az", ["account", "show"], { cwd, stdio: "pipe" });
81
+ } catch {
82
+ throw new Error("Azure CLI is not authenticated. Run: az login");
83
+ }
84
+ break;
85
+ }
86
+ case "gitlab": {
87
+ try {
88
+ execFileSync("glab", ["auth", "status"], { cwd, stdio: "pipe" });
89
+ } catch {
90
+ throw new Error(
91
+ "GitLab CLI (glab) is not installed or not authenticated. Install: https://gitlab.com/gitlab-org/cli — then run: glab auth login",
92
+ );
93
+ }
94
+ break;
95
+ }
96
+ case "bitbucket":
97
+ case "unknown":
98
+ log.warn(
99
+ TAG,
100
+ `Git provider "${provider}" — PR creation will be skipped (no CLI support)`,
101
+ );
102
+ break;
103
+ }
104
+ }
105
+
106
+ // ============ PR MERGE STATUS ============
107
+
108
+ export type PrState = "merged" | "open" | "closed" | "unknown";
109
+
110
+ const VALID_PR_URL_RE =
111
+ /^https:\/\/(github\.com|gitlab\.com|dev\.azure\.com|bitbucket\.org)\//;
112
+ const PR_URL_RE = /PR:\s*(https?:\/\/[^\s)]+)/;
113
+
114
+ /** Validate that a PR URL looks like a real provider URL. */
115
+ function isValidPrUrl(url: string): boolean {
116
+ return VALID_PR_URL_RE.test(url);
117
+ }
118
+
119
+ // ============ CI STATUS & MERGE (Task 2) ============
120
+
121
+ export type PrCiStatus = "success" | "pending" | "failure" | "unknown";
122
+
123
+ const REVIEWED_SHA_RE = /^Reviewed-SHA:\s*([0-9a-f]{7,40})\s*$/im;
124
+
125
+ export function extractReviewedSha(description: string | null): string | null {
126
+ if (!description) return null;
127
+ const m = description.match(REVIEWED_SHA_RE);
128
+ return m ? m[1] : null;
129
+ }
130
+
131
+ export function upsertReviewedSha(description: string, sha: string): string {
132
+ const line = `Reviewed-SHA: ${sha}`;
133
+ if (REVIEWED_SHA_RE.test(description)) {
134
+ return description.replace(REVIEWED_SHA_RE, line);
135
+ }
136
+ const sep = description ? "\n" : "";
137
+ return `${description}${sep}${line}`;
138
+ }
139
+
140
+ /** Derive a single CI verdict from a `gh pr view --json statusCheckRollup` array.
141
+ * Handles both CheckRun ({status, conclusion}) and legacy StatusContext ({state}).
142
+ * Empty/non-array (no checks configured) → unknown (conservative: never auto-merge). */
143
+ export function deriveCiStatus(rollup: unknown): PrCiStatus {
144
+ if (!Array.isArray(rollup) || rollup.length === 0) return "unknown";
145
+ let anyPending = false;
146
+ for (const check of rollup) {
147
+ if (typeof check !== "object" || check === null) continue;
148
+ const c = check as Record<string, unknown>;
149
+ if (typeof c.status === "string") {
150
+ // CheckRun
151
+ if (c.status.toUpperCase() !== "COMPLETED") {
152
+ anyPending = true;
153
+ continue;
154
+ }
155
+ const conclusion =
156
+ typeof c.conclusion === "string" ? c.conclusion.toUpperCase() : "";
157
+ if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion)) continue;
158
+ return "failure";
159
+ }
160
+ if (typeof c.state === "string") {
161
+ // StatusContext
162
+ const state = c.state.toUpperCase();
163
+ if (state === "SUCCESS") continue;
164
+ if (state === "PENDING") {
165
+ anyPending = true;
166
+ continue;
167
+ }
168
+ return "failure";
169
+ }
170
+ }
171
+ return anyPending ? "pending" : "success";
172
+ }
173
+
174
+ export async function getPrStatus(
175
+ prUrl: string,
176
+ cwd: string,
177
+ provider: GitProvider,
178
+ ): Promise<{ ciStatus: PrCiStatus; headSha: string | null }> {
179
+ if (provider !== "github" || !isValidPrUrl(prUrl)) {
180
+ return { ciStatus: "unknown", headSha: null };
181
+ }
182
+ try {
183
+ const { stdout } = await execFileAsync()(
184
+ "gh",
185
+ ["pr", "view", prUrl, "--json", "statusCheckRollup,headRefOid"],
186
+ { cwd, encoding: "utf-8", timeout: 10_000 },
187
+ );
188
+ const parsed = JSON.parse(stdout.trim()) as {
189
+ statusCheckRollup?: unknown;
190
+ headRefOid?: unknown;
191
+ };
192
+ const headSha =
193
+ typeof parsed.headRefOid === "string" ? parsed.headRefOid : null;
194
+ return { ciStatus: deriveCiStatus(parsed.statusCheckRollup), headSha };
195
+ } catch {
196
+ return { ciStatus: "unknown", headSha: null };
197
+ }
198
+ }
199
+
200
+ export async function mergePullRequest(
201
+ prUrl: string,
202
+ cwd: string,
203
+ provider: GitProvider,
204
+ strategy: MergeStrategy,
205
+ deleteBranch: boolean,
206
+ ): Promise<void> {
207
+ if (provider !== "github") {
208
+ throw new Error(`auto-merge unsupported for provider "${provider}"`);
209
+ }
210
+ const args = ["pr", "merge", prUrl, `--${strategy}`];
211
+ if (deleteBranch) args.push("--delete-branch");
212
+ await execFileAsync()("gh", args, {
213
+ cwd,
214
+ encoding: "utf-8",
215
+ timeout: 30_000,
216
+ });
217
+ }
218
+
219
+ export function getHeadSha(cwd: string): string | null {
220
+ try {
221
+ return execFileSync("git", ["rev-parse", "HEAD"], {
222
+ cwd,
223
+ encoding: "utf-8",
224
+ }).trim();
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Check whether a PR has been merged using the git provider CLI (async).
232
+ */
233
+ export async function checkPrMergeStatus(
234
+ prUrl: string,
235
+ cwd: string,
236
+ provider: GitProvider,
237
+ ): Promise<PrState> {
238
+ if (!isValidPrUrl(prUrl)) return "unknown";
239
+
240
+ try {
241
+ switch (provider) {
242
+ case "github": {
243
+ const { stdout } = await execFileAsync()(
244
+ "gh",
245
+ ["pr", "view", prUrl, "--json", "state", "--jq", ".state"],
246
+ { cwd, encoding: "utf-8", timeout: 10_000 },
247
+ );
248
+ switch (stdout.trim()) {
249
+ case "MERGED":
250
+ return "merged";
251
+ case "OPEN":
252
+ return "open";
253
+ case "CLOSED":
254
+ return "closed";
255
+ default:
256
+ return "unknown";
257
+ }
258
+ }
259
+ case "gitlab": {
260
+ // Extract MR ID from URL (e.g. .../merge_requests/42)
261
+ const mrMatch = prUrl.match(/merge_requests\/(\d+)/);
262
+ if (!mrMatch) return "unknown";
263
+ const { stdout } = await execFileAsync()(
264
+ "glab",
265
+ ["mr", "view", mrMatch[1], "--output", "json"],
266
+ { cwd, encoding: "utf-8", timeout: 10_000 },
267
+ );
268
+ let parsed: unknown;
269
+ try {
270
+ parsed = JSON.parse(stdout.trim());
271
+ } catch {
272
+ log.warn(
273
+ TAG,
274
+ `Failed to parse glab JSON output for MR ${mrMatch[1]}`,
275
+ );
276
+ return "unknown";
277
+ }
278
+ if (typeof parsed !== "object" || parsed === null) return "unknown";
279
+ const state = (parsed as Record<string, unknown>).state;
280
+ if (state === "merged") return "merged";
281
+ if (state === "opened") return "open";
282
+ if (state === "closed") return "closed";
283
+ return "unknown";
284
+ }
285
+ default:
286
+ return "unknown";
287
+ }
288
+ } catch {
289
+ return "unknown";
290
+ }
291
+ }
292
+
293
+ // ============ PR HEAD BRANCH RESOLUTION (for review checkout) ============
294
+
295
+ export type PrBranchResolution =
296
+ | { kind: "branch"; branch: string }
297
+ | { kind: "skip"; reason: string };
298
+
299
+ /** Strip a `refs/heads/` prefix from a fully-qualified git ref. Azure returns
300
+ * `sourceRefName` as `refs/heads/<branch>`; a short name passes through. */
301
+ function stripHeadsPrefix(ref: string): string {
302
+ return ref.replace(/^refs\/heads\//, "");
303
+ }
304
+
305
+ /** Wrap a resolved head-branch name in a PrBranchResolution, gating on the
306
+ * shared safe-ref pattern. Shared by every provider's pure parser. */
307
+ function branchOrSkip(branch: string | null): PrBranchResolution {
308
+ if (!branch) return { kind: "skip", reason: "PR has no head branch name" };
309
+ if (!SAFE_GIT_REF_PATTERN.test(branch)) {
310
+ return { kind: "skip", reason: `unsafe git ref: ${branch}` };
311
+ }
312
+ return { kind: "branch", branch };
313
+ }
314
+
315
+ /**
316
+ * Pure decision from a GitHub `gh pr view --json headRefName,isCrossRepository`
317
+ * payload (or null when the CLI call failed).
318
+ */
319
+ function decideGithubPrBranch(rawJson: string | null): PrBranchResolution {
320
+ if (rawJson === null) {
321
+ return { kind: "skip", reason: "gh pr view failed" };
322
+ }
323
+ let parsed: { headRefName?: unknown; isCrossRepository?: unknown };
324
+ try {
325
+ parsed = JSON.parse(rawJson.trim());
326
+ } catch {
327
+ return { kind: "skip", reason: "unparseable gh pr view output" };
328
+ }
329
+ if (typeof parsed !== "object" || parsed === null) {
330
+ return { kind: "skip", reason: "unparseable gh pr view output" };
331
+ }
332
+ if (parsed.isCrossRepository === true) {
333
+ return {
334
+ kind: "skip",
335
+ reason: "fork PR (cross-repo head branch not on origin)",
336
+ };
337
+ }
338
+ return branchOrSkip(
339
+ typeof parsed.headRefName === "string" ? parsed.headRefName : null,
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Pure decision from an Azure DevOps `az repos pr show --output json` payload
345
+ * (or null when the CLI call failed). Azure names the head branch as a
346
+ * fully-qualified `sourceRefName` (`refs/heads/<branch>`, stripped here) and
347
+ * marks a fork PR with a non-null `forkSource`.
348
+ */
349
+ function decideAzurePrBranch(rawJson: string | null): PrBranchResolution {
350
+ if (rawJson === null) {
351
+ return { kind: "skip", reason: "az repos pr show failed" };
352
+ }
353
+ let parsed: { sourceRefName?: unknown; forkSource?: unknown };
354
+ try {
355
+ parsed = JSON.parse(rawJson.trim());
356
+ } catch {
357
+ return { kind: "skip", reason: "unparseable az repos pr show output" };
358
+ }
359
+ if (typeof parsed !== "object" || parsed === null) {
360
+ return { kind: "skip", reason: "unparseable az repos pr show output" };
361
+ }
362
+ if (parsed.forkSource != null) {
363
+ return {
364
+ kind: "skip",
365
+ reason: "fork PR (cross-repo head branch not on origin)",
366
+ };
367
+ }
368
+ const ref =
369
+ typeof parsed.sourceRefName === "string" ? parsed.sourceRefName : null;
370
+ return branchOrSkip(ref ? stripHeadsPrefix(ref) : null);
371
+ }
372
+
373
+ /**
374
+ * Pure decision: given the provider and the raw provider-CLI stdout (or null
375
+ * when the call failed), decide the reviewable head branch. Provider-dispatched
376
+ * so a new provider is one `case` + one pure parser — callers do not change.
377
+ */
378
+ export function decidePrBranch(
379
+ provider: GitProvider,
380
+ rawJson: string | null,
381
+ ): PrBranchResolution {
382
+ switch (provider) {
383
+ case "github":
384
+ return decideGithubPrBranch(rawJson);
385
+ case "azure":
386
+ return decideAzurePrBranch(rawJson);
387
+ default:
388
+ return {
389
+ kind: "skip",
390
+ reason: `PR-link review not yet supported for provider "${provider}"`,
391
+ };
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Extract the numeric PR id from an Azure DevOps PR URL, e.g.
397
+ * `https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/123` (also the
398
+ * legacy `{org}.visualstudio.com/...` host). Returns null when no id is present.
399
+ */
400
+ export function extractAzurePrId(prUrl: string): string | null {
401
+ const m = prUrl.match(/pullrequest\/(\d+)/i);
402
+ return m ? m[1] : null;
403
+ }
404
+
405
+ /**
406
+ * Resolve a PR's reviewable head branch. Thin IO wrapper over decidePrBranch:
407
+ * GitHub via `gh pr view`, Azure DevOps via `az repos pr show`; any other
408
+ * provider short-circuits to a structured skip.
409
+ */
410
+ export async function resolvePrHeadBranch(
411
+ prUrl: string,
412
+ cwd: string,
413
+ provider: GitProvider,
414
+ ): Promise<PrBranchResolution> {
415
+ if (provider === "github") {
416
+ try {
417
+ const { stdout } = await execFileAsync()(
418
+ "gh",
419
+ ["pr", "view", prUrl, "--json", "headRefName,isCrossRepository"],
420
+ { cwd, encoding: "utf-8", timeout: 10_000 },
421
+ );
422
+ return decidePrBranch("github", stdout);
423
+ } catch (err) {
424
+ log.warn(
425
+ TAG,
426
+ `gh pr view failed for ${prUrl}: ${err instanceof Error ? err.message : String(err)}`,
427
+ );
428
+ return decidePrBranch("github", null);
429
+ }
430
+ }
431
+ if (provider === "azure") {
432
+ const prId = extractAzurePrId(prUrl);
433
+ if (!prId) {
434
+ return {
435
+ kind: "skip",
436
+ reason: `could not parse Azure PR id from ${prUrl}`,
437
+ };
438
+ }
439
+ try {
440
+ const { stdout } = await execFileAsync()(
441
+ "az",
442
+ ["repos", "pr", "show", "--id", prId, "--output", "json"],
443
+ { cwd, encoding: "utf-8", timeout: 10_000 },
444
+ );
445
+ return decidePrBranch("azure", stdout);
446
+ } catch (err) {
447
+ log.warn(
448
+ TAG,
449
+ `az repos pr show failed for ${prUrl}: ${err instanceof Error ? err.message : String(err)}`,
450
+ );
451
+ return decidePrBranch("azure", null);
452
+ }
453
+ }
454
+ return decidePrBranch(provider, null);
455
+ }
456
+
457
+ /**
458
+ * Extract a PR URL from card description. Matches the format written by completion.ts:
459
+ * `PR: https://...`
460
+ */
461
+ export function extractPrUrl(description: string | null): string | null {
462
+ if (!description) return null;
463
+ const match = description.match(PR_URL_RE);
464
+ if (!match) return null;
465
+ try {
466
+ return new URL(match[1]).href;
467
+ } catch {
468
+ return null;
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Resolve a card's PR URL: prefer the `PR:` line stamped in the description,
474
+ * else look the PR up by its branch via the provider CLI. The by-branch
475
+ * fallback is what lets merge detection + strand recovery work even when the
476
+ * approval path never wrote the URL into the description — the #530/#512/#562
477
+ * merge-strand, where a merged PR existed but the card carried no `PR:` line.
478
+ */
479
+ export function resolvePrUrl(
480
+ description: string | null,
481
+ branchName: string | null,
482
+ cwd: string,
483
+ provider: GitProvider,
484
+ ): string | null {
485
+ const fromDesc = extractPrUrl(description);
486
+ if (fromDesc) return fromDesc;
487
+ if (!branchName) return null;
488
+ return findExistingPr(branchName, cwd, provider) || null;
489
+ }
490
+
491
+ // ============ PUSH (REWORK-AWARE) ============
492
+
493
+ export function remoteBranchExists(branchName: string, cwd: string): boolean {
494
+ try {
495
+ execFileSync(
496
+ "git",
497
+ ["ls-remote", "--exit-code", "origin", `refs/heads/${branchName}`],
498
+ { cwd, stdio: "pipe" },
499
+ );
500
+ return true;
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
505
+
506
+ export function pushBranch(branchName: string, cwd: string): void {
507
+ if (remoteBranchExists(branchName, cwd)) {
508
+ log.info(TAG, `Remote branch ${branchName} exists (rework), force-pushing`);
509
+ // Resolve the remote tip explicitly so the lease anchors to a known SHA.
510
+ // Bare `--force-with-lease` only checks against the local tracking ref,
511
+ // which can be stale if it was never fetched in this worktree — that
512
+ // lets a concurrent update slip through. Fetch + pin the expected SHA.
513
+ let expectedSha: string | null = null;
514
+ try {
515
+ execFileSync("git", ["fetch", "origin", branchName], {
516
+ cwd,
517
+ stdio: "pipe",
518
+ });
519
+ expectedSha = execFileSync(
520
+ "git",
521
+ ["rev-parse", `refs/remotes/origin/${branchName}`],
522
+ { cwd, encoding: "utf-8" },
523
+ ).trim();
524
+ } catch (err) {
525
+ log.warn(
526
+ TAG,
527
+ `could not resolve remote tip for ${branchName}, falling back to weak lease: ${err instanceof Error ? err.message : err}`,
528
+ );
529
+ }
530
+ const lease = expectedSha
531
+ ? `--force-with-lease=refs/heads/${branchName}:${expectedSha}`
532
+ : "--force-with-lease";
533
+ execFileSync("git", ["push", lease, "-u", "origin", branchName], {
534
+ cwd,
535
+ stdio: "pipe",
536
+ });
537
+ } else {
538
+ execFileSync("git", ["push", "-u", "origin", branchName], {
539
+ cwd,
540
+ stdio: "pipe",
541
+ });
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Push the current branch's tip to `newRef` on origin and delete `oldRef`.
547
+ * Used when an approved attempt graduates from `agent-attempts/*` to
548
+ * `agent/*` — keeps the commits durable across the rename and avoids any
549
+ * window where the work is unreachable on origin.
550
+ */
551
+ export function renameRemoteBranch(
552
+ oldRef: string,
553
+ newRef: string,
554
+ cwd: string,
555
+ ): void {
556
+ if (oldRef === newRef) return;
557
+ let sha: string;
558
+ try {
559
+ sha = execFileSync("git", ["rev-parse", "HEAD"], {
560
+ cwd,
561
+ encoding: "utf-8",
562
+ }).trim();
563
+ } catch (err) {
564
+ throw new Error(
565
+ `renameRemoteBranch: could not resolve HEAD: ${err instanceof Error ? err.message : err}`,
566
+ );
567
+ }
568
+ log.info(TAG, `Renaming remote ${oldRef} → ${newRef}`);
569
+ execFileSync(
570
+ "git",
571
+ ["push", "origin", `${sha}:refs/heads/${newRef}`, "--force-with-lease"],
572
+ { cwd, stdio: "pipe" },
573
+ );
574
+ try {
575
+ execFileSync("git", ["push", "origin", `:refs/heads/${oldRef}`], {
576
+ cwd,
577
+ stdio: "pipe",
578
+ });
579
+ } catch (err) {
580
+ log.warn(
581
+ TAG,
582
+ `renameRemoteBranch: could not delete old ref ${oldRef}: ${err instanceof Error ? err.message : err}`,
583
+ );
584
+ }
585
+ try {
586
+ execFileSync("git", ["branch", "-m", oldRef, newRef], {
587
+ cwd,
588
+ stdio: "pipe",
589
+ });
590
+ } catch {
591
+ // Worktree may not have the old branch checked out — non-fatal.
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Best-effort public branch URL for the recovery button on a failed session.
597
+ * Returns null when we can't infer a tree URL — the daemon falls back to a
598
+ * plain `git fetch && git checkout <ref>` instruction in that case.
599
+ */
600
+ export function getBranchWebUrl(
601
+ branchName: string,
602
+ cwd: string,
603
+ ): string | null {
604
+ try {
605
+ const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
606
+ cwd,
607
+ encoding: "utf-8",
608
+ }).trim();
609
+ const encoded = branchName.split("/").map(encodeURIComponent).join("/");
610
+ if (/github\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
611
+ const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
612
+ if (m) return `https://github.com/${m[1]}/${m[2]}/tree/${encoded}`;
613
+ }
614
+ if (/gitlab\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
615
+ const m = remoteUrl.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
616
+ if (m) return `https://gitlab.com/${m[1]}/-/tree/${encoded}`;
617
+ }
618
+ if (/bitbucket\.org[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
619
+ const m = remoteUrl.match(/bitbucket\.org[:/](.+?)(?:\.git)?$/);
620
+ if (m) return `https://bitbucket.org/${m[1]}/branch/${encoded}`;
621
+ }
622
+ return null;
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+
628
+ // ============ PR CREATION (PROVIDER-AWARE) ============
629
+
630
+ export function buildPrBody(card: Card, commitLog: string): string {
631
+ return [
632
+ "## Summary",
633
+ "",
634
+ `Automated PR for card **#${card.short_id} — ${card.title}**.`,
635
+ "",
636
+ "## Commits",
637
+ "",
638
+ "```",
639
+ commitLog,
640
+ "```",
641
+ "",
642
+ "## Card",
643
+ "",
644
+ card.description?.slice(0, 500) || "No description.",
645
+ "",
646
+ "---",
647
+ "*Created by Harmony Agent Daemon*",
648
+ ].join("\n");
649
+ }
650
+
651
+ export function createPullRequest(
652
+ card: Card,
653
+ branchName: string,
654
+ worktreePath: string,
655
+ config: WorktreeConfig,
656
+ provider: GitProvider,
657
+ existingPrUrl?: string | null,
658
+ ): string | null {
659
+ // Reuse a PR the card already carries (the #660 PR-link review path: the card
660
+ // was auto-reviewed *because* it had a resolvable PR link). Re-creating would
661
+ // open a duplicate — and providers without a findExistingPr branch lookup
662
+ // (Azure) would ALWAYS duplicate. Provider-agnostic + deterministic: trust the
663
+ // URL the branch was resolved from, no CLI round-trip or wrong-PR risk.
664
+ if (existingPrUrl) {
665
+ log.info(
666
+ TAG,
667
+ `Reusing existing PR from card description: ${existingPrUrl}`,
668
+ );
669
+ return existingPrUrl;
670
+ }
671
+
672
+ let commitLog = "";
673
+ try {
674
+ commitLog = execFileSync(
675
+ "git",
676
+ ["log", "--oneline", `origin/${config.worktree.baseBranch}..HEAD`],
677
+ { cwd: worktreePath, encoding: "utf-8" },
678
+ ).trim();
679
+ } catch {
680
+ commitLog = "(unable to retrieve commit log)";
681
+ }
682
+
683
+ const title = `#${card.short_id} ${card.title}`;
684
+ const body = buildPrBody(card, commitLog);
685
+ const base = config.worktree.baseBranch;
686
+
687
+ // Check for existing PR first (rework case)
688
+ const existingUrl = findExistingPr(branchName, worktreePath, provider);
689
+ if (existingUrl) {
690
+ log.info(TAG, `PR already exists for ${branchName}, updating body...`);
691
+ updateExistingPr(branchName, body, worktreePath, provider);
692
+ return existingUrl;
693
+ }
694
+
695
+ // No existing PR — create a new one
696
+ try {
697
+ let result: string;
698
+
699
+ switch (provider) {
700
+ case "github":
701
+ result = execFileSync(
702
+ "gh",
703
+ ["pr", "create", "--title", title, "--body", body, "--base", base],
704
+ { cwd: worktreePath, encoding: "utf-8" },
705
+ ).trim();
706
+ break;
707
+
708
+ case "azure": {
709
+ const azOutput = execFileSync(
710
+ "az",
711
+ [
712
+ "repos",
713
+ "pr",
714
+ "create",
715
+ "--title",
716
+ title,
717
+ "--description",
718
+ body,
719
+ "--source-branch",
720
+ branchName,
721
+ "--target-branch",
722
+ base,
723
+ "--auto-complete",
724
+ "false",
725
+ ],
726
+ { cwd: worktreePath, encoding: "utf-8" },
727
+ ).trim();
728
+ // az repos pr create returns JSON — extract the URL
729
+ try {
730
+ const parsed = JSON.parse(azOutput);
731
+ result = parsed.remoteUrl ?? parsed.url ?? azOutput;
732
+ } catch {
733
+ result = azOutput;
734
+ }
735
+ break;
736
+ }
737
+
738
+ case "gitlab":
739
+ result = execFileSync(
740
+ "glab",
741
+ [
742
+ "mr",
743
+ "create",
744
+ "--title",
745
+ title,
746
+ "--description",
747
+ body,
748
+ "--source-branch",
749
+ branchName,
750
+ "--target-branch",
751
+ base,
752
+ "--no-editor",
753
+ ],
754
+ { cwd: worktreePath, encoding: "utf-8" },
755
+ ).trim();
756
+ break;
757
+
758
+ default:
759
+ log.warn(
760
+ TAG,
761
+ `No PR CLI for provider "${provider}" — branch pushed but no PR created`,
762
+ );
763
+ return null;
764
+ }
765
+
766
+ log.info(TAG, `PR created: ${result}`);
767
+ return result;
768
+ } catch (err) {
769
+ log.error(
770
+ TAG,
771
+ `Failed to create PR: ${err instanceof Error ? err.message : err}`,
772
+ );
773
+ return null;
774
+ }
775
+ }
776
+
777
+ export function findExistingPr(
778
+ branchName: string,
779
+ worktreePath: string,
780
+ provider: GitProvider,
781
+ ): string | null {
782
+ try {
783
+ switch (provider) {
784
+ case "github":
785
+ return execFileSync(
786
+ "gh",
787
+ ["pr", "view", branchName, "--json", "url", "--jq", ".url"],
788
+ { cwd: worktreePath, encoding: "utf-8" },
789
+ ).trim();
790
+
791
+ case "gitlab": {
792
+ const json = execFileSync(
793
+ "glab",
794
+ ["mr", "view", branchName, "--output", "json"],
795
+ { cwd: worktreePath, encoding: "utf-8" },
796
+ ).trim();
797
+ const parsed = JSON.parse(json);
798
+ return parsed.web_url || null;
799
+ }
800
+
801
+ default:
802
+ return null;
803
+ }
804
+ } catch {
805
+ return null;
806
+ }
807
+ }
808
+
809
+ export function updateExistingPr(
810
+ branchName: string,
811
+ body: string,
812
+ worktreePath: string,
813
+ provider: GitProvider,
814
+ ): void {
815
+ try {
816
+ switch (provider) {
817
+ case "github":
818
+ execFileSync("gh", ["pr", "edit", branchName, "--body", body], {
819
+ cwd: worktreePath,
820
+ stdio: "pipe",
821
+ });
822
+ break;
823
+
824
+ case "gitlab":
825
+ execFileSync(
826
+ "glab",
827
+ ["mr", "update", branchName, "--description", body],
828
+ { cwd: worktreePath, stdio: "pipe" },
829
+ );
830
+ break;
831
+ }
832
+ log.info(TAG, `Updated existing PR body for ${branchName}`);
833
+ } catch (err) {
834
+ log.warn(
835
+ TAG,
836
+ `Failed to update PR body: ${err instanceof Error ? err.message : err}`,
837
+ );
838
+ }
839
+ }