mesh-delivery: say a head whose merge check started and never answered (issue 438)
mesh/merge-gate pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The watch for unchecked heads counted any merge-check status as checked, pending included, so a head left with a pending mesh/merge-gate, or never given mesh/repo-check, waited unable to merge with nothing saying so. gitea_open_pulls now gives each head status with its state and time, and the watch says a head whose required check stays pending or unset past the delivery table's bound for a check asked and not answered.
This commit is contained in:
@@ -18,6 +18,9 @@ Every new head of an open pull request is announced as `pull.updated`, once, and
|
|||||||
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
|
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
|
||||||
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
|
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
|
||||||
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
|
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
|
||||||
|
Each of the head's statuses comes with its state and when it was set, so a head whose check started and never
|
||||||
|
answered — a required check pending, or never set beside one that is — is said the same way once that has lasted
|
||||||
|
the delivery table's bound for a check asked and not answered, an hour (issue 438).
|
||||||
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
|
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
|
||||||
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
|
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
|
||||||
at the latest, and `mesh-controller.conditions` names none for it.
|
at the latest, and `mesh-controller.conditions` names none for it.
|
||||||
|
|||||||
+26
-2
@@ -81,6 +81,16 @@ export interface OpenPull {
|
|||||||
required: string[];
|
required: string[];
|
||||||
/** The contexts the head has a status of, in any state. */
|
/** The contexts the head has a status of, in any state. */
|
||||||
statuses: string[];
|
statuses: string[];
|
||||||
|
/** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check
|
||||||
|
* that started and never answered — pending — from one that answered. */
|
||||||
|
checks: Record<string, HeadCheck>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One status on a head, the newest of its context: its state, what it said, and when it was set. */
|
||||||
|
export interface HeadCheck {
|
||||||
|
state: string;
|
||||||
|
description?: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */
|
/** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */
|
||||||
@@ -323,9 +333,10 @@ export class GiteaClient {
|
|||||||
if (pull.state !== "open" || !pull.head_sha) continue;
|
if (pull.state !== "open" || !pull.head_sha) continue;
|
||||||
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
|
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
|
||||||
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
|
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
|
||||||
const statuses = Object.keys(await this.commitStatuses(found.owner, found.name, pull.head_sha));
|
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
|
||||||
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
|
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
|
||||||
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [], statuses });
|
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
|
||||||
|
statuses: Object.keys(checks), checks });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -441,6 +452,19 @@ export class GiteaClient {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A head's statuses by context, the newest of each, with state, description and time (novox/hq issue 438):
|
||||||
|
* commitStatuses' read, keeping what a watch needs to say a check pending and since when. */
|
||||||
|
async headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>> {
|
||||||
|
const raw = await this.request<any>(`${repoPath(owner, repo)}/commits/${commitSegment(sha)}/status`);
|
||||||
|
const out: Record<string, HeadCheck> = {};
|
||||||
|
for (const s of raw?.statuses ?? []) {
|
||||||
|
if (!s?.context || out[s.context]) continue;
|
||||||
|
out[s.context] = { state: String(s.status ?? s.state ?? ""), description: String(s.description ?? ""),
|
||||||
|
updated_at: String(s.updated_at ?? s.created_at ?? "") };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
|
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
|
||||||
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
|
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
|
||||||
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
|
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ test("every open pull request is read with what its base requires and what its h
|
|||||||
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
|
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
|
||||||
}
|
}
|
||||||
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
|
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
|
||||||
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending" }] });
|
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending",
|
||||||
|
description: "the mesh is mapping this head onto its module graph", updated_at: "2026-10-11T00:11:39Z" }] });
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end("{}");
|
res.end("{}");
|
||||||
});
|
});
|
||||||
@@ -66,6 +67,9 @@ test("every open pull request is read with what its base requires and what its h
|
|||||||
["novox", "hq", 243, "main", "aaa1111", ["mesh/repo-check"], []],
|
["novox", "hq", 243, "main", "aaa1111", ["mesh/repo-check"], []],
|
||||||
["novox", "mesh-sdk", 14, "main", "bbb2222", ["mesh/merge-gate", "mesh/repo-check"], ["mesh/merge-gate"]],
|
["novox", "mesh-sdk", 14, "main", "bbb2222", ["mesh/merge-gate", "mesh/repo-check"], ["mesh/merge-gate"]],
|
||||||
]);
|
]);
|
||||||
|
// Each status with its state and time (novox/hq issue 438): a check pending is told from one that answered.
|
||||||
|
assert.deepEqual(open.map((p) => p.checks), [{}, { "mesh/merge-gate": { state: "pending",
|
||||||
|
description: "the mesh is mapping this head onto its module graph", updated_at: "2026-10-11T00:11:39Z" } }]);
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gitea_open_pulls",
|
name: "gitea_open_pulls",
|
||||||
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, in any state. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347). Reads only.",
|
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438). Reads only.",
|
||||||
input: {},
|
input: {},
|
||||||
run: async () => ({ pulls: await gitea.openPulls() }),
|
run: async () => ({ pulls: await gitea.openPulls() }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openPullsOf reads gitea_open_pulls' answer as the forge module gives it, so the tests read the real shape: the
|
||||||
|
// pull requests below are mesh-controller #212's and #211's heads and mesh-catalog #194's, as the forge said them on
|
||||||
|
// 2026-10-11 (novox/hq issue 438), with only their numbers and shas as they were.
|
||||||
|
func openPullsOf(t *testing.T, answer string) []OpenPull {
|
||||||
|
t.Helper()
|
||||||
|
var open struct {
|
||||||
|
Pulls []OpenPull `json:"pulls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(answer), &open); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return open.Pulls
|
||||||
|
}
|
||||||
|
|
||||||
|
// A head the mesh's check started on and never answered: mesh/merge-gate pending since the controller began mapping
|
||||||
|
// it, mesh/repo-check never set. Its base requires both.
|
||||||
|
const startedNeverAnswered = `{"pulls": [{
|
||||||
|
"base": "main", "head_sha": "c11222026a3b26a0cc4b277119e1ea059b82a36b",
|
||||||
|
"html_url": "http://127.0.0.1:20000/novox/mesh-controller/pulls/212", "number": 212,
|
||||||
|
"owner": "novox", "repo": "mesh-controller",
|
||||||
|
"required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||||
|
"statuses": ["mesh/merge-gate", "mesh/delivery"],
|
||||||
|
"checks": {
|
||||||
|
"mesh/delivery": {"state": "pending", "description": "checking", "updated_at": "2026-10-11T00:11:46Z"},
|
||||||
|
"mesh/merge-gate": {"state": "pending", "description": "the mesh is mapping this head onto its module graph",
|
||||||
|
"updated_at": "2026-10-11T00:11:39Z"}
|
||||||
|
}
|
||||||
|
}]}`
|
||||||
|
|
||||||
|
// **A head whose merge check started and never answered is said** (novox/hq issue 438): pending, or missing a check
|
||||||
|
// its base requires, for as long as the delivery table gives a check asked and not answered (Bounds[Proposed]) since
|
||||||
|
// this owner first saw it so, it is a stalled line naming each check that did not answer and since when. Before,
|
||||||
|
// any merge-check status counted as checked, pending included, and such a head was never said.
|
||||||
|
func TestAHeadWhoseCheckStartedAndNeverAnsweredIsSaid(t *testing.T) {
|
||||||
|
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||||
|
w.forge.open = openPullsOf(t, startedNeverAnswered)
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
w.later(Bounds[Proposed].For - time.Minute)
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||||
|
t.Fatalf("a head still within the bound of a check asked and not answered is said: %+v", lines)
|
||||||
|
}
|
||||||
|
w.later(2 * time.Minute)
|
||||||
|
lines := w.h.Stalled()
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("a head whose check started and never answered is not said: %+v", lines)
|
||||||
|
}
|
||||||
|
l := lines[0]
|
||||||
|
if l.ID != IDOf("novox/mesh-controller", "c11222026a3b26a0cc4b277119e1ea059b82a36b") || l.Number != 212 ||
|
||||||
|
l.State != Unannounced || l.Bound != Bounds[Proposed].For.String() || l.For != "1h1m0s" {
|
||||||
|
t.Fatalf("the line is not this head's, past the table's bound: %+v", l)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"mesh/merge-gate", "pending since 2026-10-11T00:11:39Z", "mesh/repo-check", "never set",
|
||||||
|
"never answered"} {
|
||||||
|
if !strings.Contains(l.Says, want) {
|
||||||
|
t.Fatalf("the line does not say %q: %s", want, l.Says)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(l.Says, "mesh/delivery") {
|
||||||
|
t.Fatalf("the line names a status that is not the merge check: %s", l.Says)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announced at last: a delivery of its own, with its own bound — no longer this line.
|
||||||
|
w.h.PullUpdated(pr("novox/mesh-controller", 212, "c11222026a3b26a0cc4b277119e1ea059b82a36b", "fix/402"))
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
for _, l := range w.h.Stalled() {
|
||||||
|
if l.State == Unannounced {
|
||||||
|
t.Fatalf("an announced head is still said unannounced: %+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A check asked again — its pending status set anew, as a recheck or a new announcement does — starts the bound again:
|
||||||
|
// the head is said only once that ask too has gone unanswered past it.
|
||||||
|
func TestACheckAskedAgainStartsItsBoundAgain(t *testing.T) {
|
||||||
|
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||||
|
w.forge.open = openPullsOf(t, startedNeverAnswered)
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
w.later(50 * time.Minute)
|
||||||
|
again := openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", "2026-10-11T01:02:00Z", 1))
|
||||||
|
w.forge.open = again
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
w.later(20 * time.Minute)
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||||
|
t.Fatalf("a check asked again twenty minutes ago is said on the first ask's time: %+v", lines)
|
||||||
|
}
|
||||||
|
w.later(41 * time.Minute)
|
||||||
|
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "pending since 2026-10-11T01:02:00Z") {
|
||||||
|
t.Fatalf("a check asked again and not answered past the bound is not said: %+v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A head whose required checks all answered is not said**, whatever they answered: a failure is a verdict, and the
|
||||||
|
// pull request says it itself. Nor is one whose only pending check is one its base does not require: hq requires
|
||||||
|
// mesh/repo-check alone.
|
||||||
|
func TestAHeadWhoseRequiredChecksAnsweredIsNotSaid(t *testing.T) {
|
||||||
|
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||||
|
w.forge.open = openPullsOf(t, `{"pulls": [{
|
||||||
|
"base": "main", "head_sha": "99df6b999bad77c51dfdd13470cf57b1f38e8c24", "number": 194, "owner": "novox", "repo": "mesh-catalog",
|
||||||
|
"required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||||
|
"statuses": ["mesh/merge-gate", "mesh/repo-check", "mesh/delivery"],
|
||||||
|
"checks": {
|
||||||
|
"mesh/delivery": {"state": "failure", "description": "rejected", "updated_at": "2026-10-11T00:05:37Z"},
|
||||||
|
"mesh/merge-gate": {"state": "success", "description": "pass: builds mounts → shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)", "updated_at": "2026-10-11T00:05:28Z"},
|
||||||
|
"mesh/repo-check": {"state": "failure", "description": "fail: its merge-check.sh failed", "updated_at": "2026-10-11T00:05:28Z"}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"base": "main", "head_sha": "11b719fd5cdec43970e513a10a44a558042599e6", "number": 308, "owner": "novox", "repo": "hq",
|
||||||
|
"required": ["mesh/repo-check"],
|
||||||
|
"statuses": ["mesh/merge-gate", "mesh/repo-check", "mesh/delivery"],
|
||||||
|
"checks": {
|
||||||
|
"mesh/merge-gate": {"state": "pending", "description": "the mesh is mapping this head onto its module graph", "updated_at": "2026-10-11T00:01:00Z"},
|
||||||
|
"mesh/repo-check": {"state": "success", "description": "pass: its merge-check.sh passed", "updated_at": "2026-10-11T00:02:00Z"}
|
||||||
|
}
|
||||||
|
}]}`)
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
w.later(3 * Bounds[Proposed].For)
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||||
|
t.Fatalf("a head whose required checks answered is said: %+v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forge module that says only which contexts a head has, not their states — the gitea module before issue 438 —
|
||||||
|
// is read as before: a head with any merge check is not said, and nothing is guessed of what it does not say.
|
||||||
|
func TestAForgeThatSaysNoStatesIsReadAsBefore(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
w.forge.open = openPullsOf(t, `{"pulls": [{"base": "main", "head_sha": "c11222026a3b26a0cc4b277119e1ea059b82a36b",
|
||||||
|
"number": 212, "owner": "novox", "repo": "mesh-controller", "required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||||
|
"statuses": ["mesh/merge-gate", "mesh/delivery"]}]}`)
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
w.later(3 * Bounds[Proposed].For)
|
||||||
|
w.h.SeeOpenPulls()
|
||||||
|
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||||
|
t.Fatalf("a head is said on states the forge did not give: %+v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +16,15 @@ import (
|
|||||||
// since this owner first saw it so is said as a delivery held: a `stalled` line in the state Unannounced, which the
|
// since this owner first saw it so is said as a delivery held: a `stalled` line in the state Unannounced, which the
|
||||||
// controller raises as `delivery.<id>.stalled` (D14) like any other delivery past its bound. The delivery domain's own
|
// controller raises as `delivery.<id>.stalled` (D14) like any other delivery past its bound. The delivery domain's own
|
||||||
// word, so no second condition and no second judge: the id is the one its delivery will have once announced.
|
// word, so no second condition and no second judge: the id is the one its delivery will have once announced.
|
||||||
|
//
|
||||||
|
// A head whose check started and never answered is said the same way (novox/hq issue 438). Counting any merge-check
|
||||||
|
// status as checked let a head that got only a pending mesh/merge-gate, or never got mesh/repo-check, wait unable to
|
||||||
|
// merge with nothing saying so. So a head is checked only when every merge check its base requires has answered —
|
||||||
|
// success, failure, error or warning, a failure being a verdict the pull request shows itself — and one with a
|
||||||
|
// required check pending or never set is said once that has lasted the delivery table's bound for a check asked and
|
||||||
|
// not answered (Bounds[Proposed]): an announced head would be said after that bound too, and it is longer than the
|
||||||
|
// controller lets one check run (its checkTimeout, 45 minutes), so a slow check or a status read a moment behind
|
||||||
|
// (issue 423) is never said.
|
||||||
|
|
||||||
// Unannounced is a pull request's head the forge never announced: not a state of the table — no delivery exists —
|
// Unannounced is a pull request's head the forge never announced: not a state of the table — no delivery exists —
|
||||||
// only what a stalled line says.
|
// only what a stalled line says.
|
||||||
@@ -29,7 +39,8 @@ const UncheckedAfter = 10 * time.Minute
|
|||||||
var mergeChecks = []string{"mesh/merge-gate", "mesh/repo-check"}
|
var mergeChecks = []string{"mesh/merge-gate", "mesh/repo-check"}
|
||||||
|
|
||||||
// OpenPull is an open pull request as the forge says it (the gitea module's gitea_open_pulls): its head, its base,
|
// OpenPull is an open pull request as the forge says it (the gitea module's gitea_open_pulls): its head, its base,
|
||||||
// the contexts the base's protection requires, and the contexts its head has a status of, in any state.
|
// the contexts the base's protection requires, the contexts its head has a status of, in any state, and each of
|
||||||
|
// those statuses with its state.
|
||||||
type OpenPull struct {
|
type OpenPull struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
@@ -39,24 +50,64 @@ type OpenPull struct {
|
|||||||
HTMLURL string `json:"html_url,omitempty"`
|
HTMLURL string `json:"html_url,omitempty"`
|
||||||
Required []string `json:"required"`
|
Required []string `json:"required"`
|
||||||
Statuses []string `json:"statuses"`
|
Statuses []string `json:"statuses"`
|
||||||
|
// Checks are the head's statuses by context, the newest of each (novox/hq issue 438). A forge module older
|
||||||
|
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
|
||||||
|
// from an answered one.
|
||||||
|
Checks map[string]HeadCheck `json:"checks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// unchecked is a head seen without a merge check, and when this owner first saw it so — its own time, since the
|
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
|
||||||
// forge's update time moves on every comment.
|
type HeadCheck struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// unchecked is a head seen without an answered merge check, what it lacks, and when this owner first saw it so —
|
||||||
|
// its own time, since the forge's update time moves on every comment.
|
||||||
type unchecked struct {
|
type unchecked struct {
|
||||||
pull OpenPull
|
pull OpenPull
|
||||||
first time.Time
|
waiting []string
|
||||||
|
first time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantsACheck is whether the base requires the mesh's merge check and the head has none of it.
|
// unanswered are the merge checks the base requires that the head has no verdict of, each as it reads — "never
|
||||||
func (p OpenPull) wantsACheck() bool {
|
// set", or pending and since when — and whether the head has any merge check at all: its check started. A head on
|
||||||
required := slices.ContainsFunc(mergeChecks, func(c string) bool { return slices.Contains(p.Required, c) })
|
// a base that requires none, or without a head, has nothing unanswered.
|
||||||
checked := slices.ContainsFunc(mergeChecks, func(c string) bool { return slices.Contains(p.Statuses, c) })
|
func (p OpenPull) unanswered() (waiting []string, started bool) {
|
||||||
return required && !checked && p.HeadSHA != ""
|
if p.HeadSHA == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
started = slices.ContainsFunc(mergeChecks, func(c string) bool {
|
||||||
|
return slices.Contains(p.Statuses, c) || p.Checks[c].State != ""
|
||||||
|
})
|
||||||
|
for _, c := range mergeChecks {
|
||||||
|
if !slices.Contains(p.Required, c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, ok := p.Checks[c]
|
||||||
|
switch {
|
||||||
|
case p.Checks == nil && started:
|
||||||
|
// A forge that says no states: a head with any merge check counts as checked, as before issue 438,
|
||||||
|
// since nothing here says whether the one it has answered.
|
||||||
|
case !ok || got.State == "":
|
||||||
|
waiting = append(waiting, c+" never set")
|
||||||
|
case got.State == "pending":
|
||||||
|
waiting = append(waiting, c+" pending since "+orUnknown(got.UpdatedAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return waiting, started
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeeOpenPulls asks the forge for every open pull request and keeps the heads that want a check and have none.
|
func orUnknown(at string) string {
|
||||||
// A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
|
if at == "" {
|
||||||
|
return "a time the forge did not say"
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeeOpenPulls asks the forge for every open pull request and keeps the heads with a required merge check not
|
||||||
|
// answered. A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
|
||||||
func (h *Holder) SeeOpenPulls() {
|
func (h *Holder) SeeOpenPulls() {
|
||||||
open, err := h.Forge.OpenPulls()
|
open, err := h.Forge.OpenPulls()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,38 +119,73 @@ func (h *Holder) SeeOpenPulls() {
|
|||||||
now := h.Now()
|
now := h.Now()
|
||||||
seen := map[string]unchecked{}
|
seen := map[string]unchecked{}
|
||||||
for _, p := range open {
|
for _, p := range open {
|
||||||
if !p.wantsACheck() {
|
waiting, _ := p.unanswered()
|
||||||
|
if len(waiting) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id := IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)
|
id := IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)
|
||||||
if h.deliveries[id] != nil {
|
if h.deliveries[id] != nil {
|
||||||
continue // announced: a delivery with its own bound
|
continue // announced: a delivery with its own bound
|
||||||
}
|
}
|
||||||
|
// The clock runs from when this owner first saw the head waiting as it waits now: a check that started, or
|
||||||
|
// was asked again — its pending status set anew — is given its whole bound from then.
|
||||||
first := now
|
first := now
|
||||||
if was, ok := h.unchecked[id]; ok {
|
if was, ok := h.unchecked[id]; ok && slices.Equal(was.waiting, waiting) {
|
||||||
first = was.first
|
first = was.first
|
||||||
} else {
|
} else {
|
||||||
h.Logf("[mesh-delivery] %s/%s#%d's head %s has no merge check; said if it has none in %s", p.Owner, p.Repo,
|
u := unchecked{pull: p, waiting: waiting}
|
||||||
p.Number, short(p.HeadSHA), UncheckedAfter)
|
h.Logf("[mesh-delivery] %s/%s#%d's head %s %s; said if so in %s", p.Owner, p.Repo, p.Number,
|
||||||
|
short(p.HeadSHA), u.what(), u.bound())
|
||||||
}
|
}
|
||||||
seen[id] = unchecked{pull: p, first: first}
|
seen[id] = unchecked{pull: p, waiting: waiting, first: first}
|
||||||
}
|
}
|
||||||
h.unchecked = seen
|
h.unchecked = seen
|
||||||
}
|
}
|
||||||
|
|
||||||
// uncheckedLines are the heads unchecked past UncheckedAfter, as stalled lines; under the lock.
|
// started is whether the head has any merge check: its check started, rather than was never asked.
|
||||||
|
func (u unchecked) started() bool {
|
||||||
|
_, started := u.pull.unanswered()
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
// bound is how long the head may wait as it does: UncheckedAfter for a check never asked, the table's bound for a
|
||||||
|
// check asked and not answered for one that started (novox/hq issue 438).
|
||||||
|
func (u unchecked) bound() time.Duration {
|
||||||
|
if u.started() {
|
||||||
|
return Bounds[Proposed].For
|
||||||
|
}
|
||||||
|
return UncheckedAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
// what is what the head lacks, in words.
|
||||||
|
func (u unchecked) what() string {
|
||||||
|
if u.started() {
|
||||||
|
return "has a merge check that has not answered: " + strings.Join(u.waiting, ", ")
|
||||||
|
}
|
||||||
|
return "has no merge check"
|
||||||
|
}
|
||||||
|
|
||||||
|
// uncheckedLines are the heads unchecked past their bound, as stalled lines; under the lock.
|
||||||
func (h *Holder) uncheckedLines(now time.Time) []StalledLine {
|
func (h *Holder) uncheckedLines(now time.Time) []StalledLine {
|
||||||
var out []StalledLine
|
var out []StalledLine
|
||||||
for id, u := range h.unchecked {
|
for id, u := range h.unchecked {
|
||||||
if h.deliveries[id] != nil || now.Sub(u.first) < UncheckedAfter {
|
if h.deliveries[id] != nil || now.Sub(u.first) < u.bound() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
h2 := "none: the forge never announced it, so there is no delivery to close — the operator's"
|
||||||
|
says := fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head has had no merge "+
|
||||||
|
"check: the mesh was never asked to check it. A new commit on its branch announces it",
|
||||||
|
u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base)
|
||||||
|
if u.started() {
|
||||||
|
says = fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head's check started "+
|
||||||
|
"and never answered: %s. No delivery of it is held here, so nothing asks it again. A new commit on "+
|
||||||
|
"its branch announces it and asks its check", u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base,
|
||||||
|
strings.Join(u.waiting, ", "))
|
||||||
|
h2 = "none: no delivery of it is held here, so there is none to close — the operator's"
|
||||||
|
}
|
||||||
out = append(out, StalledLine{ID: id, Number: u.pull.Number, State: Unannounced, For: now.Sub(u.first).Round(time.Second).String(),
|
out = append(out, StalledLine{ID: id, Number: u.pull.Number, State: Unannounced, For: now.Sub(u.first).Round(time.Second).String(),
|
||||||
Bound: UncheckedAfter.String(),
|
Bound: u.bound().String(),
|
||||||
H2: "none: the forge never announced it, so there is no delivery to close — the operator's",
|
H2: h2, Says: says})
|
||||||
Says: fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head has had no merge "+
|
|
||||||
"check: the mesh was never asked to check it. A new commit on its branch announces it",
|
|
||||||
u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base)})
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user