Skip to content

Stabilize projection ref-cache key so sync never duplicates an issue#35

Merged
androidand merged 2 commits into
mainfrom
fix/stable-projection-ref-key
Jul 13, 2026
Merged

Stabilize projection ref-cache key so sync never duplicates an issue#35
androidand merged 2 commits into
mainfrom
fix/stable-projection-ref-key

Conversation

@androidand

Copy link
Copy Markdown
Owner

Root cause

Two-way sync created a duplicate GitHub issue instead of updating the existing one. Reproduced against ExopenGitHub/ExoKit: a change pulled from issue #15 was re-synced and specsync created issue #16 rather than editing #15.

Two independent gaps lined up:

  1. Unstable ref-cache key. GitHubProvider.Name() returned bare "github" when the repo was auto-detected, but "github:owner/repo" when a repo was set (e.g. by pull, which constructs a repo-scoped provider). So pull cached the ref under "github:owner/repo", while a later specsync -slug <change> without -repo looked up refs["github"], missed it (hadRef=false), and called Push(existing=nil).
  2. Un-rediscoverable pull-linked issue. Push's duplicate defense falls back to Find, which matches only by the body marker <!-- specsync:change=<slug> -->. pull never wrote that marker back into the source issue, so Find returned nil and Push created a new issue.

Fix

  • Repo-stable key. Name() resolves the concrete target repo once — from -repo, or gh's own auto-detect via gh repo view --json nameWithOwner (through the injected run func) — and keys canonically as "github:owner/repo" regardless of how the repo was supplied. Memoized; repoFlag() wire behavior is unchanged (auto-detect still allowed on the wire).
  • Backward-compatible, self-healing lookup. syncOne resolves the canonical key, falls back to the legacy bare "github" key, and re-saves under the canonical key on the next persist. Existing refs.json caches keep working with no manual edit.
  • Pull persists the marker. pull upserts the identity marker into the source issue body (idempotent — no write if already present; honors -dry-run as a preview). The link is now durable: even if the local cache is lost, a later sync rediscovers the issue via Find and updates it.

Invariant restored: a change pulled from an issue and later pushed (any -repo/auto-detect combination) updates that same issue and never creates a duplicate.

Tests

New stable_projection_test.go covers: same-key equivalence (auto-detect vs -repo); legacy-key cache updates + migrates canonically; post-pull cache-loss rediscovery via marker (no duplicate); pull dry-run previews the marker with no GitHub write. go build ./..., go test ./... (60 pass) and gofmt on all touched files are clean.

OpenSpec

Implements openspec/changes/stable-projection-ref-key (proposal, tasks, stable-projection-identity spec), included in this PR.

🤖 Generated with Claude Code

androidand and others added 2 commits July 1, 2026 10:33
…, references)

Three researched changes for syncing local relations with GitHub issue
relations, grounded in research of OpenSpec 1.5.0 and GitHub's GA
relationship APIs:

- epic-and-subissue-projection (retargeted): project a typed `## Parent`
  edge onto GitHub native sub-issues (addSubIssue, cross-repo via
  subIssueUrl); epic = type:epic, no spec; roll up from subIssuesSummary.
- issue-dependency-sync (new): directed `## Blocked by`/`## Blocks` edges
  ↔ GitHub issue dependencies (addBlockedBy/removeBlockedBy).
- openspec-references-coordination (new): read OpenSpec references/workset
  (no new registry) to surface sibling repos + local folders for the
  two-worktree workflow.

Reconcile is genuinely two-way via a gitignored .specsync/ baseline (merge
base): edges added on either side propagate to the other, removals on
either side propagate too. Binary edges → no ambiguous conflicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two-way sync created a duplicate GitHub issue (ExoKit #15 -> #16) instead
of updating the existing one. Two gaps lined up:

- The ref-cache key was unstable: for the same repo, an auto-detected
  provider keyed "github" while an explicit/pull-constructed one keyed
  "github:owner/repo", so a ref saved by pull was invisible to a later
  auto-detect sync (hadRef=false -> Push with existing=nil).
- A pull-linked issue was un-rediscoverable: pull never wrote the identity
  marker into the source issue, so once the cache key was missed, Find
  could not recover the link and the create path fired.

Fix:
- GitHubProvider.Name() resolves the concrete target repo once (from -repo
  or gh's own auto-detect via `gh repo view`) and keys canonically as
  "github:owner/repo" regardless of how the repo was supplied. Memoized;
  repoFlag() wire behavior unchanged.
- syncOne looks up the canonical key, falls back to the legacy bare
  "github" key, and re-saves under the canonical key on the next persist
  (self-healing; existing refs.json caches keep working).
- pull upserts the identity marker into the source issue body (idempotent,
  honors -dry-run) so the link survives cache loss and Find can rediscover
  it.

Implements openspec/changes/stable-projection-ref-key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@androidand androidand merged commit a29de44 into main Jul 13, 2026
1 check passed
@androidand androidand deleted the fix/stable-projection-ref-key branch July 13, 2026 12:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant