From fb5947ea3a21a93645c8d75bf6f5e9f4c681829b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 19:09:26 +0000 Subject: [PATCH] Add local publication attempt discovery --- docs/roadmap/implementation.md | 2 + internal/publish/attempts.go | 91 +++++++++++++++++++++++++++++++++ internal/publish/reconcile.go | 1 + internal/publish/record.go | 12 ++--- internal/publish/record_test.go | 46 +++++++++++++++++ recovery.go | 37 +++++++++++++- recovery_test.go | 18 +++++++ 7 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 internal/publish/attempts.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 67cef89..6d4dc58 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -301,6 +301,8 @@ both API results and Stage 7's CLI integration, preserving publication success. ## Stage 5: Provide local attempt discovery +**Completed:** 2026-11-02 + **Outcome:** Callers can find saved attempts even after losing publication output, with useful partial discovery when a record is damaged. diff --git a/internal/publish/attempts.go b/internal/publish/attempts.go new file mode 100644 index 0000000..55f17a2 --- /dev/null +++ b/internal/publish/attempts.go @@ -0,0 +1,91 @@ +package publish + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +type Attempt struct { + RecordPath string + Evidence Evidence +} +type AttemptsResult struct { + RecordDir string + Attempts []Attempt + Diagnostics []*RecoveryError +} + +func ListAttempts(ctx context.Context, repositoryDir, override string) (AttemptsResult, error) { + if repositoryDir == "" { + repositoryDir = "." + } + result := AttemptsResult{Attempts: []Attempt{}, Diagnostics: []*RecoveryError{}} + var directory string + { + resolved, err := recordDirectoryFor(ctx, repositoryDir, override) + if err != nil { + return result, &RecoveryError{Kind: "storage_enumeration", cause: err} + } + directory = resolved + } + result.RecordDir = directory + if err := ctx.Err(); err != nil { + return result, &RecoveryError{Kind: "canceled", cause: err} + } + entries, err := os.ReadDir(directory) + if err != nil { + return result, &RecoveryError{Kind: "storage_enumeration", cause: fmt.Errorf("enumerate records in %q: %w", directory, err)} + } + paths := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") { + paths = append(paths, filepath.Join(directory, entry.Name())) + } + } + sort.Strings(paths) + for _, path := range paths { + if err := ctx.Err(); err != nil { + return result, &RecoveryError{Kind: "canceled", cause: err} + } + absolute, err := filepath.Abs(path) + if err != nil { + result.Diagnostics = append(result.Diagnostics, &RecoveryError{Kind: "record_read", RecordPath: path, cause: err}) + continue + } + evidence, err := LoadEvidence(absolute) + if err != nil { + var recordError *RecoveryError + if errors.As(err, &recordError) { + result.Diagnostics = append(result.Diagnostics, recordError) + } + continue + } + result.Attempts = append(result.Attempts, Attempt{RecordPath: absolute, Evidence: evidence}) + } + return result, nil +} + +func recordDirectoryFor(ctx context.Context, repositoryDir, override string) (string, error) { + if filepath.IsAbs(override) { + return filepath.Clean(override), nil + } + root, err := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", err + } + rootPath := strings.TrimSpace(string(root)) + if override != "" { + return filepath.Join(rootPath, override), nil + } + common, err := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--path-format=absolute", "--git-common-dir").Output() + if err != nil { + return "", err + } + return filepath.Join(strings.TrimSpace(string(common)), "releasekit", "publication-attempts"), nil +} diff --git a/internal/publish/reconcile.go b/internal/publish/reconcile.go index 2020d72..0bcb4fc 100644 --- a/internal/publish/reconcile.go +++ b/internal/publish/reconcile.go @@ -32,6 +32,7 @@ type Observation struct { // RecoveryError describes why a later publication observation is unresolved. type RecoveryError struct { Kind string + RecordPath string MissingFields []string cause error } diff --git a/internal/publish/record.go b/internal/publish/record.go index 86a9e80..0f545df 100644 --- a/internal/publish/record.go +++ b/internal/publish/record.go @@ -25,26 +25,26 @@ type recordEnvelope struct { func LoadEvidence(path string) (Evidence, error) { data, err := os.ReadFile(path) if err != nil { - return Evidence{}, &RecoveryError{Kind: "record_read", cause: fmt.Errorf("read record %q: %w", path, err)} + return Evidence{}, &RecoveryError{Kind: "record_read", RecordPath: path, cause: fmt.Errorf("read record %q: %w", path, err)} } decoder := json.NewDecoder(strings.NewReader(string(data))) var envelope recordEnvelope if err := decoder.Decode(&envelope); err != nil { - return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("decode record %q: %w", path, err)} + return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record %q: %w", path, err)} } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { - return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("record %q contains trailing JSON", path)} + return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q contains trailing JSON", path)} } if envelope.FormatVersion != recordFormatVersion { - return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)} + return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)} } if len(envelope.Evidence) == 0 || string(envelope.Evidence) == "null" { - return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("record %q has no evidence object", path)} + return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has no evidence object", path)} } var evidence Evidence if err := json.Unmarshal(envelope.Evidence, &evidence); err != nil { - return Evidence{}, &RecoveryError{Kind: "record_format", cause: fmt.Errorf("decode record evidence %q: %w", path, err)} + return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record evidence %q: %w", path, err)} } return evidence, nil } diff --git a/internal/publish/record_test.go b/internal/publish/record_test.go index 675b37c..2c3d02d 100644 --- a/internal/publish/record_test.go +++ b/internal/publish/record_test.go @@ -1,11 +1,13 @@ package publish import ( + "context" "encoding/json" "errors" "os" "os/exec" "path/filepath" + "sort" "strings" "testing" ) @@ -154,3 +156,47 @@ func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) { t.Fatalf("unexpected record: %v", err) } } + +func TestListAttemptsReturnsReadableRecordsAndDiagnosticsInPathOrder(t *testing.T) { + repo := recordRepo(t) + directory := filepath.Join(t.TempDir(), "attempts") + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + second, _ := SaveEvidence(repo, directory, Evidence{Tag: "v2.0.0"}) + first, _ := SaveEvidence(repo, directory, Evidence{Tag: "v1.0.0"}) + if err := os.WriteFile(filepath.Join(directory, "broken.json"), []byte("{"), 0600); err != nil { + t.Fatal(err) + } + listed, err := ListAttempts(t.Context(), repo, directory) + if err != nil || len(listed.Attempts) != 2 || len(listed.Diagnostics) != 1 { + t.Fatalf("list=%#v error=%v", listed, err) + } + wantPaths := []string{first, second} + sort.Strings(wantPaths) + if listed.Attempts[0].RecordPath != wantPaths[0] || listed.Attempts[1].RecordPath != wantPaths[1] { + t.Fatalf("attempt order=%v", listed.Attempts) + } + if listed.Diagnostics[0].Kind != "record_format" || listed.Diagnostics[0].RecordPath == "" { + t.Fatalf("diagnostic=%#v", listed.Diagnostics[0]) + } +} + +func TestListAttemptsDistinguishesEmptyMissingAndCanceledStorage(t *testing.T) { + empty := t.TempDir() + listed, err := ListAttempts(t.Context(), "", empty) + if err != nil || len(listed.Attempts) != 0 || len(listed.Diagnostics) != 0 { + t.Fatalf("empty list=%#v error=%v", listed, err) + } + _, err = ListAttempts(t.Context(), "", filepath.Join(t.TempDir(), "missing")) + var recovery *RecoveryError + if !errors.As(err, &recovery) || recovery.Kind != "storage_enumeration" { + t.Fatalf("missing storage error=%v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = ListAttempts(ctx, "", empty) + if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) { + t.Fatalf("canceled error=%v", err) + } +} diff --git a/recovery.go b/recovery.go index 9681e25..1cd0381 100644 --- a/recovery.go +++ b/recovery.go @@ -78,6 +78,37 @@ func LoadPublicationEvidence(path string) (PublicationEvidence, error) { return publicEvidence(evidence), publicRecoveryError(err, path) } +type PublicationAttemptsRequest struct { + RepositoryDir string + RecordDir string +} +type PublicationAttempt struct { + RecordPath string + Evidence PublicationEvidence +} +type PublicationAttemptsResult struct { + RecordDir string + Attempts []PublicationAttempt + Diagnostics []*PublicationRecoveryError +} + +// ListPublicationAttempts discovers saved local records without querying a +// remote or inferring which attempt should be reconciled. +func ListPublicationAttempts(ctx context.Context, request PublicationAttemptsRequest) (PublicationAttemptsResult, error) { + listed, err := publish.ListAttempts(ctx, request.RepositoryDir, request.RecordDir) + result := PublicationAttemptsResult{RecordDir: listed.RecordDir, Attempts: make([]PublicationAttempt, 0, len(listed.Attempts)), Diagnostics: make([]*PublicationRecoveryError, 0, len(listed.Diagnostics))} + for _, attempt := range listed.Attempts { + result.Attempts = append(result.Attempts, PublicationAttempt{RecordPath: attempt.RecordPath, Evidence: publicEvidence(attempt.Evidence)}) + } + for _, diagnostic := range listed.Diagnostics { + converted := publicRecoveryError(diagnostic, diagnostic.RecordPath) + if recovery, ok := converted.(*PublicationRecoveryError); ok { + result.Diagnostics = append(result.Diagnostics, recovery) + } + } + return result, publicRecoveryError(err, "") +} + func internalEvidence(e PublicationEvidence) publish.Evidence { return publish.Evidence(e) } func publicEvidence(e publish.Evidence) PublicationEvidence { return PublicationEvidence(e) } func publicObservation(o publish.Observation) PublicationReconciliationResult { @@ -90,7 +121,11 @@ func publicRecoveryError(err error, path string) error { } var recovery *publish.RecoveryError if errors.As(err, &recovery) { - return &PublicationRecoveryError{Kind: recovery.Kind, RecordPath: path, MissingFields: append([]string(nil), recovery.MissingFields...), cause: recovery} + recordPath := path + if recordPath == "" { + recordPath = recovery.RecordPath + } + return &PublicationRecoveryError{Kind: recovery.Kind, RecordPath: recordPath, MissingFields: append([]string(nil), recovery.MissingFields...), cause: recovery} } return &PublicationRecoveryError{Kind: "remote_observation", RecordPath: path, cause: err} } diff --git a/recovery_test.go b/recovery_test.go index b52d8b3..232e4ec 100644 --- a/recovery_test.go +++ b/recovery_test.go @@ -38,3 +38,21 @@ func TestRecoveryFacadeErrorUnwrapsCancellation(t *testing.T) { t.Fatalf("error=%v", err) } } + +func TestListPublicationAttemptsReadsExplicitExternalStore(t *testing.T) { + directory := t.TempDir() + good := filepath.Join(directory, "attempt-a.json") + if err := os.WriteFile(good, []byte(`{"format_version":1,"evidence":{"tag":"v1.2.3"}}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "attempt-b.json"), []byte("{"), 0600); err != nil { + t.Fatal(err) + } + listed, err := releasekit.ListPublicationAttempts(context.Background(), releasekit.PublicationAttemptsRequest{RecordDir: directory}) + if err != nil || listed.RecordDir != directory || len(listed.Attempts) != 1 || len(listed.Diagnostics) != 1 { + t.Fatalf("listing=%#v error=%v", listed, err) + } + if listed.Attempts[0].RecordPath != good || listed.Attempts[0].Evidence.Tag != "v1.2.3" || listed.Diagnostics[0].Kind != "record_format" { + t.Fatalf("listing=%#v", listed) + } +}