Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
adc928b
Reuse existing items that cite an imported GitHub issue
unbraind Oct 23, 2026
32f856c
Verify imports refuse ambiguous batches before any writes
unbraind Oct 23, 2026
bbe5718
Recognize issue citations beside prose labels and question marks
unbraind Oct 23, 2026
4220a37
Keep ownership parity checks scoped to citation opt-out
unbraind Oct 23, 2026
196ae8a
Merge remote-tracking branch 'origin/main' into feat/cited-issue-adop…
unbraind Oct 23, 2026
cce11c4
Adopt only single-issue items with explicit fix or tracking intent
unbraind Oct 23, 2026
92f595e
Merge remote-tracking branch 'origin/main' into feat/cited-issue-adop…
unbraind Oct 23, 2026
ef325ba
Count all GitHub reference forms before adopting issue items
unbraind Oct 23, 2026
56580ec
Trim citation URL punctuation in one backward pass
unbraind Oct 23, 2026
aa86b21
Refuse adoption when raw text mentions another issue number
unbraind Oct 23, 2026
0b4bc29
Refuse cited issue adoption across repository boundaries
unbraind Oct 23, 2026
6adb234
Require explicit opt-in for normalized cited issue adoption
unbraind Oct 23, 2026
51dea17
Bound Markdown definition normalization to its destination
unbraind Oct 23, 2026
894dbdb
Avoid redundant scans during citation normalization
unbraind Oct 23, 2026
ec85e33
Trim issue-label URL tails without an overlapping regex
unbraind Oct 23, 2026
748389f
Never adopt cited issues into items that already own another identity
unbraind Oct 23, 2026
06365ae
Count any core external_ref as structured ownership for citation adop…
unbraind Oct 23, 2026
72df4f9
Mirror every identity.ts ownership source before citation adoption
unbraind Oct 23, 2026
b4a7d0d
Share one owner-link field pattern between the owner matcher and the …
unbraind Oct 23, 2026
87d9dc7
Treat GitHub identity tags in any case as ownership before citation a…
unbraind Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .agents/pm/features/pm-github-cited-issue-adoption.toon

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions .agents/pm/history/pm-github-cited-issue-adoption.jsonl

Large diffs are not rendered by default.

24 changes: 21 additions & 3 deletions .github/workflows/pm-github-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ on:
type: string
required: false
default: ${{ github.repository }}
adopt-cited:
description: "Opt in to conservative citation adoption; tag new items gh:owner/repo#N instead"
type: boolean
required: false
default: false
pm-github-version:
description: "Deprecated: fallback only for a non-release commit; pin a release SHA instead"
type: string
Expand Down Expand Up @@ -82,6 +87,7 @@ jobs:
runs-on: ubuntu-latest
env:
REPOSITORY: ${{ inputs.repository }}
ADOPT_CITED: ${{ inputs.adopt-cited }}
SYNC_BRANCH: ${{ inputs.sync-branch }}
REQUESTED_PM_GITHUB_VERSION: ${{ inputs.pm-github-version }}
NPM_PROJECT_DIR: ${{ inputs.npm-project-dir }}
Expand Down Expand Up @@ -334,14 +340,24 @@ jobs:
- name: Preview GitHub to pm plan
env:
GH_TOKEN: ${{ github.token }}
run: pm github import "${REPOSITORY}" --state all --atomic --gate --dry-run --with-comments
shell: bash
run: |
set -euo pipefail
adopt_args=()
if [[ "${ADOPT_CITED:-false}" == true ]]; then adopt_args+=(--adopt-cited); fi
pm github import "${REPOSITORY}" --state all --atomic --gate --dry-run --with-comments "${adopt_args[@]}"

# The gate step. A finding (or unreadable scan input) fails this step, so
# the job never reaches the commit/push below with a leak in the change.
- name: Gated GitHub to pm import
env:
GH_TOKEN: ${{ github.token }}
run: pm github import "${REPOSITORY}" --state all --atomic --gate --with-comments
shell: bash
run: |
set -euo pipefail
adopt_args=()
if [[ "${ADOPT_CITED:-false}" == true ]]; then adopt_args+=(--adopt-cited); fi
pm github import "${REPOSITORY}" --state all --atomic --gate --with-comments "${adopt_args[@]}"

- name: Verify repeat import is a no-op
env:
Expand All @@ -355,7 +371,9 @@ jobs:
if [[ -s "${paths_file}" ]]; then
git --literal-pathspecs add --pathspec-from-file="${paths_file}" --pathspec-file-nul
fi
pm github import "${REPOSITORY}" --state all --atomic --gate --with-comments
adopt_args=()
if [[ "${ADOPT_CITED:-false}" == true ]]; then adopt_args+=(--adopt-cited); fi
pm github import "${REPOSITORY}" --state all --atomic --gate --with-comments "${adopt_args[@]}"
git diff --exit-code -- "${sync_scope[0]}" "${sync_scope[@]:2}"
[[ -z "$(git ls-files --others --exclude-standard -- "${sync_scope[0]}" "${sync_scope[@]:2}")" ]]
pm github gate --json
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Require opt-in before adopting cited GitHub issue items ([pm-github-cited-issue-adoption](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-cited-issue-adoption.toon))
- Explore provider identity collisions and approve scoped provenance repairs ([pm-github-identity-collisions](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-identity-collisions.toon))
- Reconcile authenticated GitHub webhooks through a durable deduplicating inbox ([pm-github-webhook-inbox](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-webhook-inbox.toon))
- Let reusable workflows carry their own release version ([pm-github-znig](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-znig.toon))
Expand Down
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,13 +361,82 @@ pm github import owner/repo --dry-run
| `--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. |
| `--adopt-cited` | boolean | Explicitly opt in to conservative citation adoption (default: off). Tag new items `gh:owner/repo#N` instead. Default imports only report citation candidates. |
| `--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](#dependency-linking---link-deps) 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`](#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.

The primary way to prevent duplicate imports is to tag an item
`gh:owner/repo#N` **when it is created**, for example
`pm create issue "Fix widget behavior" --tags "gh:acme/widgets#30"`.
For an existing item, use `pm update <id> --add-tags "gh:acme/widgets#30"`. A prose URL is context, not an
ownership declaration. Existing tag/structured URL/provider matching takes
precedence and is unchanged.

**Citation adoption is off by default.** Manual imports, the legacy alias and
scheduled gated syncs retain normal create behavior when explicit ownership is
absent. They report read-only `citation_candidates` so an agent can tag the
correct item, including when several candidates exist. No candidate is adopted
or ambiguity-blocked by default.

Use `--adopt-cited` on the CLI, `adoptCited: true` in SDK import options, or
`adopt-cited: true` on the reusable sync workflow to enable the fallback. The
fallback deliberately favors refusal over overwriting an unrelated item:

1. **Normalize before checking.** Decode complete named/numeric HTML entities and
percent-encoded bytes repeatedly until stable, bounded to eight passes.
Apply NFKC and remove zero-width/bidi control characters. Unwrap Markdown
link destinations and reference definitions and join soft line breaks for
intent matching. Retain normalized source as well as the unwrapped view for
exclusion, so a link label cannot hide a second reference. Joined-line views
also expose split references. A normalization bound exhaustion is a refusal.
2. **Allowlisted ownership.** A qualified target issue reference must appear in
the title, directly follow `Fix`, `Fixes`, `Fixed`, `Close`, `Closes`, `Closed`,
`Resolve`, `Resolves` or `Resolved` at the start of the description, or be a
URL on a whole body line `Issue: <url>` or `Tracks: <url>`. Matching ignores
case. Body fix phrases, `Related:` mentions, unanchored phrases and bare
numbers cannot authorize adoption.
3. **Exclude every other reference.** Across all normalized fields, any other
`github.com` URL, foreign repository, different `#<digits>` or
`issues`/`pull` token followed by another number vetoes adoption. Unattributed
paths/bare hashes also refuse adoption: this item projection has no verified
owning-repository context. PR URLs, including a same-number PR URL, are other
URLs. The scan over-counts code and incidental text deliberately. These
checks reduce risk; they do not make arbitrary prose an ownership contract.

| Existing prose | Default | With `--adopt-cited` |
| --- | --- | --- |
| Description: `Fixes acme/widgets#30` | Create + `adoption_disabled` hint | Eligible if no other reference |
| Body line: `Issue: https://github.com/acme/widgets/issues/30` | Create + hint | Eligible if no other reference |
| Title: `Handle acme/widgets#30` | Create + hint | Eligible if no other reference |
| Body: `Fixes acme/widgets#30` | Create + `missing_intent` hint | Create + hint |
| Body: `Related: acme/widgets#30` | Create + `missing_intent` hint | Create + hint |
| Title cites issues 30 and 31 | Create + `multiple_issues` hint | Create + hint |
| Description fixes issue 30; body has `**https://github.com/other/widgets/issues/30**` | Create + hint | Create + `multiple_issues` hint |
| Description fixes issue 30; body has `[other](https://github.com/other/widgets/issues&sol;31)` or `issues%2F31` | Create + hint | Create + `multiple_issues` hint |

Plans include `citation_candidates: [{id, reason}]`; aggregate dry-run and write
receipts include `citation_candidates: [{issue, id, reason}]`. Reasons are
`adoption_disabled`, `missing_intent`, `multiple_issues`, `normalization_limit` or `structured_owner` (the item already carries a `github_*` field, a core `external_ref` or `source_owner`, a `linear_*`/`jira_*`/`ado_*` identity field, provider provenance, a GitHub issue URL in its links or a native declaration, so prose citing another issue never re-owns it).
Candidate reporting never changes the referenced item's status, content or
fields, including a closed item's completion state.

With explicit opt-in, one eligible untagged candidate produces an `adopt` plan:
its ID receives the provenance tag and `github_*` fields through normal upsert.
Closed/canceled owners remain eligible. Two or more eligible owners refuse the
batch with typed `ambiguous_citation`, IDs and a remedy to tag the correct item.
The default path never applies that fallback ambiguity refusal. Existing
structured-identity collision checks still apply in both modes.

Dry runs show `adopt` and `wouldAdopt`; write receipts report `adopted` separately
from `imported`/`updated`. Gated idempotency counts an opt-in adopt as one landing
item. Status push, export and Projects matching retain their scope. Citation
reporting and opt-in adoption work in non-git trackers; actual `--gate` writes
still require a Git worktree for the proposed-change privacy scan.

`--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.
Expand Down Expand Up @@ -781,6 +850,7 @@ Optional inputs:

| Input | Default | Behavior |
| --- | --- | --- |
| `adopt-cited` | `false` | Explicitly enable conservative normalized citation adoption for preview, import and repeat verification. Primary prevention is creating items with `gh:owner/repo#N` tags. Leave false to retain normal creates with read-only candidate hints. |
| `npm-project-dir` | `.` | Relative npm project directory for `npm ci --ignore-scripts`, the CLI `node_modules/.bin` PATH entry, and `post-import-script`. Must have `package.json`, contain no `..` segments, and resolve inside the workspace (including symlinks); invalid paths fail before caching/install. The tracker and publication scope stay at the repository root. |
| `post-import-script` | `changelog:full` | Caller npm script name invoked with `npm run --if-present` after import and before the final scan. Override for a different package script, or set `""` to disable. Missing scripts are skipped. Raw shell commands are not accepted. Generated `CHANGELOG.md` is staged with the tracker. Changing any file outside the documented scope fails the run, whether staged, unstaged, or untracked. |
| `automation-app-client-id` | `""` | Optional GitHub App client ID, paired with the `automation_app_private_key` workflow-call secret. Enables eligible native PR runs through a fresh repo-scoped token; absent credentials retain GITHUB_TOKEN with a notice. |
Expand All @@ -806,6 +876,7 @@ jobs:
uses: unbraind/pm-github/.github/workflows/pm-github-sync.yml@RELEASE_COMMIT_SHA # vRELEASE_VERSION
with:
automation-app-client-id: ${{ vars.PM_GITHUB_APP_CLIENT_ID }}
adopt-cited: false # Tag new items gh:owner/repo#N; opt in only for deliberate adoption.
secrets:
automation_app_private_key: ${{ secrets.PM_GITHUB_APP_PRIVATE_KEY }}
```
Expand Down
Loading
Loading