pm-github 2026.7.18 → 2026.7.21
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/CHANGELOG.md +13 -0
- package/README.md +4 -0
- package/dist/index.d.ts +135 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +719 -75
- package/dist/index.js.map +1 -1
- package/manifest.json +2 -2
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// Issues use the REST API; Projects v2 is GraphQL-only (see the Projects v2
|
|
14
14
|
// section below and the pure plan/mapping logic in ./projects.ts).
|
|
15
15
|
import https from "node:https";
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
16
19
|
import { spawnSync } from "node:child_process";
|
|
17
20
|
async function loadPmComments() {
|
|
18
21
|
const sdk = (await import("@unbrained/pm-cli/sdk"));
|
|
@@ -472,6 +475,215 @@ export function indexByProvenance(items) {
|
|
|
472
475
|
return index;
|
|
473
476
|
}
|
|
474
477
|
// ---------------------------------------------------------------------------
|
|
478
|
+
// Atomic GitHub issue import (pm-cli >= 2026.7.20 commitItemMutations)
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
const ATOMIC_IMPORT_PREFIX = "github-import-";
|
|
481
|
+
let cachedCommitItemMutations;
|
|
482
|
+
function assertSdkFunction(fn, exportName) {
|
|
483
|
+
if (typeof fn !== "function") {
|
|
484
|
+
throw new CommandError(`--atomic requires @unbrained/pm-cli>=2026.7.20 with the commitItemMutations SDK primitive, but the installed SDK does not export ${exportName} as a function. Upgrade @unbrained/pm-cli to >=2026.7.20.`, EXIT_CODE.USAGE);
|
|
485
|
+
}
|
|
486
|
+
return fn;
|
|
487
|
+
}
|
|
488
|
+
async function loadAtomicSdk(importSdk = () => import("@unbrained/pm-cli/sdk")) {
|
|
489
|
+
try {
|
|
490
|
+
return await importSdk();
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
494
|
+
throw new CommandError(`--atomic requires @unbrained/pm-cli>=2026.7.20, but the SDK could not be imported: ${msg}. Install or upgrade @unbrained/pm-cli.`, EXIT_CODE.USAGE);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/** Resolve the atomic bulk-mutation helper lazily so normal imports stay compatible. */
|
|
498
|
+
export async function resolveCommitItemMutations(importSdk) {
|
|
499
|
+
if (importSdk) {
|
|
500
|
+
const mod = await loadAtomicSdk(importSdk);
|
|
501
|
+
return assertSdkFunction(mod.commitItemMutations, "commitItemMutations");
|
|
502
|
+
}
|
|
503
|
+
if (cachedCommitItemMutations)
|
|
504
|
+
return cachedCommitItemMutations;
|
|
505
|
+
const mod = await loadAtomicSdk();
|
|
506
|
+
cachedCommitItemMutations = assertSdkFunction(mod.commitItemMutations, "commitItemMutations");
|
|
507
|
+
return cachedCommitItemMutations;
|
|
508
|
+
}
|
|
509
|
+
async function resolveAtomicSdkFunctions(opts) {
|
|
510
|
+
const needsSdk = !opts.commitItemMutations || !opts.normalizeItemId || !opts.readSettings;
|
|
511
|
+
const mod = needsSdk ? await loadAtomicSdk() : undefined;
|
|
512
|
+
return {
|
|
513
|
+
commitItemMutations: opts.commitItemMutations ?? assertSdkFunction(mod?.commitItemMutations, "commitItemMutations"),
|
|
514
|
+
normalizeItemId: opts.normalizeItemId ?? assertSdkFunction(mod?.normalizeItemId, "normalizeItemId"),
|
|
515
|
+
readSettings: opts.readSettings ?? assertSdkFunction(mod?.readSettings, "readSettings"),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Derive an order-independent transaction id from the desired import state and
|
|
520
|
+
* exact ordered mutation plan. Content or target changes produce a fresh
|
|
521
|
+
* transaction; a reordered retry of the same plan resumes the durable journal.
|
|
522
|
+
*/
|
|
523
|
+
export function deriveAtomicTransactionId(repo, entries, mutations) {
|
|
524
|
+
const canonical = [...entries]
|
|
525
|
+
.sort((a, b) => a.issueNumber - b.issueNumber)
|
|
526
|
+
.map((entry) => ({
|
|
527
|
+
issueNumber: entry.issueNumber,
|
|
528
|
+
title: entry.title,
|
|
529
|
+
itemType: entry.itemType,
|
|
530
|
+
status: entry.status,
|
|
531
|
+
description: entry.description,
|
|
532
|
+
body: entry.body,
|
|
533
|
+
tags: [...entry.tags].sort(),
|
|
534
|
+
assignee: entry.assignee ?? null,
|
|
535
|
+
milestone: entry.milestone ?? null,
|
|
536
|
+
}));
|
|
537
|
+
const digest = crypto
|
|
538
|
+
.createHash("sha256")
|
|
539
|
+
.update(repo.toLowerCase())
|
|
540
|
+
.update("\x1f")
|
|
541
|
+
.update(JSON.stringify(canonical))
|
|
542
|
+
.update("\x1f")
|
|
543
|
+
// Recovery requires the exact ordered step plan. Include targets and
|
|
544
|
+
// options so a changed id_prefix or provenance match gets a fresh journal
|
|
545
|
+
// instead of colliding with an incompatible prior attempt.
|
|
546
|
+
.update(JSON.stringify(mutations))
|
|
547
|
+
.digest("hex")
|
|
548
|
+
.slice(0, 16);
|
|
549
|
+
return `${ATOMIC_IMPORT_PREFIX}${digest}`;
|
|
550
|
+
}
|
|
551
|
+
/** Stable create id keyed by the external GitHub issue, never by fetch order. */
|
|
552
|
+
export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId) {
|
|
553
|
+
const repoToken = crypto
|
|
554
|
+
.createHash("sha256")
|
|
555
|
+
.update(repo.toLowerCase())
|
|
556
|
+
.digest("hex")
|
|
557
|
+
.slice(0, 12);
|
|
558
|
+
return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix);
|
|
559
|
+
}
|
|
560
|
+
/** Map one rendered import entry to its reversible SDK mutation sequence. */
|
|
561
|
+
export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId) {
|
|
562
|
+
const sharedOptions = {
|
|
563
|
+
title: entry.title,
|
|
564
|
+
type: entry.itemType,
|
|
565
|
+
description: entry.description,
|
|
566
|
+
body: entry.body,
|
|
567
|
+
tags: entry.tags.join(","),
|
|
568
|
+
...(entry.assignee ? { assignee: entry.assignee } : {}),
|
|
569
|
+
...(entry.milestone ? { sprint: entry.milestone } : {}),
|
|
570
|
+
};
|
|
571
|
+
const managedItemId = deriveAtomicItemId(repo, entry.issueNumber, idPrefix, normalizeItemId);
|
|
572
|
+
// A missing match and a match at our deterministic external-key id use the
|
|
573
|
+
// SAME create+update upsert plan. This is essential for crash recovery: if a
|
|
574
|
+
// prior attempt stopped after create, the next provenance scan sees that
|
|
575
|
+
// item, but commitItemMutations must still receive the original plan. The
|
|
576
|
+
// create step treats an existing stable id as already applied; update then
|
|
577
|
+
// makes later content-bearing transactions refresh the item normally.
|
|
578
|
+
if (!entry.match?.id || entry.match.id === managedItemId) {
|
|
579
|
+
const createStatus = entry.status === "closed" ? "open" : entry.status;
|
|
580
|
+
const mutations = [{
|
|
581
|
+
op: "create",
|
|
582
|
+
id: managedItemId,
|
|
583
|
+
options: { ...sharedOptions, status: createStatus },
|
|
584
|
+
}, {
|
|
585
|
+
op: "update",
|
|
586
|
+
id: managedItemId,
|
|
587
|
+
options: {
|
|
588
|
+
...sharedOptions,
|
|
589
|
+
...(entry.status !== "closed" ? { status: entry.status } : {}),
|
|
590
|
+
},
|
|
591
|
+
}];
|
|
592
|
+
if (entry.status === "closed") {
|
|
593
|
+
mutations.push({
|
|
594
|
+
op: "close",
|
|
595
|
+
id: managedItemId,
|
|
596
|
+
reason: `GitHub issue #${entry.issueNumber} closed`,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
itemId: managedItemId,
|
|
601
|
+
mutations,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const itemId = entry.match.id;
|
|
605
|
+
const updateOptions = { ...sharedOptions };
|
|
606
|
+
// close has a dedicated mutation so its reason is preserved. Every other
|
|
607
|
+
// transition (open/reopen/canceled) is safely reversible as part of update.
|
|
608
|
+
if (entry.status !== "closed") {
|
|
609
|
+
updateOptions.status = entry.status;
|
|
610
|
+
}
|
|
611
|
+
const mutations = [{
|
|
612
|
+
op: "update",
|
|
613
|
+
id: itemId,
|
|
614
|
+
options: updateOptions,
|
|
615
|
+
}];
|
|
616
|
+
if (entry.status === "closed") {
|
|
617
|
+
mutations.push({
|
|
618
|
+
op: "close",
|
|
619
|
+
id: itemId,
|
|
620
|
+
reason: `GitHub issue #${entry.issueNumber} closed`,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return { itemId, mutations };
|
|
624
|
+
}
|
|
625
|
+
/** Commit a complete issue-import batch under one crash-resumable transaction. */
|
|
626
|
+
export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
|
|
627
|
+
const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
|
|
628
|
+
let idPrefix = "pm-";
|
|
629
|
+
try {
|
|
630
|
+
const settings = await readSettings(pmRoot);
|
|
631
|
+
if (settings?.id_prefix)
|
|
632
|
+
idPrefix = String(settings.id_prefix);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
// Match normal import resilience: an unreadable optional setting falls
|
|
636
|
+
// back to the canonical prefix; the mutation still validates the tracker.
|
|
637
|
+
}
|
|
638
|
+
const mutations = [];
|
|
639
|
+
const itemIds = new Map();
|
|
640
|
+
// The transaction journal fingerprints the ordered step plan. Canonicalize
|
|
641
|
+
// by the stable GitHub issue number so a retry whose API page/order changed
|
|
642
|
+
// supplies the exact same plan as well as the same transaction id.
|
|
643
|
+
for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
|
|
644
|
+
const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId);
|
|
645
|
+
itemIds.set(entry.issueNumber, planned.itemId);
|
|
646
|
+
mutations.push(...planned.mutations);
|
|
647
|
+
}
|
|
648
|
+
const transactionId = deriveAtomicTransactionId(repo, entries, mutations);
|
|
649
|
+
try {
|
|
650
|
+
const result = await commit({
|
|
651
|
+
pmRoot,
|
|
652
|
+
transactionId,
|
|
653
|
+
author: opts.atomicAuthor ?? "pm-github",
|
|
654
|
+
mutations,
|
|
655
|
+
// This option selects how CREATE steps are compensated. The SDK's
|
|
656
|
+
// commitItemMutations contract independently snapshots and version-
|
|
657
|
+
// restores every UPDATE and CLOSE step (covered by the mixed rollback
|
|
658
|
+
// integration test below this implementation).
|
|
659
|
+
createCompensation: "delete",
|
|
660
|
+
});
|
|
661
|
+
// A recovered journal may include work applied by the interrupted process
|
|
662
|
+
// as well as steps resumed now. The SDK intentionally returns the durable
|
|
663
|
+
// final results, not a per-invocation delta, so create/update counts cannot
|
|
664
|
+
// be reconstructed truthfully. Report the recovered batch separately.
|
|
665
|
+
const recovered = Boolean(result?.recovered);
|
|
666
|
+
return {
|
|
667
|
+
transactionId,
|
|
668
|
+
recovered,
|
|
669
|
+
imported: recovered ? 0 : entries.filter((entry) => !entry.match?.id).length,
|
|
670
|
+
updated: recovered ? 0 : entries.filter((entry) => Boolean(entry.match?.id)).length,
|
|
671
|
+
...(recovered ? { recoveredItems: entries.length } : {}),
|
|
672
|
+
itemIds,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
677
|
+
if (err instanceof AggregateError || /compensation failed/i.test(msg)) {
|
|
678
|
+
throw new CommandError(`Atomic GitHub import failed and compensation was incomplete. The tracker may contain partially applied state; retry the same import to resume transaction ${transactionId}, then inspect its durable journal if recovery still fails. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
|
|
679
|
+
}
|
|
680
|
+
if (err instanceof Error && err.name === "WorkspaceTransactionInterruptedError") {
|
|
681
|
+
throw new CommandError(`Atomic GitHub import was interrupted. Its durable journal is resumable; retry the same import to continue transaction ${transactionId}. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
|
|
682
|
+
}
|
|
683
|
+
throw new CommandError(`Atomic GitHub import failed after the SDK completed its normal compensation path; no new partial committed state is expected. Transaction id: ${transactionId}. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
475
687
|
// Shared import core (used by both the legacy command and the importer)
|
|
476
688
|
// ---------------------------------------------------------------------------
|
|
477
689
|
// Build the GitHub issues list URL. `since`, `assignee`, `labels` and `state`
|
|
@@ -548,6 +760,324 @@ export function composeBody(issue, comments) {
|
|
|
548
760
|
return body;
|
|
549
761
|
}
|
|
550
762
|
// ---------------------------------------------------------------------------
|
|
763
|
+
// Cross-process comment-sync lock (serializes marker dedupe, pm-github-503u)
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
//
|
|
766
|
+
// The native comment sync below does a read-markers-then-append sequence that
|
|
767
|
+
// spans several pm CLI mutations. Each individual mutation is locked by the pm
|
|
768
|
+
// CLI, but the check-then-act as a whole is not atomic across two concurrent
|
|
769
|
+
// `pm github import` processes on the same workspace: both can observe a
|
|
770
|
+
// GitHub comment id as absent and append it twice. The helper here closes that
|
|
771
|
+
// race with a lockfile in the workspace's own locks/ dir, following the pm
|
|
772
|
+
// CLI's locking convention (see core/lock/lock.js in pm-cli):
|
|
773
|
+
//
|
|
774
|
+
// - location <pm data dir>/locks/pm-github.comment-sync.<itemId>.lock — the
|
|
775
|
+
// same locks/ dir the pm CLI itself uses (gitignored runtime state), with
|
|
776
|
+
// a payload shape identical to the CLI's so `pm gc` can sweep our stale
|
|
777
|
+
// locks by their embedded ttl_seconds. The "pm-github.comment-sync."
|
|
778
|
+
// prefix keeps our namespace disjoint from the CLI's per-item mutation
|
|
779
|
+
// locks (<itemId>.lock): we hold our lock ACROSS several CLI mutations, so
|
|
780
|
+
// sharing a name would make the very mutations we wrap conflict with it.
|
|
781
|
+
// - acquire fs.openSync(path, "wx") — atomic O_EXCL create on POSIX.
|
|
782
|
+
// - stale a lock is broken (with a stderr warning) only when its owner
|
|
783
|
+
// is provably gone: the recorded owner PID is dead, or it equals our own
|
|
784
|
+
// PID (we know we don't hold it, so the PID was recycled). A lock with a
|
|
785
|
+
// LIVE owner is never age-broken — a slow-but-alive holder keeps its lock
|
|
786
|
+
// no matter how long it runs (a >TTL holder would otherwise lose the lock
|
|
787
|
+
// mid-append and the race would reopen). Only when the payload is missing
|
|
788
|
+
// or unparseable (caught mid-write, then abandoned) does the TTL (default
|
|
789
|
+
// 5 min, age from file mtime) apply as the break criterion.
|
|
790
|
+
// - release token-checked: each acquisition embeds a unique token in the
|
|
791
|
+
// payload and release() unlinks the file only while it still carries that
|
|
792
|
+
// token, so a holder whose lock was stale-broken (or swept by `pm gc`)
|
|
793
|
+
// can never unlink a successor's lock.
|
|
794
|
+
// - breaking stale locks are removed under a breaker election (an O_EXCL
|
|
795
|
+
// `<lock>.break` sidecar): only the single election winner may unlink,
|
|
796
|
+
// and it re-verifies staleness under that mutex first — two concurrent
|
|
797
|
+
// breakers can therefore never double-break, and a breaker can never
|
|
798
|
+
// unlink a fresh lock that replaced the stale one it inspected.
|
|
799
|
+
// - contend a live, fresh lock is waited out with jittered backoff up to a
|
|
800
|
+
// budget (default 30s). On timeout the caller reports "contended" and the
|
|
801
|
+
// comment sync for that item is SKIPPED (never run unlocked) — a wedged
|
|
802
|
+
// concurrent import can then cost a comment sync, recoverable by
|
|
803
|
+
// re-running import, but can never produce a duplicate comment. When the
|
|
804
|
+
// lock mechanism itself is unavailable (read-only fs etc.) the caller
|
|
805
|
+
// reports "degraded" and proceeds unlocked with a warning, matching the
|
|
806
|
+
// pre-lock best-effort behavior.
|
|
807
|
+
export const IMPORT_LOCK_TTL_MS_DEFAULT = 5 * 60_000;
|
|
808
|
+
export const IMPORT_LOCK_WAIT_MS_DEFAULT = 30_000;
|
|
809
|
+
// Resolve the pm data dir (the dir holding settings.json and locks/) from the
|
|
810
|
+
// pmRoot the extension host hands the command, which may be either the
|
|
811
|
+
// workspace root (the dir containing .agents/pm) or the data dir itself — the
|
|
812
|
+
// pm CLI accepts both for --path, and tests pass the workspace root.
|
|
813
|
+
export function resolvePmDataDir(pmRoot) {
|
|
814
|
+
const nested = path.join(pmRoot, ".agents", "pm");
|
|
815
|
+
try {
|
|
816
|
+
if (fs.statSync(nested).isDirectory())
|
|
817
|
+
return nested;
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
// Not the workspace-root form — assume pmRoot already is the data dir.
|
|
821
|
+
}
|
|
822
|
+
return pmRoot;
|
|
823
|
+
}
|
|
824
|
+
// Absolute lock file path for one item's comment-sync critical section. The
|
|
825
|
+
// item id is sanitized defensively (pm ids are already filename-safe, but the
|
|
826
|
+
// lock path must never become a traversal vector if that ever changes).
|
|
827
|
+
export function importCommentSyncLockPath(pmRoot, itemId) {
|
|
828
|
+
const safe = itemId.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
829
|
+
return path.join(resolvePmDataDir(pmRoot), "locks", `pm-github.comment-sync.${safe}.lock`);
|
|
830
|
+
}
|
|
831
|
+
function readImportLockPayload(lockPath) {
|
|
832
|
+
let raw;
|
|
833
|
+
try {
|
|
834
|
+
raw = fs.readFileSync(lockPath, "utf8");
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return undefined;
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
const parsed = JSON.parse(raw);
|
|
841
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
842
|
+
return undefined;
|
|
843
|
+
return parsed;
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
return undefined;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function isLockOwnerAlive(pid) {
|
|
850
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
|
|
851
|
+
return false;
|
|
852
|
+
try {
|
|
853
|
+
process.kill(pid, 0);
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
// EPERM means the process exists but belongs to another user — still alive.
|
|
858
|
+
// ESRCH means no such process — the lock owner is gone.
|
|
859
|
+
return err?.code === "EPERM";
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// A held lock is stale only when its owner is provably gone: the recorded
|
|
863
|
+
// owner PID is dead (crash without release), or it equals our own PID (we
|
|
864
|
+
// know we do not hold this lock, so the PID must have been recycled). A lock
|
|
865
|
+
// with a LIVE recorded owner is never age-broken — breaking a slow-but-alive
|
|
866
|
+
// holder mid-critical-section would reopen the duplicate-marker race this
|
|
867
|
+
// lock exists to close. Only when there is no usable owner PID (payload
|
|
868
|
+
// missing or unparseable — e.g. caught mid-write, then abandoned) does the
|
|
869
|
+
// TTL apply, with age taken from the payload's created_at falling back to
|
|
870
|
+
// the file mtime. (PID reuse can make a dead lock look alive and thus block
|
|
871
|
+
// until the wait budget — conservative; it can never break a live one.)
|
|
872
|
+
// Lock paths currently held by THIS process (any async task). Distinguishes a
|
|
873
|
+
// same-pid payload that is legitimately ours (in-process contention → wait)
|
|
874
|
+
// from a leftover written by a dead process whose PID we inherited (→ break).
|
|
875
|
+
const heldImportLocks = new Set();
|
|
876
|
+
function importLockStaleReason(lockPath, payload, mtimeMs, ttlMs) {
|
|
877
|
+
const pid = payload?.pid;
|
|
878
|
+
if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) {
|
|
879
|
+
if (pid === process.pid) {
|
|
880
|
+
// Our own PID: either another async task in THIS process holds it (the
|
|
881
|
+
// in-process race the caller serializes too — contend, don't break), or
|
|
882
|
+
// we provably don't hold it and the PID was recycled by a dead owner.
|
|
883
|
+
return heldImportLocks.has(lockPath)
|
|
884
|
+
? undefined
|
|
885
|
+
: `owner pid ${pid} is this process but the lock is not held in-process (pid recycled, lock abandoned)`;
|
|
886
|
+
}
|
|
887
|
+
return isLockOwnerAlive(pid) ? undefined : `owner pid ${pid} is dead`;
|
|
888
|
+
}
|
|
889
|
+
const createdMs = payload ? Date.parse(payload.created_at) : Number.NaN;
|
|
890
|
+
const ageBase = Number.isFinite(createdMs) ? createdMs : mtimeMs;
|
|
891
|
+
if (!Number.isFinite(ageBase) || Date.now() - ageBase > ttlMs) {
|
|
892
|
+
return `no usable owner pid and older than TTL ${Math.round(ttlMs / 1000)}s`;
|
|
893
|
+
}
|
|
894
|
+
return undefined;
|
|
895
|
+
}
|
|
896
|
+
const IMPORT_LOCK_INITIAL_BACKOFF_MS = 25;
|
|
897
|
+
const IMPORT_LOCK_MAX_BACKOFF_MS = 200;
|
|
898
|
+
const IMPORT_LOCK_MAX_STALE_BREAKS = 3;
|
|
899
|
+
// A breaker's critical section is a handful of syscalls (re-stat, re-read,
|
|
900
|
+
// unlink) — a crashed breaker's sidecar goes stale in seconds, not minutes.
|
|
901
|
+
export const IMPORT_LOCK_BREAKER_TTL_MS = 10_000;
|
|
902
|
+
// Break a stale lock under a breaker election so the unlink can never race
|
|
903
|
+
// another breaker: two contenders may both judge the same file stale, but
|
|
904
|
+
// without mutual exclusion the slower one would execute its unlink AFTER the
|
|
905
|
+
// winner already re-created a fresh, live lock — silently unlinking that
|
|
906
|
+
// replacement and letting both proceed (the exact double-acquire this module
|
|
907
|
+
// exists to prevent). The election is an O_EXCL sidecar (`<lock>.break`):
|
|
908
|
+
// only its single winner may unlink, and it re-verifies staleness under the
|
|
909
|
+
// mutex first, so a lock that became live since the caller's check survives.
|
|
910
|
+
// Returns true when the stale lock was removed (caller should retry acquire
|
|
911
|
+
// immediately), false when the election was lost or the lock is live again
|
|
912
|
+
// (caller should fall through to the normal wait/backoff).
|
|
913
|
+
function breakStaleImportLock(lockPath, ttlMs, reason) {
|
|
914
|
+
const breakerPath = `${lockPath}.break`;
|
|
915
|
+
let bfd;
|
|
916
|
+
try {
|
|
917
|
+
bfd = fs.openSync(breakerPath, "wx");
|
|
918
|
+
}
|
|
919
|
+
catch (err) {
|
|
920
|
+
if (err?.code === "EEXIST") {
|
|
921
|
+
// Another breaker is active. If IT crashed mid-break, its sidecar is
|
|
922
|
+
// old — clear it and let the next iteration re-run the election.
|
|
923
|
+
try {
|
|
924
|
+
if (Date.now() - fs.statSync(breakerPath).mtimeMs > IMPORT_LOCK_BREAKER_TTL_MS) {
|
|
925
|
+
fs.unlinkSync(breakerPath);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
// Sidecar vanished (election finished) — retry normally.
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
fs.closeSync(bfd);
|
|
936
|
+
// Re-verify under the mutex: the lock we judged stale may have been
|
|
937
|
+
// broken and re-acquired by a live owner since we looked at it.
|
|
938
|
+
let payload;
|
|
939
|
+
let mtimeMs = Number.NaN;
|
|
940
|
+
try {
|
|
941
|
+
mtimeMs = fs.statSync(lockPath).mtimeMs;
|
|
942
|
+
payload = readImportLockPayload(lockPath);
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
return true; // already gone — acquire can proceed immediately
|
|
946
|
+
}
|
|
947
|
+
if (!importLockStaleReason(lockPath, payload, mtimeMs, ttlMs))
|
|
948
|
+
return false;
|
|
949
|
+
console.error(`pm-github: breaking stale comment-sync lock ${lockPath} (${reason})`);
|
|
950
|
+
try {
|
|
951
|
+
fs.unlinkSync(lockPath);
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
// Already gone.
|
|
955
|
+
}
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
finally {
|
|
959
|
+
try {
|
|
960
|
+
fs.unlinkSync(breakerPath);
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
// Best-effort: an aged-out sidecar may have been cleared by a peer.
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// Acquire the cross-process comment-sync lock for one item. Never throws:
|
|
968
|
+
// every failure mode maps to one of the three acquisition statuses so the
|
|
969
|
+
// caller can decide how to degrade. `ttlMs`/`waitMs` are injectable for tests.
|
|
970
|
+
export async function acquireImportLock(pmRoot, itemId, opts = {}) {
|
|
971
|
+
const ttlMs = opts.ttlMs ?? IMPORT_LOCK_TTL_MS_DEFAULT;
|
|
972
|
+
const waitMs = opts.waitMs ?? IMPORT_LOCK_WAIT_MS_DEFAULT;
|
|
973
|
+
let lockPath;
|
|
974
|
+
try {
|
|
975
|
+
lockPath = importCommentSyncLockPath(pmRoot, itemId);
|
|
976
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
980
|
+
console.error(`pm-github: comment-sync lock unavailable (${msg}) — proceeding without cross-process serialization`);
|
|
981
|
+
return { status: "degraded" };
|
|
982
|
+
}
|
|
983
|
+
const payload = {
|
|
984
|
+
id: path.basename(lockPath, ".lock"),
|
|
985
|
+
pid: process.pid,
|
|
986
|
+
owner: "pm-github",
|
|
987
|
+
token: crypto.randomUUID(),
|
|
988
|
+
created_at: new Date().toISOString(),
|
|
989
|
+
ttl_seconds: Math.ceil(ttlMs / 1000),
|
|
990
|
+
};
|
|
991
|
+
const startedAt = Date.now();
|
|
992
|
+
let backoffMs = IMPORT_LOCK_INITIAL_BACKOFF_MS;
|
|
993
|
+
let staleBreaks = 0;
|
|
994
|
+
for (;;) {
|
|
995
|
+
let fd;
|
|
996
|
+
try {
|
|
997
|
+
fd = fs.openSync(lockPath, "wx");
|
|
998
|
+
fs.writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`);
|
|
999
|
+
fs.closeSync(fd);
|
|
1000
|
+
fd = undefined;
|
|
1001
|
+
heldImportLocks.add(lockPath);
|
|
1002
|
+
let released = false;
|
|
1003
|
+
return {
|
|
1004
|
+
status: "acquired",
|
|
1005
|
+
lock: {
|
|
1006
|
+
path: lockPath,
|
|
1007
|
+
release() {
|
|
1008
|
+
if (released)
|
|
1009
|
+
return;
|
|
1010
|
+
released = true;
|
|
1011
|
+
heldImportLocks.delete(lockPath);
|
|
1012
|
+
try {
|
|
1013
|
+
// Token check: only unlink the file while it is still OUR lock.
|
|
1014
|
+
// If it was stale-broken or gc-swept and re-acquired, the path
|
|
1015
|
+
// now holds the successor's lock — leave it alone.
|
|
1016
|
+
const current = readImportLockPayload(lockPath);
|
|
1017
|
+
if (current?.token !== payload.token)
|
|
1018
|
+
return;
|
|
1019
|
+
fs.unlinkSync(lockPath);
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
// Best-effort: already gone (stale-broken by someone else, gc).
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
if (fd !== undefined) {
|
|
1030
|
+
try {
|
|
1031
|
+
fs.closeSync(fd);
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
// Ignore close errors on the failure path.
|
|
1035
|
+
}
|
|
1036
|
+
// We created the lockfile but failed to write/close it. Remove the
|
|
1037
|
+
// empty/partial file so other processes are not blocked until the
|
|
1038
|
+
// TTL expires on a lock that was never validly held.
|
|
1039
|
+
try {
|
|
1040
|
+
fs.unlinkSync(lockPath);
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
// Best-effort: already gone.
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
const code = err?.code;
|
|
1047
|
+
if (code !== "EEXIST") {
|
|
1048
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1049
|
+
console.error(`pm-github: comment-sync lock failed (${msg}) — proceeding without cross-process serialization`);
|
|
1050
|
+
return { status: "degraded" };
|
|
1051
|
+
}
|
|
1052
|
+
// The lock exists. If it vanished before we could stat it, retry at once.
|
|
1053
|
+
let existing;
|
|
1054
|
+
let mtimeMs = Number.NaN;
|
|
1055
|
+
try {
|
|
1056
|
+
mtimeMs = fs.statSync(lockPath).mtimeMs;
|
|
1057
|
+
existing = readImportLockPayload(lockPath);
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
const staleReason = importLockStaleReason(lockPath, existing, mtimeMs, ttlMs);
|
|
1063
|
+
if (staleReason && staleBreaks < IMPORT_LOCK_MAX_STALE_BREAKS) {
|
|
1064
|
+
staleBreaks++;
|
|
1065
|
+
if (breakStaleImportLock(lockPath, ttlMs, staleReason))
|
|
1066
|
+
continue;
|
|
1067
|
+
// Lost the breaker election or the lock turned out live on re-check —
|
|
1068
|
+
// fall through to the normal wait/backoff below.
|
|
1069
|
+
}
|
|
1070
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1071
|
+
if (elapsedMs >= waitMs)
|
|
1072
|
+
return { status: "contended" };
|
|
1073
|
+
// Jittered backoff, mirroring the pm CLI's lock wait (0.5x–1.5x jitter).
|
|
1074
|
+
const jittered = Math.max(1, Math.round(backoffMs * (0.5 + Math.random())));
|
|
1075
|
+
await sleep(Math.min(jittered, waitMs - elapsedMs));
|
|
1076
|
+
backoffMs = Math.min(backoffMs * 2, IMPORT_LOCK_MAX_BACKOFF_MS);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// ---------------------------------------------------------------------------
|
|
551
1081
|
// Native comment sync (GitHub issue comments → pm comments collection)
|
|
552
1082
|
// ---------------------------------------------------------------------------
|
|
553
1083
|
//
|
|
@@ -602,45 +1132,65 @@ export function parseCreatedItemId(stdout) {
|
|
|
602
1132
|
// duplicates. Each GitHub comment becomes one pm comment authored by the
|
|
603
1133
|
// GitHub login. Failures are logged and never abort the import.
|
|
604
1134
|
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
1135
|
+
// Concurrency (pm-github-503u, limitation lifted): the read-markers-then-append
|
|
1136
|
+
// critical section is serialized across processes by a per-item lockfile (see
|
|
1137
|
+
// acquireImportLock above), so two concurrent `pm github import` runs against
|
|
1138
|
+
// the same workspace can no longer both observe a comment id as absent and
|
|
1139
|
+
// append it twice. The lock is per ITEM, not per import run: unrelated issues
|
|
1140
|
+
// in concurrent imports still sync in parallel, and only the one item whose
|
|
1141
|
+
// comments are actively being synced is serialized — the smallest scope that
|
|
1142
|
+
// closes the race. Stale locks (older than the TTL or owned by a dead PID) are
|
|
1143
|
+
// broken with a stderr warning. If a live concurrent import holds the lock
|
|
1144
|
+
// past the wait budget, this item's comment sync is skipped with a warning
|
|
1145
|
+
// (never run unlocked, so a wedged peer can cost a sync — recoverable on the
|
|
1146
|
+
// next import — but never cause a duplicate); if the lock mechanism itself is
|
|
1147
|
+
// unavailable the sync proceeds unlocked as before, also with a warning.
|
|
610
1148
|
export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot, issueNumber) {
|
|
611
1149
|
if (comments.length === 0)
|
|
612
1150
|
return { added: 0, skipped: 0 };
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const list = await pmComments(itemId, {}, { pmRoot });
|
|
618
|
-
existing = (list?.comments ?? []);
|
|
619
|
-
}
|
|
620
|
-
catch (err) {
|
|
621
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
622
|
-
console.error(`#${issueNumber}: could not read existing comments for ${itemId} — ${msg}`);
|
|
1151
|
+
const acquisition = await acquireImportLock(pmRoot, itemId);
|
|
1152
|
+
if (acquisition.status === "contended") {
|
|
1153
|
+
console.error(`#${issueNumber}: comment sync for ${itemId} skipped — another import holds the ` +
|
|
1154
|
+
`comment-sync lock; re-run import to pick up the comments`);
|
|
623
1155
|
return { added: 0, skipped: 0 };
|
|
624
1156
|
}
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (synced.has(c.id)) {
|
|
630
|
-
skipped++;
|
|
631
|
-
continue;
|
|
632
|
-
}
|
|
633
|
-
const author = c.user?.login ?? "github";
|
|
1157
|
+
const release = acquisition.status === "acquired" ? () => acquisition.lock.release() : () => { };
|
|
1158
|
+
try {
|
|
1159
|
+
let pmComments;
|
|
1160
|
+
let existing = [];
|
|
634
1161
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
1162
|
+
pmComments = await loadPmComments();
|
|
1163
|
+
const list = await pmComments(itemId, {}, { pmRoot });
|
|
1164
|
+
existing = (list?.comments ?? []);
|
|
637
1165
|
}
|
|
638
1166
|
catch (err) {
|
|
639
1167
|
const msg = err instanceof Error ? err.message : String(err);
|
|
640
|
-
console.error(`#${issueNumber}:
|
|
1168
|
+
console.error(`#${issueNumber}: could not read existing comments for ${itemId} — ${msg}`);
|
|
1169
|
+
return { added: 0, skipped: 0 };
|
|
641
1170
|
}
|
|
1171
|
+
const synced = extractSyncedCommentIds(existing);
|
|
1172
|
+
let added = 0;
|
|
1173
|
+
let skipped = 0;
|
|
1174
|
+
for (const c of comments) {
|
|
1175
|
+
if (synced.has(c.id)) {
|
|
1176
|
+
skipped++;
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
const author = c.user?.login ?? "github";
|
|
1180
|
+
try {
|
|
1181
|
+
await pmComments(itemId, { add: buildCommentText(c), author }, { pmRoot });
|
|
1182
|
+
added++;
|
|
1183
|
+
}
|
|
1184
|
+
catch (err) {
|
|
1185
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1186
|
+
console.error(`#${issueNumber}: comment ${c.id} sync failed — ${msg}`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return { added, skipped };
|
|
1190
|
+
}
|
|
1191
|
+
finally {
|
|
1192
|
+
release();
|
|
642
1193
|
}
|
|
643
|
-
return { added, skipped };
|
|
644
1194
|
}
|
|
645
1195
|
// A draft PR is an issue that is both a pull request and flagged `draft: true`.
|
|
646
1196
|
// (Plain issues are never drafts.)
|
|
@@ -706,6 +1256,7 @@ export function parseImportOptions(options) {
|
|
|
706
1256
|
commentsMode,
|
|
707
1257
|
itemType: optionString(options, "type") || "Issue",
|
|
708
1258
|
dryRun: optionEnabled(options, "dry-run", "dryRun"),
|
|
1259
|
+
atomic: optionEnabled(options, "atomic"),
|
|
709
1260
|
};
|
|
710
1261
|
}
|
|
711
1262
|
// Spawn the `pm` CLI with the given argv and return a normalized ok/stdout/stderr
|
|
@@ -715,21 +1266,63 @@ function pmRun(args) {
|
|
|
715
1266
|
const result = spawnSync("pm", args, { encoding: "utf-8" });
|
|
716
1267
|
return { ok: result.status === 0, stderr: result.stderr || "", stdout: result.stdout || "" };
|
|
717
1268
|
}
|
|
1269
|
+
async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments) {
|
|
1270
|
+
const title = issue.title.trim();
|
|
1271
|
+
if (!title)
|
|
1272
|
+
return undefined;
|
|
1273
|
+
const kind = issue.pull_request ? "PR" : "issue";
|
|
1274
|
+
const labels = issue.labels.map((label) => label.name).filter(Boolean);
|
|
1275
|
+
const tag = provenanceTag(repo, issue.number);
|
|
1276
|
+
const ghAuthorTag = authorTag(issue);
|
|
1277
|
+
const author = issue.user?.login;
|
|
1278
|
+
const syncAnnotations = opts.commentsMode === "annotations" || opts.commentsMode === "both";
|
|
1279
|
+
const shouldFetchComments = opts.withComments || syncAnnotations;
|
|
1280
|
+
const writeCommentsToBody = opts.commentsMode === "body" || opts.commentsMode === "both";
|
|
1281
|
+
let comments = [];
|
|
1282
|
+
if (shouldFetchComments) {
|
|
1283
|
+
try {
|
|
1284
|
+
comments = await fetchIssueComments(issue, repo, token);
|
|
1285
|
+
}
|
|
1286
|
+
catch (err) {
|
|
1287
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1288
|
+
console.error(`#${issue.number}: failed to fetch comments — ${msg}`);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return {
|
|
1292
|
+
issueNumber: issue.number,
|
|
1293
|
+
title,
|
|
1294
|
+
itemType: opts.itemType,
|
|
1295
|
+
status: mapState(issue.state, issue.state_reason),
|
|
1296
|
+
description: `GH ${kind} #${issue.number}: ${issue.html_url}` +
|
|
1297
|
+
(author ? ` · author @${author}` : "") +
|
|
1298
|
+
(issue.state_reason ? ` · state reason ${issue.state_reason}` : "") +
|
|
1299
|
+
(issue.created_at ? ` · created ${issue.created_at}` : "") +
|
|
1300
|
+
(issue.updated_at ? ` · updated ${issue.updated_at}` : ""),
|
|
1301
|
+
body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""),
|
|
1302
|
+
tags: [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])],
|
|
1303
|
+
assignee: issue.assignee?.login,
|
|
1304
|
+
milestone: issue.milestone?.title,
|
|
1305
|
+
comments,
|
|
1306
|
+
syncAnnotations,
|
|
1307
|
+
match,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
718
1310
|
// Run the full import flow. Idempotent: items already linked (provenance tag)
|
|
719
1311
|
// to a fetched issue are UPDATEd; new issues are created. Returns a structured
|
|
720
1312
|
// result; throws CommandError (with a semantic exitCode) on failure.
|
|
721
|
-
async function runImport(repoArg, pmRoot, opts) {
|
|
1313
|
+
export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
|
|
722
1314
|
if (!repoArg || !repoArg.includes("/")) {
|
|
723
1315
|
throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
|
|
724
1316
|
"[--labels bug,enhancement] [--since <iso>] [--assignee <login>] " +
|
|
725
|
-
"[--milestone <name>] [--include-prs] [--skip-drafts] [--with-comments]
|
|
1317
|
+
"[--milestone <name>] [--include-prs] [--skip-drafts] [--with-comments] " +
|
|
1318
|
+
"[--comments-mode body|annotations|both] [--atomic]", EXIT_CODE.USAGE);
|
|
726
1319
|
}
|
|
727
1320
|
const repo = repoArg;
|
|
728
|
-
const token = resolveGitHubToken();
|
|
1321
|
+
const token = (dependencies.resolveToken ?? resolveGitHubToken)();
|
|
729
1322
|
console.error(`Fetching issues from ${repo}…${token ? "" : " (unauthenticated — 60 req/hr)"}`);
|
|
730
1323
|
let fetched;
|
|
731
1324
|
try {
|
|
732
|
-
fetched = await fetchAllIssues(repo, opts, token);
|
|
1325
|
+
fetched = await (dependencies.fetchIssues ?? fetchAllIssues)(repo, opts, token);
|
|
733
1326
|
}
|
|
734
1327
|
catch (err) {
|
|
735
1328
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -742,59 +1335,100 @@ async function runImport(repoArg, pmRoot, opts) {
|
|
|
742
1335
|
const filtered = applyClientFilters(fetched, opts);
|
|
743
1336
|
if (filtered.length === 0) {
|
|
744
1337
|
console.error("No issues found.");
|
|
1338
|
+
if (opts.atomic && opts.dryRun) {
|
|
1339
|
+
return {
|
|
1340
|
+
dryRun: true,
|
|
1341
|
+
wouldImport: 0,
|
|
1342
|
+
wouldUpdate: 0,
|
|
1343
|
+
wouldSkip: 0,
|
|
1344
|
+
atomic: true,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
745
1347
|
return { imported: 0, updated: 0, skipped: 0 };
|
|
746
1348
|
}
|
|
747
1349
|
console.error(`Found ${filtered.length} issue(s).`);
|
|
748
1350
|
// Build the idempotency index once up-front.
|
|
749
|
-
const
|
|
1351
|
+
const shouldMatchExisting = !opts.dryRun || opts.atomic;
|
|
1352
|
+
const existing = shouldMatchExisting
|
|
1353
|
+
? indexByProvenance((dependencies.readItems ?? readPmItems)(pmRoot))
|
|
1354
|
+
: new Map();
|
|
750
1355
|
let imported = 0;
|
|
751
1356
|
let updated = 0;
|
|
752
1357
|
let skipped = 0;
|
|
1358
|
+
if (opts.atomic) {
|
|
1359
|
+
const prepared = [];
|
|
1360
|
+
for (const issue of filtered) {
|
|
1361
|
+
const entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
|
|
1362
|
+
if (!entry) {
|
|
1363
|
+
skipped++;
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
prepared.push(entry);
|
|
1367
|
+
}
|
|
1368
|
+
if (prepared.length === 0) {
|
|
1369
|
+
if (opts.dryRun) {
|
|
1370
|
+
console.error(`[dry-run] Atomic plan would import 0, update 0, skip ${skipped}.`);
|
|
1371
|
+
return {
|
|
1372
|
+
dryRun: true,
|
|
1373
|
+
wouldImport: 0,
|
|
1374
|
+
wouldUpdate: 0,
|
|
1375
|
+
wouldSkip: skipped,
|
|
1376
|
+
atomic: true,
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`, EXIT_CODE.GENERIC_FAILURE);
|
|
1380
|
+
}
|
|
1381
|
+
if (opts.dryRun) {
|
|
1382
|
+
imported = prepared.filter((entry) => !entry.match?.id).length;
|
|
1383
|
+
updated = prepared.length - imported;
|
|
1384
|
+
for (const entry of prepared) {
|
|
1385
|
+
const action = entry.match?.id ? "update" : "import";
|
|
1386
|
+
console.error(` [dry-run][atomic] #${entry.issueNumber} ${action} ${entry.title} (${entry.status})`);
|
|
1387
|
+
}
|
|
1388
|
+
console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}.`);
|
|
1389
|
+
return {
|
|
1390
|
+
dryRun: true,
|
|
1391
|
+
wouldImport: imported,
|
|
1392
|
+
wouldUpdate: updated,
|
|
1393
|
+
wouldSkip: skipped,
|
|
1394
|
+
atomic: true,
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
|
|
1398
|
+
for (const entry of prepared) {
|
|
1399
|
+
if (!entry.syncAnnotations)
|
|
1400
|
+
continue;
|
|
1401
|
+
const itemId = result.itemIds.get(entry.issueNumber);
|
|
1402
|
+
if (itemId) {
|
|
1403
|
+
await syncGithubCommentsToAnnotations(itemId, entry.comments, pmRoot, entry.issueNumber);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
if (result.recovered) {
|
|
1407
|
+
console.error(`Atomic import recovered transaction ${result.transactionId} covering ${result.recoveredItems ?? prepared.length} item(s).`);
|
|
1408
|
+
}
|
|
1409
|
+
else {
|
|
1410
|
+
console.error(`Atomically imported ${result.imported} new, updated ${result.updated} existing, skipped ${skipped}.`);
|
|
1411
|
+
}
|
|
1412
|
+
// itemIds is an internal post-commit routing map for native comments. Maps
|
|
1413
|
+
// serialize as `{}` in JSON, so keep it out of the public command result.
|
|
1414
|
+
return {
|
|
1415
|
+
transactionId: result.transactionId,
|
|
1416
|
+
recovered: result.recovered,
|
|
1417
|
+
imported: result.imported,
|
|
1418
|
+
updated: result.updated,
|
|
1419
|
+
...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}),
|
|
1420
|
+
skipped,
|
|
1421
|
+
atomic: true,
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
753
1424
|
for (const issue of filtered) {
|
|
754
|
-
const
|
|
755
|
-
if (!
|
|
1425
|
+
const prepared = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
|
|
1426
|
+
if (!prepared) {
|
|
756
1427
|
skipped++;
|
|
757
1428
|
continue;
|
|
758
1429
|
}
|
|
759
|
-
const kind = issue.pull_request ? "PR" : "issue";
|
|
760
1430
|
const labels = issue.labels.map((l) => l.name).filter(Boolean);
|
|
761
|
-
const
|
|
762
|
-
const ghAuthorTag = authorTag(issue);
|
|
763
|
-
const tags = [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])];
|
|
764
|
-
const status = mapState(issue.state, issue.state_reason);
|
|
765
|
-
const assignee = issue.assignee?.login;
|
|
766
|
-
const milestone = issue.milestone?.title;
|
|
767
|
-
const key = `${repo.toLowerCase()}#${issue.number}`;
|
|
768
|
-
const match = existing.get(key);
|
|
769
|
-
// GitHub provenance lives in the description (human-readable) and the
|
|
770
|
-
// declared schema fields (github_url/github_number/github_state/
|
|
771
|
-
// github_author/github_created_at/github_updated_at). Author + timestamps
|
|
772
|
-
// are appended additively so a re-import keeps them current.
|
|
773
|
-
const author = issue.user?.login;
|
|
774
|
-
const description = `GH ${kind} #${issue.number}: ${issue.html_url}` +
|
|
775
|
-
(author ? ` · author @${author}` : "") +
|
|
776
|
-
(issue.state_reason ? ` · state reason ${issue.state_reason}` : "") +
|
|
777
|
-
(issue.created_at ? ` · created ${issue.created_at}` : "") +
|
|
778
|
-
(issue.updated_at ? ` · updated ${issue.updated_at}` : "");
|
|
779
|
-
// Comments are fetched when --with-comments asks for the legacy body
|
|
780
|
-
// embedding OR when --comments-mode targets the native comments collection
|
|
781
|
-
// (annotations/both). In default mode (body + no --with-comments) nothing is
|
|
782
|
-
// fetched, keeping import output byte-identical to pre-2026.7.14 behavior.
|
|
783
|
-
const syncAnnotations = opts.commentsMode === "annotations" || opts.commentsMode === "both";
|
|
784
|
-
const shouldFetchComments = opts.withComments || syncAnnotations;
|
|
785
|
-
// Comments land in the body only in body/both mode (the historical path).
|
|
786
|
-
const writeCommentsToBody = opts.commentsMode === "body" || opts.commentsMode === "both";
|
|
787
|
-
let comments = [];
|
|
788
|
-
if (shouldFetchComments) {
|
|
789
|
-
try {
|
|
790
|
-
comments = await fetchComments(issue, repo, token);
|
|
791
|
-
}
|
|
792
|
-
catch (err) {
|
|
793
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
794
|
-
console.error(`#${issue.number}: failed to fetch comments — ${msg}`);
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
const body = writeCommentsToBody ? composeBody(issue, comments) : (issue.body || "");
|
|
1431
|
+
const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations, } = prepared;
|
|
798
1432
|
if (opts.dryRun) {
|
|
799
1433
|
console.error(` [dry-run] #${issue.number} ${title} (${status}, ${labels.join(",")})`);
|
|
800
1434
|
imported++;
|
|
@@ -881,7 +1515,12 @@ async function runImport(repoArg, pmRoot, opts) {
|
|
|
881
1515
|
}
|
|
882
1516
|
if (opts.dryRun) {
|
|
883
1517
|
console.error(`[dry-run] Would import ${imported}, skip ${skipped}.`);
|
|
884
|
-
return {
|
|
1518
|
+
return {
|
|
1519
|
+
dryRun: true,
|
|
1520
|
+
wouldImport: imported,
|
|
1521
|
+
wouldSkip: skipped,
|
|
1522
|
+
...(opts.atomic ? { atomic: true } : {}),
|
|
1523
|
+
};
|
|
885
1524
|
}
|
|
886
1525
|
console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
|
|
887
1526
|
if (imported === 0 && updated === 0 && skipped > 0) {
|
|
@@ -2035,6 +2674,7 @@ const IMPORT_FLAGS = [
|
|
|
2035
2674
|
{ long: "--with-comments", description: "Fetch issue comments and append them to the item body" },
|
|
2036
2675
|
{ long: "--include-comments", description: "Alias for --with-comments" },
|
|
2037
2676
|
{ long: "--comments-mode", value_name: "body|annotations|both", description: "How to persist fetched GitHub comments: `body` (default, embed in item body), `annotations` (sync to the pm item's native comments collection), or `both`. `annotations`/`both` are idempotent on re-import (dedupe by GitHub comment id)" },
|
|
2677
|
+
{ long: "--atomic", description: "Commit the complete import as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" },
|
|
2038
2678
|
{ long: "--dry-run", description: "Preview without writing" },
|
|
2039
2679
|
{ long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" },
|
|
2040
2680
|
];
|
|
@@ -2081,7 +2721,7 @@ const PROJECT_SYNC_FLAGS = [
|
|
|
2081
2721
|
];
|
|
2082
2722
|
export default defineExtension({
|
|
2083
2723
|
name: "pm-github",
|
|
2084
|
-
version: "2026.7.
|
|
2724
|
+
version: "2026.7.21",
|
|
2085
2725
|
activate(api) {
|
|
2086
2726
|
// -----------------------------------------------------------------------
|
|
2087
2727
|
// schema — declare the GitHub provenance fields so the workspace knows them
|
|
@@ -2129,6 +2769,7 @@ export default defineExtension({
|
|
|
2129
2769
|
"pm github import owner/repo --since 7d",
|
|
2130
2770
|
"pm github import owner/repo --include-comments",
|
|
2131
2771
|
"pm github import owner/repo --comments-mode annotations",
|
|
2772
|
+
"pm github import owner/repo --atomic",
|
|
2132
2773
|
"pm github import owner/repo --dry-run",
|
|
2133
2774
|
],
|
|
2134
2775
|
flags: IMPORT_FLAGS,
|
|
@@ -2136,6 +2777,7 @@ export default defineExtension({
|
|
|
2136
2777
|
"Pass <owner/repo>, e.g. `pm github import unbraind/pm-cli`.",
|
|
2137
2778
|
"Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` for private repos / 5000 req/hr.",
|
|
2138
2779
|
"Re-running is safe: existing items are updated, not duplicated.",
|
|
2780
|
+
"Use --atomic for a durable resumable journal with reverse compensation on ordinary failures.",
|
|
2139
2781
|
],
|
|
2140
2782
|
});
|
|
2141
2783
|
// -----------------------------------------------------------------------
|
|
@@ -2293,6 +2935,7 @@ export default defineExtension({
|
|
|
2293
2935
|
"pm gh-issues import unbraind/pm-cli --since 2026-01-01T00:00:00Z",
|
|
2294
2936
|
"pm github import owner/repo --with-comments",
|
|
2295
2937
|
"pm github import owner/repo --comments-mode annotations",
|
|
2938
|
+
"pm github import owner/repo --atomic",
|
|
2296
2939
|
"pm github import owner/repo --dry-run",
|
|
2297
2940
|
],
|
|
2298
2941
|
flags: IMPORT_FLAGS,
|
|
@@ -2300,6 +2943,7 @@ export default defineExtension({
|
|
|
2300
2943
|
"Pass <owner/repo>, e.g. `pm gh-issues import unbraind/pm-cli`.",
|
|
2301
2944
|
"Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` for private repos / 5000 req/hr.",
|
|
2302
2945
|
"Re-running is safe: existing items are updated, not duplicated.",
|
|
2946
|
+
"Use --atomic for a durable resumable journal with reverse compensation on ordinary failures.",
|
|
2303
2947
|
],
|
|
2304
2948
|
async run(ctx) {
|
|
2305
2949
|
return runImport(ctx.args[0], ctx.pm_root, parseImportOptions(ctx.options));
|