Files
repospawner/internal/jobs/state.go
T
unkin-agent 8786636f7c
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Address review findings on the initial service
Four issues from the review of the initial repospawner service, none of
which change the shape of a request or the file terraform-git receives.

- Encode status checks as one --check flag per context on the
  server-to-job hop, so a separator inside a context can no longer turn
  one context into several; ban commas (and cap lengths) in Validate as
  well, since a real context never holds one.
- Fail a merged request that has waited five minutes for a Woodpecker
  token that vanished after acceptance, surfacing "woodpecker token
  unavailable" through the API, instead of warning in the log forever
  from enabling-ci. Advance now leaves a terminal request alone so the
  failure sticks.
- Hold a per-name lock from the duplicate checks through the store write,
  so two concurrent submissions of one name cannot both be accepted.
- Cap the description at 500 characters and the status checks at 20
  contexts of 100 characters each, and mirror the first two caps in the
  form.
2026-08-30 14:53:27 +10:00

148 lines
4.1 KiB
Go

package jobs
import (
"git.unkin.net/unkin/repospawner/internal/store"
)
// Action is the next Job the reconciler should create for a request.
type Action string
const (
// ActionNone means the request is either waiting on a running Job or done.
ActionNone Action = ""
// ActionCreateWatch means the pull request exists and wants following.
ActionCreateWatch Action = "watch"
// ActionCreateWoodpecker means the merge landed and CI wants enabling.
ActionCreateWoodpecker Action = "woodpecker"
)
// Advance folds the observed Jobs for one request into its next state and the
// Job that should be created next. It is deliberately pure: the reconciler
// supplies the observations and performs the action.
func Advance(r store.Request, views map[Type]View) (store.Request, Action) {
// A request that already ended badly stays ended: later observations of the
// jobs that got it there must not walk it back out of a terminal state.
if r.State == store.StateFailed || r.State == store.StateClosed {
return r, ActionNone
}
if pr, ok := views[TypePR]; ok {
r = applyPR(r, pr)
}
watch, watching := views[TypeWatch]
if watching {
r = applyWatch(r, watch)
}
if r.State == store.StateFailed || r.State == store.StateClosed {
return r, ActionNone
}
// The pull request is open but nothing is following it yet.
if r.PRNumber > 0 && !watching && r.State != store.StateMerged &&
r.State != store.StateEnablingCI && r.State != store.StateReady {
r.State = store.StatePROpen
return r, ActionCreateWatch
}
if r.State != store.StateMerged && r.State != store.StateEnablingCI && r.State != store.StateReady {
return r, ActionNone
}
if !r.Woodpecker {
r.State = store.StateReady
return r, ActionNone
}
wp, ok := views[TypeWoodpecker]
if !ok {
if r.State == store.StateReady {
// The enablement Job already succeeded and its TTL expired.
return r, ActionNone
}
r.State = store.StateEnablingCI
return r, ActionCreateWoodpecker
}
return applyWoodpecker(r, wp), ActionNone
}
func applyPR(r store.Request, v View) store.Request {
switch {
case v.Succeeded:
var res PRResult
if !DecodeResult(v.Result, &res) || res.PRURL == "" {
r.State = store.StateFailed
r.Error = "pull request job finished without reporting a pull request"
return r
}
r.PRNumber, r.PRURL = res.PRNumber, res.PRURL
if r.State == store.StateOpeningPR || r.State == "" {
r.State = store.StatePROpen
}
case v.Failed:
r.State = store.StateFailed
r.Error = resultError(v.Result, "pull request job failed")
default:
if r.State == "" {
r.State = store.StateOpeningPR
}
}
return r
}
func applyWatch(r store.Request, v View) store.Request {
switch {
case v.Succeeded:
var res WatchResult
if !DecodeResult(v.Result, &res) {
r.State = store.StateFailed
r.Error = "watch job finished without reporting an outcome"
return r
}
switch {
case res.Merged:
if r.State != store.StateEnablingCI && r.State != store.StateReady {
r.State = store.StateMerged
}
case res.Closed:
r.State = store.StateClosed
default:
r.State = store.StateFailed
r.Error = resultError(v.Result, "watch job reported neither merge nor close")
}
case v.Failed:
r.State = store.StateFailed
r.Error = resultError(v.Result, "watch job failed")
default:
r.State = store.StatePROpen
}
return r
}
func applyWoodpecker(r store.Request, v View) store.Request {
switch {
case v.Succeeded:
var res WoodpeckerResult
if !DecodeResult(v.Result, &res) || !res.Enabled {
r.State = store.StateFailed
r.Error = resultError(v.Result, "woodpecker enablement job reported no activation")
return r
}
r.CIEnabled = true
r.State = store.StateReady
case v.Failed:
r.State = store.StateFailed
r.Error = resultError(v.Result, "woodpecker enablement job failed")
default:
r.State = store.StateEnablingCI
}
return r
}
// resultError prefers the job's own error string over the generic fallback.
func resultError(raw []byte, fallback string) string {
var res struct {
Error string `json:"error"`
}
if DecodeResult(raw, &res) && res.Error != "" {
return res.Error
}
return fallback
}