diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d874e72..0cb4fd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- **`bw archive`** — moves closed issues out of active consideration into an `archive/` tree on the beadwork branch. Archived issues leave the live ID space and the status/labels/blocks indexes, so they no longer show up in `ready`, `blocked`, `list`, or ID resolution (and no longer crowd ID-prefix matching) — but they stay in git and keep their titles in `bw recap`. The move is one-way; recover via git history or `bw import`. + + Only closed issues archive: pass `--close` to close an open one first. If open work still depends on an issue (it blocks an open issue, or has open children), archive refuses unless `--detach` severs those edges (dependents lose the blocker, open children are orphaned — like `delete`). Archive a date range with `bw archive --before ` (e.g. `2026-01-01`, `6 weeks`, `last monday`), which sweeps every closed issue whose `closed_at` precedes the cutoff; it previews by default and commits with `--force`, skipping issues with open work unless `--detach` is given. Archived IDs are never recycled by future `create`. The operation is replayable, so it converges cleanly through `bw sync` conflicts. + ## 0.13.1 — 2026-05-30 - **No more `ref moved` errors under concurrent use** — commands that change issue state (`start`, `create`, `update`, `delete`, `reopen`, `attach`, `label`, `comment`, `defer`, `undefer`, `dep add`/`remove`) used to die with an error like: diff --git a/cmd/bw/archive.go b/cmd/bw/archive.go new file mode 100644 index 00000000..abb0af03 --- /dev/null +++ b/cmd/bw/archive.go @@ -0,0 +1,248 @@ +package main + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/jallum/beadwork/internal/config" + "github.com/jallum/beadwork/internal/issue" + "github.com/jallum/beadwork/internal/md" +) + +type ArchiveArgs struct { + IDs []string + Before string + Close bool + Detach bool + Force bool + JSON bool +} + +func parseArchiveArgs(raw []string) (ArchiveArgs, error) { + a, err := ParseArgs(raw, + []string{"--before"}, + []string{"--close", "--detach", "--force", "-y", "--json"}, + ) + if err != nil { + return ArchiveArgs{}, err + } + args := ArchiveArgs{ + IDs: a.Pos(), + Before: a.String("--before"), + Close: a.Bool("--close"), + Detach: a.Bool("--detach"), + Force: a.Bool("--force") || a.Bool("-y"), + JSON: a.JSON(), + } + if args.Before != "" && len(args.IDs) > 0 { + return args, fmt.Errorf("cannot combine --before with explicit issue IDs") + } + if args.Before == "" && len(args.IDs) == 0 { + return args, fmt.Errorf("usage: bw archive ... | bw archive --before [--force]") + } + return args, nil +} + +func cmdArchive(store *issue.Store, args []string, w Writer, _ *config.Config) (*config.Config, error) { + aa, err := parseArchiveArgs(args) + if err != nil { + return nil, err + } + if aa.Before != "" { + return nil, runArchiveBefore(store, aa, w) + } + return nil, runArchiveIDs(store, aa, w) +} + +// runArchiveIDs archives explicitly named issues. Named issues are an explicit +// instruction, so this executes directly (no preview). +func runArchiveIDs(store *issue.Store, aa ArchiveArgs, w Writer) error { + opts := issue.ArchiveOpts{Close: aa.Close, Detach: aa.Detach} + + var archived []*issue.Issue + err := commitWithRetry(store, commitMaxRetries, func() (string, error) { + archived = archived[:0] + var lines []string + for _, id := range aa.IDs { + iss, e := store.Archive(id, opts) + if e != nil { + return "", e + } + archived = append(archived, iss) + lines = append(lines, archiveIntentLine(iss.ID, aa)) + } + return strings.Join(lines, "\n"), nil + }) + if err != nil { + return enrichArchiveError(err) + } + + if aa.JSON { + fprintJSON(w, archived) + return nil + } + for _, iss := range archived { + fmt.Fprintf(w, "archived {id:%s}: %s\n", iss.ID, md.Escape(iss.Title)) + } + return nil +} + +// runArchiveBefore sweeps closed issues whose closed_at precedes a cutoff. +// It previews by default; --force commits. Issues with open work still attached +// are skipped unless --detach is given. +func runArchiveBefore(store *issue.Store, aa ArchiveArgs, w Writer) error { + resolved, err := resolveDateBeforeNow(aa.Before, store.Now()) + if err != nil { + return err + } + cutoff, err := parseCutoffTime(resolved) + if err != nil { + return err + } + + candidates, err := store.ClosedBefore(cutoff) + if err != nil { + return err + } + + // Partition into cleanly-archivable vs. blocked-by-open-work. + type skip struct { + iss *issue.Issue + deps []string + kids []string + } + var ready []*issue.Issue + var skipped []skip + for _, iss := range candidates { + deps, kids, e := store.ArchiveCheck(iss.ID) + if e != nil { + continue + } + if (len(deps) > 0 || len(kids) > 0) && !aa.Detach { + skipped = append(skipped, skip{iss, deps, kids}) + continue + } + ready = append(ready, iss) + } + + if len(candidates) == 0 { + fmt.Fprintf(w, "no closed issues found before %s\n", resolved) + return nil + } + + // Preview mode (default): report, mutate nothing. + if !aa.Force { + fmt.Fprintf(w, "Would archive %d issue(s) closed before %s:\n", len(ready), resolved) + w.Push(2) + for _, iss := range ready { + fmt.Fprintf(w, "{id:%s}: %s\n", iss.ID, md.Escape(iss.Title)) + } + w.Pop() + if len(skipped) > 0 { + fmt.Fprintf(w, "\nSkipped %d with open work attached (use --detach to include):\n", len(skipped)) + w.Push(2) + for _, s := range skipped { + fmt.Fprintf(w, "{id:%s}: %s — %s\n", s.iss.ID, md.Escape(s.iss.Title), attachmentSummary(s.deps, s.kids)) + } + w.Pop() + } + fmt.Fprintln(w, "\nRe-run with --force to archive.") + return nil + } + + if len(ready) == 0 { + fmt.Fprintln(w, "nothing to archive") + if len(skipped) > 0 { + fmt.Fprintf(w, "(%d skipped; use --detach to include them)\n", len(skipped)) + } + return nil + } + + opts := issue.ArchiveOpts{Detach: aa.Detach} + readyIDs := make([]string, len(ready)) + for i, iss := range ready { + readyIDs[i] = iss.ID + } + + var archived []*issue.Issue + err = commitWithRetry(store, commitMaxRetries, func() (string, error) { + archived = archived[:0] + var lines []string + for _, id := range readyIDs { + iss, e := store.Archive(id, opts) + if e != nil { + return "", e + } + archived = append(archived, iss) + lines = append(lines, archiveIntentLine(iss.ID, aa)) + } + return strings.Join(lines, "\n"), nil + }) + if err != nil { + return enrichArchiveError(err) + } + + if aa.JSON { + fprintJSON(w, archived) + return nil + } + fmt.Fprintf(w, "archived %d issue(s) closed before %s\n", len(archived), resolved) + w.Push(2) + for _, iss := range archived { + fmt.Fprintf(w, "{id:%s}: %s\n", iss.ID, md.Escape(iss.Title)) + } + w.Pop() + if len(skipped) > 0 { + fmt.Fprintf(w, "(%d skipped; use --detach to include them)\n", len(skipped)) + } + return nil +} + +// archiveIntentLine builds the replayable intent for one archived issue, +// echoing the flags that governed the operation. +func archiveIntentLine(id string, aa ArchiveArgs) string { + line := "archive " + id + if aa.Close { + line += " --close" + } + if aa.Detach { + line += " --detach" + } + return line +} + +func attachmentSummary(deps, kids []string) string { + var parts []string + if len(deps) > 0 { + parts = append(parts, "open dependents: "+strings.Join(deps, ", ")) + } + if len(kids) > 0 { + parts = append(parts, "open children: "+strings.Join(kids, ", ")) + } + return strings.Join(parts, "; ") +} + +// enrichArchiveError adds an actionable hint to the not-closed case. The +// blocked-by-open-work case already carries a --detach hint in its message. +func enrichArchiveError(err error) error { + var nce *issue.NotClosedError + if errors.As(err, &nce) { + return fmt.Errorf("%w; pass --close to close it first, or close it with `bw close %s`", err, nce.ID) + } + return err +} + +// parseCutoffTime turns a resolved date string (YYYY-MM-DD or RFC3339, as +// produced by resolveDateBeforeNow) into a comparable instant. Date-only +// cutoffs are interpreted as midnight UTC. +func parseCutoffTime(resolved string) (time.Time, error) { + if t, err := time.Parse(time.RFC3339, resolved); err == nil { + return t, nil + } + if t, err := time.Parse("2006-01-02", resolved); err == nil { + return t.UTC(), nil + } + return time.Time{}, fmt.Errorf("could not parse cutoff %q", resolved) +} diff --git a/cmd/bw/archive_test.go b/cmd/bw/archive_test.go new file mode 100644 index 00000000..e274460c --- /dev/null +++ b/cmd/bw/archive_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/jallum/beadwork/internal/issue" + "github.com/jallum/beadwork/internal/testutil" +) + +func archiveFileExists(env *testutil.Env, id string) bool { + return env.MarkerExists("archive/" + id + ".json") +} + +func TestCmdArchiveClosed(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + iss, _ := env.Store.Create("Done", issue.CreateOpts{}) + env.Store.Close(iss.ID, "") + env.Repo.Commit("setup") + + var buf bytes.Buffer + _, err := cmdArchive(env.Store, []string{iss.ID}, PlainWriter(&buf), nil) + if err != nil { + t.Fatalf("cmdArchive: %v", err) + } + if !archiveFileExists(env, iss.ID) { + t.Error("archive file missing") + } + if env.IssueFileExists(iss.ID) { + t.Error("issues/.json should be gone") + } +} + +func TestCmdArchiveOpenRefusedThenClose(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + iss, _ := env.Store.Create("Active", issue.CreateOpts{}) + env.Repo.Commit("setup") + + var buf bytes.Buffer + _, err := cmdArchive(env.Store, []string{iss.ID}, PlainWriter(&buf), nil) + if err == nil { + t.Fatal("expected error archiving an open issue") + } + if !strings.Contains(err.Error(), "--close") { + t.Errorf("error should hint at --close, got: %v", err) + } + + _, err = cmdArchive(env.Store, []string{iss.ID, "--close"}, PlainWriter(&buf), nil) + if err != nil { + t.Fatalf("cmdArchive --close: %v", err) + } + if !archiveFileExists(env, iss.ID) { + t.Error("archive file missing after --close") + } +} + +func TestCmdArchiveOpenDependentRefusedThenDetach(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + blocker, _ := env.Store.Create("Blocker", issue.CreateOpts{}) + blocked, _ := env.Store.Create("Blocked", issue.CreateOpts{}) + env.Store.Link(blocker.ID, blocked.ID) + env.Store.Close(blocker.ID, "") + env.Repo.Commit("setup") + + var buf bytes.Buffer + _, err := cmdArchive(env.Store, []string{blocker.ID}, PlainWriter(&buf), nil) + if err == nil { + t.Fatal("expected refusal: open dependent") + } + if !strings.Contains(err.Error(), "--detach") { + t.Errorf("error should hint at --detach, got: %v", err) + } + + _, err = cmdArchive(env.Store, []string{blocker.ID, "--detach"}, PlainWriter(&buf), nil) + if err != nil { + t.Fatalf("cmdArchive --detach: %v", err) + } + if !archiveFileExists(env, blocker.ID) { + t.Error("archive file missing after --detach") + } +} + +func TestCmdArchiveBeforePreviewThenForce(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + mk := func(id, closedAt string) { + env.Store.Import(&issue.Issue{ + ID: id, Title: id, Status: "closed", Priority: 2, Type: "task", + Created: "2024-01-01T00:00:00Z", ClosedAt: closedAt, + Labels: []string{}, Blocks: []string{}, BlockedBy: []string{}, + }) + } + mk("test-old", "2025-06-01T00:00:00Z") + mk("test-new", "2026-03-01T00:00:00Z") + env.Repo.Commit("setup") + + // Preview (no --force): reports the old one, mutates nothing. + var preview bytes.Buffer + _, err := cmdArchive(env.Store, []string{"--before", "2026-01-01"}, PlainWriter(&preview), nil) + if err != nil { + t.Fatalf("cmdArchive --before (preview): %v", err) + } + if !strings.Contains(preview.String(), "test-old") { + t.Errorf("preview should mention test-old, got: %q", preview.String()) + } + if archiveFileExists(env, "test-old") { + t.Error("preview must not archive anything") + } + + // Execute with --force. + var run bytes.Buffer + _, err = cmdArchive(env.Store, []string{"--before", "2026-01-01", "--force"}, PlainWriter(&run), nil) + if err != nil { + t.Fatalf("cmdArchive --before --force: %v", err) + } + if !archiveFileExists(env, "test-old") { + t.Error("test-old should be archived") + } + if archiveFileExists(env, "test-new") { + t.Error("test-new is newer than cutoff; must not be archived") + } +} + +func TestCmdArchiveNoArgs(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + var buf bytes.Buffer + _, err := cmdArchive(env.Store, []string{}, PlainWriter(&buf), nil) + if err == nil { + t.Error("expected usage error with no args") + } +} + +func TestCmdArchiveBeforeWithIDsRejected(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + var buf bytes.Buffer + _, err := cmdArchive(env.Store, []string{"test-aaaa", "--before", "2026-01-01"}, PlainWriter(&buf), nil) + if err == nil { + t.Error("expected error combining --before with explicit IDs") + } +} diff --git a/cmd/bw/command.go b/cmd/bw/command.go index 23dddc0d..56452da3 100644 --- a/cmd/bw/command.go +++ b/cmd/bw/command.go @@ -273,6 +273,30 @@ var commands = []Command{ NeedsStore: true, Run: cmdDelete, }, + { + Name: "archive", + Summary: "Archive closed issues out of active consideration", + Description: "Move closed issues into the archive/ tree on the beadwork branch. Archived issues leave the live ID space and the status/labels/blocks indexes, so they no longer appear in ready, blocked, list, or ID resolution. The move is one-way; recover via git history or bw import.\n\nOnly closed issues can be archived: pass --close to close an open issue first. If open work still depends on an issue (it blocks an open issue, or has open children), archive refuses unless --detach is given to sever those edges.\n\nUse --before to sweep all closed issues whose closed_at precedes a past cutoff (e.g. \"2026-01-01\", \"6 weeks\", \"last monday\"). The sweep previews by default; pass --force to commit. Issues with open work attached are skipped unless --detach is set.", + Positionals: []Positional{ + {Name: "...", Required: false, Help: "Issue IDs to archive"}, + }, + Flags: []Flag{ + {Long: "--before", Value: "DATE", Help: "Sweep closed issues closed before a past date/expression"}, + {Long: "--close", Help: "Close the issue first if it is still open"}, + {Long: "--detach", Help: "Sever edges to open dependents/children instead of refusing"}, + {Long: "--force", Short: "-y", Help: "Execute a --before sweep (default: preview only)"}, + {Long: "--json", Help: "Output as JSON"}, + }, + Examples: []Example{ + {Cmd: "bw archive bw-a3f8", Help: "Archive one closed issue"}, + {Cmd: "bw archive bw-a3f8 --close", Help: "Close it first, then archive"}, + {Cmd: "bw archive bw-a3f8 --detach", Help: "Sever live edges, then archive"}, + {Cmd: "bw archive --before 2026-01-01", Help: "Preview closed issues before a date"}, + {Cmd: "bw archive --before 6\\ weeks --force", Help: "Archive everything closed >6 weeks ago"}, + }, + NeedsStore: true, + Run: cmdArchive, + }, { Name: "label", Summary: "Add/remove labels", @@ -540,7 +564,7 @@ var commandGroups = []struct { name string cmds []string }{ - {"Working With Issues", []string{"create", "show", "list", "update", "start", "close", "reopen", "delete", "comment", "label", "defer", "undefer", "history", "attach"}}, + {"Working With Issues", []string{"create", "show", "list", "update", "start", "close", "reopen", "delete", "archive", "comment", "label", "defer", "undefer", "history", "attach"}}, {"Finding Work", []string{"ready", "blocked"}}, {"Dependencies", []string{"dep"}}, {"Sync & Data", []string{"sync", "export", "import"}}, diff --git a/cmd/bw/recap.go b/cmd/bw/recap.go index 56e9452d..5ede2af9 100644 --- a/cmd/bw/recap.go +++ b/cmd/bw/recap.go @@ -23,11 +23,15 @@ func (s *storeLookup) Title(id string) string { if s.store == nil { return "" } - iss, err := s.store.Get(id) - if err != nil { - return "" + if iss, err := s.store.Get(id); err == nil { + return iss.Title + } + // Fall back to the archive so historical recaps keep titles for issues + // that have since been archived out of the live tree. + if iss, err := s.store.ArchivedIssue(id); err == nil { + return iss.Title } - return iss.Title + return "" } type recapArgs struct { diff --git a/docs/design.md b/docs/design.md index fdc3b039..c4c73511 100644 --- a/docs/design.md +++ b/docs/design.md @@ -21,6 +21,8 @@ blocks/ parent/ bw-a1b2/ bw-c3d4 (0 bytes) +archive/ + bw-9z8y.json (closed issues moved out of active consideration) ``` Every listing query is a directory read. Parent-child relationships use the same marker pattern, with cycle detection preventing circular hierarchies. Two agents working on different issues never touch the same file. @@ -87,4 +89,32 @@ after a ref reset). Re-stage the tree entry at that path with that blob oid. If the blob is missing from the ODB, the replay fails loudly with an error — attachments are never silently dropped. +### The `archive` intent + +``` +archive [--close] [--detach] +``` + +`bw archive` moves a closed issue out of the live tree into +`archive/.json` and removes its `status/`, `labels/`, and +`blocks/` markers. Archived issues leave the live ID space (so `ready`, +`blocked`, `list`, and ID resolution skip them) but remain in git; the +move is one-way. Because the issue is detached from the dependency graph, +its blockers/dependents lose the edge and open children are orphaned — +the same cleanup `delete` performs, except the JSON is preserved under +`archive/` rather than removed. + +`--close` closes a still-open issue before archiving it; `--detach` +authorizes severing edges to *open* dependents/children (without it, +archive refuses when live work is attached). A bulk `bw archive --before +` sweep resolves to a concrete set of ticket IDs at commit time and +emits one `archive ` line per issue — never a date expression — +so replay is deterministic. Archived IDs are excluded from ID generation +and explicit-ID validation, so they are never recycled. + +**Replay semantics.** `archive ` replays by re-running the +archive against the current tree with the given flags. Multiple `archive` +lines may appear in one commit message (the bulk sweep); each replays +independently and converges to the same end state. + `bw sync` fetches, rebases, and pushes. If rebase conflicts, it replays intents from commit messages against the current remote state. No merge drivers, no lock files, no custom conflict resolution. \ No newline at end of file diff --git a/internal/intent/archive_test.go b/internal/intent/archive_test.go new file mode 100644 index 00000000..b314a1d2 --- /dev/null +++ b/internal/intent/archive_test.go @@ -0,0 +1,79 @@ +package intent_test + +import ( + "testing" + + "github.com/jallum/beadwork/internal/intent" + "github.com/jallum/beadwork/internal/testutil" +) + +func TestReplayArchiveMovesClosedIssue(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + intents := []string{ + `create test-aaaa p1 task "Done thing"`, + `close test-aaaa`, + `archive test-aaaa`, + } + if errs := intent.Replay(env.Store, intents); len(errs) > 0 { + t.Fatalf("Replay errors: %v", errs) + } + + if _, err := env.Store.Get("test-aaaa"); err == nil { + t.Error("archived issue should not resolve via Get after replay") + } + a, err := env.Store.ArchivedIssue("test-aaaa") + if err != nil { + t.Fatalf("ArchivedIssue: %v", err) + } + if a.Title != "Done thing" { + t.Errorf("title = %q, want 'Done thing'", a.Title) + } + if a.ArchivedAt == "" { + t.Error("ArchivedAt not stamped on replay") + } +} + +func TestReplayArchiveWithCloseFlag(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + intents := []string{ + `create test-bbbb p2 task "Still open"`, + `archive test-bbbb --close`, + } + if errs := intent.Replay(env.Store, intents); len(errs) > 0 { + t.Fatalf("Replay errors: %v", errs) + } + a, err := env.Store.ArchivedIssue("test-bbbb") + if err != nil { + t.Fatalf("ArchivedIssue: %v", err) + } + if a.Status != "closed" { + t.Errorf("status = %q, want closed", a.Status) + } +} + +func TestReplayArchiveWithDetachSeversEdge(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + intents := []string{ + `create test-aaaa p2 task "Blocker"`, + `create test-bbbb p2 task "Blocked"`, + `link test-aaaa blocks test-bbbb`, + `close test-aaaa`, + `archive test-aaaa --detach`, + } + if errs := intent.Replay(env.Store, intents); len(errs) > 0 { + t.Fatalf("Replay errors: %v", errs) + } + b, err := env.Store.Get("test-bbbb") + if err != nil { + t.Fatalf("Get blocked: %v", err) + } + if len(b.BlockedBy) != 0 { + t.Errorf("blocked.BlockedBy = %v, want empty after detach archive", b.BlockedBy) + } +} diff --git a/internal/intent/intent.go b/internal/intent/intent.go index a057a04f..7e6e4684 100644 --- a/internal/intent/intent.go +++ b/internal/intent/intent.go @@ -72,6 +72,8 @@ func replayOne(store *issue.Store, raw string) error { return replayUndefer(store, parts[1:], raw) case "attach": return replayAttach(store, parts[1:], raw) + case "archive": + return replayArchive(store, parts[1:], raw) case "init": return nil // skip init intents default: @@ -296,6 +298,26 @@ func replayDelete(store *issue.Store, parts []string, raw string) error { return store.Commit(raw) } +func replayArchive(store *issue.Store, parts []string, raw string) error { + // archive [--close] [--detach] + if len(parts) < 1 { + return fmt.Errorf("malformed archive intent") + } + opts := issue.ArchiveOpts{} + for _, p := range parts[1:] { + switch p { + case "--close": + opts.Close = true + case "--detach": + opts.Detach = true + } + } + if _, err := store.Archive(parts[0], opts); err != nil { + return err + } + return store.Commit(raw) +} + func replayConfig(store *issue.Store, parts []string, raw string) error { // config key=value if len(parts) < 1 { diff --git a/internal/issue/archive.go b/internal/issue/archive.go new file mode 100644 index 00000000..bf17b8c1 --- /dev/null +++ b/internal/issue/archive.go @@ -0,0 +1,234 @@ +package issue + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// ArchiveOpts controls how Archive treats an issue. +type ArchiveOpts struct { + // Close closes the issue first if it is not already closed. Without it, + // archiving a non-closed issue fails with a *NotClosedError. + Close bool + // Detach severs live graph edges to OPEN dependents/children instead of + // refusing. Without it, archiving an issue that open work still depends on + // fails with an *ArchiveBlockedError. + Detach bool +} + +// NotClosedError reports that an issue cannot be archived because it is not +// closed (and Close was not requested). +type NotClosedError struct { + ID string + Status string +} + +func (e *NotClosedError) Error() string { + return fmt.Sprintf("%s is %s, not closed", e.ID, e.Status) +} + +// ArchiveBlockedError reports that archiving an issue would orphan open work: +// open issues it still blocks, or open children. Pass ArchiveOpts.Detach to +// sever those edges and proceed. +type ArchiveBlockedError struct { + ID string + OpenDependents []string // open issues this one blocks + OpenChildren []string // open child issues +} + +func (e *ArchiveBlockedError) Error() string { + var parts []string + if len(e.OpenDependents) > 0 { + parts = append(parts, "open dependents: "+strings.Join(e.OpenDependents, ", ")) + } + if len(e.OpenChildren) > 0 { + parts = append(parts, "open children: "+strings.Join(e.OpenChildren, ", ")) + } + return fmt.Sprintf("%s still has live work attached (%s); pass --detach to sever and archive anyway", + e.ID, strings.Join(parts, "; ")) +} + +// archiveAttachments returns the open dependents (issues iss blocks that are +// not closed) and open children of iss, sorted. These are the live edges that +// archiving without --detach would orphan. +func (s *Store) archiveAttachments(iss *Issue) (openDeps, openKids []string) { + for _, blockedID := range iss.Blocks { + if !s.IsClosed(blockedID) { + openDeps = append(openDeps, blockedID) + } + } + if children, err := s.Children(iss.ID); err == nil { + for _, c := range children { + if c.Status != "closed" { + openKids = append(openKids, c.ID) + } + } + } + sort.Strings(openDeps) + sort.Strings(openKids) + return openDeps, openKids +} + +// ArchiveCheck reports the open dependents and open children that a no-detach +// archive of id would orphan. Empty results mean the issue can be archived +// cleanly. It does not mutate anything. +func (s *Store) ArchiveCheck(id string) (openDeps, openKids []string, err error) { + id, err = s.resolveID(id) + if err != nil { + return nil, nil, err + } + iss, err := s.readIssue(id) + if err != nil { + return nil, nil, err + } + openDeps, openKids = s.archiveAttachments(iss) + return openDeps, openKids, nil +} + +// Archive moves a closed issue out of the live tree into archive/.json, +// detaching it from the dependency graph. Archived issues leave the live ID +// space and the status/labels/blocks indexes, so they no longer surface in +// ready, blocked, list, or ID resolution. The move is one-way; recovery is via +// git history or `bw import`. +// +// Eligibility: the issue must be closed. ArchiveOpts.Close closes it first if +// it is still open. If open issues still depend on it (it blocks them, or they +// are open children), Archive refuses with an *ArchiveBlockedError unless +// ArchiveOpts.Detach is set, in which case those edges are severed (mirroring +// Delete: dependents lose the blocker from BlockedBy, open children are +// orphaned). +func (s *Store) Archive(id string, opts ArchiveOpts) (*Issue, error) { + id, err := s.resolveID(id) + if err != nil { + return nil, err + } + iss, err := s.readIssue(id) + if err != nil { + return nil, err + } + + now := s.nowRFC3339() + + // Eligibility: must be closed (or closable via opts.Close). + if iss.Status != "closed" { + if !opts.Close { + return nil, &NotClosedError{ID: id, Status: iss.Status} + } + original := iss.Status + if err := s.moveStatus(id, original, "closed"); err != nil { + return nil, err + } + iss.Status = "closed" + iss.ClosedAt = now + } + + // Graph guard: open dependents (issues this one blocks) and open children. + openDeps, openKids := s.archiveAttachments(iss) + children, _ := s.Children(id) + if (len(openDeps) > 0 || len(openKids) > 0) && !opts.Detach { + return nil, &ArchiveBlockedError{ID: id, OpenDependents: openDeps, OpenChildren: openKids} + } + + // Sever this issue's outgoing block edges. + for _, blockedID := range iss.Blocks { + s.FS.Remove("blocks/" + id + "/" + blockedID) + if other, err := s.readIssue(blockedID); err == nil { + other.BlockedBy = removeStr(other.BlockedBy, id) + other.UpdatedAt = now + s.writeIssue(other) + } + } + s.FS.Remove("blocks/" + id + "/.gitkeep") + s.FS.Remove("blocks/" + id) + + // Sever incoming block edges. + for _, blockerID := range iss.BlockedBy { + s.FS.Remove("blocks/" + blockerID + "/" + id) + if other, err := s.readIssue(blockerID); err == nil { + other.Blocks = removeStr(other.Blocks, id) + other.UpdatedAt = now + s.writeIssue(other) + } + } + + // Orphan children (clear their Parent pointer to the archived issue). + for _, c := range children { + c.Parent = "" + c.UpdatedAt = now + s.writeIssue(c) + } + + // Remove label markers. + for _, label := range iss.Labels { + s.FS.Remove("labels/" + label + "/" + id) + } + + // Remove the status marker. + s.FS.Remove("status/" + iss.Status + "/" + id) + + // Detach the archived record's own edge arrays; they are no longer live. + iss.Blocks = []string{} + iss.BlockedBy = []string{} + iss.ArchivedAt = now + iss.UpdatedAt = now + + // Write to the archive tree and remove the live issue JSON. + data, err := json.MarshalIndent(iss, "", " ") + if err != nil { + return nil, err + } + data = append(data, '\n') + if err := s.FS.WriteFile("archive/"+id+".json", data); err != nil { + return nil, err + } + s.FS.Remove("issues/" + id + ".json") + + // Evict from caches and the live ID space. + delete(s.cache, id) + s.untrackID(id) + + return iss, nil +} + +// ArchivedIssue reads an archived issue by its exact ID from archive/.json. +// Archived issues are intentionally outside the live ID space, so this does not +// go through ID resolution. Returns an error if the ID is not archived. +func (s *Store) ArchivedIssue(id string) (*Issue, error) { + data, err := s.FS.ReadFile("archive/" + id + ".json") + if err != nil { + return nil, fmt.Errorf("archived issue %s not found", id) + } + var iss Issue + if err := json.Unmarshal(data, &iss); err != nil { + return nil, fmt.Errorf("corrupt archived issue %s: %w", id, err) + } + return &iss, nil +} + +// ClosedBefore returns closed issues whose closed_at timestamp precedes cutoff, +// sorted like other listings. Issues without a parseable closed_at are skipped. +// Only closed issues are considered — open work is never selected by date. +func (s *Store) ClosedBefore(cutoff time.Time) ([]*Issue, error) { + var result []*Issue + for _, id := range s.IDsWithStatus("closed") { + iss, err := s.readIssue(id) + if err != nil { + continue + } + if iss.ClosedAt == "" { + continue + } + t, err := time.Parse(time.RFC3339, iss.ClosedAt) + if err != nil { + continue + } + if t.Before(cutoff) { + result = append(result, iss) + } + } + sortIssues(result, s.Now()) + return result, nil +} diff --git a/internal/issue/archive_test.go b/internal/issue/archive_test.go new file mode 100644 index 00000000..b624b45b --- /dev/null +++ b/internal/issue/archive_test.go @@ -0,0 +1,297 @@ +package issue_test + +import ( + "errors" + "testing" + "time" + + "github.com/jallum/beadwork/internal/issue" + "github.com/jallum/beadwork/internal/testutil" +) + +func issueIDs(issues []*issue.Issue) []string { + ids := make([]string, len(issues)) + for i, iss := range issues { + ids[i] = iss.ID + } + return ids +} + +func TestArchiveClosedIssue(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + iss, _ := env.Store.Create("Done thing", issue.CreateOpts{}) + env.Store.Label(iss.ID, []string{"bug"}, nil) + env.Store.Close(iss.ID, "completed") + env.CommitIntent("setup") + + got, err := env.Store.Archive(iss.ID, issue.ArchiveOpts{}) + if err != nil { + t.Fatalf("Archive: %v", err) + } + if got.ArchivedAt == "" { + t.Error("ArchivedAt not stamped") + } + if !env.MarkerExists("archive/" + iss.ID + ".json") { + t.Error("archive/.json missing") + } + if env.IssueFileExists(iss.ID) { + t.Error("issues/.json should be gone") + } + if env.MarkerExists("status/closed/" + iss.ID) { + t.Error("status/closed marker should be gone") + } + if env.MarkerExists("labels/bug/" + iss.ID) { + t.Error("label marker should be gone") + } + if _, err := env.Store.Get(iss.ID); err == nil { + t.Error("archived issue should no longer resolve via Get") + } + a, err := env.Store.ArchivedIssue(iss.ID) + if err != nil { + t.Fatalf("ArchivedIssue: %v", err) + } + if a.Title != "Done thing" { + t.Errorf("archived title = %q, want %q", a.Title, "Done thing") + } +} + +func TestArchiveExcludedFromListAndReady(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + iss, _ := env.Store.Create("Done thing", issue.CreateOpts{}) + env.Store.Close(iss.ID, "") + env.CommitIntent("setup") + + if _, err := env.Store.Archive(iss.ID, issue.ArchiveOpts{}); err != nil { + t.Fatalf("Archive: %v", err) + } + + all, _ := env.Store.List(issue.Filter{}) + for _, l := range all { + if l.ID == iss.ID { + t.Error("archived issue appeared in List") + } + } + closed, _ := env.Store.List(issue.Filter{Status: "closed"}) + for _, l := range closed { + if l.ID == iss.ID { + t.Error("archived issue appeared in List(status=closed)") + } + } +} + +func TestArchiveRefusesOpenIssue(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + iss, _ := env.Store.Create("Active", issue.CreateOpts{}) + env.CommitIntent("setup") + + _, err := env.Store.Archive(iss.ID, issue.ArchiveOpts{}) + if err == nil { + t.Fatal("expected error archiving an open issue") + } + var nce *issue.NotClosedError + if !errors.As(err, &nce) { + t.Errorf("want *NotClosedError, got %T: %v", err, err) + } + if !env.IssueFileExists(iss.ID) { + t.Error("issue file should remain after refused archive") + } +} + +func TestArchiveClosesWithCloseOpt(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + iss, _ := env.Store.Create("Active", issue.CreateOpts{}) + env.CommitIntent("setup") + + got, err := env.Store.Archive(iss.ID, issue.ArchiveOpts{Close: true}) + if err != nil { + t.Fatalf("Archive --close: %v", err) + } + if got.Status != "closed" || got.ClosedAt == "" { + t.Errorf("want closed with ClosedAt, got status=%q closedAt=%q", got.Status, got.ClosedAt) + } + if !env.MarkerExists("archive/" + iss.ID + ".json") { + t.Error("archive/.json missing") + } + if env.MarkerExists("status/open/" + iss.ID) { + t.Error("status/open marker should be gone") + } +} + +func TestArchiveRefusesOpenDependent(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + blocker, _ := env.Store.Create("Blocker", issue.CreateOpts{}) + blocked, _ := env.Store.Create("Blocked", issue.CreateOpts{}) + env.Store.Link(blocker.ID, blocked.ID) // blocker blocks blocked + env.Store.Close(blocker.ID, "") + env.CommitIntent("setup") + + _, err := env.Store.Archive(blocker.ID, issue.ArchiveOpts{}) + if err == nil { + t.Fatal("expected refusal: open dependent") + } + var abe *issue.ArchiveBlockedError + if !errors.As(err, &abe) { + t.Fatalf("want *ArchiveBlockedError, got %T: %v", err, err) + } + if len(abe.OpenDependents) != 1 || abe.OpenDependents[0] != blocked.ID { + t.Errorf("OpenDependents = %v, want [%s]", abe.OpenDependents, blocked.ID) + } + if !env.IssueFileExists(blocker.ID) { + t.Error("blocker should remain after refused archive") + } +} + +func TestArchiveDetachDropsEdgeAndUnblocks(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + blocker, _ := env.Store.Create("Blocker", issue.CreateOpts{}) + blocked, _ := env.Store.Create("Blocked", issue.CreateOpts{}) + env.Store.Link(blocker.ID, blocked.ID) + env.Store.Close(blocker.ID, "") + env.CommitIntent("setup") + + if _, err := env.Store.Archive(blocker.ID, issue.ArchiveOpts{Detach: true}); err != nil { + t.Fatalf("Archive --detach: %v", err) + } + + b, _ := env.Store.Get(blocked.ID) + if len(b.BlockedBy) != 0 { + t.Errorf("blocked.BlockedBy = %v, want empty", b.BlockedBy) + } + if env.MarkerExists("blocks/" + blocker.ID + "/" + blocked.ID) { + t.Error("blocks marker should be gone") + } + ready, _ := env.Store.Ready() + found := false + for _, r := range ready { + if r.ID == blocked.ID { + found = true + } + } + if !found { + t.Error("blocked should be ready after its only blocker is archived") + } +} + +func TestArchiveClosedDependentNeedsNoDetach(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + blocker, _ := env.Store.Create("Blocker", issue.CreateOpts{}) + blocked, _ := env.Store.Create("Blocked", issue.CreateOpts{}) + env.Store.Link(blocker.ID, blocked.ID) + env.Store.Close(blocked.ID, "") + env.Store.Close(blocker.ID, "") + env.CommitIntent("setup") + + if _, err := env.Store.Archive(blocker.ID, issue.ArchiveOpts{}); err != nil { + t.Fatalf("Archive (closed dependent): %v", err) + } + b, _ := env.Store.Get(blocked.ID) + if len(b.BlockedBy) != 0 { + t.Errorf("blocked.BlockedBy = %v, want empty after archive", b.BlockedBy) + } +} + +func TestArchiveRefusesOpenChildThenDetachOrphans(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + parent, _ := env.Store.Create("Parent", issue.CreateOpts{}) + child, _ := env.Store.Create("Child", issue.CreateOpts{Parent: parent.ID}) + env.Store.Close(parent.ID, "") + env.CommitIntent("setup") + + _, err := env.Store.Archive(parent.ID, issue.ArchiveOpts{}) + if err == nil { + t.Fatal("expected refusal: open child") + } + var abe *issue.ArchiveBlockedError + if !errors.As(err, &abe) || len(abe.OpenChildren) != 1 || abe.OpenChildren[0] != child.ID { + t.Fatalf("want ArchiveBlockedError with open child %s, got %v", child.ID, err) + } + + if _, err := env.Store.Archive(parent.ID, issue.ArchiveOpts{Detach: true}); err != nil { + t.Fatalf("Archive --detach: %v", err) + } + c, _ := env.Store.Get(child.ID) + if c.Parent != "" { + t.Errorf("child.Parent = %q, want cleared", c.Parent) + } +} + +func TestArchiveAlreadyArchivedFails(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + iss, _ := env.Store.Create("Done", issue.CreateOpts{}) + env.Store.Close(iss.ID, "") + env.CommitIntent("setup") + if _, err := env.Store.Archive(iss.ID, issue.ArchiveOpts{}); err != nil { + t.Fatalf("Archive: %v", err) + } + if _, err := env.Store.Archive(iss.ID, issue.ArchiveOpts{}); err == nil { + t.Error("archiving an already-archived issue should fail") + } +} + +func TestArchivedIDCannotBeReused(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + iss, _ := env.Store.Create("Done", issue.CreateOpts{}) + id := iss.ID + env.Store.Close(id, "") + env.CommitIntent("setup") + if _, err := env.Store.Archive(id, issue.ArchiveOpts{}); err != nil { + t.Fatalf("Archive: %v", err) + } + + // An archived ID is out of the live space but must not be recyclable — + // reuse would collide with the archived record and break recovery. + if _, err := env.Store.Create("Imposter", issue.CreateOpts{ID: id}); err == nil { + t.Errorf("creating an issue with archived ID %q should fail", id) + } +} + +func TestClosedBefore(t *testing.T) { + env := testutil.NewEnv(t) + defer env.Cleanup() + + mk := func(id, closedAt string) { + env.Store.Import(&issue.Issue{ + ID: id, Title: id, Status: "closed", Priority: 2, Type: "task", + Created: "2024-01-01T00:00:00Z", ClosedAt: closedAt, + Labels: []string{}, Blocks: []string{}, BlockedBy: []string{}, + }) + } + mk("test-old", "2025-06-01T00:00:00Z") + mk("test-new", "2026-03-01T00:00:00Z") + // An open issue is never returned, regardless of age. + env.Store.Import(&issue.Issue{ + ID: "test-open", Title: "open", Status: "open", Priority: 2, Type: "task", + Created: "2020-01-01T00:00:00Z", + Labels: []string{}, Blocks: []string{}, BlockedBy: []string{}, + }) + env.CommitIntent("setup") + + cutoff := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + got, err := env.Store.ClosedBefore(cutoff) + if err != nil { + t.Fatalf("ClosedBefore: %v", err) + } + if len(got) != 1 || got[0].ID != "test-old" { + t.Errorf("ClosedBefore = %v, want [test-old]", issueIDs(got)) + } +} diff --git a/internal/issue/id.go b/internal/issue/id.go index 81c31368..62c803cb 100644 --- a/internal/issue/id.go +++ b/internal/issue/id.go @@ -16,14 +16,25 @@ func (s *Store) validateExplicitID(id string) (string, error) { if strings.ContainsAny(id, " \t\n\r") { return "", fmt.Errorf("invalid ID %q: must not contain whitespace", id) } - // Uniqueness check. + // Uniqueness check — against both live and archived IDs. An archived ID is + // out of the live space, but reusing it would collide with the archived + // record and break recovery. existing := s.ExistingIDs() if existing[id] { return "", fmt.Errorf("ID %q already exists", id) } + if s.isArchived(id) { + return "", fmt.Errorf("ID %q is archived", id) + } return id, nil } +// isArchived reports whether an archive/.json record exists. +func (s *Store) isArchived(id string) bool { + _, err := s.FS.Stat("archive/" + id + ".json") + return err == nil +} + func (s *Store) generateID() (string, error) { existing := s.ExistingIDs() retries := s.IDRetries @@ -45,7 +56,7 @@ func (s *Store) generateID() (string, error) { suffix.WriteByte(base36[int(v)%36]) } id := s.Prefix + "-" + suffix.String() - if !existing[id] { + if !existing[id] && !s.isArchived(id) { return id, nil } } diff --git a/internal/issue/issue.go b/internal/issue/issue.go index 8d940201..3cf49b2b 100644 --- a/internal/issue/issue.go +++ b/internal/issue/issue.go @@ -17,6 +17,7 @@ type Comment struct { } type Issue struct { + ArchivedAt string `json:"archived_at,omitempty"` Assignee string `json:"assignee"` BlockedBy []string `json:"blocked_by"` Blocks []string `json:"blocks"`