package releasekit import ( "context" "errors" "fmt" "go.maximumdirect.net/releasekit/internal/git" "go.maximumdirect.net/releasekit/internal/publish" ) // PublicationEvidence identifies one attempted publication independently of // current repository state. Reconciliation uses RepositoryDir as Git's working // directory, Tag and Endpoint as the observed ref and destination, and the // captured object IDs to classify the observation. Endpoint may contain // credentials; treat this value and saved records as sensitive. type PublicationEvidence struct { RepositoryDir string // Git working-directory context for relative endpoints; empty uses the caller's current directory. ModuleDir string // physical module directory captured for this attempt. ModulePath string // declared module path captured for this attempt. Version string // semantic version selected for this attempt. Tag string // complete Git tag ref selected for this attempt. CandidateCommit string // candidate commit captured before publication. TagObject string // annotated tag object ID captured before pushing, when available. Endpoint string // exact effective push endpoint captured for this attempt; may contain credentials. Remote string // remote name supplying supported transport configuration during later observation. RecordedAt string // UTC RFC3339Nano time when this attempt's evidence was initialized, when available. } // PublicationReconciliationResult combines saved evidence with one later // observation. It does not update local or remote state or modify the record. type PublicationReconciliationResult struct { Evidence PublicationEvidence // original selected evidence. State PublicationState // published, not_published, conflict, or unknown. ObservedTagObject string // observed remote tag-object ID, when available. ObservedCommit string // observed peeled commit ID, when available. } // PublicationRecoveryError describes an unresolved recovery operation or a // record/storage failure. Its Error text is credential-safe; Unwrap retains // the underlying cause for errors.Is and errors.As. // Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed, // transport_configuration, remote_observation, inconsistent_observation, // record_format, record_read, record_write, and storage_enumeration. // endpoint_changed identifies a confirmed URL-resolution mismatch; // transport_configuration identifies configuration-read or resolution failures, // including when the original remote name is absent. type PublicationRecoveryError struct { Kind string RecordPath string // selected or affected record path, when applicable. MissingFields []string // private-record field names required for the attempted classification. cause error } // Error returns the credential-safe recovery diagnostic. func (e *PublicationRecoveryError) Error() string { if e == nil { return "" } message := fmt.Sprintf("publication recovery %s", e.Kind) if e.RecordPath != "" { message += fmt.Sprintf(" for %q", git.DisplayDiagnostic(e.RecordPath)) } if e.cause != nil { message += ": " + git.DisplayError(e.cause).Error() } return git.DisplayDiagnostic(message) } // Unwrap returns the underlying recovery or storage cause. func (e *PublicationRecoveryError) Unwrap() error { if e == nil { return nil } return e.cause } // ReconcilePublication observes the recorded endpoint without running candidate // validation or changing local or remote state. func ReconcilePublication(ctx context.Context, evidence PublicationEvidence) (PublicationReconciliationResult, error) { observation, err := publish.Reconcile(ctx, internalEvidence(evidence)) return publicObservation(observation), publicRecoveryError(err, "") } // LoadPublicationEvidence loads one explicit private attempt record. Relative // paths are interpreted from the caller's current directory; no Git lookup is // performed. func LoadPublicationEvidence(path string) (PublicationEvidence, error) { evidence, err := publish.LoadEvidence(path) return publicEvidence(evidence), publicRecoveryError(err, path) } // PublicationAttemptsRequest selects local publication-attempt storage. type PublicationAttemptsRequest struct { RepositoryDir string // repository used to resolve an empty or relative RecordDir; empty means the current directory. RecordDir string // optional storage directory; relative paths use the repository root, and absolute paths can be listed outside a checkout. } // PublicationAttempt pairs one readable record path with the evidence it stores. type PublicationAttempt struct { RecordPath string // absolute path of the selected record. Evidence PublicationEvidence // saved identity, including any partial fields. } // PublicationAttemptsResult contains readable attempts and per-record // diagnostics from one local directory enumeration. type PublicationAttemptsResult struct { RecordDir string // resolved absolute directory selected for enumeration, when available. Attempts []PublicationAttempt // readable records in deterministic path order. Diagnostics []*PublicationRecoveryError // unreadable or unsupported records; other entries remain available. } // 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) return publicAttemptsResult(listed, err) } func publicAttemptsResult(listed publish.AttemptsResult, err error) (PublicationAttemptsResult, error) { 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 { return PublicationReconciliationResult{Evidence: publicEvidence(o.Evidence), State: PublicationState(o.State), ObservedTagObject: o.ObservedTagObject, ObservedCommit: o.ObservedCommit} } func publicRecoveryError(err error, path string) error { if err == nil { return nil } var recovery *publish.RecoveryError if errors.As(err, &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} }