347 lines
15 KiB
Go
347 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const time30s = 31 * time.Second
|
|
|
|
// Issue 1275 (the 2026-10-19 18:05 group): a group is merged whole or not at all, in its order, and a group that is held or
|
|
// half merged is said loudly. Issue 1276/AC1: a refusal naming the member's own repository check as missing asks that check.
|
|
|
|
const (
|
|
gate, repoCheck = "mesh/merge-gate", "mesh/repo-check"
|
|
)
|
|
|
|
// statusOf sets a member head's two merge checks on the forge, and makes both required.
|
|
func setChecks(w *world, repository, sha, gateState, repoState string) {
|
|
w.forge.required = []string{gate, repoCheck}
|
|
for ctx, state := range map[string]string{gate: gateState, repoCheck: repoState} {
|
|
key := repository + "@" + sha + " " + ctx
|
|
if state == "" {
|
|
delete(w.forge.statuses, key) // no status at all: the forge says it is missing
|
|
continue
|
|
}
|
|
w.forge.statuses[key] = state + " said → x"
|
|
}
|
|
}
|
|
|
|
func allGreen(w *world) {
|
|
setChecks(w, gCat, gHk, "success", "success")
|
|
setChecks(w, gCtl, gHc, "success", "success")
|
|
}
|
|
|
|
func refusedWords(repository string) error {
|
|
return errors.New("the forge refused gitea_merge_pull_request: " + repository + " is not merged: main requires " +
|
|
"mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing). A pull request merges once its checks succeed")
|
|
}
|
|
|
|
func urgentLines(w *world) []StalledLine {
|
|
var out []StalledLine
|
|
for _, l := range w.h.Stalled() {
|
|
if l.Severity == "urgent" {
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 1275/AC2: an approved group whose member has a required status that is not a success at its head when the merge starts
|
|
// is merged not at all, though the first member in order is fine; the group says which member and which status.
|
|
func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing after the ask
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 0 {
|
|
t.Fatalf("merged with a required status missing: %v", w.forge.merges)
|
|
}
|
|
lines := urgentLines(w)
|
|
if len(lines) != 1 || !strings.Contains(lines[0].Says, "feat/g") || !strings.Contains(lines[0].Says, IDOf(gCtl, gHc)) ||
|
|
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
|
t.Fatalf("the held group is not said as one urgent line naming group, member, status and need: %+v", lines)
|
|
}
|
|
// The status turns success: the whole group merges.
|
|
setChecks(w, gCtl, gHc, "success", "success")
|
|
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"})
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 2 || len(urgentLines(w)) != 0 {
|
|
t.Fatalf("merges %v, lines %+v; wanted both merged and no line", w.forge.merges, urgentLines(w))
|
|
}
|
|
}
|
|
|
|
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said urgent,
|
|
// naming the group, the refused member and the forge's words.
|
|
func TestARefusedFirstMemberStopsTheGroupAndIsSaidUrgent(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
|
|
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
|
|
}
|
|
lines := urgentLines(w)
|
|
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
|
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
|
t.Fatalf("not said as one urgent line: %+v", lines)
|
|
}
|
|
}
|
|
|
|
// 1275/AC4: a group half merged (the second member refused after the first merged) stays raised, pass after pass, until
|
|
// the second is merged; then it goes.
|
|
func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
|
for range 3 {
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
lines := urgentLines(w)
|
|
if len(lines) != 1 || lines[0].ID != IDOf(gCtl, gHc) || !strings.Contains(lines[0].Says, "feat/g") {
|
|
t.Fatalf("the half merged group is not raised: %+v", lines)
|
|
}
|
|
}
|
|
// A half merged group with no refusal on record (the pass died between members) is raised too.
|
|
d := w.delivery(IDOf(gCtl, gHc))
|
|
d.Ready.RefusedSince = nil
|
|
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "half merged") {
|
|
t.Fatalf("a half merged group without a refusal is not raised: %+v", lines)
|
|
}
|
|
w.forge.refuseRepo = nil
|
|
setChecks(w, gCtl, gHc, "success", "success")
|
|
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
|
w.settleAll()
|
|
if lines := urgentLines(w); len(lines) != 0 {
|
|
t.Fatalf("the group is whole and still raised: %+v", lines)
|
|
}
|
|
}
|
|
|
|
// 1275/AC5: a member's required status turning not-success while the ask waits withdraws the ask at the router; the
|
|
// answer to the withdrawn ask merges nothing; the group is asked again once every member may merge.
|
|
func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
ask := w.op.open()[0]
|
|
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "pending"})
|
|
setChecks(w, gCat, gHk, "success", "pending")
|
|
w.settleAll()
|
|
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != ask {
|
|
t.Fatalf("the ask was not withdrawn at the router: %v", w.op.cancelled)
|
|
}
|
|
w.op.answer(ask, OptionApprove) // an answer that arrives late
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 0 {
|
|
t.Fatalf("the answer to a withdrawn ask merged: %v", w.forge.merges)
|
|
}
|
|
if w.op.count() != 1 {
|
|
t.Fatalf("asked again while a status was still pending: %d", w.op.count())
|
|
}
|
|
setChecks(w, gCat, gHk, "success", "success")
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
if w.op.count() != 2 {
|
|
t.Fatalf("not asked again once every member may merge: %d", w.op.count())
|
|
}
|
|
}
|
|
|
|
// 1275/AC3 (review round 1): a member that is both refused and held for a required status is said by one stalled line,
|
|
// not two with the same id.
|
|
func TestAMemberBothRefusedAndHeldForAStatusIsOneStalledLine(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
setChecks(w, gCat, gHk, "success", "") // now its status is missing too; the next pass is held
|
|
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "success"})
|
|
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
if g := w.h.groups["feat/g"]; g.Hold == nil {
|
|
t.Fatalf("the group was not held for the missing status")
|
|
}
|
|
n := 0
|
|
for _, l := range w.h.Stalled() {
|
|
if l.ID == IDOf(gCat, gHk) {
|
|
n++
|
|
}
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("%d stalled lines for one member: %+v", n, w.h.Stalled())
|
|
}
|
|
}
|
|
|
|
// 1275/AC6, as novox/hq ADR 0505 §4 changed it: a member refused because its verdict is out of date (the trunk moved; its
|
|
// fresh check runs) is no stalled line while the gate answers it, and urgent once ten minutes passed with the group not
|
|
// merged; the pass stops at the refusal all the same.
|
|
func TestAnOutOfDateRefusalIsNoLineWhileAnsweredAndUrgentAfterTenMinutes(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuse = errAskedAgain
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 1 {
|
|
t.Fatalf("the pass did not stop at the refusal: %v", w.forge.merges)
|
|
}
|
|
sev := func() []string {
|
|
var out []string
|
|
for _, l := range w.h.Stalled() {
|
|
out = append(out, l.Severity)
|
|
}
|
|
return out
|
|
}
|
|
if got := sev(); len(got) != 0 {
|
|
t.Fatalf("an out of date refusal is %v at once; wanted no line while it is answered", got)
|
|
}
|
|
w.later(9 * time.Minute)
|
|
if got := sev(); len(got) != 0 {
|
|
t.Fatalf("at nine minutes it is %v; wanted still no line", got)
|
|
}
|
|
w.later(2 * time.Minute)
|
|
if got := sev(); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
|
}
|
|
}
|
|
|
|
// 1275/AC6: every other refusal (here a missing status) is urgent at once.
|
|
func TestAMissingStatusRefusalIsUrgentAtOnce(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if lines := w.h.Stalled(); len(lines) != 1 || lines[0].Severity != "urgent" {
|
|
t.Fatalf("a missing status refusal is not urgent at once: %+v", lines)
|
|
}
|
|
}
|
|
|
|
func severities(w *world) []string {
|
|
var out []string
|
|
for _, l := range w.h.Stalled() {
|
|
out = append(out, l.Severity)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 1275/AC6 (b), as ADR 0505 §4 changed it: the same out of date refusal on a LATER member, after the earlier one merged,
|
|
// is no line at once and urgent after ten minutes: the half merged group does not say it at once.
|
|
func TestAnOutOfDateRefusalOnALaterMemberIsNoLineThenUrgent(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
|
w.settleAll()
|
|
if got := severities(w); len(got) != 0 {
|
|
t.Fatalf("a later member's out of date refusal is %v at once; wanted no line while it is answered", got)
|
|
}
|
|
w.later(11 * time.Minute)
|
|
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
|
}
|
|
}
|
|
|
|
// 1275/AC6 (c): words naming an out of date check and also a missing status are urgent at once.
|
|
func TestARefusalNamingOutOfDateAndAMissingStatusIsUrgentAtOnce(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-catalog#2 is not merged: its verdict was " +
|
|
"judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccc merged into trunk bbbb was " +
|
|
"asked. main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing)")
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("a refusal with two causes is %v; wanted urgent at once", got)
|
|
}
|
|
}
|
|
|
|
// 1275/AC6 (d): words mesh-delivery cannot classify are urgent at once.
|
|
func TestARefusalInUnknownWordsIsUrgentAtOnce(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: something nobody foresaw")
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("an unclassified refusal is %v; wanted urgent at once", got)
|
|
}
|
|
}
|
|
|
|
// The merge tool's refusals, copied from gitea merge.ts with their line, `${...}` filled in.
|
|
var mergeToolRefusals = []struct {
|
|
line string
|
|
words string
|
|
outOfDated bool
|
|
}{
|
|
{"merge.ts:121", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
|
"Its check is already asked and not yet answered (building), so it is not asked again; took 3s. Ask again at cccccccccccc once " +
|
|
"both statuses pass: this tool never merges by itself (novox/hq ADR 0323). A check that never answers is asked again with " +
|
|
"mesh-delivery.recheck. Nothing was merged", true},
|
|
{"merge.ts:147", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
|
"The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both " +
|
|
"statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may move the trunk " +
|
|
"again first; this is then refused and asked once more. A check that never answers is asked again with " +
|
|
"mesh-delivery.recheck. Nothing was merged", true},
|
|
{"merge.ts:144", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb, " +
|
|
"and its check could not be asked: the forge is away. Nothing was merged", false},
|
|
{"merge.ts:156", "novox/app#7 is not merged: main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check " +
|
|
"(pending). A pull request merges once its checks succeed", false},
|
|
{"merge.ts:165", "novox/app#7 is not merged: its verdict read siblings, and which ones is not held here, so the group cannot " +
|
|
"be weighed. Ask its check again at cccccccccccc. Nothing was merged", false},
|
|
{"merge.ts:172", "novox/app#7 is not merged: its verdict was judged beside 1 sibling(s), and a sibling is not ready. A member " +
|
|
"judged beside its siblings merges once its group passed at the heads that verdict read (novox/hq ADR 0382, 0421). " +
|
|
"Nothing was merged", false},
|
|
}
|
|
|
|
// 1275/AC6 (e), as ADR 0505 §4 changed it: only the merge tool's two out of date refusals (:121, :147) are answered by
|
|
// the gate and said as no line at once; the one saying its check could not be asked (:144) and every other is urgent at
|
|
// once.
|
|
func TestOnlyTheMergeToolsTwoOutOfDateRefusalsWaitWhileAnswered(t *testing.T) {
|
|
for _, c := range mergeToolRefusals {
|
|
if got := outOfDateRefusal(c.words); got != c.outOfDated {
|
|
t.Errorf("%s: outOfDateRefusal = %v, wanted %v", c.line, got, c.outOfDated)
|
|
}
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: " + c.words)
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
got := severities(w)
|
|
if c.outOfDated && len(got) != 0 {
|
|
t.Errorf("%s: said %v at once, wanted no line while it is answered", c.line, got)
|
|
}
|
|
if !c.outOfDated && (len(got) != 1 || got[0] != "urgent") {
|
|
t.Errorf("%s: severity %v at once, wanted urgent", c.line, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1275/AC6 (f): the severity never steps down: a missing status refusal, then an out of date one, stays urgent.
|
|
func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
w.forge.refuseRepo = map[string]error{gCat: errAskedAgain}
|
|
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
if d := w.delivery(IDOf(gCat, gHk)); !outOfDateRefusal(d.Ready.Answer) {
|
|
t.Fatalf("the second refusal was not the out of date one: %q", d.Ready.Answer)
|
|
}
|
|
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("severity %v after an out of date refusal that followed an urgent one; wanted urgent", got)
|
|
}
|
|
}
|