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/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
- // Known limitation (tracked in pm-github-503u): the read-markers-then-append
606
- // sequence is not atomic across processes. Two concurrent imports of the same
607
- // repository into the same workspace can both observe a comment id as absent
608
- // and append it twice. Individual appends are still atomic (pm CLI locks per
609
- // mutation), so the worst case is a duplicate comment, never corruption.
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
- let pmComments;
614
- let existing = [];
615
- try {
616
- pmComments = await loadPmComments();
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 synced = extractSyncedCommentIds(existing);
626
- let added = 0;
627
- let skipped = 0;
628
- for (const c of comments) {
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
- await pmComments(itemId, { add: buildCommentText(c), author }, { pmRoot });
636
- added++;
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}: comment ${c.id} sync failed — ${msg}`);
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] [--comments-mode body|annotations|both]", EXIT_CODE.USAGE);
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 existing = opts.dryRun ? new Map() : indexByProvenance(readPmItems(pmRoot));
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 title = issue.title.trim();
755
- if (!title) {
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 tag = provenanceTag(repo, issue.number);
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 { dryRun: true, wouldImport: imported, wouldSkip: skipped };
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.18",
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));