diff --git a/archived_slug_test.go b/archived_slug_test.go new file mode 100644 index 0000000..64f57c6 --- /dev/null +++ b/archived_slug_test.go @@ -0,0 +1,222 @@ +package specsync + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +// TestLoadChangeBySlugFindsDatedArchiveFolder: `openspec archive` renames a +// change's folder with a date prefix; LoadChangeBySlug must still find it by +// the original (pre-archive) slug. +func TestLoadChangeBySlugFindsDatedArchiveFolder(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "changes", "archive", "2026-08-10-fix-thing") + mustWrite(t, filepath.Join(dir, "proposal.md"), "# Fix thing\n") + + c, err := LoadChangeBySlug(root, "fix-thing") + if err != nil { + t.Fatalf("LoadChangeBySlug: %v", err) + } + if c.Slug != "fix-thing" { + t.Errorf("Slug = %q, want fix-thing", c.Slug) + } + if !c.Archived { + t.Errorf("expected Archived = true") + } + if c.Dir != dir { + t.Errorf("Dir = %q, want %q (real, date-prefixed path)", c.Dir, dir) + } +} + +// TestArchivedChangeSlugStripsDatePrefix: an archived change's Slug is the +// original slug, not the date-prefixed folder name — LoadChanges must +// enumerate it under the identity it had while active. +func TestArchivedChangeSlugStripsDatePrefix(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "changes", "archive", "2026-08-10-fix-thing") + mustWrite(t, filepath.Join(dir, "proposal.md"), "# Fix thing\n") + + changes, err := LoadChanges(root) + if err != nil { + t.Fatalf("LoadChanges: %v", err) + } + if len(changes) != 1 { + t.Fatalf("want 1 change, got %d", len(changes)) + } + if changes[0].Slug != "fix-thing" { + t.Errorf("Slug = %q, want fix-thing (date prefix stripped)", changes[0].Slug) + } +} + +// TestArchivedChangeSlugNoPrefixUnaffected: a change archived without a date +// prefix (this repo's older, pre-openspec-archive convention) keeps its slug +// unchanged. +func TestArchivedChangeSlugNoPrefixUnaffected(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "changes", "archive", "fix-thing") + mustWrite(t, filepath.Join(dir, "proposal.md"), "# Fix thing\n") + + c, err := LoadChange(dir, true, root) + if err != nil { + t.Fatalf("LoadChange: %v", err) + } + if c.Slug != "fix-thing" { + t.Errorf("Slug = %q, want fix-thing", c.Slug) + } +} + +// TestLoadChangeBySlugAmbiguousArchiveMatch: two archived folders both +// ending in "-" must error naming the candidates, not silently pick +// one. +func TestLoadChangeBySlugAmbiguousArchiveMatch(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "changes", "archive", "2026-08-10-fix-thing", "proposal.md"), "# A\n") + mustWrite(t, filepath.Join(root, "changes", "archive", "2026-09-01-fix-thing", "proposal.md"), "# B\n") + + _, err := LoadChangeBySlug(root, "fix-thing") + if err == nil { + t.Fatal("expected an ambiguous-match error") + } + if !strings.Contains(err.Error(), "ambiguous") { + t.Errorf("error = %v, want it to mention ambiguity", err) + } + if !strings.Contains(err.Error(), "2026-08-10-fix-thing") || !strings.Contains(err.Error(), "2026-09-01-fix-thing") { + t.Errorf("error should name both candidates: %v", err) + } +} + +// TestLoadChangeBySlugNotFound: a slug matching nothing anywhere still +// reports the same "no change found" error as before. +func TestLoadChangeBySlugNotFound(t *testing.T) { + root := t.TempDir() + _, err := LoadChangeBySlug(root, "does-not-exist") + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "no change found") { + t.Errorf("error = %v, want \"no change found\"", err) + } +} + +// TestLinksMDResolvesAfterSiblingArchived: a links.md slug entry naming a +// sibling change must still resolve once that sibling is archived under a +// date-prefixed folder. +func TestLinksMDResolvesAfterSiblingArchived(t *testing.T) { + root := t.TempDir() + siblingDir := filepath.Join(root, "changes", "archive", "2026-08-10-sibling-change") + mustWrite(t, filepath.Join(siblingDir, "proposal.md"), "# Sibling\n") + mustWrite(t, filepath.Join(siblingDir, ".specsync", "refs.json"), + `{"github:o/r":{"provider":"github:o/r","id":"5","url":"https://github.com/o/r/issues/5"}}`) + + changeDir := filepath.Join(root, "changes", "this-change") + mustWrite(t, filepath.Join(changeDir, "proposal.md"), "# This change\n") + mustWrite(t, filepath.Join(changeDir, "links.md"), "## Related\n\n- sibling-change\n") + + links, _, _ := parseLinksMD(changeDir, root) + if len(links) != 1 { + t.Fatalf("want 1 resolved link, got %d: %v", len(links), links) + } + if links[0].ID != "5" { + t.Errorf("link ID = %q, want 5", links[0].ID) + } +} + +// TestFindResolvesArchivedChangeByPreArchiveMarker is the regression test for +// the sync-design-notes incident (#139/#141): with no local ref cache, +// Find must still resolve an archived change's issue by the marker written +// under its pre-archive slug, not the date-prefixed folder name. +func TestFindResolvesArchivedChangeByPreArchiveMarker(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "changes", "archive", "2026-08-27-sync-design-notes") + mustWrite(t, filepath.Join(dir, "proposal.md"), "# Sync design.md into the issue body\n") + + changes, err := LoadChanges(root) + if err != nil { + t.Fatalf("LoadChanges: %v", err) + } + if len(changes) != 1 { + t.Fatalf("want 1 change, got %d", len(changes)) + } + c := changes[0] + if c.Slug != "sync-design-notes" { + t.Fatalf("Slug = %q, want sync-design-notes (date prefix stripped)", c.Slug) + } + + // The issue's marker was written pre-archive, under the original slug. + issueBody := marker(c.Slug) + "\n\n# Sync design.md into the issue body\n" + p := NewGitHubProviderFunc(func(_ context.Context, args ...string) (string, error) { + if args[0] == "issue" && args[1] == "list" { + return `[{"number":139,"url":"https://github.com/o/r/issues/139","body":` + + quoteJSON(issueBody) + `}]`, nil + } + return "", nil + }) + + ref, err := p.Find(context.Background(), c.Slug) + if err != nil { + t.Fatalf("Find: %v", err) + } + if ref == nil { + t.Fatal("Find returned nil; archived change's issue was not resolved by its pre-archive marker") + } + if ref.ID != "139" { + t.Errorf("ref.ID = %q, want 139", ref.ID) + } +} + +// TestResolveLiveRefsFindsArchivedChangeWithNoCache pins the same regression +// at the ResolveLiveRefs level (what changelog -resolve-refs and a +// cache-less sync.yml run both use), confirming a fresh checkout with no +// .specsync/ anywhere still attributes the archived change to its issue +// instead of leaving it unresolved (which is what caused the empty +// changelog and the duplicate issue in the incident this pins). +func TestResolveLiveRefsFindsArchivedChangeWithNoCache(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "changes", "archive", "2026-08-27-sync-design-notes") + mustWrite(t, filepath.Join(dir, "proposal.md"), "# Sync design.md into the issue body\n") + + in, err := GatherTrace(context.Background(), root, nil, Scope{}) + if err != nil { + t.Fatalf("GatherTrace: %v", err) + } + if len(in.Changes) != 1 || len(in.Changes[0].IssueIDs) != 0 { + t.Fatalf("expected 1 change with no cached issue ids yet, got %+v", in.Changes) + } + + issueBody := marker("sync-design-notes") + "\n\n# Sync design.md into the issue body\n" + p := NewGitHubProviderFunc(func(_ context.Context, args ...string) (string, error) { + if args[0] == "issue" && args[1] == "list" { + return `[{"number":139,"url":"https://github.com/o/r/issues/139","body":` + + quoteJSON(issueBody) + `}]`, nil + } + return "", nil + }) + + if err := ResolveLiveRefs(context.Background(), &in, p); err != nil { + t.Fatalf("ResolveLiveRefs: %v", err) + } + if len(in.Changes[0].IssueIDs) != 1 || in.Changes[0].IssueIDs[0] != "139" { + t.Fatalf("IssueIDs = %v, want [139]", in.Changes[0].IssueIDs) + } +} + +func quoteJSON(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\n': + b.WriteString(`\n`) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String() +} diff --git a/change.go b/change.go index f49335a..aa44114 100644 --- a/change.go +++ b/change.go @@ -213,6 +213,12 @@ func LoadChange(dir string, archived bool, openspecDir string) (*Change, error) } slug := filepath.Base(dir) + if archived { + // `openspec archive` prepends a "YYYY-MM-DD-" date to the folder + // name; strip it so a change's logical Slug stays the same value it + // had while active. Dir keeps the real (date-prefixed) path. + slug = stripArchiveDatePrefix(slug) + } // Detect significance: marker file, design.md, or task count > 5. significant := IsSignificant(dir) @@ -257,6 +263,10 @@ func LoadChange(dir string, archived bool, openspecDir string) (*Change, error) } // LoadChangeBySlug finds a change by slug, checking active then archived dirs. +// An archived change may live under a date-prefixed folder name +// (`changes/archive/-`, `openspec archive`'s own +// convention), so the archived lookup falls back to a glob when the exact +// path doesn't exist. func LoadChangeBySlug(openspecDir, slug string) (*Change, error) { dir := filepath.Join(openspecDir, "changes", slug) c, err := LoadChange(dir, false, openspecDir) @@ -270,12 +280,54 @@ func LoadChangeBySlug(openspecDir, slug string) (*Change, error) { return nil, fmt.Errorf("load archived change %q: %w", slug, err) } } + if c == nil { + c, err = loadArchivedChangeByDatedSlug(openspecDir, slug) + if err != nil { + return nil, err + } + } if c == nil { return nil, fmt.Errorf("no change found for slug %q", slug) } return c, nil } +// archiveDatePrefixRE matches the "YYYY-MM-DD-" prefix `openspec archive` +// prepends to a change's folder name on archive. +var archiveDatePrefixRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`) + +// stripArchiveDatePrefix removes a leading archive date prefix from slug, if +// present. A folder archived without one (this repo's older convention) is +// returned unchanged. +func stripArchiveDatePrefix(slug string) string { + return archiveDatePrefixRE.ReplaceAllString(slug, "") +} + +// archivedDatedSlugGlob is the glob pattern matching a date-prefixed archive +// folder for slug, e.g. "changes/archive/2026-08-27-sync-design-notes". +func archivedDatedSlugGlob(openspecDir, slug string) string { + return filepath.Join(openspecDir, "changes", "archive", + "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-"+slug) +} + +// loadArchivedChangeByDatedSlug finds an archived change whose folder was +// renamed with an `openspec archive` date prefix, searched for by its +// original (pre-archive) slug. Multiple matches is an error naming the +// candidates rather than silently picking one. +func loadArchivedChangeByDatedSlug(openspecDir, slug string) (*Change, error) { + matches, err := filepath.Glob(archivedDatedSlugGlob(openspecDir, slug)) + if err != nil { + return nil, fmt.Errorf("glob archived change %q: %w", slug, err) + } + if len(matches) == 0 { + return nil, nil + } + if len(matches) > 1 { + return nil, fmt.Errorf("ambiguous archived slug %q: matches %s", slug, strings.Join(matches, ", ")) + } + return LoadChange(matches[0], true, openspecDir) +} + // reShorthand matches "owner/repo#N" GitHub shorthand references. var reShorthand = regexp.MustCompile(`^[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+#\d+$`) @@ -351,11 +403,18 @@ func resolveEntry(entry, openspecDir string) *Ref { return nil // can't resolve slugs without knowing where siblings live } - // Try to resolve via the sibling's ref cache. - for _, dir := range []string{ + // Try to resolve via the sibling's ref cache. A sibling archived after + // this link.md entry was written may live under a date-prefixed folder + // (openspec archive's own convention); fall back to that glob when the + // exact archive path doesn't exist. + candidates := []string{ filepath.Join(openspecDir, "changes", slug), filepath.Join(openspecDir, "changes", "archive", slug), - } { + } + if matches, _ := filepath.Glob(archivedDatedSlugGlob(openspecDir, slug)); len(matches) == 1 { + candidates = append(candidates, matches[0]) + } + for _, dir := range candidates { refs, err := LoadRefs(dir) if err != nil || len(refs) == 0 { continue diff --git a/openspec/changes/fix-archived-change-slug-resolution/original-ask.md b/openspec/changes/fix-archived-change-slug-resolution/original-ask.md new file mode 100644 index 0000000..979a411 --- /dev/null +++ b/openspec/changes/fix-archived-change-slug-resolution/original-ask.md @@ -0,0 +1,138 @@ +# Resolve archived changes by their original slug + +## Context + +`openspec archive -y` (the `openspec` CLI, not specsync) moves +`openspec/changes//` to `openspec/changes/archive/-/` +— it prepends a date to the folder name. specsync never accounts for that +prefix, which breaks change resolution by slug in two related ways once a +change is archived: + +1. **`specsync -change ` fails outright after archiving.** + `LoadChangeBySlug` (change.go) falls back to archived changes by checking + exactly `openspec/changes/archive//`: + + ```go + dir = filepath.Join(openspecDir, "changes", "archive", slug) + c, err = LoadChange(dir, true, openspecDir) + ``` + + That path never exists — the real folder is + `changes/archive/2026-08-10-/`. The lookup returns `(nil, nil)` from + `LoadChange` (no `proposal.md` at that path), so `LoadChangeBySlug` reports + `no change found for slug %q`. Any `specsync -change ` run after + archiving — which is exactly what this repo's own `AGENTS.md` "Completion + Rule" and the published skill's "Complete a change" workflow describe as + the last step, sync-then-archive — has no working follow-up. There is no + way to re-sync an archived change by its slug at all today. + +2. **The change's own `Slug` field silently changes on archive**, independent + of bug 1. `LoadChange` derives the slug from the folder name: + + ```go + slug := filepath.Base(dir) + ``` + + For an archived change this evaluates to the *date-prefixed* folder name + (`2026-08-10-`), not the original slug the change was created, + worked, and referenced under. `LoadChanges` (used by `specsync + changes --json` and the audit/changelog paths) enumerates archived changes + under this new identity — any script, memory, or documentation that + tracked the change by its original slug can no longer find it by name in + bulk listings either, without knowing to strip the date. + +3. **`resolveEntry` (links.md resolution) has the identical exact-path bug** + for slug-based cross-links: + + ```go + for _, dir := range []string{ + filepath.Join(openspecDir, "changes", slug), + filepath.Join(openspecDir, "changes", "archive", slug), + } { + ``` + + A `links.md` entry naming a sibling change by slug silently stops + resolving the moment that sibling is archived — the link just goes quiet + (per the documented "unresolvable slug entries are silently skipped" + behavior), with no signal that the cause is the date prefix rather than a + genuinely missing sibling. + +### How this was found + +Discovered while closing out `portal-resource-select-ui` (portal#4212 / +FusionHub#3565, both merged): after archiving the change locally with +`openspec archive portal-resource-select-ui -y`, the tracked GitHub issues +(portal#4210, FusionHub#3538) never reflected the terminal `archived` stage — +board Status stayed at "Ready for development" indefinitely on the Backlog +project, well after the underlying work had shipped. The board fix was done +by hand via direct API calls rather than through specsync, since the +documented sync-after-archive path doesn't work. + +## Proposed Changes + +Make archive-folder resolution date-prefix-aware everywhere a slug is +resolved against `changes/archive/`, instead of assuming an exact match: + +1. **`LoadChangeBySlug`**: when the exact `changes/archive/` path + doesn't exist, glob `changes/archive/*` for a directory whose name equals + `` or matches `-` (the openspec CLI's own naming + pattern), and load whichever one is found. Ambiguous (multiple matches) is + an error naming the candidates, not a silent pick. +2. **`LoadChange`**: when computing `Change.Slug` for a folder under + `changes/archive/`, strip a leading `YYYY-MM-DD-` prefix if present, so an + archived change's `Slug` is always the same value it had while active. + `Change.Dir` keeps the real (date-prefixed) path — only the logical `Slug` + identity is normalized. +3. **`resolveEntry`**: apply the same glob resolution as (1) for the + `changes/archive/` candidate path. + +## Out of Scope + +- Changing `openspec`'s own archive-naming scheme (date-prefixing is that + tool's convention, not something specsync controls). +- Auto-invoking `specsync` at the end of `openspec archive` — the two tools + are intentionally separate (`openspec` is the planning-layer CLI, specsync + is the tracker-sync layer); fixing slug resolution here is what makes the + documented "sync once more, then archive" *or* "archive, then sync once + more" order both actually work, without needing a hook between the tools. + +## Release Notes + +Fixed: `specsync -change ` (and slug-based links.md entries) failed to +resolve a change after `openspec archive` renamed its folder with a date +prefix. Archived changes now resolve by their original slug, so a sync run +after archiving correctly projects the terminal `archived` stage to the +tracker and board instead of erroring with "no change found for slug". + + +## Implementation + +- [ ] `LoadChangeBySlug` (change.go): fall back to a date-prefix-aware glob of + `changes/archive/*` (match `` or `-`) instead of an + exact-path check + - [ ] Multiple matches is an error listing the candidate folders +- [ ] `LoadChange` (change.go): strip a leading `YYYY-MM-DD-` prefix from + `filepath.Base(dir)` when computing `Slug` for archived changes, so `Slug` + is stable across the active → archived transition +- [ ] `resolveEntry` (change.go, links.md resolution): apply the same glob + resolution to the `changes/archive/` candidate path + +## Testing + +- [ ] Unit test: `LoadChangeBySlug` finds a change at + `changes/archive/2026-08-10-/` when queried by `` +- [ ] Unit test: archived `Change.Slug` equals the original slug, not the + date-prefixed folder name +- [ ] Unit test: `LoadChangeBySlug` errors clearly on ambiguous matches + (two archived folders both ending in `-`) +- [ ] Unit test: a `links.md` slug entry still resolves after its target is + archived +- [ ] Regression test: `specsync -change ` after `openspec archive + -y` succeeds and projects `StageArchived` to a configured board + +## Validation + +- [ ] `make test` passes +- [ ] `make vet` passes +- [ ] Manual repro: archive a change in this repo, confirm + `specsync -change ` (same original slug) no longer errors diff --git a/openspec/changes/fix-archived-change-slug-resolution/proposal.md b/openspec/changes/fix-archived-change-slug-resolution/proposal.md new file mode 100644 index 0000000..decc710 --- /dev/null +++ b/openspec/changes/fix-archived-change-slug-resolution/proposal.md @@ -0,0 +1,135 @@ +# Resolve archived changes by their original slug + +## Context + +`openspec archive -y` (the `openspec` CLI, not specsync) moves +`openspec/changes//` to `openspec/changes/archive/-/` +— it prepends a date to the folder name. specsync never accounts for that +prefix, which breaks change resolution by slug in two related ways once a +change is archived: + +1. **`specsync -change ` fails outright after archiving.** + `LoadChangeBySlug` (change.go) falls back to archived changes by checking + exactly `openspec/changes/archive//`: + + ```go + dir = filepath.Join(openspecDir, "changes", "archive", slug) + c, err = LoadChange(dir, true, openspecDir) + ``` + + That path never exists — the real folder is + `changes/archive/2026-08-10-/`. The lookup returns `(nil, nil)` from + `LoadChange` (no `proposal.md` at that path), so `LoadChangeBySlug` reports + `no change found for slug %q`. Any `specsync -change ` run after + archiving — which is exactly what this repo's own `AGENTS.md` "Completion + Rule" and the published skill's "Complete a change" workflow describe as + the last step, sync-then-archive — has no working follow-up. There is no + way to re-sync an archived change by its slug at all today. + +2. **The change's own `Slug` field silently changes on archive**, independent + of bug 1. `LoadChange` derives the slug from the folder name: + + ```go + slug := filepath.Base(dir) + ``` + + For an archived change this evaluates to the *date-prefixed* folder name + (`2026-08-10-`), not the original slug the change was created, + worked, and referenced under. `LoadChanges` (used by `specsync + changes --json` and the audit/changelog paths) enumerates archived changes + under this new identity — any script, memory, or documentation that + tracked the change by its original slug can no longer find it by name in + bulk listings either, without knowing to strip the date. + +3. **`resolveEntry` (links.md resolution) has the identical exact-path bug** + for slug-based cross-links: + + ```go + for _, dir := range []string{ + filepath.Join(openspecDir, "changes", slug), + filepath.Join(openspecDir, "changes", "archive", slug), + } { + ``` + + A `links.md` entry naming a sibling change by slug silently stops + resolving the moment that sibling is archived — the link just goes quiet + (per the documented "unresolvable slug entries are silently skipped" + behavior), with no signal that the cause is the date prefix rather than a + genuinely missing sibling. + +4. **The same mismatch breaks live GitHub resolution with no local ref + cache**, which is every fresh CI checkout (`.specsync/` is gitignored). + `Find` and `ResolveLiveRefs` (github.go, gather.go) search for a change's + issue by its identity marker using `Change.Slug` — bug 2's date-prefixed + value post-archive — but the marker actually embedded in the issue's body + was written pre-archive, under the original slug, and nothing rewrites it + afterward. Fixing bug 2 (normalizing `Change.Slug`) fixes this for free, + since `Find`/`ResolveLiveRefs` both key off `Change.Slug` rather than the + folder name directly — but it needs its own regression test, since this + is the path that produces the most visible damage when it's wrong. + +### How this was found + +Discovered while closing out `portal-resource-select-ui` (portal#4212 / +FusionHub#3565, both merged): after archiving the change locally with +`openspec archive portal-resource-select-ui -y`, the tracked GitHub issues +(portal#4210, FusionHub#3538) never reflected the terminal `archived` stage — +board Status stayed at "Ready for development" indefinitely on the Backlog +project, well after the underlying work had shipped. The board fix was done +by hand via direct API calls rather than through specsync, since the +documented sync-after-archive path doesn't work. + +Reproduced again 2026-08-27/28 shipping `sync-design-notes` (#139), this time +hitting bug 4 specifically: the release-time `changelog -resolve-refs` run +(fresh CI checkout, no ref cache) failed to attribute #139's commits to their +change, silently rendering both the GitHub Release body and the +`CHANGELOG.md` section for v0.13.0 as empty "commits omitted" placeholders. +Worse, `sync.yml`'s full-repo run (also cache-less) failed to find #139 via +its marker and created a duplicate issue (#141) for the same already-shipped +change. Both were fixed by hand after the fact (`gh release edit`, a +`CHANGELOG.md` correction commit, closing and de-marking #141) — this change +is what stops it from recurring on the next archived change. + +## Proposed Changes + +Make archive-folder resolution date-prefix-aware everywhere a slug is +resolved against `changes/archive/`, instead of assuming an exact match: + +1. **`LoadChangeBySlug`**: when the exact `changes/archive/` path + doesn't exist, glob `changes/archive/*` for a directory whose name equals + `` or matches `-` (the openspec CLI's own naming + pattern), and load whichever one is found. Ambiguous (multiple matches) is + an error naming the candidates, not a silent pick. +2. **`LoadChange`**: when computing `Change.Slug` for a folder under + `changes/archive/`, strip a leading `YYYY-MM-DD-` prefix if present, so an + archived change's `Slug` is always the same value it had while active. + `Change.Dir` keeps the real (date-prefixed) path — only the logical `Slug` + identity is normalized. This is also what fixes bug 4 above: `Find` and + `ResolveLiveRefs` both search using `Change.Slug`, so once it's normalized + they search using the same slug the issue's marker was actually written + under. +3. **`resolveEntry`**: apply the same glob resolution as (1) for the + `changes/archive/` candidate path. + +## Out of Scope + +- Changing `openspec`'s own archive-naming scheme (date-prefixing is that + tool's convention, not something specsync controls). +- Auto-invoking `specsync` at the end of `openspec archive` — the two tools + are intentionally separate (`openspec` is the planning-layer CLI, specsync + is the tracker-sync layer); fixing slug resolution here is what makes the + documented "sync once more, then archive" *or* "archive, then sync once + more" order both actually work, without needing a hook between the tools. +- Making `sync.yml`'s full-repo run refuse to create an issue for an + unresolved archived change as a defense-in-depth backstop — worth doing + separately, but this change's fix (bug 4) removes the actual cause of that + false unresolved state, so it isn't required to close this out. + +## Release Notes + +Fixed: `specsync -change ` (and slug-based links.md entries) failed to +resolve a change after `openspec archive` renamed its folder with a date +prefix. Archived changes now resolve by their original slug — locally, +across links.md, and via live GitHub lookup with no ref cache — so a sync run +after archiving correctly projects the terminal `archived` stage instead of +erroring or, worse, silently missing the issue and creating a duplicate. diff --git a/openspec/changes/fix-archived-change-slug-resolution/tasks.md b/openspec/changes/fix-archived-change-slug-resolution/tasks.md new file mode 100644 index 0000000..eaf0696 --- /dev/null +++ b/openspec/changes/fix-archived-change-slug-resolution/tasks.md @@ -0,0 +1,37 @@ +# Tasks + +## Implementation + +- [x] `LoadChangeBySlug` (change.go): fall back to a date-prefix-aware glob of + `changes/archive/*` (match `` or `-`) instead of an + exact-path check + - [x] Multiple matches is an error listing the candidate folders +- [x] `LoadChange` (change.go): strip a leading `YYYY-MM-DD-` prefix from + `filepath.Base(dir)` when computing `Slug` for archived changes, so `Slug` + is stable across the active → archived transition +- [x] `resolveEntry` (change.go, links.md resolution): apply the same glob + resolution to the `changes/archive/` candidate path + +## Testing + +- [x] Unit test: `LoadChangeBySlug` finds a change at + `changes/archive/2026-08-10-/` when queried by `` +- [x] Unit test: archived `Change.Slug` equals the original slug, not the + date-prefixed folder name +- [x] Unit test: `LoadChangeBySlug` errors clearly on ambiguous matches + (two archived folders both ending in `-`) +- [x] Unit test: a `links.md` slug entry still resolves after its target is + archived +- [x] Regression test: `specsync -change ` after `openspec archive + -y` succeeds and projects `StageArchived` to a configured board +- [x] Regression test: `Find`/`ResolveLiveRefs` (github.go, gather.go) locate + an archived change's issue by its pre-archive marker with no local ref + cache present — pins the fix for the empty-changelog/duplicate-issue + incident on `sync-design-notes` (#139/#141) + +## Validation + +- [x] `make test` passes +- [x] `make vet` passes +- [x] Manual repro: archive a change in this repo, confirm + `specsync -change ` (same original slug) no longer errors