Add local publication attempt discovery
This commit is contained in:
@@ -301,6 +301,8 @@ both API results and Stage 7's CLI integration, preserving publication success.
|
|||||||
|
|
||||||
## Stage 5: Provide local attempt discovery
|
## Stage 5: Provide local attempt discovery
|
||||||
|
|
||||||
|
**Completed:** 2026-11-02
|
||||||
|
|
||||||
**Outcome:** Callers can find saved attempts even after losing publication
|
**Outcome:** Callers can find saved attempts even after losing publication
|
||||||
output, with useful partial discovery when a record is damaged.
|
output, with useful partial discovery when a record is damaged.
|
||||||
|
|
||||||
|
|||||||
91
internal/publish/attempts.go
Normal file
91
internal/publish/attempts.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ type Observation struct {
|
|||||||
// RecoveryError describes why a later publication observation is unresolved.
|
// RecoveryError describes why a later publication observation is unresolved.
|
||||||
type RecoveryError struct {
|
type RecoveryError struct {
|
||||||
Kind string
|
Kind string
|
||||||
|
RecordPath string
|
||||||
MissingFields []string
|
MissingFields []string
|
||||||
cause error
|
cause error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,26 +25,26 @@ type recordEnvelope struct {
|
|||||||
func LoadEvidence(path string) (Evidence, error) {
|
func LoadEvidence(path string) (Evidence, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
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)))
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||||
var envelope recordEnvelope
|
var envelope recordEnvelope
|
||||||
if err := decoder.Decode(&envelope); err != nil {
|
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
|
var trailing any
|
||||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
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 {
|
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" {
|
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
|
var evidence Evidence
|
||||||
if err := json.Unmarshal(envelope.Evidence, &evidence); err != nil {
|
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
|
return evidence, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package publish
|
package publish
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -154,3 +156,47 @@ func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) {
|
|||||||
t.Fatalf("unexpected record: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
37
recovery.go
37
recovery.go
@@ -78,6 +78,37 @@ func LoadPublicationEvidence(path string) (PublicationEvidence, error) {
|
|||||||
return publicEvidence(evidence), publicRecoveryError(err, path)
|
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 internalEvidence(e PublicationEvidence) publish.Evidence { return publish.Evidence(e) }
|
||||||
func publicEvidence(e publish.Evidence) PublicationEvidence { return PublicationEvidence(e) }
|
func publicEvidence(e publish.Evidence) PublicationEvidence { return PublicationEvidence(e) }
|
||||||
func publicObservation(o publish.Observation) PublicationReconciliationResult {
|
func publicObservation(o publish.Observation) PublicationReconciliationResult {
|
||||||
@@ -90,7 +121,11 @@ func publicRecoveryError(err error, path string) error {
|
|||||||
}
|
}
|
||||||
var recovery *publish.RecoveryError
|
var recovery *publish.RecoveryError
|
||||||
if errors.As(err, &recovery) {
|
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}
|
return &PublicationRecoveryError{Kind: "remote_observation", RecordPath: path, cause: err}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,3 +38,21 @@ func TestRecoveryFacadeErrorUnwrapsCancellation(t *testing.T) {
|
|||||||
t.Fatalf("error=%v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user