mesh-delivery acts on events and keeps each pull request's key; the forge's announcements carry a message id (issues 975, 976, design 47 milestone 2) #345
@@ -12,6 +12,9 @@
|
||||
"serves": ["current", "kill", "pause", "resume"]
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery.pulls"
|
||||
],
|
||||
"requires": [
|
||||
"artifact-store",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// **The forge's announcements carry a message id** (novox/hq ADR 0406 §6 and §7, issue 976). A merge, a new head and a
|
||||
// close are announced by more than one path — the poll, the forge's own event, the merge tool's ask of a check — and
|
||||
// were kept from being announced twice by files of what had been announced. They are now announced with a message id
|
||||
// that is the same for the same announcement, whichever path makes it: the runtime makes an event's `x-event-id` header
|
||||
// the bus's message id (mesh-tools node-tools/internal/bus/bus.go, `nats.MsgId`), and the bus stores a message it has
|
||||
// seen the id of, within its de-duplication window, once.
|
||||
//
|
||||
// The SDK's `emit` takes no id, so this publishes through the broker with the headers `emit` stamps and the id given.
|
||||
// It returns to `emit` once the SDK takes an id.
|
||||
//
|
||||
// The ids: a merge by its merge commit, a head by the head and the trunk commit it was announced against (the check asked
|
||||
// again against a new trunk commit is another announcement), a close by its head.
|
||||
|
||||
import { broker } from "@novox/mesh-sdk/messaging";
|
||||
import { state as keptState } from "@novox/mesh-sdk/state";
|
||||
|
||||
type Said = { owner?: string; repo?: string; number?: number; head_sha?: string; base_sha?: string; merge_commit_sha?: string };
|
||||
|
||||
/** The message id of an announcement, or undefined for an event that is not one of these three. */
|
||||
export function announcementId(type: string, body: unknown): string | undefined {
|
||||
const b = (body ?? {}) as Said;
|
||||
const pull = `${b.owner}/${b.repo}#${b.number}`;
|
||||
switch (type) {
|
||||
case "pull.merged":
|
||||
return `merged:${pull}@${b.merge_commit_sha ?? ""}`;
|
||||
case "pull.updated":
|
||||
return `updated:${pull}@${b.head_sha ?? ""}:${b.base_sha ?? ""}`;
|
||||
case "pull.closed":
|
||||
return `closed:${pull}@${b.head_sha ?? ""}`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Announce a merge, a head or a close: published with its message id. */
|
||||
export async function announce(type: string, body: unknown): Promise<void> {
|
||||
const id = announcementId(type, body);
|
||||
if (!id) throw new Error(`${type} is not an announcement with a message id`);
|
||||
const node = process.env.MESH_NODE ?? "unknown";
|
||||
await broker().publish({
|
||||
key: type,
|
||||
node,
|
||||
body,
|
||||
headers: {
|
||||
"x-event-id": id,
|
||||
"x-source": process.env.MESH_MODULE ?? "unknown",
|
||||
"x-node": node,
|
||||
"x-time": new Date().toISOString(),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- the watermark: the one value of the old files that is not the bus's to remember --------------------------------
|
||||
|
||||
/** What the module keeps of how far its poll of merges has looked: a time, in its own state. */
|
||||
interface Kept {
|
||||
get(key: string): Promise<{ value: unknown } | null>;
|
||||
put(key: string, value: unknown): Promise<number>;
|
||||
}
|
||||
|
||||
const WATERMARK = "merges-since";
|
||||
|
||||
/** The time the poll of merges last looked from, or "" when none is kept or the state cannot be reached: a merge made
|
||||
* before it is history, one after it is announced. */
|
||||
export async function readWatermark(state: Kept = keptState("announced")): Promise<string> {
|
||||
try {
|
||||
const entry = await state.get(WATERMARK);
|
||||
const since = (entry?.value as { since?: string } | undefined)?.since;
|
||||
return typeof since === "string" ? since : "";
|
||||
} catch (err) {
|
||||
console.error(`[gitea] where the poll of merges last looked from could not be read, so none is kept — ${err instanceof Error ? err.message : err}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the time the poll of merges looked from; a state that cannot be reached is said, and the next pass keeps it. */
|
||||
export async function keepWatermark(since: string, state: Kept = keptState("announced")): Promise<void> {
|
||||
try {
|
||||
await state.put(WATERMARK, { since });
|
||||
} catch (err) {
|
||||
console.error(`[gitea] where the poll of merges looked from could not be kept — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
+41
-56
@@ -39,6 +39,7 @@
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
import { commitStatusTool } from "./delivery.js";
|
||||
import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
||||
@@ -90,32 +91,17 @@ async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
// everything it sees then predates the watching.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
|
||||
// What has been announced is no longer kept in a file (novox/hq ADR 0406 §6, §7, issue 976): an announcement carries a
|
||||
// message id (announce.ts), so the same merge announced by the poll and by the forge's event, or again after a restart,
|
||||
// is stored once by the bus — within its de-duplication window, and past it the consumers' idempotence per merge commit
|
||||
// holds. This process remembers what it announced meanwhile, so it does not ask the forge for the same files twice.
|
||||
// The one value kept is where the poll last looked from (`since`), in the module's own state: a merge made while this
|
||||
// module was away, after it, is announced when it is back; one before it is history. With none kept — the first look
|
||||
// ever — nothing is announced, because everything it sees then predates the watching.
|
||||
const announced = new Set<string>();
|
||||
let primedMerges = false;
|
||||
// since is the moment the watching began: a merge made before it is history, whatever page of the
|
||||
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
|
||||
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
|
||||
// rebuilt everything built from that repository, once per old merge (2026-09-28).
|
||||
let since = "";
|
||||
if (mergedRecord && existsSync(mergedRecord)) {
|
||||
try {
|
||||
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
|
||||
const list = Array.isArray(kept) ? kept : kept.announced;
|
||||
for (const sha of list) announced.add(sha);
|
||||
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
|
||||
primedMerges = true;
|
||||
} catch {
|
||||
// An unreadable record is treated as no record: prime again rather than re-announce history.
|
||||
}
|
||||
}
|
||||
function keepAnnounced(): void {
|
||||
if (!mergedRecord) return;
|
||||
mkdirSync(join(mergedRecord, ".."), { recursive: true });
|
||||
const tmp = mergedRecord + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
||||
renameSync(tmp, mergedRecord);
|
||||
}
|
||||
let watermarkRead = false;
|
||||
// **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the
|
||||
// repository's update time; asking every repository for its pull requests on every tick took longer than the
|
||||
// tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a
|
||||
@@ -123,10 +109,14 @@ function keepAnnounced(): void {
|
||||
let lastLook = "";
|
||||
const MARGIN_MS = 60_000;
|
||||
async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
if (!watermarkRead) {
|
||||
since = await readWatermark();
|
||||
primedMerges = since !== "";
|
||||
watermarkRead = true;
|
||||
}
|
||||
const began = new Date().toISOString();
|
||||
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
|
||||
const repos = movedSince(await client.listAllRepos(), floor);
|
||||
let changed = false;
|
||||
for (const repo of repos) {
|
||||
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
||||
for (const pull of pulls) {
|
||||
@@ -134,13 +124,12 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
const closedKey = `closed:${repo.full_name}#${pull.number}`;
|
||||
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
|
||||
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) {
|
||||
await emit("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
|
||||
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
|
||||
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
|
||||
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
|
||||
await siblingMoves(client, repo.full_name, pull.number);
|
||||
}
|
||||
announced.add(closedKey);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
|
||||
@@ -152,11 +141,11 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
}
|
||||
else announced.add(pull.merge_commit_sha);
|
||||
announced.add(pull.merge_commit_sha);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!primedMerges) since = new Date().toISOString();
|
||||
if (!primedMerges || changed) keepAnnounced();
|
||||
// The next look starts from this one's start less the margin; kept, so a restart looks from there.
|
||||
since = primedMerges ? new Date(Date.parse(began) - MARGIN_MS).toISOString() : began;
|
||||
await keepWatermark(since);
|
||||
primedMerges = true;
|
||||
lastLook = began;
|
||||
}
|
||||
@@ -196,7 +185,7 @@ async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): P
|
||||
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
await emit("pull.merged", {
|
||||
await announce("pull.merged", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
@@ -226,32 +215,17 @@ async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): P
|
||||
announced.delete(pull.merge_commit_sha);
|
||||
throw err;
|
||||
}
|
||||
keepAnnounced();
|
||||
return true;
|
||||
}
|
||||
|
||||
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
|
||||
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
|
||||
// announces nothing twice; the first look on a machine with no record announces only what moved in the
|
||||
// last day, so a forge's whole backlog is not checked at once.
|
||||
const pullsRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-announced.json") : null;
|
||||
// **Every new head of an open pull request is announced** (novox/hq to-be 45 §9): the mesh checks it against every
|
||||
// machine of its facts before it merges. What this process announced is remembered in memory only (novox/hq ADR 0406
|
||||
// §6, §7, issue 976): the announcement carries a message id (announce.ts), so the poll's, the forge event's and the
|
||||
// merge tool's are one to the bus. After a start, the first look announces a head only if the forge shows no merge check
|
||||
// of it yet — a head with one was announced — and only what moved in the last day, so a forge's whole backlog is not
|
||||
// checked at once and a restart asks no check again against a trunk that moved.
|
||||
let heads: Announced = {};
|
||||
let primedPulls = false;
|
||||
if (pullsRecord && existsSync(pullsRecord)) {
|
||||
try {
|
||||
heads = (JSON.parse(readFileSync(pullsRecord, "utf8")) as { heads: Announced }).heads ?? {};
|
||||
primedPulls = true;
|
||||
} catch {
|
||||
// An unreadable record is no record: the first look announces only the last day's.
|
||||
}
|
||||
}
|
||||
function keepHeads(): void {
|
||||
if (!pullsRecord) return;
|
||||
mkdirSync(join(pullsRecord, ".."), { recursive: true });
|
||||
const tmp = pullsRecord + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify({ heads }));
|
||||
renameSync(tmp, pullsRecord);
|
||||
}
|
||||
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
|
||||
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
@@ -261,13 +235,12 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
|
||||
if (!claimHead(heads, key, sha)) return false;
|
||||
try {
|
||||
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
|
||||
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
|
||||
await announce("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
|
||||
} catch (err) {
|
||||
if (before === undefined) delete heads[key];
|
||||
else heads[key] = before;
|
||||
throw err;
|
||||
}
|
||||
keepHeads();
|
||||
// A sibling's new head puts out of date every verdict that read the sibling at another head (ADR 0382 §5).
|
||||
await siblingMoves(client, repo.full_name, pull.number);
|
||||
// Said, so an operator knows the mesh was asked to check it.
|
||||
@@ -279,6 +252,16 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
|
||||
}
|
||||
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
|
||||
|
||||
/** Whether the forge shows a merge check of the head, in any state: only an announced head has one. */
|
||||
async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
|
||||
try {
|
||||
const checks = await client.headChecks(repo.owner, repo.name, String(pull.head_sha));
|
||||
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks;
|
||||
} catch {
|
||||
return false; // the forge cannot say: announced, and the bus's message id collapses it if it was
|
||||
}
|
||||
}
|
||||
|
||||
let lastPullLook = "";
|
||||
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
|
||||
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
|
||||
@@ -288,7 +271,6 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
const began = new Date().toISOString();
|
||||
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
|
||||
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
|
||||
let changed = false;
|
||||
// A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
|
||||
// pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
|
||||
let sweep = Date.now() - lastSweep >= SWEEP_MS;
|
||||
@@ -309,14 +291,17 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
// The first look after a start remembers nothing: a head the forge already shows a merge check of was announced.
|
||||
if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) {
|
||||
heads[key] = String(pull.head_sha);
|
||||
continue;
|
||||
}
|
||||
if (fresh) {
|
||||
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
|
||||
} else heads[key] = String(pull.head_sha);
|
||||
changed = true;
|
||||
}
|
||||
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
|
||||
}
|
||||
if (!primedPulls || changed) keepHeads();
|
||||
if (changedTrunks) keepTrunks();
|
||||
changedTrunks = false;
|
||||
primedPulls = true;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package busdedup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// **The same announcement published twice with one message id is stored once** (novox/hq ADR 0406 §6, issue 976). The
|
||||
// forge module announces a merge, a head and a close with a deterministic `x-event-id` (announce.ts); the runtime makes
|
||||
// that header the bus's message id (mesh-tools node-tools/internal/bus/bus.go, `nats.MsgId`), and the events stream's
|
||||
// message de-duplication keeps the second. The publish here is the runtime's own: the headers the module stamps, the id
|
||||
// as the message id. A real JetStream server is linked into the test and started for it alone (the TypeScript harness
|
||||
// has none, so this is the Go half of the module's tests).
|
||||
|
||||
const subject = "mesh.mod.gitea.event.pull.merged"
|
||||
|
||||
func publish(t *testing.T, js nats.JetStreamContext, id string) *nats.PubAck {
|
||||
t.Helper()
|
||||
msg := nats.NewMsg(subject)
|
||||
msg.Header.Set("x-event-id", id)
|
||||
msg.Header.Set("x-source", "gitea")
|
||||
msg.Header.Set("content-type", "application/json")
|
||||
msg.Data = []byte(`{"owner":"novox","repo":"app","number":7,"merge_commit_sha":"mmm222"}`)
|
||||
ack, err := js.PublishMsg(msg, nats.MsgId(id))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ack
|
||||
}
|
||||
|
||||
func TestTheSameAnnouncementPublishedTwiceWithOneMessageIdIsStoredOnce(t *testing.T) {
|
||||
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true,
|
||||
StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(30 * time.Second) {
|
||||
t.Fatal("a bus for this test did not come up within 30s")
|
||||
}
|
||||
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||
nc, err := nats.Connect(s.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := nc.JetStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The events stream as the controller makes it: two minutes of message de-duplication.
|
||||
if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.>"}, Duplicates: 2 * time.Minute}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const id = "merged:novox/app#7@mmm222"
|
||||
first := publish(t, js, id)
|
||||
second := publish(t, js, id) // the poll and the forge's event both announced it
|
||||
if first.Duplicate || !second.Duplicate {
|
||||
t.Fatalf("the first publish was a duplicate: %v; the second: %v (wanted false, true)", first.Duplicate, second.Duplicate)
|
||||
}
|
||||
publish(t, js, "merged:novox/app#8@nnn333") // another merge is another message
|
||||
jsNew, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream, err := jsNew.Stream(context.Background(), "EVENTS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := stream.Info(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.State.Msgs != 2 {
|
||||
t.Fatalf("the stream stores %d messages for two announcements published three times; wanted 2", info.State.Msgs)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked.
|
||||
// Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { announce } from "./announce.js";
|
||||
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import type { CommitStatus, GiteaClient } from "./client.js";
|
||||
import { mergeableUnder, newestByContext } from "./checks.js";
|
||||
@@ -27,7 +27,7 @@ export interface MergeDeps {
|
||||
}
|
||||
|
||||
export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition {
|
||||
const deps = { ...given, emit: given.emit ?? emit, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR,
|
||||
const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR,
|
||||
now: given.now ?? (() => new Date()) };
|
||||
return {
|
||||
name: "gitea_merge_pull_request",
|
||||
|
||||
@@ -498,7 +498,8 @@
|
||||
}
|
||||
],
|
||||
"state": [
|
||||
"bound"
|
||||
"bound",
|
||||
"announced"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
// **The forge's announcements carry a message id** (novox/hq ADR 0406 §6, §7, issue 976): the same announcement,
|
||||
// however many paths make it — the poll, the forge's event, the merge tool's ask — has one id, which the runtime makes
|
||||
// the bus's message id, so the bus stores it once. This replaces the files of what was announced.
|
||||
|
||||
const merged = { owner: "novox", repo: "app", number: 7, head_sha: "aaa111", merge_commit_sha: "mmm222", base: "main" };
|
||||
const updated = { owner: "novox", repo: "app", number: 7, head_sha: "aaa111", base_sha: "ttt333", base: "main" };
|
||||
const closed = { owner: "novox", repo: "app", number: 7, head_sha: "aaa111", base: "main" };
|
||||
|
||||
test("the same merge, head or close announced twice has the same id, and another has another", async () => {
|
||||
const { announcementId } = await import("../announce.ts");
|
||||
assert.equal(announcementId("pull.merged", merged), "merged:novox/app#7@mmm222");
|
||||
assert.equal(announcementId("pull.merged", { ...merged, title: "worded differently" }), announcementId("pull.merged", merged));
|
||||
assert.notEqual(announcementId("pull.merged", { ...merged, merge_commit_sha: "other" }), announcementId("pull.merged", merged));
|
||||
assert.equal(announcementId("pull.updated", updated), "updated:novox/app#7@aaa111:ttt333");
|
||||
assert.notEqual(announcementId("pull.updated", { ...updated, base_sha: "moved" }), announcementId("pull.updated", updated),
|
||||
"the check asked again against a new trunk commit is another announcement");
|
||||
assert.notEqual(announcementId("pull.updated", { ...updated, head_sha: "bbb" }), announcementId("pull.updated", updated));
|
||||
assert.equal(announcementId("pull.updated", { ...updated, base_sha: undefined }), "updated:novox/app#7@aaa111:");
|
||||
assert.equal(announcementId("pull.closed", closed), "closed:novox/app#7@aaa111");
|
||||
});
|
||||
|
||||
test("an announcement is published through the broker with its id as the event id, and the provenance emit stamps", async () => {
|
||||
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||
const published: any[] = [];
|
||||
useBroker(() => ({ publish: async (env: any) => { published.push(env); } }) as any);
|
||||
process.env.MESH_MODULE = "gitea";
|
||||
process.env.MESH_NODE = "anchor";
|
||||
const { announce } = await import("../announce.ts");
|
||||
await announce("pull.merged", merged);
|
||||
await announce("pull.merged", { ...merged });
|
||||
assert.equal(published.length, 2);
|
||||
assert.equal(published[0].key, "pull.merged");
|
||||
assert.equal(published[0].headers["x-event-id"], "merged:novox/app#7@mmm222");
|
||||
assert.equal(published[1].headers["x-event-id"], published[0].headers["x-event-id"], "one id for one announcement");
|
||||
assert.equal(published[0].headers["x-source"], "gitea");
|
||||
assert.equal(published[0].headers["x-node"], "anchor");
|
||||
assert.equal(published[0].headers["content-type"], "application/json");
|
||||
assert.ok(published[0].headers["x-time"]);
|
||||
assert.equal(published[0].node, "anchor");
|
||||
assert.deepEqual(published[0].body, merged);
|
||||
});
|
||||
|
||||
test("an announcement of a kind it has no id for is refused, never published with a made-up one", async () => {
|
||||
const { announce, announcementId } = await import("../announce.ts");
|
||||
assert.equal(announcementId("repo.created", { owner: "a" }), undefined);
|
||||
await assert.rejects(() => announce("repo.created", { owner: "a" }), /not an announcement with a message id/);
|
||||
});
|
||||
|
||||
test("the watermark is kept in the module's state, and an unreachable state is no record", async () => {
|
||||
const { readWatermark, keepWatermark } = await import("../announce.ts");
|
||||
const kept = new Map<string, unknown>();
|
||||
const store = { get: async (k: string) => (kept.has(k) ? { key: k, value: kept.get(k), revision: 1 } : null),
|
||||
put: async (k: string, v: unknown) => { kept.set(k, v); return 1; } };
|
||||
assert.equal(await readWatermark(store as any), "", "none yet");
|
||||
await keepWatermark("2026-10-17T12:00:00.000Z", store as any);
|
||||
assert.equal(await readWatermark(store as any), "2026-10-17T12:00:00.000Z");
|
||||
const away = { get: async () => { throw new Error("no runtime"); }, put: async () => { throw new Error("no runtime"); } };
|
||||
assert.equal(await readWatermark(away as any), "", "a state that cannot be reached is no record");
|
||||
await keepWatermark("2026-10-17T12:01:00.000Z", away as any); // said, not thrown
|
||||
});
|
||||
@@ -471,7 +471,7 @@ func (h *Holder) lapsedBehind(w Walk, d *Delivery) string {
|
||||
}
|
||||
|
||||
// lapsedSaid is why a walk whose word stops that lapsed give still waits: the members of their groups not yet
|
||||
// delivered before them, what the last tick held it for, or the controller's refusal of the word. Under the lock.
|
||||
// delivered before them, what the last look held it for, or the controller's refusal of the word. Under the lock.
|
||||
func (h *Holder) lapsedSaid(w Walk) string {
|
||||
var parts []string
|
||||
_, behind := h.lapsedOf(w)
|
||||
@@ -592,6 +592,7 @@ func (h *Holder) words() []func() {
|
||||
}
|
||||
h.deliverErr[walk] = err.Error() // said by `stalled` past the grace (review of novox/hq issue 753)
|
||||
h.Logf("[mesh-delivery] the walk %s could not be let go yet: %v", walk, err)
|
||||
h.again("a walk's word the controller refused or did not answer")
|
||||
return
|
||||
}
|
||||
delete(h.deliverErr, walk)
|
||||
@@ -639,7 +640,7 @@ func (h *Holder) endedAsNothingToMove(w Walk) []string {
|
||||
|
||||
// WalksWaiting is `stalled` asked with `walks` (novox/hq issue 529): for each walk waiting for this owner's word,
|
||||
// what holds it, in the shape of a stalled line naming the walk — each held delivery it carries (State held, its
|
||||
// reason and the verb that releases it), else why its word was not given at the last tick (State published), else
|
||||
// reason and the verb that releases it), else why its word was not given at the last look (State published), else
|
||||
// that no live delivery holds it (State unheld). A walk whose word is being given is not listed. The controller's
|
||||
// `plans` says these instead of pointing at a hand start; the self-check never raises a condition for them.
|
||||
func (h *Holder) WalksWaiting() []StalledLine {
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// this point; the Approve covers every member at those heads.
|
||||
// - A push after the ask cancels it (the mark is cleared with it): the new head needs a new mark and a new Approve.
|
||||
// - A Decline merges nothing, is said once on the pull request, and clears the mark.
|
||||
// - An ask that ended without a choice (expired, cancelled, refused by the router) is asked again at the next tick.
|
||||
// - An ask that ended without a choice (expired, cancelled, refused by the router) is asked again when the router says so.
|
||||
// - The ask, its state and the warrant's words are kept on the delivery's ReadyRun and shown by `show`.
|
||||
//
|
||||
// The terminal Approve (ADR 0398 §3) is issue 953; today the proving channel is the operator's phone.
|
||||
@@ -262,7 +262,7 @@ func (h *Holder) headsHold(d *Delivery, c *ReadyRun) bool {
|
||||
}
|
||||
|
||||
// endStaleAsks ends every ask whose heads are no longer its group's: a push to any member, marked or not, ends the
|
||||
// approval (ADR 0398 §3). Under the lock; the cancels run with the tick's asks.
|
||||
// approval (ADR 0398 §3). Under the lock; the cancels run with the look's asks.
|
||||
func (h *Holder) endStaleAsks() {
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
@@ -326,7 +326,8 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
h.keep(m)
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] the operator could not be asked about %s (%v); asked again at the next tick", d.ID, err)
|
||||
h.again("the operator's Approve, which could not be asked")
|
||||
h.Logf("[mesh-delivery] the operator could not be asked about %s (%v); asked again in %s", d.ID, err, askAgainAfter)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +413,7 @@ func (h *Holder) decided(id, state string, w *asks.Warrant) []func() {
|
||||
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
|
||||
h.keep(d)
|
||||
}
|
||||
h.Logf("[mesh-delivery] the operator's ask %s ended without a choice (%s); asked again at the next tick", id, why)
|
||||
h.Logf("[mesh-delivery] the operator's ask %s ended without a choice (%s); asked again at once", id, why)
|
||||
}
|
||||
if w == nil || w.Outcome != asks.OutcomeChosen {
|
||||
why := "the router says " + state
|
||||
@@ -442,6 +443,7 @@ func (h *Holder) decided(id, state string, w *asks.Warrant) []func() {
|
||||
for _, d := range covered {
|
||||
r := d.Ready
|
||||
r.Warrant = w.Says()
|
||||
h.mergeAgainNow(d) // the operator chose: the merge is asked at once
|
||||
switch option.ID {
|
||||
case OptionApprove:
|
||||
r.Approved = &now
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// **The acceptance of acting on events** (novox/hq ADR 0406, design 47 milestone 2, ADR 0391 phase 3, issue 975): a
|
||||
// pull request with a passing verdict gets its `mesh/ready` label event and its `ready at <head>` comment event, and
|
||||
// the merge is asked within five seconds of the comment event — with no tick, no poll and no reconcile to find it.
|
||||
// Proved over a real bus: a JetStream server is linked into the test and started for it alone; the module is this
|
||||
// test binary started again as the bundle, speaking MCP on its standard streams to a runtime played here, which does
|
||||
// what the tool runner does for a bundle — its state on the buckets the controller would make from the manifest, its
|
||||
// events delivered from the bus's streams, the controller's verbs and the forge's tools answered by the test (the
|
||||
// pattern of mesh-issues' bus_test).
|
||||
|
||||
const bundleVar = "MESH_DELIVERY_TEST_BUNDLE"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv(bundleVar) == "1" {
|
||||
main()
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type testBus struct {
|
||||
nc *nats.Conn
|
||||
js jetstream.JetStream
|
||||
kvs map[string]jetstream.KeyValue
|
||||
}
|
||||
|
||||
func newTestBus(t *testing.T) *testBus {
|
||||
t.Helper()
|
||||
opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(),
|
||||
NoLog: true, NoSigs: true}
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("a bus for this test could not be made: %v", err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(30 * time.Second) {
|
||||
s.Shutdown()
|
||||
t.Fatal("a bus for this test did not come up within 30s")
|
||||
}
|
||||
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||
nc, err := nats.Connect(s.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &testBus{nc: nc, js: js, kvs: map[string]jetstream.KeyValue{}}
|
||||
// The controller's job: a bucket per state the manifest declares.
|
||||
m, _ := readManifest(t)
|
||||
for _, name := range m.State {
|
||||
kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: m.Module + "_" + name, History: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.kvs[name] = kv
|
||||
}
|
||||
// The streams the events are on (mesh.mod.<module>.event.<name>, mesh.seat.<seat>.event.<name>).
|
||||
if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS",
|
||||
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + ControllerSeat + ".event.>", "mesh.seat." + asks.Seat + ".event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// runtime is the half of the conversation the tool runner plays for one running bundle.
|
||||
type runtime struct {
|
||||
t *testing.T
|
||||
b *testBus
|
||||
cmd *exec.Cmd
|
||||
in io.WriteCloser
|
||||
mu sync.Mutex
|
||||
next int
|
||||
wait map[string]chan json.RawMessage
|
||||
|
||||
// what the bundle subscribed to, and asked of the mesh.
|
||||
subs []string
|
||||
asked []askedOfMesh
|
||||
notify chan struct{}
|
||||
// the asks the operator was asked, and the controller's walks and the forge's open pull requests as the test plays
|
||||
// them.
|
||||
operatorAsks []asks.Ask
|
||||
openPulls []OpenPull
|
||||
}
|
||||
|
||||
type askedOfMesh struct {
|
||||
Key string
|
||||
Body map[string]any
|
||||
At time.Time
|
||||
}
|
||||
|
||||
func (b *testBus) start(t *testing.T) *runtime {
|
||||
t.Helper()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^$")
|
||||
cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_SERVED_MODULE=mesh-delivery", "MESH_MODULE=mesh-delivery", "MESH_NODE=test")
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = testLog{t}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &runtime{t: t, b: b, cmd: cmd, in: in, wait: map[string]chan json.RawMessage{}, notify: make(chan struct{}, 64)}
|
||||
go r.read(out)
|
||||
t.Cleanup(func() { _ = in.Close(); _ = cmd.Wait() })
|
||||
return r
|
||||
}
|
||||
|
||||
type testLog struct{ t *testing.T }
|
||||
|
||||
func (w testLog) Write(p []byte) (int, error) {
|
||||
defer func() { _ = recover() }() // the bundle may speak after the test ended
|
||||
w.t.Logf("bundle: %s", strings.TrimRight(string(p), "\n"))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type rpcMessage struct {
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r *runtime) say(m map[string]any) {
|
||||
m["jsonrpc"] = "2.0"
|
||||
raw, _ := json.Marshal(m)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, _ = r.in.Write(append(raw, '\n'))
|
||||
}
|
||||
|
||||
func (r *runtime) read(out io.Reader) {
|
||||
sc := bufio.NewScanner(out)
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<24)
|
||||
for sc.Scan() {
|
||||
var m rpcMessage
|
||||
if json.Unmarshal(sc.Bytes(), &m) != nil {
|
||||
continue
|
||||
}
|
||||
if m.Method == "" {
|
||||
var id string
|
||||
_ = json.Unmarshal(m.ID, &id)
|
||||
r.mu.Lock()
|
||||
ch := r.wait[id]
|
||||
delete(r.wait, id)
|
||||
r.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- sc.Bytes()
|
||||
}
|
||||
continue
|
||||
}
|
||||
go r.answer(m)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runtime) ping() {
|
||||
select {
|
||||
case r.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// asksOf are the asks of the mesh made so far under a key.
|
||||
func (r *runtime) asksOf(key string) []askedOfMesh {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var out []askedOfMesh
|
||||
for _, a := range r.asked {
|
||||
if a.Key == key {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// until waits for what the test needs to be true, woken by every message of the bundle.
|
||||
func (r *runtime) until(what string, within time.Duration, ok func() bool) {
|
||||
r.t.Helper()
|
||||
deadline := time.After(within)
|
||||
for {
|
||||
if ok() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.notify:
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
case <-deadline:
|
||||
if ok() {
|
||||
return
|
||||
}
|
||||
r.t.Fatalf("within %s: %s did not happen", within, what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toolAnswer is a tool's or a verb's answer as the runtime hands it over.
|
||||
func toolAnswer(answer any) any {
|
||||
return map[string]any{"ok": true, "output": "", "answer": answer}
|
||||
}
|
||||
|
||||
func (r *runtime) answer(m rpcMessage) {
|
||||
var p struct {
|
||||
State string `json:"state"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Pattern string `json:"pattern"`
|
||||
Subject string `json:"subject"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &p)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
defer r.ping()
|
||||
result, err := func() (any, error) {
|
||||
switch m.Method {
|
||||
case "mesh/subscribe":
|
||||
r.mu.Lock()
|
||||
r.subs = append(r.subs, p.Pattern)
|
||||
r.mu.Unlock()
|
||||
return map[string]any{}, nil
|
||||
case "mesh/publish":
|
||||
return map[string]any{}, nil // the module's own events: not needed here
|
||||
case "mesh/seat.publish":
|
||||
// The ask for the operator, published under the module's name.
|
||||
if strings.Contains(p.Subject, ".accept.ask.") {
|
||||
var a asks.Ask
|
||||
if err := json.Unmarshal(p.Body, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.operatorAsks = append(r.operatorAsks, a)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
case "mesh/seat.record":
|
||||
return nil, nil
|
||||
case "mesh/ask":
|
||||
var a struct {
|
||||
Key string `json:"key"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(m.Params, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.asked = append(r.asked, askedOfMesh{Key: a.Key, Body: a.Body, At: time.Now()})
|
||||
pulls := append([]OpenPull{}, r.openPulls...)
|
||||
r.mu.Unlock()
|
||||
switch a.Key {
|
||||
case "seat:" + ControllerSeat + ".delivery-walks":
|
||||
return toolAnswer(map[string]any{"held": true, "walks": []any{}}), nil
|
||||
case "seat:" + ControllerSeat + ".delivery-plan":
|
||||
return toolAnswer(map[string]any{"plan": DeliveryPlan{Repository: "novox/app", Moved: []string{"app"},
|
||||
Summary: "builds app, sent to the node"}}), nil
|
||||
case "seat:" + ControllerSeat + ".delivery-check":
|
||||
return toolAnswer(map[string]any{"asked": "check-1"}), nil
|
||||
case "gitea.gitea_open_pulls":
|
||||
return toolAnswer(map[string]any{"pulls": pulls}), nil
|
||||
case "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", "gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_add_comment":
|
||||
return toolAnswer(map[string]any{}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("the runtime answers no ask %s in this test", a.Key)
|
||||
}
|
||||
kv := r.b.kvs[p.State]
|
||||
if kv == nil {
|
||||
return nil, fmt.Errorf("the state %q is not declared", p.State)
|
||||
}
|
||||
switch m.Method {
|
||||
case "mesh/state.get":
|
||||
e, err := kv.Get(ctx, p.Key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"key": p.Key, "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil
|
||||
case "mesh/state.create":
|
||||
rev, err := kv.Create(ctx, p.Key, p.Value)
|
||||
return map[string]any{"revision": rev}, err
|
||||
case "mesh/state.update":
|
||||
rev, err := kv.Update(ctx, p.Key, p.Value, p.Revision)
|
||||
return map[string]any{"revision": rev}, err
|
||||
case "mesh/state.put":
|
||||
rev, err := kv.Put(ctx, p.Key, p.Value)
|
||||
return map[string]any{"revision": rev}, err
|
||||
case "mesh/state.delete":
|
||||
return map[string]any{}, kv.Delete(ctx, p.Key)
|
||||
case "mesh/state.keys":
|
||||
keys, err := kv.Keys(ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return keys, err
|
||||
}
|
||||
return nil, fmt.Errorf("the runtime offers no %s to this bundle", m.Method)
|
||||
}()
|
||||
if err != nil {
|
||||
code := -32000
|
||||
var api *jetstream.APIError
|
||||
if errors.Is(err, jetstream.ErrKeyExists) || errors.As(err, &api) && api.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence {
|
||||
code = -32010
|
||||
}
|
||||
r.say(map[string]any{"id": m.ID, "error": map[string]any{"code": code, "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
r.say(map[string]any{"id": m.ID, "result": result})
|
||||
}
|
||||
|
||||
// deliverEvents is the runtime reading the events streams and handing each event to the bundle, once, as
|
||||
// `mesh/event`, the way the tool runner does for a module's consumer.
|
||||
func (r *runtime) deliverEvents(ctx context.Context) {
|
||||
stream, err := r.b.js.Stream(ctx, "EVENTS")
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
cons, err := stream.OrderedConsumer(ctx, jetstream.OrderedConsumerConfig{})
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
cc, err := cons.Consume(func(msg jetstream.Msg) {
|
||||
key := keyOfSubject(msg.Subject())
|
||||
r.mu.Lock()
|
||||
id := fmt.Sprintf("event-%d", r.next)
|
||||
r.next++
|
||||
r.mu.Unlock()
|
||||
r.say(map[string]any{"id": id, "method": "mesh/event", "params": map[string]any{"envelope": stdio.Envelope{
|
||||
Key: key, Node: "test", Body: msg.Data(), Headers: map[string]string{"x-source": strings.SplitN(key, ".", 2)[0]}}}})
|
||||
})
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
r.t.Cleanup(cc.Stop)
|
||||
}
|
||||
|
||||
// keyOfSubject is an event's key, as the runtime gives it to a module: mesh.mod.<module>.event.<name> is
|
||||
// <module>.<name>, and mesh.seat.<seat>.event.<name> is <seat>.<name>.
|
||||
func keyOfSubject(subject string) string {
|
||||
parts := strings.Split(subject, ".")
|
||||
if len(parts) < 5 || parts[3] != "event" {
|
||||
return subject
|
||||
}
|
||||
return parts[2] + "." + strings.Join(parts[4:], ".")
|
||||
}
|
||||
|
||||
func (b *testBus) publish(t *testing.T, subject string, body any) time.Time {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.js.Publish(context.Background(), subject, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func TestAReadyMarkAndItsApproveAreMergedWithinSecondsOfTheirEventsOverARealBus(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
rt := b.start(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
rt.deliverEvents(ctx)
|
||||
// The module has started when it hears what it follows, and the operator's answers.
|
||||
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.subs) >= len(followed)+1
|
||||
})
|
||||
|
||||
// A pull request into main is announced, and the controller's verdict on its head passes.
|
||||
const head = "1111111111111111111111111111111111111111"
|
||||
announced := pr("novox/app", 7, head, "fix/x", "src/a.go")
|
||||
announced.BaseSHA = "t1t1t1t1t1t1"
|
||||
announced.Title = "Fix the apostrophe (issue 975)"
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
|
||||
rt.until("the module to keep the delivery of the head as ready", 10*time.Second, func() bool {
|
||||
keys, err := b.kvs["deliveries"].Keys(context.Background())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range keys {
|
||||
e, err := b.kvs["deliveries"].Get(context.Background(), k)
|
||||
var d Delivery
|
||||
if err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Ready && d.Commit == head {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
// The pull request's key holds its head, the trunk commit it was asked against and the verdict.
|
||||
rt.until("the pull request's key to hold head, trunk and verdict", 5*time.Second, func() bool {
|
||||
e, err := b.kvs["pulls"].Get(context.Background(), "novox.app.7")
|
||||
var k PullKey
|
||||
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
|
||||
})
|
||||
reads := len(rt.asksOf("gitea.gitea_open_pulls"))
|
||||
|
||||
// The reviewer's label and comment: the forge shows the label alone at first, and the mark whole once the comment is
|
||||
// written. Nothing is asked for the label alone.
|
||||
rt.mu.Lock()
|
||||
rt.openPulls = []OpenPull{markedPull("novox/app", 7, head, "")}
|
||||
rt.mu.Unlock()
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.labeled", labeled(7, ReadyLabel))
|
||||
rt.until("the forge's open pull requests to be read for the label", 5*time.Second, func() bool {
|
||||
return len(rt.asksOf("gitea.gitea_open_pulls")) > reads
|
||||
})
|
||||
rt.mu.Lock()
|
||||
rt.openPulls = []OpenPull{markedPull("novox/app", 7, head, head)}
|
||||
nothing := len(rt.operatorAsks)
|
||||
rt.mu.Unlock()
|
||||
if nothing != 0 {
|
||||
t.Fatalf("the operator was asked for a label with no head named")
|
||||
}
|
||||
commented := b.publish(t, "mesh.mod.gitea.event.forge.pull-request.commented", commentedReady(7, head))
|
||||
|
||||
// The operator is asked, within the comment's event, and answers at once (a person's Approve, proven by the router).
|
||||
rt.until("the operator to be asked to Approve the merge", 5*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.operatorAsks) == 1
|
||||
})
|
||||
rt.mu.Lock()
|
||||
ask := rt.operatorAsks[0]
|
||||
rt.mu.Unlock()
|
||||
opt, _ := ask.Option(OptionApprove)
|
||||
b.publish(t, "mesh.seat."+asks.Seat+".event.decided."+Module, asks.Warrant{Ask: ask.ID, Asker: Module,
|
||||
Outcome: asks.OutcomeChosen, Option: OptionApprove, Label: opt.Label, Level: opt.Level,
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"}, At: time.Now(), AskDigest: ask.Digest()})
|
||||
|
||||
rt.until("the merge to be asked of the forge", 5*time.Second, func() bool { return len(rt.asksOf("gitea.gitea_merge_pull_request")) >= 1 })
|
||||
merges := rt.asksOf("gitea.gitea_merge_pull_request")
|
||||
if len(merges) != 1 || merges[0].Body["head"] != head || merges[0].Body["number"] != float64(7) {
|
||||
t.Fatalf("the merge was asked %+v; wanted once, for pull request 7 at the marked head", merges)
|
||||
}
|
||||
if took := merges[0].At.Sub(commented); took > 5*time.Second {
|
||||
t.Fatalf("the merge was asked %s after the ready comment's event; wanted within 5s", took)
|
||||
} else {
|
||||
t.Logf("the merge was asked %s after the ready comment's event", took)
|
||||
}
|
||||
// The forge's open pull requests were read for the mark by the events, not by a poll: the label's and the comment's.
|
||||
if got := len(rt.asksOf("gitea.gitea_open_pulls")) - reads; got < 2 || got > 3 {
|
||||
t.Fatalf("the forge's open pull requests were read %d times for one mark", got)
|
||||
}
|
||||
// Quiet follows: nothing asks the forge or the controller again with no event to say so.
|
||||
quiet := len(rt.asked)
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
rt.mu.Lock()
|
||||
more := len(rt.asked) - quiet
|
||||
rt.mu.Unlock()
|
||||
if more > 3 {
|
||||
t.Fatalf("%d asks of the mesh in a quiet second and a half after the merge: something polls", more)
|
||||
}
|
||||
}
|
||||
|
||||
// A ready mark on a pull request whose check nobody asked yet asks it at once, on the ready stream (the check's `ready`).
|
||||
func TestAReadyMarkAsksTheCheckOfAPullRequestWithNoVerdictAsReady(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
rt := b.start(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
rt.deliverEvents(ctx)
|
||||
rt.until("the module to subscribe", 20*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.subs) >= len(followed)+1
|
||||
})
|
||||
const head = "2222222222222222222222222222222222222222"
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", pr("novox/app", 8, head, "fix/y", "src/a.go"))
|
||||
rt.mu.Lock()
|
||||
rt.openPulls = []OpenPull{markedPull("novox/app", 8, head, head)}
|
||||
rt.mu.Unlock()
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.commented", commentedReady(8, head))
|
||||
rt.until("the check to be asked", 5*time.Second, func() bool { return len(rt.asksOf("seat:"+ControllerSeat+".delivery-check")) >= 1 })
|
||||
body := rt.asksOf("seat:" + ControllerSeat + ".delivery-check")[0].Body
|
||||
var members []Member
|
||||
if err := json.Unmarshal([]byte(fmt.Sprint(body["members"])), &members); err != nil || len(members) != 1 || !members[0].Ready {
|
||||
t.Fatalf("the check was asked for %v (%v); wanted one member with ready", body["members"], err)
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,38 @@ func (h *Holder) Flush() {
|
||||
retryAfter(j.e.Tries+1))
|
||||
}
|
||||
}
|
||||
h.owedAgain()
|
||||
}
|
||||
|
||||
// owedAgain says when what is still owed is tried again: an effect the forge or the bus could not take, at the time
|
||||
// retryAfter gave it, and a delivery or group not yet kept, after retryFirst. Flush is the bounded retry of both.
|
||||
func (h *Holder) owedAgain() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for id := range h.dirty {
|
||||
if h.deliveries[id] != nil {
|
||||
h.lookAt(h.Now().Add(retryFirst), "a delivery not yet kept, kept again before it is said")
|
||||
}
|
||||
}
|
||||
for id := range h.dirtyGroup {
|
||||
if h.groups[id] != nil {
|
||||
h.lookAt(h.Now().Add(retryFirst), "a group not yet kept, kept again before it is said")
|
||||
}
|
||||
}
|
||||
for _, d := range h.deliveries {
|
||||
for _, e := range d.Owed {
|
||||
if e.Next != nil {
|
||||
h.lookAt(*e.Next, "an owed effect the forge or the bus could not take")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, g := range h.groups {
|
||||
for _, e := range g.Owed {
|
||||
if e.Next != nil {
|
||||
h.lookAt(*e.Next, "an owed effect the forge or the bus could not take")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An owed effect that fails is tried again later each time: retryFirst after the first failure, doubling up
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Events that start a step (novox/hq ADR 0406 §1, issue 975).
|
||||
//
|
||||
// This owner acts on events: a ready mark, a verdict, an Approve, a group's turn. The forge's label and comment events
|
||||
// say that a pull request's ready mark may have changed; they do not say whether it counts. Which accounts' "ready at"
|
||||
// comments count is the forge module's rule (its trusted setting ready-authors, the gitea module's readyMarkOf), so a
|
||||
// label added or a "ready at" comment written makes this owner read the forge's open pull requests once, and the mark
|
||||
// it reads there is the mark (seeMarks). The label taken off needs no read: it only clears.
|
||||
|
||||
// ForgePullEvent is the forge module's `forge.pull-request.labeled`, `.unlabeled` and `.commented`
|
||||
// (modules/gitea/webhook.ts): the pull request, and the label added or taken off, or the comment written.
|
||||
type ForgePullEvent struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
HeadSHA string `json:"head_sha,omitempty"`
|
||||
Base string `json:"base,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
CommentID int `json:"comment_id,omitempty"`
|
||||
}
|
||||
|
||||
// ForgeStatusEvent is the forge module's `forge.status`: a commit's status was set.
|
||||
type ForgeStatusEvent struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
SHA string `json:"sha"`
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// readyAtComment is a comment that names the head a pull request is marked ready at, as the forge module reads it
|
||||
// (modules/gitea/client.ts READY_AT): "ready at <full sha>" on a line of its own.
|
||||
var readyAtComment = regexp.MustCompile(`(?im)^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b`)
|
||||
|
||||
func eventPullKey(e ForgePullEvent) string {
|
||||
return strings.ToLower(e.Owner+"/"+e.Repo) + "#" + strconv.Itoa(e.Number)
|
||||
}
|
||||
|
||||
// wantForge says the forge's open pull requests are to be read at the next look: a ready mark may have been made.
|
||||
func (h *Holder) wantForge() {
|
||||
h.mu.Lock()
|
||||
h.forgeWanted = true
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// takeForgeWanted answers whether the forge's open pull requests are to be read, and takes the want.
|
||||
func (h *Holder) takeForgeWanted() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
wanted := h.forgeWanted
|
||||
h.forgeWanted = false
|
||||
return wanted
|
||||
}
|
||||
|
||||
// PullLabeled takes a label added to a pull request: the ready label asks for the mark to be read.
|
||||
func (h *Holder) PullLabeled(e ForgePullEvent) {
|
||||
if e.Label == ReadyLabel {
|
||||
h.wantForge()
|
||||
}
|
||||
}
|
||||
|
||||
// PullUnlabeled takes a label taken off a pull request: the ready label clears its mark, with no read.
|
||||
func (h *Holder) PullUnlabeled(e ForgePullEvent) {
|
||||
if e.Label != ReadyLabel {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if _, marked := h.marks[eventPullKey(e)]; marked {
|
||||
delete(h.marks, eventPullKey(e))
|
||||
h.Logf("[mesh-delivery] %s's ready label was taken off: its mark is cleared", eventPullKey(e))
|
||||
}
|
||||
}
|
||||
|
||||
// PullCommented takes a comment on a pull request: one that names a head to be ready at asks for the mark to be read.
|
||||
func (h *Holder) PullCommented(e ForgePullEvent) {
|
||||
if readyAtComment.MatchString(e.Comment) {
|
||||
h.wantForge()
|
||||
}
|
||||
}
|
||||
|
||||
// AskDecided takes the router's `decided` event for an ask this owner made: its warrant is applied to the deliveries
|
||||
// the ask covers, and what is said on their pull requests is said.
|
||||
func (h *Holder) AskDecided(id, state string, w *asks.Warrant) {
|
||||
h.mu.Lock()
|
||||
said := h.decided(id, state, w)
|
||||
h.mu.Unlock()
|
||||
for _, s := range said {
|
||||
s()
|
||||
}
|
||||
}
|
||||
|
||||
// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/merge-gate, mesh/repo-check) says a
|
||||
// check started, answered, or was put back, so the forge's open pull requests are read to see which heads still wait on
|
||||
// one (SeeOpenPulls). A status of any other context shows nothing the merge needs.
|
||||
func (h *Holder) CommitStatus(e ForgeStatusEvent) {
|
||||
if slices.Contains(mergeChecks, e.Context) {
|
||||
h.wantForge()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// **A ready mark, a verdict and an Approve are acted on as events** (novox/hq ADR 0406 §1, issue 975): the merge
|
||||
// is asked within the event that makes it possible, never at the next tick or the next minute.
|
||||
|
||||
const (
|
||||
labeledEvent = "gitea.forge.pull-request.labeled"
|
||||
unlabeledEvent = "gitea.forge.pull-request.unlabeled"
|
||||
commentedEvent = "gitea.forge.pull-request.commented"
|
||||
)
|
||||
|
||||
// hear is the runtime handing this owner one event, as handle takes it.
|
||||
func hear(t *testing.T, w *world, key string, body any) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := handle(w.h, stdio.Envelope{Key: key, Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.React() // the look the event pokes, taken by the holder's worker
|
||||
}
|
||||
|
||||
func labeled(number int, label string) ForgePullEvent {
|
||||
return ForgePullEvent{Owner: "novox", Repo: "app", Number: number, HeadSHA: readyHead, Base: "main", Label: label,
|
||||
Labels: []string{label}}
|
||||
}
|
||||
|
||||
func commentedReady(number int, head string) ForgePullEvent {
|
||||
return ForgePullEvent{Owner: "novox", Repo: "app", Number: number, Author: "reviewer", CommentID: 5,
|
||||
Comment: "ready at " + head + "\n\napproved: the tests are the change's"}
|
||||
}
|
||||
|
||||
// greenAnnounced is a pull request into main, announced and judged green, the operator not answering by himself.
|
||||
func greenAnnounced(t *testing.T) *world {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
return w
|
||||
}
|
||||
|
||||
func TestALabelAndAReadyAtCommentAskTheOperatorAtOnceWithNoTick(t *testing.T) {
|
||||
w := greenAnnounced(t)
|
||||
// The label first: the forge reads it without a comment naming a head yet. Nothing is asked.
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, "")}
|
||||
hear(t, w, labeledEvent, labeled(7, ReadyLabel))
|
||||
if w.op.count() != 0 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a label with no head named was acted on: %d asks, merges %v", w.op.count(), w.forge.merges)
|
||||
}
|
||||
// The comment naming the head: the forge now reads the mark whole, and the ask is made within this event.
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
hear(t, w, commentedEvent, commentedReady(7, readyHead))
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("the comment that completes the mark asked the operator %d times, wanted once, with no tick", w.op.count())
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready == nil || d.Ready.By != "reviewer" || d.Ready.AskID == "" {
|
||||
t.Fatalf("the mark is not kept on the delivery: %+v", d.Ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommentThatIsNotAReadyMarkReadsNothingFromTheForge(t *testing.T) {
|
||||
w := greenAnnounced(t)
|
||||
before := w.forge.openCalls
|
||||
c := commentedReady(7, readyHead)
|
||||
c.Comment = "looks fine to me, though I would not say ready at all"
|
||||
hear(t, w, commentedEvent, c)
|
||||
hear(t, w, labeledEvent, labeled(7, "kind/bug"))
|
||||
if w.forge.openCalls != before {
|
||||
t.Fatalf("the forge's open pull requests were read %d times for events that are no ready mark", w.forge.openCalls-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReadyLabelTakenOffClearsTheMarkAtOnce(t *testing.T) {
|
||||
w := greenAnnounced(t)
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
hear(t, w, labeledEvent, labeled(7, ReadyLabel))
|
||||
w.h.mu.Lock()
|
||||
marks := len(w.h.marks)
|
||||
w.h.mu.Unlock()
|
||||
if marks != 1 {
|
||||
t.Fatalf("the label did not make a mark: %d", marks)
|
||||
}
|
||||
asked := w.op.count()
|
||||
// The forge no longer shows the label; the event alone says so, and the forge is not read for it.
|
||||
reads := w.forge.openCalls
|
||||
w.forge.open = nil
|
||||
hear(t, w, unlabeledEvent, labeled(7, ReadyLabel))
|
||||
w.h.mu.Lock()
|
||||
marks = len(w.h.marks)
|
||||
w.h.mu.Unlock()
|
||||
if marks != 0 || w.forge.openCalls != reads || w.op.count() != asked {
|
||||
t.Fatalf("the mark was not cleared by the event alone: marks %d, reads %d, asks %d", marks, w.forge.openCalls-reads, w.op.count()-asked)
|
||||
}
|
||||
}
|
||||
|
||||
// approve is the operator's Approve of the one open ask, heard as the router's `decided` event.
|
||||
func approve(w *world) {
|
||||
open := w.op.open()
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
state, warrant, _ := w.op.Where(open[0])
|
||||
w.h.AskDecided(open[0], state, warrant)
|
||||
w.h.React()
|
||||
}
|
||||
|
||||
func TestAnApproveHeardAsAnEventAsksTheMergeAtOnce(t *testing.T) {
|
||||
w := greenAnnounced(t)
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
hear(t, w, commentedEvent, commentedReady(7, readyHead))
|
||||
open := w.op.open()
|
||||
if len(open) != 1 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("asks %v, merges %v", open, w.forge.merges)
|
||||
}
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
|
||||
t.Fatalf("the Approve, heard as an event, merged %v; wanted once at the marked head", w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
// Another merge moves the trunk: the verdict is out of date and the merge tool refuses, asking the check again.
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.auto = false
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
hear(t, w, commentedEvent, commentedReady(7, readyHead))
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the merge tool was asked %v; wanted once to judge it again", w.forge.merges)
|
||||
}
|
||||
// The verdict against the trunk's head passes: merged within the verdict's own event, no minute waited.
|
||||
w.forge.refuse = nil
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
raw, _ := json.Marshal(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
if err := handle(w.h, stdio.Envelope{Key: ControllerSeat + ".checked", Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.React()
|
||||
if len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
|
||||
t.Fatalf("the passing verdict did not ask the merge at once: %v %+v", w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
}
|
||||
|
||||
var errAskedAgain = &askedAgainError{}
|
||||
|
||||
type askedAgainError struct{}
|
||||
|
||||
func (*askedAgainError) Error() string {
|
||||
return "the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked"
|
||||
}
|
||||
@@ -106,6 +106,10 @@ type fakeController struct {
|
||||
moves map[string][]string
|
||||
plans int
|
||||
down bool
|
||||
// walkReads counts the reads of the controller's walks (Walks).
|
||||
walkReads int
|
||||
// checkReady is the ready flag of each member of each check asked.
|
||||
checkReady []bool
|
||||
// refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753).
|
||||
refuseDeliver bool
|
||||
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
|
||||
@@ -176,6 +180,9 @@ func (c *fakeController) Check(group string, ms []Member) (string, error) {
|
||||
heads = append(heads, m.ID)
|
||||
}
|
||||
c.checks = append(c.checks, group+":"+strings.Join(heads, ","))
|
||||
for _, m := range ms {
|
||||
c.checkReady = append(c.checkReady, m.Ready)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func (c *fakeController) Deliver(walk, why string) error {
|
||||
@@ -347,6 +354,7 @@ func (c *fakeController) cut(id string) Walk {
|
||||
func (c *fakeController) Walks(walk string) (bool, []Walk, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.walkReads++
|
||||
if c.down {
|
||||
return false, nil, errors.New("down")
|
||||
}
|
||||
@@ -399,8 +407,9 @@ type fakeForge struct {
|
||||
containsDown bool
|
||||
containsAsks []string
|
||||
// open is what OpenPulls answers; openDown a forge that cannot say.
|
||||
open []OpenPull
|
||||
openDown bool
|
||||
open []OpenPull
|
||||
openDown bool
|
||||
openCalls int
|
||||
// merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722).
|
||||
merges, comments, unlabels []string
|
||||
refuse error
|
||||
@@ -412,6 +421,7 @@ type fakeForge struct {
|
||||
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.openCalls++
|
||||
if f.openDown {
|
||||
return nil, errors.New("the forge is away")
|
||||
}
|
||||
@@ -602,11 +612,25 @@ func (w *world) later(d time.Duration) {
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// settleAll ticks and flushes until nothing changes, a few times.
|
||||
// settleAll lets the router's answers reach the holder as its `decided` events, and takes a look, a few times.
|
||||
func (w *world) settleAll() {
|
||||
for range 4 {
|
||||
w.h.Tick()
|
||||
w.h.Flush()
|
||||
w.decide()
|
||||
w.h.React()
|
||||
}
|
||||
}
|
||||
|
||||
// decide hands the holder the router's `decided` event for every ask of its that has closed.
|
||||
func (w *world) decide() {
|
||||
w.h.mu.Lock()
|
||||
ids := w.h.pendingAsks()
|
||||
w.h.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
state, warrant, _ := w.op.Where(id)
|
||||
if state == "" || state == "open" || state == "held" {
|
||||
continue
|
||||
}
|
||||
w.h.AskDecided(id, state, warrant)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ type Holder struct {
|
||||
Logf func(format string, a ...any)
|
||||
// Operator is the operator channel, asked for the Approve of a merge into main (approval.go).
|
||||
Operator Operator
|
||||
// cancels are asks to take back, gathered under the lock and run with the other asks of the tick.
|
||||
// Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none.
|
||||
Pulls PullKeys
|
||||
// cancels are asks to take back, gathered under the lock and run with the other asks of the look.
|
||||
cancels []func()
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -55,11 +57,11 @@ type Holder struct {
|
||||
// unchecked are the open pull requests' heads seen without a merge check (unchecked.go).
|
||||
unchecked map[string]unchecked
|
||||
// walks are the walks' and batches' last records (answers.go); orphans the waiting walks no live delivery
|
||||
// holds, by when that was first seen; turning the walks whose turn came this tick, for some deliveries.
|
||||
// holds, by when that was first seen; turning the walks whose turn came this look, for some deliveries.
|
||||
walks map[string]seenWalk
|
||||
orphans map[string]time.Time
|
||||
turning map[string]*turnWant
|
||||
// waitsWhy is, by walk, why the last tick did not give a waiting walk its word: a delivery it carries not at
|
||||
// waitsWhy is, by walk, why the last look did not give a waiting walk its word: a delivery it carries not at
|
||||
// its turn, or a stopped change it carries (novox/hq issue 529). Said by WalksWaiting.
|
||||
waitsWhy map[string]string
|
||||
// asking are the walks whose word is being asked right now, outside the lock: not asked twice meanwhile.
|
||||
@@ -84,6 +86,16 @@ type Holder struct {
|
||||
tried owedTries
|
||||
// marks are the open pull requests read with a ready mark, by owner/repo#number (novox/hq issue 722, ready.go).
|
||||
marks map[string]*marked
|
||||
// forgeWanted is whether the forge's open pull requests are to be read at the next React: a ready mark may have been
|
||||
// made (events.go). reactMu keeps one React at a time.
|
||||
forgeWanted bool
|
||||
// rereadWalks is whether the controller's walks are to be read once more: a merge passed the bound a walk opens
|
||||
// within, and the walks were last read before it (unwalkedPast).
|
||||
rereadWalks bool
|
||||
reactMu sync.Mutex
|
||||
// poke asks for a look (react.go); wanted is when the next is wanted, and its timer.
|
||||
poke chan struct{}
|
||||
wanted lookWanted
|
||||
}
|
||||
|
||||
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
|
||||
@@ -125,6 +137,7 @@ func (h *Holder) init() {
|
||||
if h.Emit == nil {
|
||||
h.Emit = func(string, any) error { return nil }
|
||||
}
|
||||
h.poke = make(chan struct{}, 1)
|
||||
h.deliveries = map[string]*Delivery{}
|
||||
h.groups = map[string]*Group{}
|
||||
h.dirty = map[string]bool{}
|
||||
@@ -247,6 +260,7 @@ func (h *Holder) keep(d *Delivery) {
|
||||
h.dirty[d.ID] = true
|
||||
delete(h.written, d.ID)
|
||||
h.Logf("[mesh-delivery] %s could not be kept yet (%v); kept again before it is said", d.ID, err)
|
||||
h.again("a delivery that could not be kept")
|
||||
return
|
||||
}
|
||||
delete(h.dirty, d.ID)
|
||||
@@ -262,6 +276,7 @@ func (h *Holder) keepGroup(g *Group) {
|
||||
if err := h.Store.PutGroup(g); err != nil {
|
||||
h.dirtyGroup[g.ID] = true
|
||||
h.Logf("[mesh-delivery] group %s could not be kept yet (%v)", g.ID, err)
|
||||
h.again("a group that could not be kept")
|
||||
return
|
||||
}
|
||||
delete(h.dirtyGroup, g.ID)
|
||||
@@ -307,14 +322,15 @@ var trunkLike = map[string]bool{"main": true, "master": true, "trunk": true, "":
|
||||
|
||||
// PullUpdated takes a pull request's new head: a delivery proposed, an older head of the same pull request
|
||||
// superseded, and the delivery put in the group its branch name makes.
|
||||
func (h *Holder) PullUpdated(p PullEvent) {
|
||||
func (h *Holder) PullUpdated(p PullEvent) (isNew bool) {
|
||||
if p.HeadSHA == "" || p.Number == 0 {
|
||||
h.Refuse(errors.New("a pull request announced with no head or no number; nothing proposed"))
|
||||
return
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
id := IDOf(p.repository(), p.HeadSHA)
|
||||
isNew = h.deliveries[id] == nil
|
||||
now := h.Now()
|
||||
startedOver := ""
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
@@ -351,7 +367,7 @@ func (h *Holder) PullUpdated(p PullEvent) {
|
||||
d, t, err := h.freshAnnouncement(p, now, startedOver)
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
ts := []Transition{t}
|
||||
// An announcement heard again from the bus's history may carry its head's verdict already: the forge holds
|
||||
@@ -364,6 +380,7 @@ func (h *Holder) PullUpdated(p PullEvent) {
|
||||
if !h.takePlaceOf(replaced, d) {
|
||||
h.joinGroup(d)
|
||||
}
|
||||
return isNew
|
||||
}
|
||||
|
||||
// startedOverFor is why a delivery already kept at a head's id is started over for a pull request's number rather
|
||||
@@ -509,6 +526,8 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.marks, strings.ToLower(p.Owner+"/"+p.Repo)+fmt.Sprintf("#%d", p.Number)) // a merged pull request keeps no mark
|
||||
h.forgetUnchecked(p.Owner, p.Repo, p.Number)
|
||||
var d *Delivery
|
||||
if p.HeadSHA != "" {
|
||||
d = h.deliveries[IDOf(p.repository(), p.HeadSHA)]
|
||||
@@ -523,6 +542,9 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if d != nil && d.MergedAs != "" && sameCommit(d.MergedAs, p.MergeCommit) {
|
||||
return // this merge is already taken: heard again, past the bus's de-duplication window, it changes nothing
|
||||
}
|
||||
now := h.Now()
|
||||
var ts []Transition
|
||||
if over := startedOverFor(d, p.Number); over != "" {
|
||||
@@ -667,6 +689,8 @@ func verdictOfState(s string) string {
|
||||
// go of it within the week anyway.
|
||||
func (h *Holder) PullClosed(p PullEvent) {
|
||||
h.mu.Lock()
|
||||
delete(h.marks, strings.ToLower(p.Owner+"/"+p.Repo)+fmt.Sprintf("#%d", p.Number)) // a closed pull request keeps no mark
|
||||
h.forgetUnchecked(p.Owner, p.Repo, p.Number)
|
||||
for _, d := range h.deliveries {
|
||||
if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" {
|
||||
d.ClosedUnmerged = true
|
||||
@@ -736,6 +760,9 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
return
|
||||
}
|
||||
g.Check.Verdict, g.Check.Summary = c.Verdict, c.Summary
|
||||
for _, m := range h.membersOf(g) {
|
||||
h.mergeAgainNow(m) // the group's composed check answered: its members' merges are asked at once
|
||||
}
|
||||
// Each member's head and the trunk commit it was merged into (novox/hq ADR 0323 §6).
|
||||
for _, m := range c.Members {
|
||||
for id := range g.Check.Commits {
|
||||
@@ -796,6 +823,7 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
v.Gate = "error"
|
||||
}
|
||||
d.Check, d.AskedAgain = v, nil
|
||||
h.mergeAgainNow(d) // a verdict taken: the merge is asked at once, not a minute after a refusal
|
||||
if c.Plan != nil {
|
||||
d.Plan = c.Plan
|
||||
}
|
||||
@@ -815,6 +843,11 @@ func (h *Holder) WalkMoved(w Walk) {
|
||||
}
|
||||
|
||||
func (h *Holder) walkMoved(w Walk) {
|
||||
for _, d := range h.deliveries {
|
||||
if d.Group != "" {
|
||||
h.mergeAgainNow(d) // a walk moved: a group member's turn to merge may have come, asked at once
|
||||
}
|
||||
}
|
||||
if w.Release != nil {
|
||||
h.creditBacklog(w)
|
||||
return
|
||||
@@ -990,9 +1023,11 @@ const carriedBy = "carried by "
|
||||
// which says, for the log, why it is asked: what the walk is to the delivery that asks.
|
||||
func (h *Holder) askWalk(id, which string, now time.Time) func() {
|
||||
if asked, was := h.walkAsked[id]; was && now.Sub(asked) < time.Hour {
|
||||
h.lookAt(asked.Add(time.Hour), "a walk's record asked of the controller by its id")
|
||||
return nil
|
||||
}
|
||||
h.walkAsked[id] = now
|
||||
h.lookAt(now.Add(time.Hour), "a walk's record asked of the controller by its id")
|
||||
return func() {
|
||||
_, walks, err := h.Controller.Walks(id)
|
||||
h.mu.Lock()
|
||||
@@ -1053,11 +1088,11 @@ func (h *Holder) Reconcile() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- the tick: groups, turns, adoption --------------------------------------------------------------------
|
||||
// --- the look: groups, turns, adoption --------------------------------------------------------------------
|
||||
|
||||
// Tick takes every step the facts allow that needs another's word: a group's order and composed check, a
|
||||
// look takes every step the facts allow that needs another's word: a group's order and composed check, a
|
||||
// delivery's turn, a walk with no delivery made one. The asking happens outside the lock.
|
||||
func (h *Holder) Tick() {
|
||||
func (h *Holder) look() {
|
||||
for _, ask := range h.plan() {
|
||||
ask()
|
||||
}
|
||||
@@ -1099,17 +1134,20 @@ func (h *Holder) plan() []func() {
|
||||
if !u.walk.Open() {
|
||||
if now.Sub(u.first) > 10*time.Minute {
|
||||
delete(h.unmatched, id)
|
||||
} else {
|
||||
h.lookAt(u.first.Add(10*time.Minute), "a walk no merge was heard for, ended: forgotten after ten minutes")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if u.walk.Created.After(h.started) && now.Sub(u.first) < matchGrace {
|
||||
h.lookAt(u.first.Add(matchGrace), "a walk no merge was heard for: made a delivery after the grace")
|
||||
continue
|
||||
}
|
||||
h.adopt(u.walk)
|
||||
delete(h.unmatched, id)
|
||||
}
|
||||
|
||||
// Plans not known for a delivery that has its diffset: asked once a tick.
|
||||
// Plans not known for a delivery that has its diffset: asked at each look.
|
||||
for _, d := range h.deliveries {
|
||||
if d.Plan == nil && !d.State.Final() && len(d.Paths) > 0 && (d.State == Ready || d.State == Held || d.MergedAs != "") {
|
||||
d := d
|
||||
@@ -1120,6 +1158,7 @@ func (h *Holder) plan() []func() {
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's plan cannot be asked yet: %v", id, err)
|
||||
h.again("a delivery's plan that could not be asked")
|
||||
return
|
||||
}
|
||||
if d := h.deliveries[id]; d != nil && d.Plan == nil {
|
||||
@@ -1173,14 +1212,14 @@ func (h *Holder) plan() []func() {
|
||||
|
||||
// Held, or merged with no walk opened for it: ended by what is already true of it, never asked of a person
|
||||
// (novox/hq issue 319). A plan that moves nothing is delivered; one whose every module a later merge into the
|
||||
// same trunk delivered is covered. Read again each tick, so a delivery read back after a restart, or held
|
||||
// same trunk delivered is covered. Read again at each look, so a delivery read back after a restart, or held
|
||||
// before this was known, ends by itself.
|
||||
// A published delivery whose walk ended done before its word is read the same way (novox/hq issue 474): what
|
||||
// that walk did not build, a later delivered merge may have; and when the cover test says none did, past the
|
||||
// grace, it is stopped saying so — never left waiting for a start that cannot come.
|
||||
// A delivery that failed or was stopped is read for a later finished walk of its repository that delivered its
|
||||
// change after all: a walk that failed in an outage and was resumed, or the walk of a later merge that contains it
|
||||
// (novox/hq issue 747). Read each tick, from what the walks and the kept deliveries say, so one stuck before this
|
||||
// (novox/hq issue 747). Read at each look, from what the walks and the kept deliveries say, so one stuck before this
|
||||
// was built clears by itself after a restart.
|
||||
h.coverAsked = map[string]bool{}
|
||||
if h.coverAskedSince == nil {
|
||||
@@ -1201,6 +1240,9 @@ func (h *Holder) plan() []func() {
|
||||
}
|
||||
// Held back only within the bound: a forge that keeps erroring never holds a walk for good.
|
||||
h.coverAsked[d.ID] = now.Sub(since) < coverAskBound
|
||||
if h.coverAsked[d.ID] {
|
||||
h.lookAt(since.Add(coverAskBound), "a walk held back while the forge is asked what a later walk delivered")
|
||||
}
|
||||
}
|
||||
if by != "" {
|
||||
d.DeliveredBy = by
|
||||
@@ -1345,6 +1387,7 @@ func (h *Holder) strand(d *Delivery, pending []string, now time.Time) func() {
|
||||
ended = d.Since
|
||||
}
|
||||
if now.Sub(ended) < strandedAfter {
|
||||
h.lookAt(ended.Add(strandedAfter), "a merge its walk did not build: stopped after the grace for a later merge to cover it")
|
||||
return nil
|
||||
}
|
||||
note := ""
|
||||
@@ -1508,8 +1551,20 @@ func (h *Holder) unwalkedPast(d *Delivery, now time.Time) bool {
|
||||
if _, batched := h.batchOf(d); batched {
|
||||
return false // its batch is assembling or queued: a walk is not owed yet (ADR 0276)
|
||||
}
|
||||
return d.State == Ready && d.MergedAs != "" && d.Walk == nil && !d.MovesNothing() && d.HeldWhy == "" &&
|
||||
now.Sub(d.MergedAt) > unwalked && h.reconciled.After(d.MergedAt.Add(unwalked))
|
||||
if d.State != Ready || d.MergedAs == "" || d.Walk != nil || d.MovesNothing() || d.HeldWhy != "" {
|
||||
return false
|
||||
}
|
||||
bound := d.MergedAt.Add(unwalked)
|
||||
if now.Sub(d.MergedAt) <= unwalked {
|
||||
h.lookAt(bound, "a merge no walk opened for within ten minutes")
|
||||
return false
|
||||
}
|
||||
if !h.reconciled.After(bound) {
|
||||
// The walks were not read since the bound: read once now (React), the only read besides the one at the start.
|
||||
h.rereadWalks = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// coverOf says which later merges cover a merged delivery, or nothing, and which later merge commits the forge
|
||||
@@ -1615,9 +1670,11 @@ func carries(c, d *Delivery, module string) bool {
|
||||
func (h *Holder) askContains(d *Delivery, in string, now time.Time) func() {
|
||||
key := d.ID + " in " + in
|
||||
if tried, was := h.askTried[key]; was && now.Sub(tried) < time.Minute {
|
||||
h.lookAt(tried.Add(time.Minute), "the forge asked whether a later merge contains this one")
|
||||
return nil
|
||||
}
|
||||
h.askTried[key] = now
|
||||
h.lookAt(now.Add(time.Minute), "the forge asked whether a later merge contains this one")
|
||||
id, owner, repo, merged := d.ID, d.Owner(), d.Repo(), d.MergedAs
|
||||
return func() {
|
||||
yes, err := h.Forge.Contains(owner, repo, merged, in)
|
||||
@@ -1650,14 +1707,19 @@ func heldForGroup(d *Delivery, g *Group) bool {
|
||||
// no check this owner asked, and proposed past the grace the controller's own check of its announcement is
|
||||
// given. Nil otherwise — once asked, a late verdict is the bound's and H2's to speak for.
|
||||
func (h *Holder) askCheck(d *Delivery, now time.Time) func() {
|
||||
if d.State != Proposed || d.Check != nil || d.CheckAsk != nil || d.MergedAs != "" || d.Number == 0 ||
|
||||
now.Sub(d.Since) < checkGrace {
|
||||
if d.State != Proposed || d.Check != nil || d.CheckAsk != nil || d.MergedAs != "" || d.Number == 0 {
|
||||
return nil
|
||||
}
|
||||
if now.Sub(d.Since) < checkGrace && d.Ready == nil { // a ready mark makes the check worth asking at once
|
||||
h.lookAt(d.Since.Add(checkGrace), "a proposed delivery's check, asked once the grace has passed with no verdict")
|
||||
return nil
|
||||
}
|
||||
if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute {
|
||||
h.lookAt(tried.Add(time.Minute), "a proposed delivery's check that could not be asked")
|
||||
return nil
|
||||
}
|
||||
h.askTried[d.ID] = now
|
||||
h.lookAt(now.Add(time.Minute), "a proposed delivery's check that could not be asked")
|
||||
id, m := d.ID, MemberOf(d)
|
||||
why := fmt.Sprintf("proposed for %s with no verdict and no check asked: its announcement was heard from the "+
|
||||
"bus's history, or the controller missed it", now.Sub(d.Since).Round(time.Minute))
|
||||
@@ -1791,6 +1853,7 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s's order cannot be asked yet: %v", id, err)
|
||||
h.again("a group's order that could not be asked")
|
||||
return
|
||||
}
|
||||
g.Order, g.Pairs, g.Cycle, g.Contradictions, g.OrderOf = o.Order, o.Pairs, o.Cycle, o.Contradictions, commits
|
||||
@@ -1825,6 +1888,7 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s's composed check cannot be asked yet: %v", id, err)
|
||||
h.again("a group's composed check that could not be asked")
|
||||
return
|
||||
}
|
||||
g.Check = &GroupCheck{Asked: asked, At: h.Now(), Commits: commits}
|
||||
@@ -1922,6 +1986,7 @@ func (h *Holder) groupTurns(g *Group, members []*Delivery) []func() {
|
||||
if !w.until.IsZero() {
|
||||
u := w.until
|
||||
until = &u
|
||||
h.lookAt(u, "a group member's turn that waits for a later merge to carry it, until the wait's bound")
|
||||
}
|
||||
if d.TurnAwaits != w.said || (d.TurnAwaitsUntil == nil) != (until == nil) ||
|
||||
until != nil && !d.TurnAwaitsUntil.Equal(*until) {
|
||||
@@ -2427,6 +2492,7 @@ func (h *Holder) askedToLeave(walk string) {
|
||||
h.leaveAsked = map[string]leaveAsk{}
|
||||
}
|
||||
h.leaveAsked[walk] = leaveAsk{at: h.Now(), revision: h.walks[walk].walk.Revision}
|
||||
h.lookAt(h.Now().Add(5*time.Minute), "a walk asked to leave a stopped merge out, asked again if it went nowhere")
|
||||
}
|
||||
|
||||
// leaveStopped asks, for every open walk or batch that carries a stopped delivery's merge, that the merge be left
|
||||
@@ -2474,6 +2540,7 @@ func (h *Holder) leaveStopped() []func() {
|
||||
// Once per record of it: the record that answers an ask is newer. Asked again after five minutes, should
|
||||
// the ask have gone nowhere.
|
||||
if a, asked := h.leaveAsked[id]; asked && seen.walk.Revision <= a.revision && now.Sub(a.at) < 5*time.Minute {
|
||||
h.lookAt(a.at.Add(5*time.Minute), "a walk asked to leave a stopped merge out, asked again if it went nowhere")
|
||||
continue
|
||||
}
|
||||
walk := id
|
||||
|
||||
@@ -283,7 +283,7 @@ func TestTheSwitchAdoptsRunningWalksAndHoldsUnknownWaitingOnes(t *testing.T) {
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.Tick()
|
||||
w.h.React()
|
||||
if s := w.state(IDOf("novox/mesh-catalog", "cccccccccccc3333")); s != Delivering {
|
||||
t.Fatalf("a running walk at the switch was adopted as %s", s)
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func TestTheSwitchAdoptsRunningWalksAndHoldsUnknownWaitingOnes(t *testing.T) {
|
||||
t.Fatal("a new walk was made a delivery before its merge had time to be heard")
|
||||
}
|
||||
w.later(3 * time.Minute)
|
||||
w.h.Tick()
|
||||
w.h.React()
|
||||
held := IDOf("novox/app", "dddddddddddd4444")
|
||||
if s := w.state(held); s != Held {
|
||||
t.Fatalf("a waiting walk with no pull request is %s", s)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
@@ -31,30 +32,36 @@ func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() }
|
||||
func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now }
|
||||
|
||||
// The events this owner follows: the forge's pull requests, and the controller's verdicts and walks.
|
||||
// The forge's label and comment events say a ready mark changed, its status events that a check answered (events.go).
|
||||
var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed",
|
||||
ControllerSeat + ".checked", ControllerSeat + ".plan-moved"}
|
||||
"gitea.forge.pull-request.labeled", "gitea.forge.pull-request.unlabeled", "gitea.forge.pull-request.commented",
|
||||
"gitea.forge.status", ControllerSeat + ".checked", ControllerSeat + ".plan-moved"}
|
||||
|
||||
func main() {
|
||||
operator := &asker.Client{Module: Module, State: stdio.State("asked")}
|
||||
h := &Holder{
|
||||
Store: kvStore{},
|
||||
Controller: seatController{ask: stdio.Ask},
|
||||
Forge: toolForge{ask: stdio.Ask},
|
||||
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
|
||||
Logf: logf,
|
||||
Operator: &asker.Client{Module: Module, State: stdio.State("asked")},
|
||||
Operator: operator,
|
||||
Pulls: kvPulls{},
|
||||
}
|
||||
h.init()
|
||||
l := &listening{now: "not yet: starting"}
|
||||
go run(h, l)
|
||||
go run(h, l, operator)
|
||||
if err := stdio.Serve("", tools(h, l)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run reads back the state, then the controller's walks, then takes the events, and keeps time — each
|
||||
// retried, each failure said, never given up on quietly.
|
||||
func run(h *Holder, l *listening) {
|
||||
// run reads back the state, then takes the events, then reads once what it may have missed while it was away — the
|
||||
// controller's walks, the forge's open pull requests, the operator's asks — and acts on events from then on
|
||||
// (novox/hq ADR 0406 §1, issue 975): no tick, no forge poll, no reconcile loop. Each step is retried, each failure
|
||||
// said, never given up on quietly.
|
||||
func run(h *Holder, l *listening, operator *asker.Client) {
|
||||
time.Sleep(500 * time.Millisecond) // Serve first: the state is reached through it
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := h.Load()
|
||||
@@ -64,30 +71,7 @@ func run(h *Holder, l *listening) {
|
||||
logf("[mesh-delivery] cannot read back its deliveries yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
if err := h.Reconcile(); err != nil {
|
||||
logf("[mesh-delivery] the controller's walks cannot be read yet (%v); asked again each half minute", err)
|
||||
}
|
||||
go func() {
|
||||
tick := time.NewTicker(10 * time.Second)
|
||||
defer tick.Stop()
|
||||
last, looked := time.Now(), time.Time{}
|
||||
for now := range tick.C {
|
||||
if now.Sub(last) >= 30*time.Second {
|
||||
last = now
|
||||
if err := h.Reconcile(); err != nil {
|
||||
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
|
||||
}
|
||||
}
|
||||
// Every open pull request, from the forge itself, every minute (novox/hq issues 347, 722): what says a
|
||||
// head nobody checks, and the ready marks a merge is asked on.
|
||||
if now.Sub(looked) >= time.Minute {
|
||||
looked = now
|
||||
h.SeeOpenPulls()
|
||||
}
|
||||
h.Tick()
|
||||
h.Flush()
|
||||
}
|
||||
}()
|
||||
go h.Work()
|
||||
for _, pattern := range followed {
|
||||
pattern := pattern
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
@@ -100,13 +84,54 @@ func run(h *Holder, l *listening) {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
// The operator's answers: the router's `decided` event, in place of reading every pending ask each tick.
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := operator.Listen(
|
||||
func(_ asks.Option, w asks.Warrant) error { h.AskDecided(w.Ask, "answered", &w); h.Poke(); return nil },
|
||||
func(handled asker.Handled, w asks.Warrant, err error) {
|
||||
if err != nil {
|
||||
logf("[mesh-delivery] the answer to the ask %s: %s: %v", w.Ask, handled, err)
|
||||
}
|
||||
if handled == asker.Refused || handled == asker.Recorded {
|
||||
h.AskDecided(w.Ask, string(handled), &w)
|
||||
h.Poke()
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
l.set("not yet: " + err.Error())
|
||||
logf("[mesh-delivery] not hearing the operator's answers yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
l.set("listening")
|
||||
logf("[mesh-delivery] listening: %s", strings.Join(followed, ", "))
|
||||
// Read once, now that events arrive: what happened while this owner was away. The forge's open pull requests (ready
|
||||
// marks, heads nobody checks) are read by the first look; the asks the router closed meanwhile are read here.
|
||||
h.wantForge()
|
||||
h.hearAsks()
|
||||
h.Poke()
|
||||
// retries: the controller's walks, until they have been read once; plan-moved events carry every move after
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := h.Reconcile()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
logf("[mesh-delivery] the controller's walks cannot be read yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
// handle takes one event. An event that cannot be read is said and acknowledged: read again it would fail
|
||||
// again, and the controller's walks are read back by comparison anyway.
|
||||
// handle takes one event, then asks for a look at every step the event makes possible (Work, React). An event that cannot be read is
|
||||
// said and acknowledged: read again it would fail again, and the controller's walks are read back by comparison anyway.
|
||||
func handle(h *Holder, e stdio.Envelope) error {
|
||||
taken := take(h, e)
|
||||
h.Poke()
|
||||
return taken
|
||||
}
|
||||
|
||||
func take(h *Holder, e stdio.Envelope) error {
|
||||
switch e.Key {
|
||||
case "gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed":
|
||||
var p PullEvent
|
||||
@@ -116,12 +141,36 @@ func handle(h *Holder, e stdio.Envelope) error {
|
||||
}
|
||||
switch e.Key {
|
||||
case "gitea.pull.updated":
|
||||
h.PullUpdated(p)
|
||||
if h.PullUpdated(p) {
|
||||
h.wantForge() // a head not seen before: the forge shows whether anyone checks it
|
||||
}
|
||||
h.KeepPull(p)
|
||||
case "gitea.pull.merged":
|
||||
h.PullMerged(p)
|
||||
default:
|
||||
h.PullClosed(p)
|
||||
}
|
||||
case "gitea.forge.pull-request.labeled", "gitea.forge.pull-request.unlabeled", "gitea.forge.pull-request.commented":
|
||||
var p ForgePullEvent
|
||||
if err := json.Unmarshal(e.Body, &p); err != nil {
|
||||
h.Refuse(fmt.Errorf("%s is not readable: %v", e.Key, err))
|
||||
return nil
|
||||
}
|
||||
switch e.Key {
|
||||
case "gitea.forge.pull-request.labeled":
|
||||
h.PullLabeled(p)
|
||||
case "gitea.forge.pull-request.unlabeled":
|
||||
h.PullUnlabeled(p)
|
||||
default:
|
||||
h.PullCommented(p)
|
||||
}
|
||||
case "gitea.forge.status":
|
||||
var s ForgeStatusEvent
|
||||
if err := json.Unmarshal(e.Body, &s); err != nil {
|
||||
h.Refuse(fmt.Errorf("a commit status is not readable: %v", err))
|
||||
return nil
|
||||
}
|
||||
h.CommitStatus(s)
|
||||
case ControllerSeat + ".checked":
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(e.Body, &c); err != nil {
|
||||
@@ -129,6 +178,7 @@ func handle(h *Holder, e stdio.Envelope) error {
|
||||
return nil
|
||||
}
|
||||
h.Checked(c)
|
||||
h.KeepVerdict(c)
|
||||
case ControllerSeat + ".plan-moved":
|
||||
var w Walk
|
||||
if err := json.Unmarshal(e.Body, &w); err != nil {
|
||||
@@ -193,7 +243,7 @@ func need(a map[string]any, keys ...string) error {
|
||||
|
||||
func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
seat := "mesh-delivery."
|
||||
return []stdio.Tool{
|
||||
list := []stdio.Tool{
|
||||
{Name: seat + "deliveries",
|
||||
Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " +
|
||||
"(owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, " +
|
||||
@@ -369,4 +419,14 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
return h.MarkReady(strArg(a, "id"), strArg(a, "reviewed-by"), strArg(a, "why"))
|
||||
}},
|
||||
}
|
||||
// A verb that changes a delivery asks for a look at what it makes possible (react.go).
|
||||
for i := range list {
|
||||
run := list[i].Run
|
||||
list[i].Run = func(a map[string]any) (any, error) {
|
||||
out, err := run(a)
|
||||
h.Poke()
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
if !reflect.DeepEqual(m.Emits, []string{"transition", "group"}) {
|
||||
t.Fatalf("emits %v", m.Emits)
|
||||
}
|
||||
if !reflect.DeepEqual(m.State, []string{"deliveries", "groups", "asked"}) {
|
||||
if !reflect.DeepEqual(m.State, []string{"deliveries", "groups", "asked", "pulls"}) {
|
||||
t.Fatalf("state %v", m.State)
|
||||
}
|
||||
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestClearMarkWithAnEmptyHeadSaysWhatWasWrongOnceItStayedEmpty(t *testing.T)
|
||||
if len(w.forge.comments) != 0 || len(w.forge.unlabels) != 0 {
|
||||
t.Fatalf("an empty-head mark seen once was cleared: %v", w.forge.comments)
|
||||
}
|
||||
w.later(readyTryEvery + time.Second)
|
||||
w.later(markGrace + time.Second)
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "no \"ready at\" comment by a listed account") ||
|
||||
@@ -117,12 +117,12 @@ func TestAMarkReadyJustMadeIsNotClearedByAReadThatPredatesItsComment(t *testing.
|
||||
if _, err := w.h.MarkReady(id, "a@b", "ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.later(readyTryEvery + time.Second) // the label alone was read long enough ago to count as stale...
|
||||
w.later(markGrace + time.Second) // the label alone was read long enough ago to count as stale...
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, fullHead, "")}
|
||||
w.h.SeeOpenPulls()
|
||||
w.h.mu.Lock()
|
||||
w.h.deliveries[id].Ready.Seen = w.now // ...but the mark was made just now
|
||||
w.h.marks["novox/app#7"].firstEmpty = w.now.Add(-2 * readyTryEvery)
|
||||
w.h.marks["novox/app#7"].firstEmpty = w.now.Add(-2 * markGrace)
|
||||
w.h.mu.Unlock()
|
||||
before := len(w.forge.comments)
|
||||
w.settleAll()
|
||||
|
||||
@@ -32,6 +32,8 @@ type Member struct {
|
||||
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
||||
CloneURL string `json:"clone_url,omitempty"`
|
||||
After []string `json:"after,omitempty"`
|
||||
// Ready is whether the pull request carries a ready mark: its check is published on the ready stream (ADR 0406 §3).
|
||||
Ready bool `json:"ready,omitempty"`
|
||||
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
|
||||
// the controller's verdict names when no build judges the head.
|
||||
BaseSHA string `json:"base_sha,omitempty"`
|
||||
@@ -39,7 +41,7 @@ type Member struct {
|
||||
|
||||
// MemberOf is a delivery as a member.
|
||||
func MemberOf(d *Delivery) Member {
|
||||
return Member{ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
|
||||
return Member{Ready: d.Ready != nil && d.Ready.Merged == nil, ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
|
||||
PathsTruncated: d.PathsTruncated, Removed: d.Removed, ModuleDirs: d.ModuleDirs, ModuleDirsSaid: d.ModuleDirsSaid,
|
||||
CloneURL: d.CloneURL, After: d.After, BaseSHA: d.BaseSHA}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The pull request's key (novox/hq ADR 0406 §4, issue 975): mesh-delivery keeps one key per pull request on the bus,
|
||||
// holding its head, the trunk commit its current check is asked against and its verdict, written by compare-and-set.
|
||||
// It writes a new head when the forge says one (`pull.updated`, which carries the head and the trunk commit it was
|
||||
// announced against), and a new trunk commit only when the check is asked again against one — the merge tool's ask is
|
||||
// that same announcement. A merge in a group's order, and a trunk move that leaves an inactive pull request unasked,
|
||||
// are announced as nothing (the gitea module announces `pull.updated` only for a head and for the merge tool's ask), so
|
||||
// they write nothing. A verdict for that head and trunk commit writes the verdict. The build seat reads the key.
|
||||
|
||||
// PullKey is what the bus keeps of one pull request.
|
||||
type PullKey struct {
|
||||
Head string `json:"head"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
}
|
||||
|
||||
// PullKeys keeps the pull requests' keys: Change reads the key, applies the change to what it read and writes the result
|
||||
// by compare-and-set when the change says to write; a write another writer won reads the key again and applies again.
|
||||
type PullKeys interface {
|
||||
Change(key string, apply func(PullKey) (PullKey, bool)) error
|
||||
}
|
||||
|
||||
// pullKeyName is a pull request's key: owner.repo.number, with any dot in a name made an underscore.
|
||||
func pullKeyName(owner, repo string, number int) string {
|
||||
clean := func(s string) string { return strings.NewReplacer(".", "_", "/", "_").Replace(s) }
|
||||
return fmt.Sprintf("%s.%s.%d", clean(owner), clean(repo), number)
|
||||
}
|
||||
|
||||
// KeepPull writes the key of a pull request whose head was announced, when the head or the trunk commit it was
|
||||
// announced against is not what the key holds; a new head or trunk clears the verdict.
|
||||
func (h *Holder) KeepPull(p PullEvent) {
|
||||
if h.Pulls == nil || p.Number == 0 || p.HeadSHA == "" {
|
||||
return
|
||||
}
|
||||
err := h.Pulls.Change(pullKeyName(p.Owner, p.Repo, p.Number), func(k PullKey) (PullKey, bool) {
|
||||
if sameCommit(k.Head, p.HeadSHA) && k.Head != "" && (p.BaseSHA == "" || sameCommit(k.Trunk, p.BaseSHA)) {
|
||||
return k, false
|
||||
}
|
||||
return PullKey{Head: p.HeadSHA, Trunk: p.BaseSHA}, true
|
||||
})
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the key of %s/%s#%d could not be written: %v", p.Owner, p.Repo, p.Number, err)
|
||||
h.again("a pull request's key that could not be written")
|
||||
}
|
||||
}
|
||||
|
||||
// KeepVerdict writes a verdict into the key of its pull request, when the key holds that head and trunk commit.
|
||||
func (h *Holder) KeepVerdict(c CheckedEvent) {
|
||||
if h.Pulls == nil || c.Group != "" || c.Number == 0 || c.Commit == "" || c.Trunk == "" {
|
||||
return
|
||||
}
|
||||
verdict := c.Verdict
|
||||
if verdict == "" {
|
||||
return
|
||||
}
|
||||
err := h.Pulls.Change(pullKeyName(c.Owner, c.Repo, c.Number), func(k PullKey) (PullKey, bool) {
|
||||
if !sameCommit(k.Head, c.Commit) || !sameCommit(k.Trunk, c.Trunk) || k.Verdict == verdict {
|
||||
return k, false
|
||||
}
|
||||
k.Verdict = verdict
|
||||
return k, true
|
||||
})
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", c.Owner, c.Repo, c.Number, err)
|
||||
h.again("a pull request's key that could not be written")
|
||||
}
|
||||
}
|
||||
|
||||
// kvPulls keeps the keys in the module's own state `pulls`.
|
||||
type kvPulls struct{}
|
||||
|
||||
// changeAttempts is how often a lost compare-and-set is read and applied again before it is said.
|
||||
const changeAttempts = 10
|
||||
|
||||
func (kvPulls) Change(key string, apply func(PullKey) (PullKey, bool)) error {
|
||||
state := stdio.State("pulls")
|
||||
for range changeAttempts {
|
||||
e, err := state.Get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var k PullKey
|
||||
if e != nil {
|
||||
if err := json.Unmarshal(e.Value, &k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
next, write := apply(k)
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
if e == nil {
|
||||
_, err = state.Create(key, next)
|
||||
} else {
|
||||
_, err = state.Update(key, next, e.Revision)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, stdio.ErrChanged) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s was written by another writer first %d times: %w", key, changeAttempts, stdio.ErrChanged)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **The pull request's key** (novox/hq ADR 0406 §4, issue 975): one key per pull request on the bus, holding its head,
|
||||
// the trunk commit its current check is asked against and its verdict, written by compare-and-set. A head the forge
|
||||
// announces writes head and trunk; a verdict for that head and trunk writes the verdict; nothing else writes.
|
||||
|
||||
type fakePulls struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]PullKey
|
||||
writes int
|
||||
// lose makes the first n writes lose to another writer, which sets rival.
|
||||
lose int
|
||||
rival PullKey
|
||||
}
|
||||
|
||||
func newFakePulls() *fakePulls { return &fakePulls{keys: map[string]PullKey{}} }
|
||||
|
||||
func (f *fakePulls) Change(key string, apply func(PullKey) (PullKey, bool)) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for tries := 0; tries < 5; tries++ {
|
||||
next, write := apply(f.keys[key])
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
if f.lose > 0 { // another writer was first: read again and apply again
|
||||
f.lose--
|
||||
f.keys[key] = f.rival
|
||||
continue
|
||||
}
|
||||
f.keys[key] = next
|
||||
f.writes++
|
||||
return nil
|
||||
}
|
||||
return errors.New("lost the race too often")
|
||||
}
|
||||
|
||||
func (f *fakePulls) get(key string) PullKey {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.keys[key]
|
||||
}
|
||||
|
||||
const appKey = "novox.app.7"
|
||||
|
||||
func announcedAt(head, trunk string) PullEvent {
|
||||
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
|
||||
p.BaseSHA = trunk
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAnAnnouncedHeadWritesTheKeyAndTheSameAnnouncementAgainWritesNothing(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
p := announcedAt(readyHead, "t1t1t1t1t1t1")
|
||||
w.h.PullUpdated(p)
|
||||
w.h.KeepPull(p)
|
||||
if got := pulls.get(appKey); got.Head != readyHead || got.Trunk != "t1t1t1t1t1t1" || got.Verdict != "" {
|
||||
t.Fatalf("the key holds %+v", got)
|
||||
}
|
||||
w.h.KeepPull(p)
|
||||
if pulls.writes != 1 {
|
||||
t.Fatalf("the same announcement wrote %d times", pulls.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVerdictForTheKeysHeadAndTrunkIsWrittenAndOneForAnotherIsNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
old := judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1")
|
||||
w.h.KeepVerdict(old)
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another head was written")
|
||||
}
|
||||
other := judgedAt("novox/app", 7, readyHead, "pass", "t9t9t9t9t9t9")
|
||||
w.h.KeepVerdict(other)
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another trunk commit was written")
|
||||
}
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if got := pulls.get(appKey); got.Verdict != "pass" {
|
||||
t.Fatalf("the key holds %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.KeepPull(announcedAt(readyHead, "t2t2t2t2t2t2"))
|
||||
if got := pulls.get(appKey); got.Trunk != "t2t2t2t2t2t2" || got.Verdict != "" {
|
||||
t.Fatalf("the check asked again against a new trunk left %+v", got)
|
||||
}
|
||||
w.h.KeepPull(announcedAt(pushed, "t2t2t2t2t2t2"))
|
||||
if got := pulls.get(appKey); got.Head != pushed {
|
||||
t.Fatalf("a new head left %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
pulls.lose, pulls.rival = 1, PullKey{Head: readyHead, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
|
||||
w.h.Pulls = pulls
|
||||
// The rival writer already holds this very head and trunk with a verdict: the change finds nothing to write.
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
if got := pulls.get(appKey); got.Verdict != "pass" || pulls.writes != 0 {
|
||||
t.Fatalf("after the lost race the key holds %+v after %d writes", got, pulls.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPullRequestMarkedReadyHasItsCheckAskedOnTheReadyStream(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
if len(w.ctl.checkReady) == 0 || !w.ctl.checkReady[0] {
|
||||
t.Fatalf("the check of a marked pull request was asked with ready %v", w.ctl.checkReady)
|
||||
}
|
||||
// An unmarked one is asked as before.
|
||||
w2 := newWorld(t)
|
||||
w2.h.PullUpdated(pr("novox/app", 8, pushed, "feat/y", "src/a.go"))
|
||||
w2.later(checkGrace + 1)
|
||||
w2.settleAll()
|
||||
if len(w2.ctl.checkReady) == 0 || w2.ctl.checkReady[0] {
|
||||
t.Fatalf("the check of an unmarked pull request was asked with ready %v", w2.ctl.checkReady)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrunkLessVerdictWritesNothingToTheKey(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", ""))
|
||||
if got := pulls.get(appKey); got.Verdict != "" {
|
||||
t.Fatalf("a verdict naming no trunk commit was written: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's catch-up after a restart can announce a merge again past the bus's de-duplication window (issue 975):
|
||||
// heard again, it changes nothing, and moves no trunk back.
|
||||
func TestTheSameMergeHeardAgainAfterALaterMergeMovesNoTrunkBack(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
a, b := "aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb"
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/app", 8, a, "feat/y", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/app", 9, b, "feat/z", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "m8m8m8m8m8m8"))
|
||||
w.h.PullMerged(merged("novox/app", 8, a, "m8m8m8m8m8m8"))
|
||||
w.h.PullMerged(merged("novox/app", 9, b, "m9m9m9m9m9m9"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
before := w.delivery(id)
|
||||
w.h.PullMerged(merged("novox/app", 8, a, "m8m8m8m8m8m8")) // announced again, past the window
|
||||
after := w.delivery(id)
|
||||
if after.TrunkNow != before.TrunkNow || after.TrunkNow != "m9m9m9m9m9m9" {
|
||||
t.Fatalf("the trunk moved from %q to %q on a merge heard again; wanted it to stay m9m9m9m9m9m9", before.TrunkNow, after.TrunkNow)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// How this owner keeps time (novox/hq ADR 0406 §1, issue 975). It has no tick, no forge poll and no reconcile loop:
|
||||
// a look (React) is taken after each event that can change what a step needs, and a step that waits on the clock — a
|
||||
// grace, a bound, a retry after a failure — says when it must be looked at again (lookAt). The one timer below is
|
||||
// armed for the earliest of those and fires one more look; it is never a tick, since a look that finds nothing
|
||||
// waiting arms nothing.
|
||||
|
||||
// askAgainAfter is how long after a failed ask of the controller, the forge or the operator it is asked again: the
|
||||
// failure is the reason, and it is said in the log when it happens.
|
||||
const askAgainAfter = 30 * time.Second
|
||||
|
||||
// lookWanted is the earliest time a look must be taken again, and the bounded step that wants it.
|
||||
type lookWanted struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
step string
|
||||
working bool
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
// lookAt says a bounded step, waiting on the clock, must be looked at again at a time. Safe to call with or without
|
||||
// the holder's lock.
|
||||
func (h *Holder) lookAt(at time.Time, step string) {
|
||||
h.wanted.mu.Lock()
|
||||
defer h.wanted.mu.Unlock()
|
||||
if h.wanted.at.IsZero() || at.Before(h.wanted.at) {
|
||||
h.wanted.at, h.wanted.step = at, step
|
||||
}
|
||||
}
|
||||
|
||||
// again says an ask that failed is asked again after askAgainAfter: a bounded step retried by the look's timer.
|
||||
func (h *Holder) again(step string) { h.lookAt(h.Now().Add(askAgainAfter), step) }
|
||||
|
||||
// Poke asks for a look; any number of pokes before it starts are one look.
|
||||
func (h *Holder) Poke() {
|
||||
select {
|
||||
case h.poke <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Work takes a look each time it is poked, for ever: the one goroutine that acts on events.
|
||||
func (h *Holder) Work() {
|
||||
h.wanted.mu.Lock()
|
||||
h.wanted.working = true
|
||||
h.wanted.mu.Unlock()
|
||||
for range h.poke {
|
||||
h.React()
|
||||
}
|
||||
}
|
||||
|
||||
// React takes every step the facts allow, after an event that can change what they are: the forge's open pull requests
|
||||
// read when a ready mark may have been made, the steps that need another's word (look), what is owed (Flush), and then
|
||||
// the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
|
||||
func (h *Holder) React() {
|
||||
h.reactMu.Lock()
|
||||
defer h.reactMu.Unlock()
|
||||
h.wanted.mu.Lock()
|
||||
h.wanted.at, h.wanted.step = time.Time{}, ""
|
||||
h.wanted.mu.Unlock()
|
||||
if h.uncheckedDue() {
|
||||
h.wantForge()
|
||||
}
|
||||
if h.takeForgeWanted() {
|
||||
if err := h.SeeOpenPulls(); err != nil {
|
||||
h.wantForge()
|
||||
h.again("the forge's open pull requests, read when a ready mark or a check may have changed")
|
||||
}
|
||||
}
|
||||
h.look()
|
||||
if h.takeReread() {
|
||||
// The one read of the controller's walks besides the one at the start: a merge no walk opened for, at its bound.
|
||||
if err := h.Reconcile(); err != nil {
|
||||
h.Logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
|
||||
h.mu.Lock()
|
||||
h.rereadWalks = true
|
||||
h.mu.Unlock()
|
||||
h.again("the controller's walks, read once at the bound of a merge no walk opened for")
|
||||
} else {
|
||||
h.look()
|
||||
}
|
||||
}
|
||||
h.Flush()
|
||||
h.arm()
|
||||
}
|
||||
|
||||
// arm sets the timer for the earliest time a step wants a look at, or stops it when none does.
|
||||
func (h *Holder) arm() {
|
||||
h.wanted.mu.Lock()
|
||||
defer h.wanted.mu.Unlock()
|
||||
if !h.wanted.working {
|
||||
return
|
||||
}
|
||||
if h.wanted.timer != nil {
|
||||
h.wanted.timer.Stop()
|
||||
h.wanted.timer = nil
|
||||
}
|
||||
if h.wanted.at.IsZero() {
|
||||
return
|
||||
}
|
||||
wait := max(h.wanted.at.Sub(h.Now()), 100*time.Millisecond)
|
||||
// retries: the time-bound step the last look left waiting (a grace, a bound or an ask that failed), at its time
|
||||
h.wanted.timer = time.AfterFunc(wait, h.Poke)
|
||||
}
|
||||
|
||||
// takeReread answers whether the controller's walks are to be read once more, and takes the want.
|
||||
func (h *Holder) takeReread() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
reread := h.rereadWalks
|
||||
h.rereadWalks = false
|
||||
return reread
|
||||
}
|
||||
@@ -43,9 +43,14 @@ type ReadyMark struct {
|
||||
// ReadyLabel is the label that marks a pull request ready.
|
||||
const ReadyLabel = "mesh/ready"
|
||||
|
||||
// readyTryEvery is how often a marked head's merge is asked while the merge tool refuses it: a check it asked takes
|
||||
// minutes, and a refusal costs the forge a few reads.
|
||||
const readyTryEvery = time.Minute
|
||||
// mergeRetryAfter is how long after the merge tool refused a marked head's merge it is asked again, when nothing
|
||||
// happened meanwhile that decides whether it may merge (a verdict, an Approve, a group's turn are events, and ask it at
|
||||
// once): a check it asked takes minutes, and a refusal costs the forge a few reads. A retry of a bounded step (askMerge).
|
||||
const mergeRetryAfter = time.Minute
|
||||
|
||||
// markGrace is how long a mark not complete (the label, no "ready at" comment yet), and one this owner has just
|
||||
// cleared, are left before they are judged again: the comment follows the label, and a clearing takes a moment.
|
||||
const markGrace = time.Minute
|
||||
|
||||
// readyRefusedFor is how long the merge tool may refuse a marked head before it is said as stalled: longer than a
|
||||
// check takes (the controller's checkTimeout is 45 minutes), so a check on its way is never said.
|
||||
@@ -65,6 +70,8 @@ type ReadyRun struct {
|
||||
Tries int `json:"tries,omitempty"`
|
||||
LastTry time.Time `json:"last_try"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
// merging is whether a merge ask is on its way, outside the lock: not asked twice meanwhile. Not kept.
|
||||
merging bool
|
||||
// RefusedSince is when the merge tool began refusing it without a break.
|
||||
RefusedSince *time.Time `json:"refused_since,omitempty"`
|
||||
// Merged is when the merge tool merged it.
|
||||
@@ -112,10 +119,10 @@ func (r *ReadyRun) mergedBy() string {
|
||||
// marked is a pull request read with a ready mark.
|
||||
type marked struct {
|
||||
pull OpenPull
|
||||
// cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery.
|
||||
// cleared is when the mark was cleared, the label taken off: not cleared again within markGrace.
|
||||
cleared time.Time
|
||||
// firstEmpty is when a mark with no head (a label no "ready at" comment follows yet) was first read so: it is a
|
||||
// mark not yet complete, and is cleared only once it has stayed so for readyTryEvery (novox/hq issue 873).
|
||||
// mark not yet complete, and is cleared only once it has stayed so for markGrace (novox/hq issue 873).
|
||||
firstEmpty time.Time
|
||||
}
|
||||
|
||||
@@ -166,14 +173,17 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
p, mark := m.pull, *m.pull.Ready
|
||||
repository := strings.ToLower(p.Owner + "/" + p.Repo)
|
||||
if !sameFull(mark.Head, p.HeadSHA) {
|
||||
if mark.Head == "" && now.Sub(m.firstEmpty) < readyTryEvery {
|
||||
if mark.Head == "" && now.Sub(m.firstEmpty) < markGrace {
|
||||
h.lookAt(m.firstEmpty.Add(markGrace), "a ready label whose comment naming the head has not followed yet")
|
||||
continue // the label first, its comment not yet: a mark not complete, not an old one
|
||||
}
|
||||
if d := h.deliveries[IDOf(repository, p.HeadSHA)]; d != nil && d.Ready != nil &&
|
||||
sameFull(d.Ready.Head, p.HeadSHA) && now.Sub(d.Ready.Seen) < readyTryEvery {
|
||||
sameFull(d.Ready.Head, p.HeadSHA) && now.Sub(d.Ready.Seen) < markGrace {
|
||||
h.lookAt(d.Ready.Seen.Add(markGrace), "a mark made through mark-ready, its comment possibly not yet read")
|
||||
continue // marked through mark-ready a moment ago: this read may predate its comment
|
||||
}
|
||||
if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery {
|
||||
if !m.cleared.IsZero() && now.Sub(m.cleared) < markGrace {
|
||||
h.lookAt(m.cleared.Add(markGrace), "a stale ready mark whose clearing may not have taken")
|
||||
continue
|
||||
}
|
||||
m.cleared = now
|
||||
@@ -189,6 +199,7 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
}
|
||||
if d.Ready == nil || d.Ready.Head != mark.Head || (d.Ready.Declined != nil && mark.At != d.Ready.MarkedAt) {
|
||||
d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now}
|
||||
h.Poke() // its check, if none is asked, is worth asking now (askCheck)
|
||||
h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head",
|
||||
d.ID, shortOf(mark.Head, 12), orNone(mark.By))
|
||||
h.keep(d)
|
||||
@@ -199,10 +210,11 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
}
|
||||
if r.Declined != nil {
|
||||
// The operator declined: nothing is merged, and the mark is taken off (said once when it was declined).
|
||||
if m.cleared.IsZero() || now.Sub(m.cleared) >= readyTryEvery {
|
||||
if m.cleared.IsZero() || now.Sub(m.cleared) >= markGrace {
|
||||
m.cleared = now
|
||||
asks = append(asks, h.unmark(k, p, mark))
|
||||
}
|
||||
h.lookAt(m.cleared.Add(markGrace), "a declined ready mark whose clearing may not have taken")
|
||||
continue
|
||||
}
|
||||
if d.State == Rejected {
|
||||
@@ -255,11 +267,15 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
if approvalAsk != nil {
|
||||
asks = append(asks, approvalAsk)
|
||||
}
|
||||
if waits != "" || (!r.LastTry.IsZero() && now.Sub(r.LastTry) < readyTryEvery) {
|
||||
if waits != "" || r.merging {
|
||||
continue
|
||||
}
|
||||
if !r.LastTry.IsZero() && now.Sub(r.LastTry) < mergeRetryAfter {
|
||||
h.lookAt(r.LastTry.Add(mergeRetryAfter), "a merge the forge refused")
|
||||
continue
|
||||
}
|
||||
r.Tries++
|
||||
r.LastTry = now
|
||||
r.LastTry, r.merging = now, true
|
||||
h.keep(d)
|
||||
asks = append(asks, h.askMerge(d.ID, p, mark.Head))
|
||||
}
|
||||
@@ -268,6 +284,14 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
return asks
|
||||
}
|
||||
|
||||
// mergeAgainNow lets a marked delivery's merge be asked at once: something that decides whether it may merge happened (a
|
||||
// verdict, an Approve, a group's turn), so the wait after a refusal does not hold it. Under the lock.
|
||||
func (h *Holder) mergeAgainNow(d *Delivery) {
|
||||
if d != nil && d.Ready != nil && d.Ready.Merged == nil {
|
||||
d.Ready.LastTry = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// readyWaits is what a marked delivery's merge waits on; empty when it is asked now. A ready delivery is asked; so is
|
||||
// a proposed one whose verdict went out of date and whose check nobody asked again — the merge tool asks it.
|
||||
func (h *Holder) readyWaits(d *Delivery) string {
|
||||
@@ -325,6 +349,7 @@ func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
r.merging = false
|
||||
if err == nil {
|
||||
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
|
||||
h.Logf("[mesh-delivery] %s merged on its ready mark at %s, its check passed against its trunk's head", id,
|
||||
@@ -338,8 +363,9 @@ func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is marked ready, and the merge tool did not merge it (asked again in %s): %s", id,
|
||||
readyTryEvery, answer)
|
||||
mergeRetryAfter, answer)
|
||||
}
|
||||
h.lookAt(now.Add(mergeRetryAfter), "a merge the forge refused")
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
}
|
||||
@@ -355,6 +381,7 @@ func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
|
||||
if mark.Head == "" {
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's ready label has no \"ready at <head>\" comment of a listed account after it, and could not be taken off yet: %v", k, err)
|
||||
h.again("a ready label that could not be taken off")
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s's ready label taken off: no \"ready at <head>\" comment of a listed account was written after it; mark-ready writes both in order", k)
|
||||
@@ -362,6 +389,7 @@ func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v",
|
||||
k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err)
|
||||
h.again("a stale ready mark that could not be cleared")
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12),
|
||||
@@ -378,6 +406,7 @@ func (h *Holder) unmark(k string, p OpenPull, mark ReadyMark) func() {
|
||||
return func() {
|
||||
if err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID); err != nil {
|
||||
h.Logf("[mesh-delivery] %s's ready mark could not be taken off after the operator's Decline yet: %v", k, err)
|
||||
h.again("a declined ready mark that could not be taken off")
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
@@ -431,7 +460,7 @@ func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
|
||||
H2: "none: the merge is asked again each minute — the operator's",
|
||||
H2: "none: the merge is asked again after each verdict, Approve or turn, and each minute — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is marked ready at %s, and the merge tool has refused it since %s: %s", d.Repository,
|
||||
d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ var Table = []Row{
|
||||
case d.Check != nil:
|
||||
return errors.New("its verdict is known")
|
||||
case d.CheckAsk == nil:
|
||||
return errors.New("its check was never asked: the tick asks it, not H2")
|
||||
return errors.New("its check was never asked: a look asks it, not H2")
|
||||
case d.CheckAsk.Reasked > 0:
|
||||
return errors.New("its check was re-asked once already: the operator's")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A timer in this module names the bounded step it retries** (novox/hq ADR 0406 "How it is checked", issue 975): the
|
||||
// module acts on events, so a clock that fires on its own — a ticker, time.Tick — is a poll, and a one-shot timer
|
||||
// (time.AfterFunc, time.NewTimer) is allowed only for a bounded step, said in a comment on the line above:
|
||||
// `// retries: <step>`. This reads the module's Go source, not its tests.
|
||||
|
||||
var (
|
||||
pollingClock = regexp.MustCompile(`\btime\.(NewTicker|Tick)\(`)
|
||||
oneShot = regexp.MustCompile(`\btime\.(AfterFunc|NewTimer)\(`)
|
||||
retriesLine = regexp.MustCompile(`^\s*//\s*retries:\s*\S`)
|
||||
)
|
||||
|
||||
// timerFaults are the lines of a source file that make a clock of their own without naming the step it retries.
|
||||
func timerFaults(name, src string) []string {
|
||||
var faults []string
|
||||
lines := strings.Split(src, "\n")
|
||||
for i, line := range lines {
|
||||
code, _, _ := strings.Cut(line, "//")
|
||||
switch {
|
||||
case pollingClock.MatchString(code):
|
||||
faults = append(faults, name+":"+strconv.Itoa(i+1)+": a ticker polls; act on an event, or name a bounded step's one-shot timer: "+strings.TrimSpace(line))
|
||||
case oneShot.MatchString(code) && (i == 0 || !retriesLine.MatchString(lines[i-1])):
|
||||
faults = append(faults, name+":"+strconv.Itoa(i+1)+": a timer with no `// retries: <step>` comment on the line above: "+strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
return faults
|
||||
}
|
||||
|
||||
func TestNoTimerInTheModuleFiresWithoutNamingTheBoundedStepItRetries(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no source read: %v", err)
|
||||
}
|
||||
timers := 0
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fault := range timerFaults(f, string(raw)) {
|
||||
t.Error(fault)
|
||||
}
|
||||
timers += len(oneShot.FindAllString(string(raw), -1))
|
||||
}
|
||||
if timers == 0 {
|
||||
t.Fatal("the test found no timer at all: it is not reading the source it should")
|
||||
}
|
||||
}
|
||||
|
||||
// The checker itself: the tick this module had before it acted on events, and a timer that names nothing, fail; a
|
||||
// named one passes.
|
||||
func TestTheTimerCheckFailsOnATickerAndOnAnUnnamedTimer(t *testing.T) {
|
||||
was := "\tgo func() {\n\t\ttick := time.NewTicker(10 * time.Second)\n\t\tdefer tick.Stop()\n\t}()\n"
|
||||
if got := timerFaults("main.go", was); len(got) != 1 || !strings.Contains(got[0], "main.go:2") {
|
||||
t.Fatalf("the old tick was not found: %v", got)
|
||||
}
|
||||
if got := timerFaults("x.go", "for range time.Tick(time.Second) {}\n"); len(got) != 1 {
|
||||
t.Fatalf("time.Tick was not found: %v", got)
|
||||
}
|
||||
if got := timerFaults("x.go", "\ttime.AfterFunc(d, f)\n"); len(got) != 1 {
|
||||
t.Fatalf("an unnamed timer was not found: %v", got)
|
||||
}
|
||||
if got := timerFaults("x.go", "\t// retries:\n\tt := time.NewTimer(d)\n"); len(got) != 1 {
|
||||
t.Fatalf("a comment that names no step passed: %v", got)
|
||||
}
|
||||
if got := timerFaults("x.go", "\t// retries: the refused merge, after a minute\n\ttime.AfterFunc(d, f)\n"); len(got) != 0 {
|
||||
t.Fatalf("a named timer failed: %v", got)
|
||||
}
|
||||
if got := timerFaults("x.go", "\t// time.NewTicker was here\n"); len(got) != 0 {
|
||||
t.Fatalf("a comment about a ticker failed: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A step that waits on the clock says when it is looked at again** (novox/hq ADR 0406 §1, issue 975): there is no
|
||||
// tick to find it, so each grace, bound and retry is registered (lookAt) when a look meets it, and the one timer fires
|
||||
// a look for the earliest. A look that meets nothing waiting registers nothing.
|
||||
|
||||
func (w *world) wantedAt() (time.Time, string) {
|
||||
w.h.wanted.mu.Lock()
|
||||
defer w.h.wanted.mu.Unlock()
|
||||
return w.h.wanted.at, w.h.wanted.step
|
||||
}
|
||||
|
||||
func TestALookThatMeetsNothingWaitingOnTheClockWantsNoOtherLook(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.React()
|
||||
if at, step := w.wantedAt(); !at.IsZero() {
|
||||
t.Fatalf("a look with nothing waiting on the clock wants another at %s for %q", at, step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProposedDeliveryWithNoVerdictHasItsCheckAskedWhenItsGraceEndsWithNoTick(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.ctl.down = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.React()
|
||||
since := w.delivery(id).Since
|
||||
if at, step := w.wantedAt(); !at.Equal(since.Add(checkGrace)) || !strings.Contains(step, "check") {
|
||||
t.Fatalf("the look wanted at %s for %q; wanted when the grace ends, %s", at, step, since.Add(checkGrace))
|
||||
}
|
||||
if w.delivery(id).CheckAsk != nil {
|
||||
t.Fatal("the check was asked before the grace ended")
|
||||
}
|
||||
w.later(checkGrace + time.Second)
|
||||
w.h.React()
|
||||
if w.delivery(id).CheckAsk == nil {
|
||||
t.Fatal("the look the grace's end wants did not ask the check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMergeTheForgeRefusedIsAskedAgainAfterAMinuteByTheTimerAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: not mergeable yet")
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("merges %v", w.forge.merges)
|
||||
}
|
||||
at, step := w.wantedAt()
|
||||
if want := w.now.Add(mergeRetryAfter); !at.Equal(want) || !strings.Contains(step, "merge") {
|
||||
t.Fatalf("the look wanted at %s for %q; wanted %s for the refused merge", at, step, want)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.later(mergeRetryAfter + time.Second)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || w.delivery(IDOf("novox/app", readyHead)).Ready.Merged == nil {
|
||||
t.Fatalf("the refused merge was not asked again at the timer: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMergeNoWalkOpenedForReadsTheControllersWalksOnceAtItsBound(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atCC)
|
||||
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
|
||||
w.h.PullUpdated(cc)
|
||||
w.h.Checked(verdictMoving(cat, 101, headCC, "claude-code"))
|
||||
w.h.PullMerged(mergedAt(cc, mergeCC, atCC))
|
||||
w.h.WalkMoved(w.ctl.put(catWalk("plan-5", "eeeeeeeeeeee5555", atCC.Add(time.Minute), "dunst")))
|
||||
w.h.React()
|
||||
if at, step := w.wantedAt(); at.IsZero() || at.After(atCC.Add(unwalked)) {
|
||||
t.Fatalf("the look wanted at %s for %q; wanted by the bound, %s", at, step, atCC.Add(unwalked))
|
||||
}
|
||||
reads := w.ctl.walkReads
|
||||
w.later(unwalked + time.Second)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
id := IDOf(cat, headCC)
|
||||
if d := w.delivery(id); d.State != Held || !strings.Contains(d.HeldWhy, "plan-5") {
|
||||
t.Fatalf("it is %s: %q", d.State, d.HeldWhy)
|
||||
}
|
||||
if got := w.ctl.walkReads - reads; got != 1 {
|
||||
t.Fatalf("the controller's walks were read %d times at the bound; wanted once", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEffectTheForgeCouldNotTakeIsTriedAgainAtItsTimeByTheTimerAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.down = true
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.React()
|
||||
at, step := w.wantedAt()
|
||||
if at.IsZero() || !strings.Contains(step, "owed") || at.After(w.now.Add(retryFirst)) {
|
||||
t.Fatalf("the look wanted at %s for %q; wanted within %s for the owed effect", at, step, retryFirst)
|
||||
}
|
||||
w.forge.down = false
|
||||
w.later(retryFirst + time.Second)
|
||||
w.h.React()
|
||||
if len(w.forge.notes) == 0 {
|
||||
t.Fatal("the owed effect was not tried again at its time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskTheControllerCouldNotAnswerIsAskedAgainAfterAWhileByTheTimerAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
w.ctl.down = true
|
||||
w.h.mu.Lock()
|
||||
d := w.h.deliveries[IDOf("novox/app", readyHead)]
|
||||
d.Plan = nil
|
||||
w.h.mu.Unlock()
|
||||
w.h.React()
|
||||
at, step := w.wantedAt()
|
||||
if want := w.now.Add(askAgainAfter); !at.Equal(want) || !strings.Contains(step, "plan") {
|
||||
t.Fatalf("the look wanted at %s for %q; wanted %s for the plan that could not be asked", at, step, want)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
//
|
||||
// The forge's announcer is the only way the mesh hears of a pull request's head, and on 2026-10-09 it missed one: a
|
||||
// pull request opened after its push moved nothing the announcer read, and waited unchecked, unable to merge, with
|
||||
// nothing saying so. This owner asks the forge itself for every open pull request every few minutes, and one whose
|
||||
// nothing saying so. This owner asks the forge itself for every open pull request when it starts and when an event says
|
||||
// what the forge shows may have changed — a merge check's status, a new head, a ready mark (events.go), not a poll
|
||||
// (novox/hq ADR 0406 §1, issue 975) — and once more at a head's bound, and one whose
|
||||
// base requires the merge check — mesh/merge-gate or mesh/repo-check — with neither on its head for UncheckedAfter
|
||||
// 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
|
||||
@@ -93,6 +95,8 @@ type unchecked struct {
|
||||
// asked again from one still waiting on the same ask.
|
||||
pending map[string]string
|
||||
first time.Time
|
||||
// read is when the forge was read that showed it so.
|
||||
read time.Time
|
||||
// held is the state of the delivery of this head, ready or rejected, when one is held here (novox/hq issue
|
||||
// 441); empty for a head with no delivery.
|
||||
held State
|
||||
@@ -166,12 +170,12 @@ func askedAgain(was, now map[string]string) bool {
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// answered, and the ready marks (seeMarks). A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
|
||||
func (h *Holder) SeeOpenPulls() error {
|
||||
open, err := h.Forge.OpenPulls()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the forge's open pull requests cannot be read: %v", err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -194,7 +198,7 @@ func (h *Holder) SeeOpenPulls() {
|
||||
// The clock runs from when this owner first saw the head waiting, and starts again only on a newer ask: a
|
||||
// check that started, or was asked again — its pending status set anew — is given its whole bound from then.
|
||||
// A check that answers does not start it again, so a head is said on the bound from its first sight.
|
||||
u := unchecked{pull: p, waiting: waiting, pending: pending, first: now, held: held}
|
||||
u := unchecked{pull: p, waiting: waiting, pending: pending, first: now, read: now, held: held}
|
||||
if was, ok := h.unchecked[id]; ok && !askedAgain(was.pending, pending) {
|
||||
u.first = was.first
|
||||
} else {
|
||||
@@ -205,6 +209,7 @@ func (h *Holder) SeeOpenPulls() {
|
||||
}
|
||||
h.unchecked = seen
|
||||
h.seeMarks(open)
|
||||
return nil
|
||||
}
|
||||
|
||||
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
|
||||
@@ -273,3 +278,32 @@ func short(sha string) string {
|
||||
}
|
||||
return sha
|
||||
}
|
||||
|
||||
// uncheckedDue is whether a head seen waiting has reached the bound it is said at without the forge being read since: it
|
||||
// is read once more then, to say it only if it still waits. A head not yet at its bound is looked at again when it is.
|
||||
// Where the poll used to find a status that never came, an event does not exist to find it; this read is bounded to one
|
||||
// per head and its bound. Called by React.
|
||||
func (h *Holder) uncheckedDue() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now, due := h.Now(), false
|
||||
for _, u := range h.unchecked {
|
||||
at := u.first.Add(u.bound())
|
||||
switch {
|
||||
case now.Before(at):
|
||||
h.lookAt(at, "a head whose merge check has not answered, read from the forge once more at its bound")
|
||||
case u.read.Before(at):
|
||||
due = true
|
||||
}
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
// forgetUnchecked drops the heads seen waiting of a pull request that closed or merged.
|
||||
func (h *Holder) forgetUnchecked(owner, repo string, number int) {
|
||||
for id, u := range h.unchecked {
|
||||
if strings.EqualFold(u.pull.Owner, owner) && strings.EqualFold(u.pull.Repo, repo) && u.pull.Number == number {
|
||||
delete(h.unchecked, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A head nobody checks is found from events, not from a poll** (novox/hq ADR 0406 §1, issue 975, issues 347 and 722):
|
||||
// the forge's open pull requests are read when this owner starts and when an event says something they show may have
|
||||
// changed — a merge check's status, a new head, a ready mark — and once more at the bound a head's check is said.
|
||||
|
||||
func statusEvent(sha, context, state string) ForgeStatusEvent {
|
||||
return ForgeStatusEvent{Owner: "novox", Repo: "hq", SHA: sha, Context: context, State: state}
|
||||
}
|
||||
|
||||
func TestAMergeCheckStatusReadsTheOpenPullRequestsAndAnotherContextDoesNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
before := w.forge.openCalls
|
||||
hear(t, w, "gitea.forge.status", statusEvent("cccc", "mesh/delivery", "success"))
|
||||
if w.forge.openCalls != before {
|
||||
t.Fatalf("a status of a context that is no merge check read the forge %d times", w.forge.openCalls-before)
|
||||
}
|
||||
hear(t, w, "gitea.forge.status", statusEvent("cccc", "mesh/merge-gate", "pending"))
|
||||
if w.forge.openCalls != before+1 {
|
||||
t.Fatalf("a merge check's status read the forge %d times, wanted once", w.forge.openCalls-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeadWhoseCheckNeverAnsweredIsSaidFromTheEventsAndOneReadAtTheBound(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 1, Base: "main", HeadSHA: "cccc",
|
||||
Required: []string{"mesh/merge-gate"}, Statuses: []string{"mesh/merge-gate"},
|
||||
Checks: map[string]HeadCheck{"mesh/merge-gate": {State: "pending", UpdatedAt: "2026-10-06T12:00:00Z"}}}}
|
||||
hear(t, w, "gitea.forge.status", statusEvent("cccc", "mesh/merge-gate", "pending"))
|
||||
if len(w.h.Stalled()) != 0 {
|
||||
t.Fatal("a head just seen waiting is said already")
|
||||
}
|
||||
reads := w.forge.openCalls
|
||||
// Minutes pass with no event: nothing polls. At the bound, one read confirms it, and it is said.
|
||||
for range 3 {
|
||||
w.later(5 * time.Minute)
|
||||
w.h.React()
|
||||
}
|
||||
w.later(Bounds[Proposed].For)
|
||||
w.h.React()
|
||||
w.h.React()
|
||||
if got := w.forge.openCalls - reads; got != 1 {
|
||||
t.Fatalf("the forge's open pull requests were read %d times between the status and the bound's end; wanted once, at the bound", got)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || lines[0].State != Unanswered {
|
||||
t.Fatalf("the head whose check never answered is not said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewlyAnnouncedHeadReadsTheForgeAndAKnownOneDoesNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
before := w.forge.openCalls
|
||||
hear(t, w, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
if w.forge.openCalls != before+1 {
|
||||
t.Fatalf("a new head read the forge %d times, wanted once", w.forge.openCalls-before)
|
||||
}
|
||||
hear(t, w, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
if w.forge.openCalls != before+1 {
|
||||
t.Fatalf("the same head announced again read the forge again (%d reads)", w.forge.openCalls-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingReadsTheForgeWithoutAnEvent(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
before := w.forge.openCalls
|
||||
for range 6 {
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
}
|
||||
if w.forge.openCalls != before {
|
||||
t.Fatalf("the forge's open pull requests were read %d times with no event", w.forge.openCalls-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClosedOrMergedPullRequestIsNoLongerSaidUnchecked(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 1, Base: "main", HeadSHA: "cccc",
|
||||
Required: []string{"mesh/merge-gate"}}}
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(11 * time.Minute)
|
||||
if len(w.h.Stalled()) != 1 {
|
||||
t.Fatal("not said")
|
||||
}
|
||||
closed := pr("novox/hq", 1, "cccc", "feat/x")
|
||||
hear(t, w, "gitea.pull.closed", closed)
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a closed pull request is still said: %+v", lines)
|
||||
}
|
||||
}
|
||||
@@ -385,7 +385,7 @@ func (h *Holder) MarkReady(id, reviewedBy, why string) (string, error) {
|
||||
h.keep(d)
|
||||
waits := h.readyWaits(d)
|
||||
if waits == "" {
|
||||
return fmt.Sprintf("%s is marked ready at %s; its merge is asked once the next tick reads the mark", id, shortOf(head, 12)), nil
|
||||
return fmt.Sprintf("%s is marked ready at %s; its merge is asked once the forge's label and comment events reach this owner", id, shortOf(head, 12)), nil
|
||||
}
|
||||
return fmt.Sprintf("%s is marked ready at %s; its merge waits: %s", id, shortOf(head, 12), waits), nil
|
||||
}
|
||||
@@ -450,8 +450,9 @@ func (h *Holder) Release(id, why, by string) (string, error) {
|
||||
"delivery was stopped, and is asked to leave it out first", id, by, walk, strings.Join(stopped, ", "))
|
||||
}
|
||||
if err := h.Controller.Deliver(walk, "released by "+by+": "+why); err != nil {
|
||||
return "", fmt.Errorf("%s is delivering on %s's word, and its walk %s could not be let go yet (%v): the next "+
|
||||
"tick asks again", id, by, walk, err)
|
||||
h.again("a walk's word the controller refused or did not answer")
|
||||
return "", fmt.Errorf("%s is delivering on %s's word, and its walk %s could not be let go yet (%v): it is "+
|
||||
"asked again in %s", id, by, walk, err, askAgainAfter)
|
||||
}
|
||||
h.mu.Lock()
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
@@ -568,7 +569,7 @@ func (h *Holder) StopWay(id, why, by, way string) (string, error) {
|
||||
a()
|
||||
}
|
||||
if batch != "" {
|
||||
// Asked now, not at the next tick; and the batch's walk is never let go while it carries the merge.
|
||||
// Asked now, not at the next look; and the batch's walk is never let go while it carries the merge.
|
||||
did = fmt.Sprintf("; its merge waits in the batch %s, which was asked now to leave it out and is never let go "+
|
||||
"while it carries it: %s", batch, h.stopLeaving(batch, why, by, []string{merge}))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
module mesh-delivery
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.51.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,2 +1,27 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
|
||||
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
"gitea.pull.updated",
|
||||
"gitea.pull.merged",
|
||||
"gitea.pull.closed",
|
||||
"gitea.forge.pull-request.labeled",
|
||||
"gitea.forge.pull-request.unlabeled",
|
||||
"gitea.forge.pull-request.commented",
|
||||
"gitea.forge.status",
|
||||
"mesh-controller.checked",
|
||||
"mesh-controller.plan-moved"
|
||||
],
|
||||
@@ -60,7 +64,8 @@
|
||||
"state": [
|
||||
"deliveries",
|
||||
"groups",
|
||||
"asked"
|
||||
"asked",
|
||||
"pulls"
|
||||
],
|
||||
"tools": [
|
||||
"delivery_status"
|
||||
|
||||
Reference in New Issue
Block a user