Files
releasekit/internal/publish/reconcile.go

154 lines
5.0 KiB
Go

package publish
import (
"context"
"errors"
"go.maximumdirect.net/releasekit/internal/git"
)
// Evidence is the identity captured when one publication was attempted.
type Evidence struct {
RepositoryDir string `json:"repository_dir,omitempty"`
ModuleDir string `json:"module_dir,omitempty"`
ModulePath string `json:"module_path,omitempty"`
Version string `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
CandidateCommit string `json:"candidate_commit,omitempty"`
TagObject string `json:"tag_object,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Remote string `json:"remote,omitempty"`
RecordedAt string `json:"recorded_at,omitempty"`
}
// Observation combines the original evidence with authoritative remote state.
type Observation struct {
Evidence Evidence
State State
ObservedTagObject string
ObservedCommit string
}
// RecoveryError describes why a later publication observation is unresolved.
type RecoveryError struct {
Kind string
RecordPath string
MissingFields []string
cause error
}
func (e *RecoveryError) Error() string {
if e == nil {
return ""
}
message := "publication recovery " + e.Kind
if e.cause != nil {
return message + ": " + git.DisplayError(e.cause).Error()
}
return message
}
func (e *RecoveryError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func classify(evidence Evidence, remoteTag git.RemoteRef) (Observation, error) {
result := Observation{Evidence: evidence, State: Unknown, ObservedTagObject: remoteTag.Object, ObservedCommit: remoteTag.Peeled}
missing := []string{}
if evidence.Endpoint == "" {
missing = append(missing, "endpoint")
}
if evidence.Tag == "" {
missing = append(missing, "tag")
}
if len(missing) > 0 {
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: missing}
}
if remoteTag.State == git.RefAbsent {
result.State = NotPublished
return result, nil
}
if evidence.TagObject == "" {
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: []string{"tag_object"}}
}
if remoteTag.State != git.RefPresent {
return result, &RecoveryError{Kind: "inconsistent_observation", cause: git.ErrRemoteStateUnreadable}
}
if !git.EqualObjectID(remoteTag.Object, evidence.TagObject) {
result.State = Conflict
return result, nil
}
if remoteTag.Peeled != "" {
if evidence.CandidateCommit == "" {
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: []string{"candidate_commit"}}
}
if !git.EqualObjectID(remoteTag.Peeled, evidence.CandidateCommit) {
result.State = Conflict
return result, nil
}
}
result.State = Published
return result, nil
}
func observe(ctx context.Context, evidence Evidence) (Observation, error) {
if err := ctx.Err(); err != nil {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "canceled", cause: err}
}
if evidence.Endpoint == "" || evidence.Tag == "" {
return classify(evidence, git.RemoteRef{})
}
if evidence.TagObject != "" && !git.ValidObjectID(evidence.TagObject) {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
}
if evidence.CandidateCommit != "" && !git.ValidObjectID(evidence.CandidateCommit) {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
}
valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag)
if err != nil {
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
}
if !valid {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
}
endpoint, err := git.ObserveEndpoint(ctx, evidence.RepositoryDir, evidence.Endpoint, evidence.Remote)
if err != nil {
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
}
ref, err := endpoint.QueryTag(ctx, evidence.RepositoryDir, evidence.Tag)
if err != nil {
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
}
result, err := classify(evidence, ref)
if err != nil && ctx.Err() != nil {
return result, &RecoveryError{Kind: "canceled", cause: ctx.Err()}
}
return result, err
}
func observationFailure(ctx context.Context, err error) *RecoveryError {
kind := "remote_observation"
switch {
case errors.Is(err, git.ErrEndpointChanged):
kind = "endpoint_changed"
case errors.Is(err, git.ErrTransportConfiguration):
kind = "transport_configuration"
}
return recoveryFailure(ctx, kind, err)
}
// Reconcile observes the recorded endpoint and classifies its exact tag.
func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) {
return observe(ctx, evidence)
}
// recoveryFailure classifies failed operations while preserving caller cancellation.
func recoveryFailure(ctx context.Context, kind string, err error) *RecoveryError {
if contextErr := ctx.Err(); contextErr != nil {
return &RecoveryError{Kind: "canceled", cause: contextErr}
}
return &RecoveryError{Kind: kind, cause: err}
}