package api
import (
"context"
"errors"
"fmt"
"log"
"strings"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/work"
"craftdesign.group/quarry/internal/zitcli"
)
// RefusalMergeConflict is a pair archive's answer when the pair does not
// merge: there is no tree to test, and the conflicting paths are the
// verdict (RFC 0009 §premerge).
const RefusalMergeConflict = "merge_conflict"
// parsePair reads "pair:<branchTip>:<targetTip>". Both tips must be full
// check-in hashes: a pair names two fixed trees, and a name that could
// move would make the cached archive a lie.
func parsePair(v string) (branchTip, targetTip string, ok bool) {
rest, ok := strings.CutPrefix(v, "pair:")
if !ok {
return "", "", false
}
branchTip, targetTip, ok = strings.Cut(rest, ":")
if !ok || !fullHash(branchTip) || !fullHash(targetTip) {
return "", "", false
}
return branchTip, targetTip, true
}
// pairArchive is archive.get for a premerge pair: the tree accepting
// branchTip onto targetTip would write, built from this forge's own
// store and never written to it. The runner's archive checkout fetches
// it exactly as it fetches a check-in, so a pair run needs no zit and no
// clone on the worker. A pair never changes, so its archive is cached
// like a check-in's.
func (d Deps) pairArchive(ctx context.Context, repo *Repo, in archiveIn, format string) (archiveFile, error) {
branchTip, targetTip, ok := parsePair(in.Version)
if !ok {
return archiveFile{}, fmt.Errorf("a pair is pair:<branch tip>:<target tip>, both full check-in hashes: %w", ops.ErrInvalid)
}
if format != "tar" {
return archiveFile{}, fmt.Errorf("a pair's tree is served as tar only: %w", ops.ErrInvalid)
}
name := in.Repo + "-pair-" + branchTip[:12] + "-" + targetTip[:12]
build := func(dst string) error {
return d.ArchiveLane.DoNamed(ctx, "archive", in.Repo, func() error {
return d.Bulk.DoNamed(ctx, "archive", in.Repo, func() error {
return repo.Zit.MergeArchiveTo(ctx, branchTip, targetTip, name, dst, maxArchiveBytes)
})
})
}
produce := func() (string, bool, error) {
var path string
var err error
temp := d.Archives == nil
if temp {
path, err = tempArchive(build)
} else {
path, err = d.Archives.Get(archiveKey(in.Repo, d.ZitVersion, in.Version, format), build)
}
var mc *zitcli.MergeConflict
switch {
case errors.As(err, &mc):
return "", false, &ops.Refusal{Code: RefusalMergeConflict, Message: mc.Error(),
Fields: map[string]string{"state": mc.State, "paths": strings.Join(mc.Paths, "\n")}}
case errors.Is(err, work.ErrBusy):
return "", false, fmt.Errorf("archive queue is full, retry shortly (%w): %w", work.ErrBusy, ops.ErrUnavailable)
case errors.Is(err, zitcli.ErrNoVersion):
return "", false, fmt.Errorf("no such check-in in pair %s: %w", in.Version, ops.ErrNotFound)
case err != nil:
log.Printf("archive %s@%s: %v", in.Repo, in.Version, err)
return "", false, fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
}
return path, temp, nil
}
return archiveFile{name: name + ".tar.gz", ctype: "application/gzip", version: in.Version, format: format,
requested: in.Version, build: produce}, nil
}