Merge pull request 'An approved group merges whole or not at all, and two groups never interleave in one repository (issue 1275)' (#501) from fix/1275-group-merge-whole into main
This commit was merged in pull request #501.
This commit is contained in:
+32
-5
@@ -98,6 +98,10 @@ export interface GiteaPull {
|
||||
html_url: string;
|
||||
/** The description: where a pull request says it goes after another (`after: <repository>`, novox/hq ADR 0239). */
|
||||
body?: string;
|
||||
/** Whether the forge would merge it into its base now (gitea's `mergeable`): false while it is in conflict, while the
|
||||
* forge is still working that out after its base moved, or while it is a work in progress; absent when the forge did not
|
||||
* say. What a group's merge reads of every member before it merges any (novox/hq issue 1275). */
|
||||
mergeable?: boolean;
|
||||
}
|
||||
|
||||
/** An open pull request with what its base requires and what its head has: what gitea_open_pulls answers. */
|
||||
@@ -789,14 +793,29 @@ export class GiteaClient {
|
||||
|
||||
/** Merge a pull request — never forced past its checks, and, given the head that was checked, only that head:
|
||||
* the forge refuses the merge when the branch moved since (novox/hq ADR 0266). */
|
||||
/** How long a merge waits before it asks again after the forge's 405 "Please try again later" (novox/hq issue 1275):
|
||||
* the forge says so while it works out whether the pull request merges, for a few seconds after its base moved. */
|
||||
static mergeRetryAfter: number[] = [1000, 2000, 4000];
|
||||
|
||||
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false,
|
||||
headCommit?: string): Promise<void> {
|
||||
indexOf(index);
|
||||
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
|
||||
...(headCommit ? { head_commit_id: headCommit } : {}) }),
|
||||
});
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
|
||||
...(headCommit ? { head_commit_id: headCommit } : {}) }),
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
// Only the forge's transient answer is asked again, briefly; every other refusal, and this one past the last
|
||||
// wait, is the caller's to say. head_commit_id keeps a head that moved in between from landing.
|
||||
const wait = GiteaClient.mergeRetryAfter[attempt];
|
||||
if (wait === undefined || !tryAgainLater(err)) throw err;
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ----
|
||||
@@ -842,6 +861,7 @@ export class GiteaClient {
|
||||
updated_at: p.updated_at ?? undefined,
|
||||
html_url: p.html_url,
|
||||
body: typeof p.body === "string" ? p.body : undefined,
|
||||
mergeable: typeof p.mergeable === "boolean" && !p.merged ? p.mergeable : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1229,3 +1249,10 @@ export function directoriesAbove(paths: string[]): string[] {
|
||||
function encodePath(path: string): string {
|
||||
return pathSegments("file path", path);
|
||||
}
|
||||
|
||||
/** Whether an error is the forge's 405 "Please try again later" on a merge: its answer while the pull request's
|
||||
* mergeability is being worked out (or it is in conflict, which a later ask says the same way). */
|
||||
export function tryAgainLater(err: unknown): boolean {
|
||||
const words = err instanceof Error ? err.message : String(err);
|
||||
return / 405 /.test(words) && /try again later/i.test(words);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// **The forge's 405 "Please try again later" on a merge is asked again briefly** (novox/hq issue 1275, 2026-10-26 13:40):
|
||||
// mesh-delivery#73's merge was answered so a few seconds after its base moved, and counted as refused at once. The forge
|
||||
// says it while it works out whether the pull request merges; the merge is asked again after a short wait, and every other
|
||||
// refusal is answered at once. A pull request's own mergeable is carried, so a group can be read whole before it merges.
|
||||
|
||||
/** A forge whose merge of novox/hq#7 answers each of `answers` in turn (the last for ever), and whose pull request says
|
||||
* `mergeable`. */
|
||||
async function serve(answers: { status: number; body: string }[], mergeable?: boolean) {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
let merges = 0;
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "", "http://x");
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7/merge" && req.method === "POST") {
|
||||
const a = answers[Math.min(merges++, answers.length - 1)];
|
||||
res.statusCode = a.status;
|
||||
return res.end(a.body);
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7") {
|
||||
return res.end(JSON.stringify({ number: 7, state: "open", merged: false, base: { ref: "main" },
|
||||
head: { ref: "b", sha: "a".repeat(40) }, html_url: "h/7", ...(mergeable === undefined ? {} : { mergeable }) }));
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
GiteaClient.mergeRetryAfter = [1, 1, 1];
|
||||
return { client, server, merges: () => merges };
|
||||
}
|
||||
|
||||
const later = { status: 405, body: '{"message":"Please try again later"}' };
|
||||
const ok = { status: 200, body: "" };
|
||||
|
||||
test("a merge answered 'try again later' while the forge works out its mergeability, then merged", async (t) => {
|
||||
const cases: { name: string; answers: { status: number; body: string }[]; merged: boolean; asked: number }[] = [
|
||||
{ name: "merged on the third ask", answers: [later, later, ok], merged: true, asked: 3 },
|
||||
{ name: "still 'later' after the last wait: refused, with the forge's words", answers: [later], merged: false, asked: 4 },
|
||||
{ name: "a refusal of another kind is answered at once", answers: [{ status: 409, body: '{"message":"head moved"}' }], merged: false, asked: 1 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
await t.test(c.name, async () => {
|
||||
const { client, server, merges } = await serve(c.answers);
|
||||
try {
|
||||
const done = client.mergePullRequest("novox", "hq", 7, "merge", false, "a".repeat(40));
|
||||
if (c.merged) await done;
|
||||
else await assert.rejects(done, (err: Error) => /40[59]/.test(err.message));
|
||||
assert.equal(merges(), c.asked);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a pull request carries the forge's mergeable, and none when the forge did not say", async () => {
|
||||
for (const mergeable of [true, false, undefined]) {
|
||||
const { client, server } = await serve([ok], mergeable);
|
||||
try {
|
||||
assert.equal((await client.getPullRequest("novox", "hq", 7)).mergeable, mergeable);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -492,7 +492,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_statuses",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success; for a pull request, also the forge's own mergeable (false while it is in conflict with its base or the forge is still working that out). Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -508,11 +508,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||
if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`);
|
||||
let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string;
|
||||
head_sha?: string; merge_commit_sha?: string; merged_at?: string; html_url: string } | undefined;
|
||||
head_sha?: string; merge_commit_sha?: string; merged_at?: string; html_url: string; mergeable?: boolean } | undefined;
|
||||
if (number > 0) {
|
||||
const p = await gitea.getPullRequest(owner, repo, number);
|
||||
pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head,
|
||||
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, merged_at: p.merged_at, html_url: p.html_url };
|
||||
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, merged_at: p.merged_at, html_url: p.html_url,
|
||||
...(p.mergeable === undefined ? {} : { mergeable: p.mergeable }) };
|
||||
if (!ref) ref = p.head_sha ?? "";
|
||||
if (!base) base = p.base ?? "";
|
||||
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
|
||||
|
||||
@@ -390,12 +390,12 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
if gid == "" {
|
||||
break
|
||||
}
|
||||
says, err := h.notASuccess(m)
|
||||
says, forge, err := h.notASuccess(m)
|
||||
if err != nil {
|
||||
readErr = err
|
||||
break
|
||||
}
|
||||
if says != "" {
|
||||
if says != "" && !forge { // the forge's merge answer is weighed at the merge, where it is asked again (group_merge.go)
|
||||
holdMember, holdSays = m.id, says
|
||||
break
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -28,26 +29,46 @@ func readOf(d *Delivery, head string) memberRead {
|
||||
return memberRead{id: d.ID, owner: d.Owner(), repo: d.Repo(), base: d.Base, head: head, number: d.Number}
|
||||
}
|
||||
|
||||
// notAsSuccess reads a member's required statuses at its head and says, in the forge's words, which are not a success;
|
||||
// empty when all are. An error is a
|
||||
// forgeMergeGrace is how long the forge may say a member does not merge now before the group is held for it (issue 1275):
|
||||
// the forge says so for a few seconds after a base moved, while it works out whether the pull request still merges; within
|
||||
// the grace the merge is asked again every forgeMergeAgain. Past it, the member is in conflict with its trunk: held, said
|
||||
// urgent, and asked again on the next event (a new head, a status).
|
||||
const (
|
||||
forgeMergeGrace = 2 * time.Minute
|
||||
forgeMergeAgain = 5 * time.Second
|
||||
)
|
||||
|
||||
// notASuccess reads a member's required statuses at its head and says, in the forge's words, which are not a success,
|
||||
// and whether the forge says the pull request merges into its base now (its head still the one approved, and not in
|
||||
// conflict); empty when all hold. forge says the reason is the forge's merge answer rather than a status. An error is a
|
||||
// read that could not be made: the member then counts as not mergeable.
|
||||
func (h *Holder) notASuccess(m memberRead) (says string, err error) {
|
||||
func (h *Holder) notASuccess(m memberRead) (says string, forge bool, err error) {
|
||||
fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", false, err
|
||||
}
|
||||
if fc.Merge.Mergeable == nil {
|
||||
return "", fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
return "", false, fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
}
|
||||
if *fc.Merge.Mergeable {
|
||||
return "", nil
|
||||
if !*fc.Merge.Mergeable {
|
||||
var parts []string
|
||||
for _, b := range fc.Merge.Blocking {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State))
|
||||
}
|
||||
return fmt.Sprintf("%s requires %s; not a success: %s", fc.Merge.Branch, strings.Join(fc.Merge.Required, ", "),
|
||||
strings.Join(parts, ", ")), false, nil
|
||||
}
|
||||
var parts []string
|
||||
for _, b := range fc.Merge.Blocking {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State))
|
||||
if p := fc.Pull; p != nil && m.number > 0 {
|
||||
if p.HeadSHA != "" && !sameCommit(p.HeadSHA, m.head) {
|
||||
return fmt.Sprintf("its head is now %s, not the %s approved", shortOf(p.HeadSHA, 12), shortOf(m.head, 12)), false, nil
|
||||
}
|
||||
if p.Mergeable != nil && !*p.Mergeable {
|
||||
base := firstOf(p.Base, m.base)
|
||||
return fmt.Sprintf("the forge says it does not merge into %s now (in conflict with it, a work in progress, or still "+
|
||||
"being worked out after %s moved)", base, base), true, nil
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s requires %s; not a success: %s", fc.Merge.Branch, strings.Join(fc.Merge.Required, ", "),
|
||||
strings.Join(parts, ", ")), nil
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// unmergedLive are a group's members not yet merged and not over, in the group's order. Under the lock.
|
||||
@@ -61,10 +82,13 @@ func (h *Holder) unmergedLive(g *Group) []*Delivery {
|
||||
return out
|
||||
}
|
||||
|
||||
// groupMergeable reads every member's required statuses at the head the Approve covers, and says whether all are a success.
|
||||
// When one is not, or cannot be read, nothing is handed to the bridge: the group is held, said urgent (ADR 0441 §2, §4), and
|
||||
// the target's turn is given back so the next event that decides whether it may merge asks it again. A read that could not
|
||||
// be made is a failed ask, tried again after askAgainAfter. Outside the lock.
|
||||
// groupMergeable reads every member's required statuses, its head and the forge's merge answer at the head the Approve
|
||||
// covers, and says whether all hold: no member is merged unless every member merges now (issue 1275, 2026-10-26: two of
|
||||
// three merged and the third refused). When one does not, or cannot be read, nothing is handed to the bridge: the group is
|
||||
// held, said urgent (ADR 0441 §2, §4), and the target's turn is given back so the next event that decides whether it may
|
||||
// merge asks it again. A read that could not be made is a failed ask, tried again after askAgainAfter; the forge saying a
|
||||
// member does not merge now is asked again every forgeMergeAgain for forgeMergeGrace before the group is held. Outside
|
||||
// the lock.
|
||||
func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
release := func() {
|
||||
if d := h.deliveries[target]; d != nil && d.Ready != nil {
|
||||
@@ -73,7 +97,7 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
}
|
||||
}
|
||||
for _, m := range reads {
|
||||
says, err := h.notASuccess(m)
|
||||
says, forge, err := h.notASuccess(m)
|
||||
if err == nil && says == "" {
|
||||
continue
|
||||
}
|
||||
@@ -85,6 +109,25 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if forge {
|
||||
if h.unmergeableSince == nil {
|
||||
h.unmergeableSince = map[string]time.Time{}
|
||||
}
|
||||
key, now := gid+" "+m.id, h.Now()
|
||||
first, seen := h.unmergeableSince[key]
|
||||
if !seen {
|
||||
first = now
|
||||
h.unmergeableSince[key] = now
|
||||
h.Logf("[mesh-delivery] group %s: nothing is merged yet: %s at %s: %s; asked again every %s for %s", gid, m.id,
|
||||
shortOf(m.head, 12), says, forgeMergeAgain, forgeMergeGrace)
|
||||
}
|
||||
if now.Sub(first) < forgeMergeGrace {
|
||||
release()
|
||||
h.lookAt(now.Add(forgeMergeAgain), "a group's member the forge was still weighing")
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
}
|
||||
if g := h.groups[gid]; g != nil {
|
||||
if g.Hold == nil || g.Hold.Kind != "blocked" || g.Hold.Says != says || g.Hold.Member != m.id {
|
||||
h.Logf("[mesh-delivery] group %s is approved and NOT merged: %s at %s: %s", gid, m.id, shortOf(m.head, 12), says)
|
||||
@@ -99,13 +142,18 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
// No timer: the status turning success is an event on the forge (CommitStatus, mergeAgainNow), and it asks the
|
||||
// merge again at once (issue 1275: a pass that stops part way resumes on an event).
|
||||
// No timer: the status turning success, or a new head, is an event on the forge (CommitStatus, mergeAgainNow), and
|
||||
// it asks the merge again at once (issue 1275: a pass that stops part way resumes on an event).
|
||||
release()
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
for key := range h.unmergeableSince {
|
||||
if strings.HasPrefix(key, gid+" ") {
|
||||
delete(h.unmergeableSince, key)
|
||||
}
|
||||
}
|
||||
if g := h.groups[gid]; g != nil && g.Hold != nil {
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
@@ -114,6 +162,98 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// passWaits is why an approved merge is not handed yet because another approved merge's pass is under way into a
|
||||
// repository it goes into; empty when none is (issue 1275, 2026-10-26 13:40: group feat/890-placement merged
|
||||
// mesh-delivery#72 between the members of fix/734-lost-ask, whose mesh-delivery#73 then conflicted). A pass is under way
|
||||
// from its first member handed until every member is merged; one half merged stays under way while it is refused or held,
|
||||
// until it is whole, declined, or its remaining members are gone or asked anew (a new head is a new Approve). The first
|
||||
// merge handed has the repositories; the other is handed once that pass ends, on the event that ends it. This holds
|
||||
// whether or not mesh/check gates the merge: it waits for a merge, never for a check (ADR 0551). Under the lock.
|
||||
func (h *Holder) passWaits(d *Delivery) string {
|
||||
c := h.covering(d)
|
||||
if c == nil || c.Approved == nil || c.AskID == "" {
|
||||
return ""
|
||||
}
|
||||
mine := h.unmergedRepos(c)
|
||||
for _, p := range h.passes() {
|
||||
if p.run.AskID == c.AskID || !h.passUnderWay(p.run) {
|
||||
continue
|
||||
}
|
||||
for repo := range h.unmergedRepos(p.run) {
|
||||
if mine[repo] {
|
||||
return fmt.Sprintf("approved; waits for the merge of %s into %s to end, so no other merge lands there between "+
|
||||
"its members (issue 1275)", p.name, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pass is an approved merge not merged whole: the run holding its Approve and the name of its group (or pull request).
|
||||
type pass struct {
|
||||
run *ReadyRun
|
||||
name string
|
||||
}
|
||||
|
||||
// passes are the approved merges not merged whole and not declined, each ask once. Unlike approvedMerges it keeps one whose
|
||||
// members merged or were refused: a half-merged pass still holds its repositories (issue 1275). Under the lock.
|
||||
func (h *Holder) passes() []pass {
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
seen := map[string]bool{}
|
||||
var out []pass
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
r := d.Ready
|
||||
if r == nil || r.Approved == nil || r.Declined != nil || r.AskID == "" || len(r.Heads) == 0 || seen[r.AskID] ||
|
||||
d.Base != MergeBranch || h.allMerged(r.Heads) {
|
||||
continue
|
||||
}
|
||||
seen[r.AskID] = true
|
||||
out = append(out, pass{run: r, name: askGroup(d)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unmergedRepos are the repositories (lowercase) of the deliveries an Approve covers that are not merged and may still be
|
||||
// merged on it: not over (closed, held), at the head approved, and covered by this Approve, not a newer one. Under the lock.
|
||||
func (h *Holder) unmergedRepos(r *ReadyRun) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for id, sha := range r.Heads {
|
||||
m := h.deliveries[id]
|
||||
if m == nil || m.MergedAs != "" || (m.Ready != nil && m.Ready.Merged != nil) || m.State.Final() || !sameCommit(m.Commit, sha) {
|
||||
continue
|
||||
}
|
||||
if c := h.covering(m); c == nil || c.AskID != r.AskID {
|
||||
continue // asked anew (its new head, or a new Approve): that ask is the pass now
|
||||
}
|
||||
out[strings.ToLower(m.Repository)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// passUnderWay is whether an approved merge's pass has started and not ended: a member is handed to the bridge, or some
|
||||
// are merged and others may still merge on this Approve, refused or held or not (issue 1275, review: a held half-merged
|
||||
// group that let another group start, then asked again, waited on that group while it waited on it). Under the lock.
|
||||
func (h *Holder) passUnderWay(r *ReadyRun) bool {
|
||||
if len(h.unmergedRepos(r)) == 0 {
|
||||
return false
|
||||
}
|
||||
for id := range r.Heads {
|
||||
m := h.deliveries[id]
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.MergedAs != "" || (m.Ready != nil && m.Ready.Merged != nil) || (m.Ready != nil && m.Ready.merging) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// groupLines are the groups that are held or half merged, as urgent stalled lines the gate says as conditions (conditions.go):
|
||||
// one for each, on its first member not merged, until every member is merged or a person decides (issue 1275). A member
|
||||
// the merge tool refused is said by readyLines, with its group; this adds a group held for a required status, and a
|
||||
@@ -149,9 +289,10 @@ func (h *Holder) groupLines(now time.Time) []StalledLine {
|
||||
}
|
||||
out = append(out, StalledLine{ID: g.Hold.Member, Number: numberOf(h.deliveries[g.Hold.Member]), State: first.State,
|
||||
For: now.Sub(g.Hold.Since).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
H2: "none: the required status turns success, which asks the merge again — or the operator's decision",
|
||||
Says: fmt.Sprintf("group %s %s (merged %d of %d): %s waits on a required status "+
|
||||
"at its head: %s. Needed: that status turns success (its check asked again), or the operator decides.",
|
||||
H2: "none: the required status turns success or a new head merges, which asks the merge again — or the operator's decision",
|
||||
Says: fmt.Sprintf("group %s %s (merged %d of %d): %s does not merge now "+
|
||||
"at its head: %s. Needed: that status turns success (its check asked again), a new head that merges "+
|
||||
"(rebased on its trunk), or the operator decides.",
|
||||
g.ID, stands, merged, len(h.membersOf(g)), g.Hold.Member, g.Hold.Says)})
|
||||
case merged > 0 && !refused && !running:
|
||||
out = append(out, StalledLine{ID: first.ID, Number: first.Number, State: first.State,
|
||||
@@ -177,3 +318,69 @@ func holdMember(g *Group) string {
|
||||
}
|
||||
return g.Hold.Member
|
||||
}
|
||||
|
||||
// MergeAgain is the tool delivery_merge_again: on the operator's word, the merge of an approved delivery the forge refused,
|
||||
// or of a group held part way, is asked again at once at the heads the Approve named (issue 1275): a refusal the forge
|
||||
// meant as "later", a conflict cleared on the trunk, or a refusal repeated until the gate stopped asking. A delivery whose
|
||||
// head moved is asked again by its new head, never by this. Refused, naming why, for one that is not an approved merge
|
||||
// waiting after a refusal or a hold.
|
||||
func (h *Holder) MergeAgain(id, why string) (string, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
return "", fmt.Errorf("no delivery %q", id)
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return "", fmt.Errorf("say why: the merge asked again is the operator's word, kept in the log")
|
||||
}
|
||||
c := h.covering(d)
|
||||
if c == nil || c.Approved == nil || c.Declined != nil || c.Heads[d.ID] != d.Commit {
|
||||
return "", fmt.Errorf("%s is not approved at its head %s: nothing to ask again (a new head is asked on its own)", id, shortOf(d.Commit, 12))
|
||||
}
|
||||
if d.MergedAs != "" || (d.Ready != nil && d.Ready.Merged != nil) {
|
||||
return "", fmt.Errorf("%s is merged already", id)
|
||||
}
|
||||
g := h.groups[d.Group]
|
||||
if d.Group == "" {
|
||||
g = nil
|
||||
}
|
||||
held := g != nil && g.Hold != nil && g.Hold.Kind == "blocked"
|
||||
refused := false
|
||||
for hid := range c.Heads {
|
||||
if m := h.deliveries[hid]; m != nil && m.Ready != nil && m.Ready.Merged == nil && m.Ready.RefusedSince != nil {
|
||||
refused = true
|
||||
}
|
||||
}
|
||||
if !held && !refused {
|
||||
return "", fmt.Errorf("%s was neither refused nor held: %s", id, firstOf(d.readyWords(), "it is merged at its turn"))
|
||||
}
|
||||
if waits := h.passWaits(d); waits != "" {
|
||||
return "", fmt.Errorf("%s is not asked again yet: it %s; it is asked at that pass's end", id, strings.TrimPrefix(waits, "approved; "))
|
||||
}
|
||||
for hid := range c.Heads {
|
||||
if m := h.deliveries[hid]; m != nil && m.Ready != nil && m.Ready.Merged == nil {
|
||||
m.Ready.Repeats, m.Ready.LastTry, m.Ready.merging = 0, time.Time{}, false
|
||||
h.keep(m)
|
||||
}
|
||||
}
|
||||
if held {
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
}
|
||||
for key := range h.unmergeableSince {
|
||||
if g != nil && strings.HasPrefix(key, g.ID+" ") {
|
||||
delete(h.unmergeableSince, key)
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: its merge is asked again on the operator's word, at the heads approved: %s", id, why)
|
||||
return fmt.Sprintf("%s's merge is asked again at the next look, every member read first; nothing merges unless every member merges now", id), nil
|
||||
}
|
||||
|
||||
// readyWords is what an approved delivery's run says it waits for, if anything.
|
||||
func (d *Delivery) readyWords() string {
|
||||
if d.Ready == nil {
|
||||
return ""
|
||||
}
|
||||
return d.Ready.Waits
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ type Holder struct {
|
||||
// (onestatus.go, issue 1302); runningSince when this owner first saw each running (ADR 0548 §2, turns.go).
|
||||
checker map[string]checkerVerdict
|
||||
runningSince map[string]time.Time
|
||||
// unmergeableSince is when the forge first said a group's member does not merge now, by group and member (issue 1275,
|
||||
// group_merge.go): within forgeMergeGrace that is the forge still working out mergeability, asked again shortly.
|
||||
unmergeableSince map[string]time.Time
|
||||
// Turns is the gate's bucket turns (turns.go, ADR 0548 §3); nil keeps none. turnsHeld are the turns as written there,
|
||||
// by repository, read once at the first look (turnsLoaded).
|
||||
Turns TurnStore
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Issue 1275, met a third time (2026-10-26 13:40, group fix/734-lost-ask): the operator approved mesh-sdk#92,
|
||||
// mesh-controller#472 and mesh-delivery#73; the first two merged, the forge answered #73's merge 405 "Please try again
|
||||
// later", and in the seconds between, group feat/890-placement merged mesh-delivery#72, so #73 then conflicted. A group is
|
||||
// merged only when every member merges now, and no other approved merge lands in a repository between its members.
|
||||
|
||||
const (
|
||||
wSDK, wCtl, wDel = "novox/mesh-sdk", "novox/mesh-controller", "novox/mesh-delivery"
|
||||
wH92, wH472, wH73 = "3203358c4cb5fa5bed223f7ce85577bbb70a346e", "ccc7ba334c82554d809b5b38d8eb0eb5f36be7be", "bf574172154a9f6ed7d4e790643e0dcab2a10a91"
|
||||
wH72, wH73b = "72727272727272727272727272727272727272aa", "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"
|
||||
forgeLater = "Gitea API /repos/novox/mesh-delivery/pulls/73/merge: 405 {\"message\":\"Please try again later\"}"
|
||||
conflictWords = "does not merge into main now"
|
||||
headMovedWords = "its head is now"
|
||||
)
|
||||
|
||||
var lostAsk = []member{
|
||||
{wSDK, 92, wH92, "go/buildstep/ask.go"},
|
||||
{wCtl, 472, wH472, "internal/grants.go"},
|
||||
{wDel, 73, wH73, "modules/mesh-delivery-checker/internal/check/lostask.go,modules/mesh-delivery-checker/go.mod"},
|
||||
}
|
||||
|
||||
// forgePull says what the forge answers of a pull request: its head and whether it merges now.
|
||||
func forgePull(w *world, repository string, number int, head string, mergeable bool) {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
m := mergeable
|
||||
w.forge.pulls[fmt.Sprintf("%s#%d", repository, number)] = ForgePull{Number: number, State: "open", Base: "main", HeadSHA: head, Mergeable: &m}
|
||||
}
|
||||
|
||||
func TestAnApprovedGroupMergesNoMemberUnlessEveryMemberMergesNow(t *testing.T) {
|
||||
approve := func(t *testing.T) *world {
|
||||
w := approvedGroupOf(t, lostAsk...)
|
||||
allGreenFor(w, lostAsk...)
|
||||
for _, m := range lostAsk {
|
||||
forgePull(w, m.repo, m.number, m.head, true)
|
||||
}
|
||||
return w
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
// before is the forge as the Approve lands; after, once the grace has passed (nil: as before).
|
||||
before, after func(w *world)
|
||||
// merged is how many members merge in the end; line what the urgent line says, when one stands.
|
||||
merged int
|
||||
line string
|
||||
}{
|
||||
{name: "a member in conflict with its trunk: none merged, the group held and said urgent", merged: 0, line: conflictWords,
|
||||
before: func(w *world) {
|
||||
forgePull(w, wDel, 73, wH73, false)
|
||||
w.forge.refuseRepo = map[string]error{wDel: errors.New(forgeLater)} // what the merge tool would answer
|
||||
}},
|
||||
{name: "the forge still weighing a member: none merged, then every member once it says yes", merged: 3,
|
||||
before: func(w *world) { forgePull(w, wDel, 73, wH73, false) },
|
||||
after: func(w *world) { forgePull(w, wDel, 73, wH73, true) }},
|
||||
{name: "a member's head moved after the Approve: none merged, said urgent", merged: 0, line: headMovedWords,
|
||||
before: func(w *world) { forgePull(w, wDel, 73, wH73b, true) }},
|
||||
{name: "every member merges now: all merged in one pass", merged: 3, before: func(*world) {}},
|
||||
}
|
||||
// The recovery of a group that ended half merged: its remaining member's new head, or the operator's word.
|
||||
t.Run("a half-merged group is made whole by its remaining member's new head", halfMergedMadeWholeByANewHead)
|
||||
t.Run("a member refused part way is asked again on the operator's word", refusedPartWayAskedAgainOnTheOperatorsWord)
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := approve(t)
|
||||
c.before(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if c.after == nil && c.merged == 3 {
|
||||
if len(w.forge.merges) != 3 {
|
||||
t.Fatalf("merged %v, wanted all three", w.forge.merges)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a member was merged while another does not merge now: %v", w.forge.merges)
|
||||
}
|
||||
if c.after != nil {
|
||||
c.after(w)
|
||||
}
|
||||
w.later(forgeMergeGrace + time30s)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != c.merged {
|
||||
t.Fatalf("merged %v, wanted %d", w.forge.merges, c.merged)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if c.line == "" {
|
||||
if len(lines) != 0 {
|
||||
t.Fatalf("a group merged whole still says %+v", lines)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(lines) != 1 || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, IDOf(wDel, wH73)) || !strings.Contains(lines[0].Says, c.line) ||
|
||||
!strings.Contains(lines[0].Says, "nothing is merged") {
|
||||
t.Fatalf("the held group is not one urgent line naming the member and why: %+v", lines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handing are the pull requests the gate has handed the forge bridge and the bridge has not answered, in every merges key.
|
||||
func handing(w *world) []sdk.MergePull {
|
||||
w.merges.mu.Lock()
|
||||
defer w.merges.mu.Unlock()
|
||||
var out []sdk.MergePull
|
||||
for _, v := range w.merges.values {
|
||||
for _, p := range v.Pulls {
|
||||
if p.State == sdk.MergeMerging {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Repository < out[j].Repository })
|
||||
return out
|
||||
}
|
||||
|
||||
func handedWords(ps []sdk.MergePull) string {
|
||||
var out []string
|
||||
for _, p := range ps {
|
||||
out = append(out, fmt.Sprintf("%s#%d", p.Repository, p.Number))
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// Two approved merges into one repository: the one whose pass started has the repository until every member merged, and
|
||||
// the other is handed only then (2026-10-26: mesh-delivery#72 of feat/890-placement merged between fix/734-lost-ask's
|
||||
// members). This holds while mesh/check gates no merge too (ADR 0551): it waits for a merge, never for a check.
|
||||
func TestTwoApprovedMergesIntoOneRepositoryDoNotInterleave(t *testing.T) {
|
||||
for _, gates := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("check-gates-merge %v", gates), func(t *testing.T) {
|
||||
w := approvedGroupOf(t, lostAsk...)
|
||||
if !gates {
|
||||
ungated(w)
|
||||
}
|
||||
w.merges.manual = true
|
||||
allGreenFor(w, lostAsk...)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := handedWords(handing(w)); got != "novox/mesh-sdk#92" {
|
||||
t.Fatalf("handed %q, wanted the group's first member", got)
|
||||
}
|
||||
// feat/890-placement: a lone pull request into mesh-delivery, approved while the group's pass is under way.
|
||||
w.h.PullUpdated(pr(wDel, 72, wH72, "feat/890-placement", "modules/mesh-delivery-checker/go.mod"))
|
||||
w.h.Checked(judged(wDel, 72, wH72, "pass", gTrunk0))
|
||||
setChecks(w, wDel, wH72, "success", "success")
|
||||
w.settleAll()
|
||||
if open := w.op.open(); len(open) != 1 {
|
||||
t.Fatalf("the lone pull request is asked once: %v", open)
|
||||
} else {
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
}
|
||||
w.settleAll()
|
||||
var order []string
|
||||
steps := []struct {
|
||||
repo string
|
||||
number int
|
||||
head string
|
||||
}{{wSDK, 92, wH92}, {wCtl, 472, wH472}, {wDel, 73, wH73}}
|
||||
if !gates {
|
||||
// While the check gates the merge, #72's verdict went out of date with #73's merge and it waits for its
|
||||
// check against the new trunk; while it gates none, #72 is handed once the group's pass ended.
|
||||
steps = append(steps, struct {
|
||||
repo string
|
||||
number int
|
||||
head string
|
||||
}{wDel, 72, wH72})
|
||||
}
|
||||
for _, next := range steps {
|
||||
handed := handing(w)
|
||||
if len(handed) != 1 || handed[0].Repository != next.repo || handed[0].Number != next.number {
|
||||
t.Fatalf("after %v: handed %q, wanted only %s#%d", order, handedWords(handed), next.repo, next.number)
|
||||
}
|
||||
merge := strings.Repeat(string(rune('a'+next.number%20)), 40)
|
||||
bridgeMerged(w, next.repo, next.number, next.head, merge)
|
||||
w.h.PullMerged(merged(next.repo, next.number, next.head, merge))
|
||||
w.settleAll()
|
||||
order = append(order, fmt.Sprintf("%s#%d", next.repo, next.number))
|
||||
}
|
||||
if left := handing(w); len(left) != 0 {
|
||||
t.Fatalf("still handed: %q", handedWords(left))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A group that ended half merged (its last member refused after the others merged) is made whole by its remaining member's
|
||||
// new head: rebased on its trunk, checked, composed with the merged members, asked once and merged, and the urgent line
|
||||
// is cleared (issue 1275, the recovery of 2026-10-26 13:40).
|
||||
func halfMergedMadeWholeByANewHead(t *testing.T) {
|
||||
w := approvedGroupOf(t, lostAsk...)
|
||||
allGreenFor(w, lostAsk...)
|
||||
w.forge.refuseRepo = map[string]error{wDel: errors.New(forgeLater)}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 3 || len(urgentLines(w)) != 1 {
|
||||
t.Fatalf("the case: two merged and the third refused, said urgent: %v %+v", w.forge.merges, urgentLines(w))
|
||||
}
|
||||
w.forge.refuseRepo = nil
|
||||
// #73 rebased on its trunk: a new head, checked, composed with the members merged.
|
||||
w.h.PullUpdated(pr(wDel, 73, wH73b, "feat/g", "modules/mesh-delivery-checker/go.mod"))
|
||||
w.h.Checked(judged(wDel, 73, wH73b, "pass", gT1))
|
||||
setChecks(w, wDel, wH73b, "success", "success")
|
||||
w.settleAll()
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("the remaining member's new head is asked once: %v", open)
|
||||
}
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if last := w.forge.merges[len(w.forge.merges)-1]; len(w.forge.merges) != 4 || !strings.Contains(last, "mesh-delivery#73@"+wH73b) {
|
||||
t.Fatalf("the new head is not merged alone: %v", w.forge.merges)
|
||||
}
|
||||
if lines := urgentLines(w); len(lines) != 0 {
|
||||
t.Fatalf("the group is whole and still said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// delivery_merge_again (issue 1275, the tools rule): a member refused after its siblings merged, with no new head and no
|
||||
// trunk move to ask it again, is asked again on the operator's word, at the head approved; a delivery neither refused nor
|
||||
// held is refused, naming why.
|
||||
func refusedPartWayAskedAgainOnTheOperatorsWord(t *testing.T) {
|
||||
w := approvedGroupOf(t, lostAsk...)
|
||||
allGreenFor(w, lostAsk...)
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), "too early"); err == nil {
|
||||
t.Fatal("a delivery not approved is asked again")
|
||||
}
|
||||
w.forge.refuseRepo = map[string]error{wDel: errors.New(forgeLater)}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.forge.refuseRepo = nil
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 3 {
|
||||
t.Fatalf("the case: the third member refused and not asked again by itself: %v", w.forge.merges)
|
||||
}
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), ""); err == nil {
|
||||
t.Fatal("asked again with no why")
|
||||
}
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), "the forge said try again later"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 4 || len(urgentLines(w)) != 0 {
|
||||
t.Fatalf("not merged on the operator's word: %v %+v", w.forge.merges, urgentLines(w))
|
||||
}
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), "again"); err == nil || !strings.Contains(err.Error(), "merged already") {
|
||||
t.Fatalf("a merged delivery is asked again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// approveSecondGroup makes a second group of these members on branch, checked, composed together and approved; the world
|
||||
// already holds group feat/g.
|
||||
func approveSecondGroup(t *testing.T, w *world, branch string, ms ...member) {
|
||||
t.Helper()
|
||||
for _, m := range ms {
|
||||
w.h.PullUpdated(pr(m.repo, m.number, m.head, branch, strings.Split(m.paths, ",")...))
|
||||
w.h.Checked(judged(m.repo, m.number, m.head, "pass", gTrunk0))
|
||||
}
|
||||
allGreenFor(w, ms...)
|
||||
w.settleAll()
|
||||
g := w.h.groups[branch]
|
||||
if g == nil || g.Check == nil {
|
||||
t.Fatalf("no composed check was asked for %s: %+v", branch, g)
|
||||
}
|
||||
c := CheckedEvent{Group: branch, ID: g.Check.Asked, Verdict: "pass", Summary: "composed"}
|
||||
for _, m := range ms {
|
||||
owner, repo, _ := strings.Cut(m.repo, "/")
|
||||
c.Members = append(c.Members, struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{Owner: owner, Repo: repo, Commit: m.head, Trunk: gTrunk0[:12]})
|
||||
}
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
for _, key := range w.op.open() {
|
||||
if strings.Contains(key, strings.ReplaceAll(branch, "/", "_")) || strings.Contains(key, branch) {
|
||||
w.op.answer(key, OptionApprove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A group held half merged keeps its repositories until it is whole or declined: another group sharing one waits, and the
|
||||
// operator's word that asks the held group again never leaves the two waiting on each other (issue 1275, review round 1:
|
||||
// a2 waited for group B, b2 waited for group A, for ever). And the operator's word is refused while another pass it would
|
||||
// wait for is under way, naming it.
|
||||
func TestAHeldHalfMergedGroupKeepsItsRepositoriesAndNoTwoPassesWaitOnEachOther(t *testing.T) {
|
||||
groupA := []member{lostAsk[0], lostAsk[2]} // mesh-sdk#92, then mesh-delivery#73
|
||||
groupB := []member{lostAsk[1], {wDel, 72, wH72, "modules/mesh-delivery-checker/go.mod"}}
|
||||
t.Run("half merged and held: the other group waits, and both merge once the held one is asked again", func(t *testing.T) {
|
||||
w := approvedGroupOf(t, groupA...)
|
||||
allGreenFor(w, groupA...)
|
||||
w.forge.refuseRepo = map[string]error{wDel: errors.New(forgeLater)}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("the case: mesh-sdk#92 merged, mesh-delivery#73 refused: %v", w.forge.merges)
|
||||
}
|
||||
approveSecondGroup(t, w, "feat/890-placement", groupB...)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("group feat/890-placement merged while feat/g is half merged into a repository both go into: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuseRepo = nil
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), "the forge said try again later"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
want := []string{"mesh-sdk#92", "mesh-delivery#73", "mesh-delivery#73", "mesh-controller#472", "mesh-delivery#72"}
|
||||
if len(w.forge.merges) != len(want) {
|
||||
t.Fatalf("merged %v, wanted %v: the held group first, then the other", w.forge.merges, want)
|
||||
}
|
||||
for i, m := range want {
|
||||
if !strings.Contains(w.forge.merges[i], m+"@") {
|
||||
t.Fatalf("merged %v, wanted %v in that order", w.forge.merges, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("the operator's word is refused while another pass it would wait for is under way", func(t *testing.T) {
|
||||
w := approvedGroupOf(t, groupA...)
|
||||
allGreenFor(w, groupA...)
|
||||
w.forge.refuseRepo = map[string]error{wSDK: errors.New(forgeLater)} // refused first: nothing of feat/g merged
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.merges.manual = true
|
||||
approveSecondGroup(t, w, "feat/890-placement", groupB...)
|
||||
w.settleAll()
|
||||
if got := handedWords(handing(w)); got != "novox/mesh-controller#472" {
|
||||
t.Fatalf("the other group's pass did not start: handed %q", got)
|
||||
}
|
||||
_, err := w.h.MergeAgain(IDOf(wSDK, wH92), "asked again")
|
||||
if err == nil || !strings.Contains(err.Error(), "feat/890-placement") {
|
||||
t.Fatalf("asked again while a pass it waits for is under way: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -376,6 +376,20 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
}
|
||||
return h.HandNow(strArg(a, "id"), strArg(a, "why"))
|
||||
}},
|
||||
{Name: "delivery_merge_again",
|
||||
Description: "On the operator's word, ask again at once the merge of an approved delivery the forge refused, or of an approved " +
|
||||
"group held or half merged, at the heads the Approve named (novox/hq issue 1275): every member is read first and " +
|
||||
"nothing merges unless every member merges now. For a refusal that was the forge's \"try again later\", a conflict " +
|
||||
"cleared on the trunk, or a refusal the gate stopped asking after it repeated. A member whose head moved is asked by its " +
|
||||
"new head, never by this. Refused, naming why, for a delivery that is not an approved merge waiting after a refusal or a hold.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("the delivery's id (owner/repository@commit), any member of the group"),
|
||||
"why": str("the operator's word, kept in the log")}, "required": []string{"id", "why"}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if err := need(a, "id", "why"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.MergeAgain(strArg(a, "id"), strArg(a, "why"))
|
||||
}},
|
||||
{Name: "delivery_status",
|
||||
Description: "Whether this owner works: how many deliveries in each state, the groups, what is owed to " +
|
||||
"the forge and the bus and not yet done, how many owed effects were tried since it started (done, failed " +
|
||||
|
||||
@@ -48,6 +48,9 @@ type ForgePull struct {
|
||||
// MergedAt is when it merged, as the forge says it (RFC 3339); empty when not merged or not said.
|
||||
MergedAt string `json:"merged_at,omitempty"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
// Mergeable is the forge's own word on whether it merges into its base now (gitea's mergeable): false while it is in
|
||||
// conflict with its base, or while the forge works that out after the base moved; nil when the forge did not say.
|
||||
Mergeable *bool `json:"mergeable,omitempty"`
|
||||
}
|
||||
|
||||
// ForgeStatus is one status of a commit, the newest of its context.
|
||||
|
||||
@@ -254,6 +254,9 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
if waits == "" && gates {
|
||||
waits = h.turnWaits(d, now) // nothing merges into a repository while a check of other heads judges it (ADR 0548 §2)
|
||||
}
|
||||
if waits == "" {
|
||||
waits = h.passWaits(d) // no merge lands in a repository between another approved group's members (issue 1275)
|
||||
}
|
||||
if waits != r.Waits {
|
||||
r.Waits = waits
|
||||
h.keep(d)
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
],
|
||||
"tools": [
|
||||
"delivery_hand_now",
|
||||
"delivery_merge_again",
|
||||
"delivery_status",
|
||||
"delivery_turns"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user