A true round-trip GitHub Issues integration for pm-cli: import, export, status-sync, search, and validate.
Import issues from any GitHub repo as pm items, export pm items back to GitHub (safely, dry-run by default), push status changes upstream, reach GitHub from pm search, and track GitHub provenance on each item. Works unauthenticated (60 req/hr); set GITHUB_TOKEN/GH_TOKEN or run gh auth login for 5000 req/hr and private repos.
pm install npm:pm-github --globalThe
npm:prefix is required. A barepm install pm-githubresolves only a local directory or a bundled alias, never the registry, and agithub.com/unbraind/pm-githubsource cannot work either — pm copies a GitHub source as-is without building it, and this repository does not commitdist/.
| SDK capability | What it provides |
|---|---|
importers |
pm github import <owner/repo> — idempotent native import pipeline |
importers (exporter) |
pm github export — pm items → GitHub issues (dry-run by default; upsert) |
commands |
pm gh-issues import (legacy import alias), pm github sync (push status), pm github gate (fail-closed pre-push privacy gate), pm github validate (diagnostics), pm github project list|fields|import|sync (Projects v2) |
schema |
declares github_url, github_number, github_state, github_author, github_created_at, github_updated_at item fields |
hooks |
afterCommand — opt-in sync reminder (PM_GITHUB_SYNC) for linked items |
preflight |
early warning when a mutating github command lacks a token |
search |
github search provider — pm search reaches GitHub for imported items |
Import idempotency, export, status sync, Projects v2 sync, and search fallback
all depend on seeing every pm item, including closed and canceled work. Before
any of those paths plans or performs a GitHub operation, pm-github requests a
strict, full, unbounded pm list --all response and verifies its completeness,
omission, pagination, output-budget, count, identity, and consumed-field
contracts. An incomplete or unverifiable response fails closed; it is never
treated as an empty or partial workspace.
The production subprocess uses the same canonical argument contract as:
pm --pm-path <tracker> \
--output-include full \
--output-limit unbounded \
--output-budget unbounded \
list --all --json --include-body --strict-readThe output controls precede the command so the host owns the complete-result
contract; list --all includes terminal items without invoking the deprecated
list-all alias.
The subprocess still has a 64 MiB byte safety cap. Set
PM_JSON_MAX_BUFFER=<bytes> to a larger positive safe integer for an unusually
large tracker. Raising that transport cap does not weaken the completeness
checks.
pm github import unbraind/pm-cli
pm github import owner/repo --issue 189
pm github import owner/repo --issue '189,#190,owner/repo#191'
pm github import owner/repo --limit 10
pm github import owner/repo --state all
pm github import owner/repo --labels bug,enhancement
pm github import owner/repo --since 2026-01-01T00:00:00Z # incremental sync
pm github import owner/repo --assignee octocat
pm github import owner/repo --milestone "v1.0"
pm github import owner/repo --include-prs
pm github import owner/repo --atomic
pm github import owner/repo --link-deps # map issue-body dependencies to pm edges
pm github import owner/repo --dry-runFlags
| Flag | Type | Description |
|---|---|---|
--issue <N[,N...]> |
string | Fetch only selected issue endpoints; accepts numbers, #N, and same-repo owner/repo#N references. Conflicts with --state, --all, --labels, --since, --assignee, --milestone, and --limit. |
--limit <n> |
positive integer | Cap list-mode imports, newest-updated first. Returns limit and truncated (true when more matching issues exist). Invalid with --issue. |
--all |
boolean | Include closed issues (shorthand for --state all) |
--state <state> |
string | open | closed | all (default: open) |
--labels <labels> |
string | Comma-separated label filter |
--since <iso> |
string | Only issues updated after this ISO timestamp (incremental sync) |
--assignee <login> |
string | Filter by assignee login |
--milestone <name> |
string | Filter by milestone title |
--include-prs |
boolean | Include pull requests (default: skip PRs) |
--skip-drafts |
boolean | Exclude draft pull requests (only meaningful with --include-prs) |
--with-comments |
boolean | Fetch issue comments and append them to the item body |
--comments-mode <mode> |
body|annotations|both |
How fetched GitHub comments are persisted (default body). annotations syncs comments into the pm item's native comments collection via the SDK; both writes the body section AND native comments. annotations/both are idempotent on re-import (dedupe by GitHub comment id) |
--atomic |
boolean | Commit every create, update, close, and reopen in one workspace-writer-locked, crash-resumable transaction (requires pm CLI/SDK >=2026.7.20). Normal failure compensation restores updated/closed items and deletes newly created items; an incomplete compensation is reported explicitly for retry or repair. |
--link-deps |
boolean | After import, map dependency references in issue bodies (Blocked by #N, Depends on owner/repo#N, Blocks #N) to pm dependency edges between the linked items. Idempotent, best-effort, and re-runnable; see Dependency linking below. |
--gate |
boolean | Fail-closed privacy gate: verify plan completeness (every fetched issue accounted for exactly once) and provenance idempotency (every re-import lands on exactly one gh:owner/repo#N-tagged item) BEFORE the write, then scan the proposed tracker change with pm github gate AFTER it. Any finding — or any unreadable scan input — exits non-zero so an automated pipeline can never commit or push the change. Embeds gate receipts in the result. |
--dry-run |
boolean | Preview without writing |
--type <type> |
string | Override pm item type (default: Issue) |
Each imported item records GitHub provenance: the gh:owner/repo#N idempotency tag, a github_author:<login> tag, and an enriched description (author @<login> · state reason <reason> · created <iso> · updated <iso>). GitHub issues closed as not_planned import as pm canceled instead of closed, preserving the difference between completed work and deliberately dropped work. The integration declares the github_url, github_number, github_state, github_author, github_created_at, and github_updated_at schema fields.
--issue deduplicates references and fetches each selected issue directly, including closed issues, without listing the repository. Every selected number is fetched and checked before any tracker write. A missing number fails the whole import; --dry-run instead reports missingIssues and previews the available selection. Pull request numbers require --include-prs. Selection combines with comment modes, dependency linking, atomic imports, gates, dry runs, and custom item types, preserving provenance-based re-import idempotency.
--limit bounds the matching list after PR, draft, and milestone filters. Pagination stops once one extra matching issue establishes truncation, so at most n + 1 eligible issues are retained; GitHub pages may also contain excluded entries. Without a limit, existing list ordering is unchanged.
--atomic derives its durable transaction identity from the repository, complete rendered issue state, and exact ordered mutation plan (including target item ids), and derives each create id from the stable owner/repo#number external key rather than fetch position. Retrying the same response in a different order therefore resumes without duplicates; changed content, workspace prefixes, or resolved targets create a fresh compatible transaction. Native comment annotations run only after the item transaction commits and retain their cross-process deduplication lock.
By default (--comments-mode body, or --with-comments), fetched GitHub issue comments are flattened into the item body as blockquoted markdown under a ### GitHub comments (N) heading — the historical behavior, byte-identical across releases.
--comments-mode annotations instead syncs each GitHub comment into the pm item's native comments collection via the public SDK comments() primitive, so agents get structured, queryable comments (pm comments <id>) instead of body-embedded text. Each stored comment carries a hidden marker with the GitHub comment id (<!-- pm-github:comment:N -->), so re-running import is idempotent — already-synced comments are skipped and never duplicated. --comments-mode both writes the body section and the native comments.
When --with-comments is combined with --comments-mode annotations, the two are reconciled to both (the legacy flag asks for body embedding, the mode asks for native comments → both), so neither is silently dropped.
pm github import owner/repo --comments-mode annotations # native comments only
pm github import owner/repo --comments-mode both # body section + native comments
pm github import owner/repo --with-comments # legacy body embedding (default shape)
pm github import owner/repo --with-comments --comments-mode annotations # same as --comments-mode bothGitHub issue authors declare cross-issue dependencies in prose — Blocked by #12, Depends on owner/repo#5, Blocks #9. A flat import throws that structure away, leaving pm items with no blocker graph. --link-deps runs an opt-in second pass that parses those references from each issue's body and materializes them as pm dependency edges between the corresponding pm items, resolved through the same gh:owner/repo#N provenance tags the import writes.
The effect is context you can act on: pm next and pm deps surface the real ready/blocked ordering instead of an undifferentiated list.
pm github import owner/repo --link-deps
pm github import owner/repo --atomic --link-deps
pm github import owner/repo --link-deps --dry-run # reports candidate reference count onlyReference grammar (case-insensitive; -/: glue tolerated; multiple refs like #1, #2 and #3):
| Phrase | pm edge on the source item |
|---|---|
Blocked by #N |
blocked_by → the referenced item |
Depends on #N |
blocked_by → the referenced item |
Blocks #N |
blocks → the referenced item |
Both bare #N (resolved against the imported repo) and explicit owner/repo#N cross-repo references are supported, the latter only when that issue is also present in the workspace.
Guarantees
- Idempotent — edges dedupe by id + kind, so re-running import never duplicates them.
- Path-agnostic — runs identically after the normal and
--atomicimport. - Safe — references inside fenced/inline code spans are ignored; self-references and references to issues not in the workspace are skipped (the latter counted as
unresolvedDependencyRefs); a failed individual edge never fails the import (it is reported independencyLinkFailuresand the pass is re-runnable). - Cycle-aware, not cycle-blocking — ordering cycles the mapped edges introduce are reported in
orderingCycleWarnings(computed via the pm SDKcollectNewOrderingCycleWarningsadvisory over the workspace before/after the pass) rather than rejected, mirroring the SDK's warn-don't-reject contract for legacy graph debt.
The import result gains linkedDependencies, unresolvedDependencyRefs, orderingCycleWarnings (and dependencyLinkFailures when non-empty); a --dry-run --link-deps result reports wouldLinkDependencyCandidates.
Safe by default. Export previews the create/update plan and writes nothing unless you explicitly opt in with --apply and name a --repo. With a --repo, items already linked to an issue in that repo (via the gh:owner/repo#N provenance tag) are updated (upsert) instead of duplicated.
pm github export --repo owner/repo # DRY-RUN: print the create/update plan, write nothing
pm github export --repo owner/repo --format md
pm github export --repo owner/repo --ids pm-12,pm-34
pm --json github export --repo owner/repo # return the plan as JSON
pm github export --repo owner/repo --apply # actually create/update issues (requires a token)
pm github export --repo owner/repo --ids pm-12,pm-34 --apply| Flag | Type | Description |
|---|---|---|
--repo <owner/repo> |
string | Target repo; decides create-vs-update and is required for --apply |
--ids <pm-1,pm-2> |
string | Scope export to specific pm item IDs (comma-separated); unknown IDs fail fast |
--format <json|md> |
string | Dry-run output format (default: json) |
--apply |
boolean | Perform real GitHub writes (alias: --no-dry-run, legacy --push). Requires a token + --repo |
--dry-run |
boolean | Force preview even alongside --apply (dry-run always wins) |
Push pm status changes back to GitHub: close/reopen the linked issue to match the pm item's status. Requires a token and explicit --repo.
pm github sync --repo owner/repo --dry-run # preview the close/reopen plan
pm github sync --repo owner/repo --ids pm-12,pm-34 --dry-run
pm github sync --repo owner/repo # push the changes--ids scopes sync to specific pm item IDs (comma-separated). Unknown IDs fail fast so agent runs do not silently skip typoed targets.
GitHub Projects v2 boards are a GraphQL-only surface, distinct from Issues. These commands keep a pm workspace and a Projects v2 board in lockstep — project management = context management — without ever losing data: nothing is deleted or archived on either side, every action is idempotent via a gh-project:owner/number#itemId provenance tag, and a pm status (or board Status) that has no clear counterpart is skipped, never guessed.
Needs a token with project/read:project scope (GITHUB_TOKEN/GH_TOKEN or gh auth login).
Discover the Projects v2 owned by a user or org (read-only).
pm github project list unbraind
pm github project list unbraind --jsonIntrospect a board's fields and — crucially — its Status single-select options, so you can design a --status-map (read-only).
pm github project fields unbraind/5Import every board item (draft issues included) as pm items. Idempotent: an item already linked (by project tag, or by the gh:repo#N issue it wraps) is updated, not duplicated. The board's Status option maps to the pm status.
pm github project import unbraind/5 --dry-run
pm github project import unbraind/5
pm github project import unbraind/5 --status-map in_progress=Doing,closed=ShippedBidirectionally sync pm items and a board. Safe by default: with no --apply it previews both directions and writes nothing.
--push(pm → board): adds missing pm items to the board — attaching the existing GitHub issue when the pm item is issue-linked, otherwise creating a draft issue — and sets each item's Status from its pm status.--pull(board → pm): updates each linked pm item's status from the board's Status column (status only — never touches title/body/tags).--applywrites; with neither direction flag it defaults to--push(so pm is never mutated silently).--prefer pm|githubresolves conflicts when applying both directions (defaultpm).
pm github project sync unbraind/5 # preview both directions
pm github project sync unbraind/5 --push --apply # pm → board
pm github project sync unbraind/5 --pull --apply # board → pm
pm github project sync unbraind/5 --push --pull --apply --prefer pm
pm github project sync unbraind/5 --push --apply --ids pm-1,pm-2 # scope by id
pm github project sync unbraind/5 --push --apply --no-add-missing # only reconcile linked itemsThe GitHub Projects v2 item node id is case-sensitive but pm normalizes tag values to lowercase, so the node id is hex-encoded inside the provenance tag to round-trip losslessly. This is what makes re-sync idempotent instead of silently double-adding.
Registers a github search provider so pm search ... --semantic can reach GitHub. It asks GitHub which issues in the target repo match your query, then returns hits for the pm items you've already imported from those issues (matched by the gh:owner/repo#N provenance tag).
pm config project set ... # set search.provider = "github" in .agents/pm/settings.json
export PM_GITHUB_REPO=owner/repo # or pass the repo another way
pm search "uppercase dashes" --semantic # hits = imported items whose upstream issue matchesEnable it by setting search.provider to "github" in .agents/pm/settings.json and pointing it at a repo via the PM_GITHUB_REPO env var.
GitHub issue text is untrusted: anyone who can open an issue can put a credential, a personal email, or a host path in it. An automated import writes that text into the pm tracker, and a sync pipeline then pushes the tracker to a public branch — publishing the leak with it. pm github gate is the fail-closed step between the write and any push:
pm github gate # scan the proposed tracker change in the working tree
pm github gate --json # machine-readable report (also via the global --json)
pm github gate --diff sync.patch # scan an explicit unified diff file instead
pm github gate --allowlist .pm-github-gate-allowlist.jsonIt scans only the proposed change — the added lines and their filenames in the staged/working/untracked diff under the resolved pm tracker path (never a hardcoded .agents/pm; the SDK-resolved path wins), or an explicit --diff file — so pre-existing reviewed content is not re-litigated on every run, and removed content (which cannot publish anything new) is never scanned. A finding in a filename uses field: "file_path" and redacts a sensitive item id. It fails closed (non-zero, machine-readable report with item_id + field + rule + the sha256 hash of the matched content, never the matched content itself) on:
- credentials — GitHub (
ghp_/gho_/ghu_/ghs_/ghr_and fine-grainedgithub_pat_), npm, AWS access key ids, Slack (including app and cookie credentials), OpenAI, Anthropic tokens, genericAuthorization: Bearervalues,-----BEGIN … PRIVATE KEY-----blocks, and high-entropy assignments to secret-named identifiers (token = "…",api_key: …,accessToken: …) whose value has high measured entropy. UUID-shaped credential values and values containing dates receive the same checks; - personal data — email addresses other than no-reply identities (
@users.noreply.github.com,noreply@…,@noreply.…), phone numbers in international (+…) or North-American ((555) 123-4567,555-123-4567) notation, and unformatted numbers under phone/tel/mobile contact labels; - host paths — absolute local filesystem paths (POSIX home/system directories and Windows drive paths with either separator), and named home-directory references.
Unreadable input, malformed or truncated diffs, binary input, invalid UTF-8, Git failures, malformed allowlists, and scanner errors fail the gate. Untracked operational state in the tracker root (locks/, extensions/, checkpoints/) is excluded. Explicitly staged operational files remain in scope. Staged and unstaged changes are scanned separately, including staged content that the working copy subsequently removed.
False positives are allowlisted by content hash, not by pattern: put the sha256 from the finding into .pm-github-gate-allowlist.json at the repository root (or pass --allowlist <file>) with a written justification. An entry suppresses exactly the reviewed content, can never widen to a pattern, and a missing justification fails the gate:
{
"<sha256 of the reviewed finding content>": { "reason": "Reviewed: public support address in the import docs item." }
}--gate composes the gate into the import pipeline, in the order a sync workflow needs:
- dry-run plan — every fetched issue is prepared up front;
- completeness check — every fetched issue is accounted for exactly once (planned or explicitly skipped), nothing outside the fetch appears, counts reconcile — all before any mutation;
- idempotency check — no duplicate
gh:owner/repo#Nprovenance tags in the existing corpus, every new entry born with its tag, no two plan entries resolving to the same item — so a second run reuses the existing item; - write — the ordinary (atomic or per-item) import;
- gate — the proposed tracker change is scanned; a finding exits non-zero, so the caller never commits or pushes.
Rendered values are also scanned in memory before mutation, so a known finding cannot be written to disk. After mutation, the importer verifies that every planned provenance tag exists exactly once, then scans the tracker diff. A completed SDK journal cannot substitute for missing item files.
The gate receipt reports the verdict, scanned file count, findings, and allowlisted count. With --json, successful reports go to stdout; failures use the host CLI's JSON error envelope on stderr, with the redacted gate report serialized in detail (jq ' .detail | fromjson ' gate-error.json). Unreadable inputs produce a nonzero JSON refusal.
Fleet repos sync their issues into pm through ONE audited implementation: the reusable workflow_call workflow at .github/workflows/pm-github-sync.yml in this repository. It runs the gated pipeline in fail-closed order — checkout → install the pinned extension → pm github validate → dry-run plan → gated import → verify the repeat import is a no-op → pm health --strict-exit → and only then commit, push the sync branch, and open/update a review PR whose body links every changed item as https://github.com/<repo>/blob/main/.agents/pm/<folder>/<id>.toon. Because --gate exits non-zero on any finding, the job can never reach the push step with a leak in the change. Actions are pinned by commit SHA; permissions are least-privilege (contents: write, pull-requests: write, issues: read).
A fleet repo owns the schedule and calls it with the pinned versions (see docs/sync-workflow-caller.yml):
name: Sync GitHub issues into pm
on:
schedule:
- cron: "17 2 * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
issues: read
jobs:
gated-sync:
uses: unbraind/pm-github/.github/workflows/pm-github-sync.yml@RELEASE_COMMIT_SHA
with:
pm-github-version: "RELEASE_VERSION" # required: exact published versionReplace the two release placeholders after the gated extension is published; the current published version does not contain this feature. The calling repo needs @unbrained/pm-cli as a devDependency (the workflow's npm ci provides the CLI) and its pm tracker at .agents/pm committed on main. Under Settings → Actions → General → Workflow permissions it must also allow read and write token permissions and enable Allow GitHub Actions to create and approve pull requests (an organization can disable this), or the run pushes the sync branch and then fails to open the review PR. A PR that GITHUB_TOKEN opens or edits does not trigger pull_request workflows, so the sync PR gets no CI checks until a maintainer re-runs them (for example by closing and reopening the PR); the reusable workflow always uses the job's GITHUB_TOKEN.
node test/helpers/public-acceptance.ts packs the candidate, installs it beside
its pinned host CLI in a disposable Git project, retrieves a paginated public
issue/comment snapshot, and replays the unchanged responses through the installed
HTTP client. It verifies a nonempty gated Node import, byte-identical Node and
three native Bun repeats, and strict health. No remote is configured.
Each import retains the original 45-second execution deadline. The acceptance watchdog sends SIGTERM to the process group, then SIGKILL after a five-second cleanup window; any timeout fails acceptance even if the child later exits zero. A real spinning-child negative control verifies forced cleanup and unchanged fixture bytes. The HTTP client also enforces a 30-second wall-clock deadline through redirect and response-body completion, even when native socket timeouts do not fire. The comment-lock retry honors its existing wait budget when an exclusive-create collision cannot be statted, including a dangling symlink. This reproduced lock defect is separate from the intermittent historical Bun repeat: recent packed repeats pass, but its original cause is not established.
Read-only check of the integration: gh CLI presence, token resolvability (and source), and—with --repo—repo accessibility. When --repo is given it also surfaces the remaining GitHub API quota (X-RateLimit-Remaining/Limit/Reset) and warns when the quota is running low. Never mutates anything.
pm github validate
pm github validate --repo owner/repo
pm --json github validate --repo owner/repoMIT
Release checks require type checking, docstrings, ESLint, zero source duplication, exact 100/100/100/100 coverage across authored TypeScript and JavaScript modules including operational scripts, production dependency audit, package packing, Bun behavior, strict PM health, and pm-changelog validation. Unloaded modules count at zero; no source ignores or lowered thresholds are accepted. Coverage shortfalls block release. The shell changelog-date verifier is exercised separately and is outside the V8 percentage denominator. The daily release workflow publishes only when commits exist after the latest release tag and uses pm-changelog to generate CHANGELOG.md and GitHub release notes.
npm run changelog:full reads the complete tracker through the SDK and feeds pm-changelog. Closed work supplies release history; the explicit changelog-unreleased tag includes a pending candidate without closing its PM item. Other open work stays excluded.
This repo tracks its project management in .agents/pm/ and ships a committed .gitattributes
that maps those tracker artifacts to pm-cli's field-aware Git merge drivers, so concurrent-branch
tracker edits merge cleanly. The driver definitions live in per-clone Git config; npm install /
npm ci wires them automatically via the prepare script, scripts/prepare-merge-driver.ts: the launcher template pm-ops ships, copied unchanged, which a test compares byte for byte with the pinned template. It runs pm-ops's installer, which calls pm merge install when the pm CLI is on PATH and skips with a notice when it is not. A production install of a clone (npm ci --omit=dev) has no pm-ops, so the launcher skips with one notice, while a stale or broken pm-ops fails the install. Registry installs of this package never run prepare. Being Node-based, it behaves identically on POSIX shells and Windows cmd.exe. To (re)run
manually: npm run merge:install.
After merging a branch that touched .agents/pm/, reconcile any residual history-hash drift with
pm merge reconcile (pm-cli ≥ 2026.7.22): preview with pm merge reconcile --dry-run, apply with
pm merge reconcile --message "post-merge reconcile", then confirm with pm validate, which scans the
whole tracker and flags remaining history drift across every affected item (pm merge reconcile
itself lists each affected stream in its output; pm history --verify <id> spot-checks one item). The field-aware driver already unions every author's
content, so reconcile only re-greens the hash chain (no data loss) — see the authoritative
pm-cli merge-safety guide. The
older blunt pm history-repair --all remains available as a lower-level primitive.