Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83adc46fe0 | ||
|
|
67fe9a4dbe | ||
|
|
1a985d68fd | ||
|
|
68117bd74d | ||
|
|
3b03c220ad | ||
|
|
d16fa1e052 | ||
|
|
6a39da5485 | ||
|
|
beafa55d97 | ||
|
|
dd462a80e2 | ||
|
|
a390bb40f8 | ||
|
|
09f0b0d5ab | ||
|
|
c0880c4dc2 | ||
|
|
b9104454f9 | ||
|
|
73a10fafc7 | ||
|
|
954f84ea19 | ||
|
|
9f3a8ee391 | ||
|
|
cad6194447 | ||
|
|
3fd1b47fe7 | ||
|
|
5b57422523 | ||
|
|
523d7846cd | ||
|
|
502cda34cb | ||
|
|
c996121e9d | ||
|
|
f863e852c9 | ||
|
|
58b337770d | ||
|
|
ff1886ac62 | ||
|
|
28fdbbd346 | ||
|
|
3ee80c98ab | ||
|
|
8e0ef84060 | ||
|
|
68c141849f | ||
|
|
a9ad306693 | ||
|
|
a3e9985c15 | ||
|
|
08200bc178 | ||
|
|
a9ff48e200 | ||
|
|
66aa204f67 | ||
|
|
a76910a0c0 | ||
|
|
dc86203d29 | ||
|
|
9efdd87a73 | ||
|
|
301cd2287f | ||
|
|
524e3405e5 | ||
|
|
6e6d1f3e47 | ||
|
|
eda55ceb9e | ||
|
|
e8c3448797 | ||
|
|
5c1ff4bb15 | ||
|
|
280aeb2f47 | ||
|
|
b767aac8e0 | ||
|
|
920199862e | ||
|
|
8dd2a42448 | ||
|
|
d1a9056a1c | ||
|
|
a3476a7bf2 | ||
|
|
1c3158e098 | ||
|
|
75957fc3c0 | ||
|
|
ea83746297 | ||
|
|
b60ee706d4 | ||
|
|
4a60deb4a8 | ||
|
|
fc10031d22 | ||
|
|
2985122856 | ||
|
|
20342f2172 | ||
|
|
7bb588ca1b | ||
|
|
a2c0c58677 | ||
|
|
c441d83ba7 | ||
|
|
e601e88e95 | ||
|
|
fb8e677cd8 | ||
|
|
5aca005cb7 | ||
|
|
9f9e9fc9d3 | ||
|
|
f737418bb3 | ||
|
|
5b235a6e80 | ||
|
|
de6741a335 | ||
|
|
dd8305d97d | ||
|
|
06dfce87f5 | ||
|
|
7db4bd215d | ||
|
|
1639269ad4 | ||
|
|
7397051771 | ||
|
|
95089762ef | ||
|
|
ce3b12f349 | ||
|
|
acb82992ee | ||
|
|
0a7669b48f | ||
|
|
f140099a75 | ||
|
|
f3a59ee63a | ||
|
|
33a97e6d13 | ||
|
|
e7dce9616c | ||
|
|
1ccb617a86 | ||
|
|
dd3ed68483 | ||
|
|
428b42181d | ||
|
|
59c0906c79 | ||
|
|
75a9a45c6f | ||
|
|
b4527d9091 | ||
|
|
a32b7af3fd | ||
|
|
a6295d6b98 | ||
|
|
afb94d925e | ||
|
|
26d81b09b5 | ||
|
|
72ad5ad138 | ||
|
|
54db38be08 | ||
|
|
4301da630d | ||
|
|
e75e793a4b |
+54
-1
@@ -3,6 +3,7 @@
|
||||
// gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea
|
||||
// does.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
@@ -21,6 +22,19 @@ function commitSegment(sha: unknown): string {
|
||||
if (!isCommit(v)) throw new Refused(`${JSON.stringify(sha)} is not a commit. Nothing was sent`);
|
||||
return v;
|
||||
}
|
||||
/** git's hash of a tree from its entries: sha1 of "tree <length>\0" and, for each entry in git's order (byte order of the
|
||||
* name, a tree compared as its name and a slash), "<mode> <name>\0" and the 20 raw bytes of its sha. git writes a
|
||||
* tree's mode 40000 where the forge says 040000. */
|
||||
export function gitTreeHash(entries: { path: string; mode: string; type: string; sha: string }[]): string {
|
||||
const ordered = entries
|
||||
.map((e) => ({ e, key: Buffer.from(e.path + (e.type === "tree" ? "/" : "")) }))
|
||||
.sort((a, b) => Buffer.compare(a.key, b.key));
|
||||
const body = Buffer.concat(ordered.map(({ e }) => {
|
||||
if (!/^[0-9a-f]{40}$/i.test(String(e.sha))) throw new Error(`the forge named no sha for ${e.path}`);
|
||||
return Buffer.concat([Buffer.from(`${String(e.mode).replace(/^0+/, "")} ${e.path}\0`), Buffer.from(e.sha, "hex")]);
|
||||
}));
|
||||
return createHash("sha1").update(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).digest("hex");
|
||||
}
|
||||
export type { BranchProtection, ProtectionWanted } from "./protection.js";
|
||||
|
||||
/** A repository, trimmed to what the mesh cares about. */
|
||||
@@ -92,6 +106,8 @@ export interface OpenPull {
|
||||
repo: string;
|
||||
number: number;
|
||||
base: string;
|
||||
/** The pull request's head branch: what a delivery group is named by (novox/hq ADR 0249). */
|
||||
head?: string;
|
||||
head_sha: string;
|
||||
html_url?: string;
|
||||
/** The contexts the base branch's protection requires; none when it requires none, or cannot be read. */
|
||||
@@ -374,7 +390,7 @@ export class GiteaClient {
|
||||
const rule = rules.get(base);
|
||||
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
|
||||
const labels = found.labels ?? [];
|
||||
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
|
||||
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head: pull.head, head_sha: pull.head_sha,
|
||||
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
|
||||
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name) });
|
||||
}
|
||||
@@ -510,6 +526,43 @@ export class GiteaClient {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** **A commit's trees: its root, and the tree of every directory that holds a `module.json`** (novox/hq issue 1234,
|
||||
* ADR 0437 §4 and §5), from the forge's one recursive listing of the commit, read page by page. A directory is
|
||||
* spelled as the repository spells it, no slash at either end, the root as `""`.
|
||||
*
|
||||
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
|
||||
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
|
||||
* see, since a directory left out would be taken for one without a module. Reads only. */
|
||||
async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record<string, string> }> {
|
||||
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
|
||||
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
|
||||
const entries: any[] = [];
|
||||
for (let page = 1; ; page++) {
|
||||
if (page > 200) throw cut("more than 200 pages");
|
||||
const got = await this.request<any>(`${base}&page=${page}&per_page=1000`);
|
||||
if (!Array.isArray(got?.tree)) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} has no tree`);
|
||||
entries.push(...got.tree);
|
||||
if (got.truncated !== true) break;
|
||||
// Truncated with nothing more to ask for: the forge itself could not give the rest.
|
||||
if (got.tree.length === 0 || entries.length >= Number(got.total_count ?? 0)) throw cut("truncated");
|
||||
}
|
||||
// **The listing's top `sha` is the commit's, not the root tree's** (seen live, novox/mesh-delivery at 7f61817), so the
|
||||
// root tree is hashed from the root's own entries, as git does (gitTreeHash).
|
||||
const root = gitTreeHash(entries.filter((e) => typeof e?.path === "string" && !e.path.includes("/")));
|
||||
const trees: Record<string, string> = {};
|
||||
const dirOf = new Map<string, string>();
|
||||
for (const e of entries) if (e?.type === "tree" && typeof e.path === "string") dirOf.set(e.path, String(e.sha));
|
||||
for (const e of entries) {
|
||||
if (e?.type !== "blob" || typeof e.path !== "string") continue;
|
||||
if (e.path !== "module.json" && !e.path.endsWith("/module.json")) continue;
|
||||
const dir = e.path === "module.json" ? "" : e.path.slice(0, -"/module.json".length);
|
||||
const tree = dir === "" ? root : dirOf.get(dir);
|
||||
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
|
||||
trees[dir] = tree;
|
||||
}
|
||||
return { root, trees };
|
||||
}
|
||||
|
||||
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
||||
async holdsFile(owner: string, repo: string, sha: string, file: string): Promise<boolean> {
|
||||
return this.exists(`${repoPath(owner, repo)}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -35,14 +36,33 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
|
||||
}
|
||||
var file, process map[string]any
|
||||
for _, r := range m.Resources {
|
||||
for _, k := range strs(r["takes-config"]) {
|
||||
if k == ConfigKey {
|
||||
file = r
|
||||
}
|
||||
if r["id"] == "repositories-file" {
|
||||
file = r
|
||||
}
|
||||
}
|
||||
if file == nil || file["type"] != "file" {
|
||||
t.Fatalf("no file takes the value %s", ConfigKey)
|
||||
t.Fatalf("no file repositories-file")
|
||||
}
|
||||
// 1128/AC1: a plain template the provisioner client fills, so an approved value lands at once: a file the
|
||||
// controller merges (merge, takes-config) takes a config change only on the node's next send (ADR 0348 §10).
|
||||
if file["merge"] != nil || file["takes-config"] != nil {
|
||||
t.Errorf("repositories-file is merged by the controller (merge %v, takes-config %v), want a plain template",
|
||||
file["merge"], file["takes-config"])
|
||||
}
|
||||
text, err := os.ReadFile("../../" + file["template"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(text), "${config:"+ConfigKey+"}") {
|
||||
t.Fatalf("the template %s does not say ${config:%s}: %s", file["template"], ConfigKey, text)
|
||||
}
|
||||
filled := filepath.Join(t.TempDir(), "repositories.json")
|
||||
value := `{"mesh-delivery":{"private":false,"description":"d"}}`
|
||||
if err := os.WriteFile(filled, []byte(strings.ReplaceAll(string(text), "${config:"+ConfigKey+"}", value)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, err := ReadValue(filled); err != nil || len(v) != 1 || v["mesh-delivery"].Description != "d" {
|
||||
t.Errorf("the filled template reads as %v, %v; want mesh-delivery", v, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past a status refused,
|
||||
// as every repository's main has had since 2026-10-07.
|
||||
func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{"mesh-delivery": {Description: "d"}})
|
||||
rule := f.repos["novox/mesh-delivery"].Rules[mainRule]
|
||||
if rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("main lets an administrator merge past its statuses: %v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its
|
||||
// statuses kept, and a second pass changes nothing.
|
||||
func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "other/x"},
|
||||
"block_admin_merge_override": false,
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"mesh-delivery": {Description: "d"}})
|
||||
rule := repo.Rules[mainRule]
|
||||
if rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("the administrator's override is still allowed: %v", rule)
|
||||
}
|
||||
for _, c := range []string{"mesh/merge-gate", "mesh/repo-check", "other/x"} {
|
||||
if !has(strs(rule["status_check_contexts"]), c) {
|
||||
t.Errorf("a status was dropped: %v", rule["status_check_contexts"])
|
||||
}
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
}
|
||||
before := len(f.writes())
|
||||
run(t, s, Value{"mesh-delivery": {Description: "d"}})
|
||||
if got := f.writes(); len(got) != before {
|
||||
t.Errorf("a second pass wrote %s", strings.Join(got[before:], ", "))
|
||||
}
|
||||
}
|
||||
@@ -333,12 +333,14 @@ func statuses(rules ...rule) []string {
|
||||
}
|
||||
|
||||
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges,
|
||||
// requiring the statuses (ADR 0428 §5). A field the forge keeps beside these is left as it is.
|
||||
// requiring the statuses (ADR 0428 §5), and no administrator merging past them, as every repository's main has been
|
||||
// since 2026-10-07 (issue 1141). A field the forge keeps beside these is left as it is.
|
||||
func narrowed(r rule, toolsAccount string, also ...rule) map[string]any {
|
||||
return map[string]any{
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []string{toolsAccount},
|
||||
"enable_status_check": true, "status_check_contexts": statuses(append([]rule{r}, also...)...),
|
||||
"block_admin_merge_override": true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +352,8 @@ func isRight(r rule, toolsAccount string) bool {
|
||||
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
|
||||
return false
|
||||
}
|
||||
return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"]))
|
||||
return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"])) &&
|
||||
r["block_admin_merge_override"] == true
|
||||
}
|
||||
|
||||
func hasAll(have []string) bool {
|
||||
@@ -394,9 +397,6 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error)
|
||||
if n, _ := r["required_approvals"].(float64); n > approvals {
|
||||
approvals = n
|
||||
}
|
||||
if r["block_admin_merge_override"] == true {
|
||||
body["block_admin_merge_override"] = true
|
||||
}
|
||||
}
|
||||
if approvals > 0 {
|
||||
body["required_approvals"] = approvals
|
||||
|
||||
@@ -225,6 +225,7 @@ func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
more := f.add("novox/more", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "mesh/delivery"},
|
||||
"block_admin_merge_override": true, // right since issue 1141
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"only": {}, "more": {}})
|
||||
|
||||
@@ -1 +1 @@
|
||||
{}
|
||||
{"repositories": ${config:repositories}}
|
||||
|
||||
@@ -94,6 +94,8 @@ export const QUERY_KEYS: ReadonlySet<string> = new Set([
|
||||
"limit", "page", "state", "type", "q", "sort", "order", "since", "before", "ref", "sha", "path", "labels",
|
||||
"milestones", "keyword", "stat", "verification", "files", "not", "mode", "include_desc", "archived", "private",
|
||||
"exclusive", "uid", "priority_repo_id", "created_by", "assigned_by", "mentioned_by", "whitespace", "binary",
|
||||
// Read flags of the forge's tree listing (gitea_commit_trees, novox/hq issue 1234): they widen what one read answers, never what a call may write.
|
||||
"recursive", "per_page",
|
||||
]);
|
||||
|
||||
/** The query a call sends: only allow-listed keys, each as given; refused when any other key is there. */
|
||||
|
||||
@@ -322,11 +322,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/repositories.json",
|
||||
"mode": "0600",
|
||||
"template": "files/repositories.json",
|
||||
"merge": "json",
|
||||
"takes-config": [
|
||||
"repositories"
|
||||
]
|
||||
"template": "files/repositories.json"
|
||||
},
|
||||
{
|
||||
"id": "consumers",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// Fixtures follow Gitea's GitTreeResponse as its API documents it (swagger: sha, url, tree[{path, mode, type, size,
|
||||
// sha, url}], truncated, page, total_count) and as the forge answers GET /repos/{o}/{r}/git/trees/{sha}; the entry
|
||||
// fields are those, with sha values written out. The live capture (1234/AC6): GET
|
||||
// /repos/novox/mesh-delivery/git/trees/7f61817 (non-recursive) answered the COMMIT's sha at the top, not the root
|
||||
// tree's; the root tree is computed from the root's own entries, as git does.
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const COMMIT = "c".repeat(40);
|
||||
const h = (c: string) => c.repeat(40);
|
||||
const entry = (path: string, type: "tree" | "blob", sha: string) => ({
|
||||
path, mode: type === "tree" ? "040000" : "100644", type, size: type === "tree" ? 0 : 12, sha, url: `u/${sha}`,
|
||||
});
|
||||
const LISTING = [
|
||||
entry("README.md", "blob", h("1")),
|
||||
entry("module.json", "blob", h("2")),
|
||||
entry("modules", "tree", h("3")),
|
||||
entry("modules/a", "tree", h("4")),
|
||||
entry("modules/a/module.json", "blob", h("5")),
|
||||
entry("modules/a/main.go", "blob", h("6")),
|
||||
entry("modules/b", "tree", h("7")),
|
||||
entry("modules/b/c", "tree", h("8")),
|
||||
entry("modules/b/c/module.json", "blob", h("9")),
|
||||
entry("modules/d", "tree", h("a")),
|
||||
entry("modules/d/notes.md", "blob", h("b")),
|
||||
];
|
||||
|
||||
/** git's own hash of a tree from its entries (an independent restatement for the fixtures): mode without leading zero. */
|
||||
function gitTree(es: { path: string; type: string; sha: string; mode: string }[]): string {
|
||||
const ordered = es.map((e) => ({ ...e, key: e.path + (e.type === "tree" ? "/" : "") })).sort((a, b) => Buffer.compare(Buffer.from(a.key), Buffer.from(b.key)));
|
||||
const body = Buffer.concat(ordered.map((e) => Buffer.concat([Buffer.from(`${e.mode.replace(/^0+/, "")} ${e.path}\0`), Buffer.from(e.sha, "hex")])));
|
||||
return createHash("sha1").update(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).digest("hex");
|
||||
}
|
||||
const ROOT = gitTree(LISTING.filter((e) => !e.path.includes("/")));
|
||||
|
||||
async function serve(pages: any[]) {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const asked: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
asked.push(req.url ?? "");
|
||||
const url = new URL(req.url ?? "", "http://x");
|
||||
if (url.pathname !== `/api/v1/repos/novox/hq/git/trees/${COMMIT}` || url.searchParams.get("recursive") !== "true") {
|
||||
res.statusCode = 404;
|
||||
return res.end("{}");
|
||||
}
|
||||
const page = Number(url.searchParams.get("page") ?? 1);
|
||||
res.end(JSON.stringify(pages[page - 1] ?? { sha: COMMIT, tree: [], truncated: false, page, total_count: 0 }));
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
return { client, server, asked };
|
||||
}
|
||||
|
||||
test("a commit's trees: the root and the tree of each directory holding a module.json", async () => {
|
||||
// 1234/AC1 and 1234/AC4
|
||||
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
|
||||
try {
|
||||
const got = await client.commitTrees("novox", "hq", COMMIT);
|
||||
assert.deepEqual(got, { root: ROOT, trees: { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") } });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a listing the forge pages is read page by page", async () => {
|
||||
// 1234/AC1
|
||||
const { client, server, asked } = await serve([
|
||||
{ sha: COMMIT, tree: LISTING.slice(0, 5), truncated: true, page: 1, total_count: LISTING.length },
|
||||
{ sha: COMMIT, tree: LISTING.slice(5, 9), truncated: true, page: 2, total_count: LISTING.length },
|
||||
{ sha: COMMIT, tree: LISTING.slice(9), truncated: false, page: 3, total_count: LISTING.length },
|
||||
]);
|
||||
try {
|
||||
const got = await client.commitTrees("novox", "hq", COMMIT);
|
||||
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
|
||||
assert.equal(asked.length, 3, `asked ${asked.join(" ")}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a listing the forge says is cut fails, naming the repository and the commit, and answers no trees", async () => {
|
||||
// 1234/AC2
|
||||
const { client, server } = await serve([{ sha: COMMIT, tree: LISTING, truncated: true, page: 1, total_count: LISTING.length }]);
|
||||
try {
|
||||
await assert.rejects(client.commitTrees("novox", "hq", COMMIT), (err: Error) => {
|
||||
assert.match(err.message, /novox\/hq/);
|
||||
assert.ok(err.message.includes(COMMIT), err.message);
|
||||
assert.match(err.message, /cut/);
|
||||
return true;
|
||||
});
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a sha that is not a full commit, or no owner or repo, is refused before the forge is asked", async () => {
|
||||
// 1234/AC3
|
||||
const { getGiteaTools } = await import("../tools/index.ts");
|
||||
const { client, server, asked } = await serve([]);
|
||||
try {
|
||||
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
|
||||
assert.ok(tool, "the tool is listed");
|
||||
for (const args of [
|
||||
{ owner: "novox", repo: "hq", sha: "abc1234" },
|
||||
{ owner: "novox", repo: "hq", sha: "main" },
|
||||
{ owner: "novox", repo: "hq", sha: "" },
|
||||
{ owner: "novox", repo: "hq", sha: "c".repeat(41) },
|
||||
{ owner: "", repo: "hq", sha: COMMIT },
|
||||
{ owner: "novox", repo: " ", sha: COMMIT },
|
||||
]) await assert.rejects(tool.run(args as any), /./, JSON.stringify(args));
|
||||
assert.equal(asked.length, 0, `the forge was asked ${asked.join(" ")}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("gitea_open_pulls says each pull request's head branch beside its head sha", async () => {
|
||||
// 1234/AC5
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url ?? "";
|
||||
const send = (v: unknown) => res.end(JSON.stringify(v));
|
||||
if (url.startsWith("/api/v1/repos/issues/search")) {
|
||||
return send(url.includes("page=1") ? [{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" } }] : []);
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) {
|
||||
return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "feat/x", sha: "aaa1111" }, html_url: "h" });
|
||||
}
|
||||
if (url.includes("/branch_protections")) { res.statusCode = 403; return res.end("{}"); }
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) return send({ name: "main" });
|
||||
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
try {
|
||||
const [p] = await client.openPulls();
|
||||
assert.deepEqual(p, { owner: "novox", repo: "hq", number: 7, base: "main", head: "feat/x", head_sha: "aaa1111", html_url: "h",
|
||||
required: [], statuses: [], checks: {}, labels: [] });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the root tree is git's hash of the root's own entries, never the listing's top sha (the commit's)", async () => {
|
||||
// 1234/AC6, from the live capture named in the header: novox/mesh-delivery at 7f61817.
|
||||
const commit = "7f618177d6773d8d2d2b9e0b10e13b85ec700c59";
|
||||
const listing = [
|
||||
entry("README.md", "blob", "a087d22cbcbecce42256315671ff7e4d6ca85a7c"),
|
||||
entry("merge-check.sh", "blob", "c4ef9a3dd6390b0edde7e4137095553897cecfb0"),
|
||||
entry("modules", "tree", "31c0abb312ee6ce4693a7aacdd8b3fba871d6cf3"),
|
||||
entry("modules/x", "tree", h("d")),
|
||||
entry("modules/x/module.json", "blob", h("e")),
|
||||
];
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const server = createServer((req, res) => {
|
||||
res.end(JSON.stringify(new URL(req.url ?? "", "http://x").pathname.endsWith(`/git/trees/${commit}`)
|
||||
? { sha: commit, tree: listing, truncated: false, page: 1, total_count: listing.length } : {}));
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
try {
|
||||
const got = await client.commitTrees("novox", "mesh-delivery", commit);
|
||||
assert.equal(got.root, "659bcaaac6c16972ec380267a4eba69d15558c26");
|
||||
assert.deepEqual(got.trees, { "modules/x": h("d") });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
@@ -539,7 +539,7 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_list_branches",
|
||||
"gitea_delete_branch",
|
||||
"gitea_branch_protection_get", "gitea_branch_protection_set",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_contains",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
|
||||
"gitea_list_labels", "gitea_create_label",
|
||||
"gitea_api",
|
||||
],
|
||||
|
||||
@@ -523,6 +523,22 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
input: {},
|
||||
run: async () => ({ pulls: await gitea.openPulls() }),
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_trees",
|
||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the commit, its full 40-hex sha" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
|
||||
const sha = String(args.sha ?? "").trim();
|
||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
|
||||
return gitea.commitTrees(owner, repo, sha);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_contains",
|
||||
description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
// Keycloak runs the moved path with the grant file still open for its consumers not moved (issue 937), fixed for
|
||||
// keycloak by issue 1332: mesh-mail-web, grafana and car-hunter are not moved, so without this fix keycloak never
|
||||
// made their clients and held them for retirement. These tests drive the SDK's harness.Moved exactly as main.go
|
||||
// wires it, against a fake Keycloak and an in-memory bus — no real backend.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider/harness"
|
||||
)
|
||||
|
||||
// grantGives writes a grant file (MESH_RECEIVES) naming consumers the plans do not move (issue 937): each one's
|
||||
// secret file, and the json the grant path reads.
|
||||
func grantGives(t *testing.T, given ...map[string]any) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, g := range given {
|
||||
as := g["as"].(string)
|
||||
secret := filepath.Join(dir, as+".secret")
|
||||
if err := os.WriteFile(secret, []byte("pw-"+as+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g["secret"] = secret
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"requirement": ProvisionName, "given": given})
|
||||
at := filepath.Join(dir, "mesh.json")
|
||||
if err := os.WriteFile(at, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// noOpPlan puts one assignment the bus can read that binds nothing of keycloak's, so the moved path has plans it
|
||||
// could read (m.asked answers "read", issue 241) even when nothing is moved.
|
||||
func noOpPlan(bus *bucketBus, node, module string) {
|
||||
raw, _ := json.Marshal(map[string]any{"node": node, "module": module})
|
||||
_ = bus.Put(ctx, provider.AssignmentsBucket, node+"."+module, raw)
|
||||
}
|
||||
|
||||
// waitUntil polls cond until it is true or the timeout passes.
|
||||
func waitUntil(t *testing.T, timeout time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("condition never held within %s", timeout)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// movedWorld builds keycloak's moved path, as main.go wires it, against a fake Keycloak and a bucket bus. Nothing
|
||||
// runs until start is called, so a test can seed the backend first; it is stopped when the test ends.
|
||||
func movedWorld(t *testing.T, h *harness.Harness) (f *fakeKeycloak, start func()) {
|
||||
t.Helper()
|
||||
var o OidcClients
|
||||
f, o = oidcWorld(t)
|
||||
h.Adapter = provisioner{clients: o, announce: func(string, map[string]any) {}, log: func(string, ...any) {}}
|
||||
if h.Every == 0 {
|
||||
h.Every = 5 * time.Millisecond
|
||||
}
|
||||
consumersFile := filepath.Join(t.TempDir(), "consumers.json")
|
||||
if err := os.WriteFile(consumersFile, []byte("[]"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _, _, err := moved(consumersFile, h, testIssuer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus := &bucketBus{buckets: map[string]map[string][]byte{}}
|
||||
noOpPlan(bus, "elsewhere", "unrelated")
|
||||
p := provider.New(provider.Options{Node: h.Node, Module: "keycloak", Bus: bus,
|
||||
Vault: provider.ConsumersFile{Paths: []string{consumersFile}},
|
||||
Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, Serves: servedValues(testIssuer)}},
|
||||
Make: run.Make, Gone: run.Gone, Every: h.Every, Logf: func(string, ...any) {}})
|
||||
return f, func() {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
go func() { _ = run.Run(runCtx, p, bus) }()
|
||||
}
|
||||
}
|
||||
|
||||
// 1332/AC1: a consumer only the grant file gives (oidc-client for novox.mesh-mail-web, not moved) is wanted and
|
||||
// Adapter.Create is asked for its client, though keycloak runs on the moved path.
|
||||
func TestAGrantFileConsumerNotMovedIsMadeOnTheMovedPath(t *testing.T) {
|
||||
mailWeb := "mesh_novox_mail_web"
|
||||
receives := grantGives(t, map[string]any{"as": mailWeb, "node": "novox", "values": map[string]any{
|
||||
"label": "mail-web", "endpoint": "web", "callback": "/login/generic_oauth", "name": "mail.example.org"}})
|
||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "novox"}
|
||||
f, start := movedWorld(t, h)
|
||||
start()
|
||||
waitUntil(t, time.Second, func() bool {
|
||||
found := false
|
||||
f.set(func() {
|
||||
for _, c := range f.clients {
|
||||
if c["clientId"] == mailWeb {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
})
|
||||
return found
|
||||
})
|
||||
}
|
||||
|
||||
// 1332/AC2: a grant-file consumer whose client already exists in the backend (mesh_ace_grafana) counts as asked and
|
||||
// is not held for retirement on the moved path.
|
||||
func TestAGrantFileConsumerAlreadyInKeycloakIsNotRetiredOnTheMovedPath(t *testing.T) {
|
||||
grafana := "mesh_ace_grafana"
|
||||
receives := grantGives(t, map[string]any{"as": grafana, "node": "ace", "values": map[string]any{
|
||||
"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth", "name": "grafana.example.org"}})
|
||||
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "ace",
|
||||
StablePasses: 1, StableFor: time.Millisecond}
|
||||
f, start := movedWorld(t, h)
|
||||
// Seed the backend with the client the mesh made earlier, exactly as grafana's already is — before anything
|
||||
// runs, so the harness's one-time seed (issue 1332) sees it.
|
||||
f.set(func() {
|
||||
f.clients["existing-grafana"] = Rep{"id": "existing-grafana", "clientId": grafana, "enabled": true,
|
||||
"attributes": map[string]any{Mark: "true"}}
|
||||
})
|
||||
start()
|
||||
// Many passes, well past StablePasses and StableFor if the login were ever counted unasked.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
f.set(func() {
|
||||
c := f.clients["existing-grafana"]
|
||||
if c["enabled"] == false {
|
||||
t.Fatalf("retired %v though its grant still gives it (issue 1332)", c)
|
||||
}
|
||||
if _, retired := attributes(c)[MarkRetired]; retired {
|
||||
t.Fatalf("marked retired %v though its grant still gives it (issue 1332)", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -51,15 +51,9 @@ func main() {
|
||||
say("%v — the provisioner cannot run without it", err)
|
||||
} else if consumers := handedConsumersFile(os.Getenv, os.Stat); consumers != "" {
|
||||
// Handed to the provisioner (novox/hq ADR 0373): the provider helper answers the consumers its
|
||||
// machine's client fills the file with; the grant directory is not read. Retirement is the
|
||||
// harness's, never run, so its guards and its tools stand (issue 805).
|
||||
h = &harness.Harness{
|
||||
Resource: ProvisionName,
|
||||
Adapter: provisioner{clients: OidcClients{KC: kc, Realm: realm}, guard: guard, announce: announce, log: logStderr},
|
||||
Log: logStderr,
|
||||
Announce: announce,
|
||||
Node: os.Getenv("MESH_NODE"),
|
||||
}
|
||||
// machine's client fills the file with, and the grant file still gives the consumers not moved
|
||||
// (issue 937, 1332). Retirement is the harness's, never run, so its guards and its tools stand (issue 805).
|
||||
h = movedHarness(os.Getenv, provisioner{clients: OidcClients{KC: kc, Realm: realm}, guard: guard, announce: announce, log: logStderr})
|
||||
run, p, bus, err := moved(consumers, h, issuer)
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
@@ -101,6 +95,20 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// movedHarness is the harness the moved path shares with the SDK's helper: what main wires together, pulled out so
|
||||
// it can be built and inspected without starting the process. Receives is MESH_RECEIVES, read exactly as the grant
|
||||
// path reads it (novox/hq issue 937), so the grant file still gives every consumer not moved (novox/hq issue 1332).
|
||||
func movedHarness(getenv func(string) string, adapter harness.Adapter) *harness.Harness {
|
||||
return &harness.Harness{
|
||||
Resource: ProvisionName,
|
||||
Receives: getenv("MESH_RECEIVES"),
|
||||
Adapter: adapter,
|
||||
Log: logStderr,
|
||||
Announce: announce,
|
||||
Node: getenv("MESH_NODE"),
|
||||
}
|
||||
}
|
||||
|
||||
// handedConsumersFile is the consumers' file when keycloak is handed to the provisioner: the file MESH_CONSUMERS_FILE
|
||||
// names, which the controller places only where it hands the module. Empty, where it is not set or the file is not
|
||||
// there, and keycloak then reads the grant directory exactly as it always did (issue 805).
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
// What main wires for the moved path (novox/hq issue 1332): the harness it builds from the environment, the same
|
||||
// way the grant path reads its own settings.
|
||||
|
||||
import "testing"
|
||||
|
||||
// 1332/AC3: the moved-path harness, built from the environment, reads MESH_RECEIVES the way the grant path does
|
||||
// (main.go's other branch, line 67), as postgres's moved harness does (postgres-provider/main.go:43).
|
||||
func TestTheMovedPathHarnessReadsMESH_RECEIVESAsTheGrantPathDoes(t *testing.T) {
|
||||
env := map[string]string{"MESH_RECEIVES": "/grants/mesh.json", "MESH_NODE": "novox"}
|
||||
getenv := func(k string) string { return env[k] }
|
||||
h := movedHarness(getenv, provisioner{})
|
||||
if h.Receives != env["MESH_RECEIVES"] {
|
||||
t.Fatalf("Receives = %q, want %q — the grant file still gives every consumer not moved (issue 937), "+
|
||||
"read exactly as MESH_RECEIVES on the grant path, as postgres's moved harness does "+
|
||||
"(postgres-provider/main.go:43)", h.Receives, env["MESH_RECEIVES"])
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,16 @@ func valuesOf(e provider.Entry) map[string]any {
|
||||
// moved is keycloak handed to the provisioner: the SDK's moved path (harness.Moved) answering its consumers, and the
|
||||
// harness (never run) whose retirement and tools it shares. What differs from postgres is only this declaration
|
||||
// (issue 849).
|
||||
//
|
||||
// **A consumer not moved is still the grant file's** (issue 937, ADR 0349 clause 17, fixed here by issue 1332):
|
||||
// only a moved consumer's plan names keycloak as its provider, so the controller still gives every other consumer
|
||||
// through keycloak's grant file (MESH_RECEIVES) until R1 (issue 682) — mesh-mail-web, grafana and car-hunter among
|
||||
// them. Each client it gives is made and checked by the harness's grant pass exactly as on the grant path; a client
|
||||
// a plan names is the helper's alone.
|
||||
func moved(consumersFile string, h *harness.Harness, issuer string) (*harness.Moved, *provider.Provider, provider.Bus, error) {
|
||||
return harness.NewMoved(consumersFile, h, harness.MovedSpec{Module: "keycloak", Provision: ProvisionName,
|
||||
Offer: provider.Offer{Credential: provider.PerConsumer, Serves: servedValues(issuer)},
|
||||
Login: func(e provider.Entry) string { return e.As },
|
||||
Values: valuesOf,
|
||||
Noun: "client id", Made: "client", Backend: "Keycloak", WithGrants: false})
|
||||
Noun: "client id", Made: "client", Backend: "Keycloak", WithGrants: true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# mesh-delivery-gate
|
||||
|
||||
The `mesh-delivery-gate` module of the mesh catalogue: the gate of design 47. It claims the seat `mesh-delivery`, whose
|
||||
name stays (ADR 0437 §11), and takes over from the module `mesh-delivery` (ADR 0437 §3, ADR 0450 §6). Its buckets are
|
||||
`mesh-delivery-gate_*`.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
## A backup that failed before the send
|
||||
|
||||
The controller backs up the data of the modules a delivery moves before it asks or sends anything (ADR 0404). When its
|
||||
walk's record, in `plan-moved`, names a backup that failed or stayed silent and nothing of the plan was sent, the
|
||||
delivery goes from delivering to held (the observed row `backup failed`); the held reason names the machine, the
|
||||
module and the backup's words, and the pull request's comment shows it. The hold does not lift by itself. `release`
|
||||
of such a delivery asks the controller (`delivery-backups-again`) to take the backups again and the delivery goes on
|
||||
delivering; a record from before the controller cleared the failure does not hold it again. A held delivery already
|
||||
ends as covered or delivered when a later merge covers it.
|
||||
|
||||
## Taking the seat over from today's module
|
||||
|
||||
The gate claims the seat `mesh-delivery` beside the module `mesh-delivery`, which the catalogue no longer holds. The controller
|
||||
keeps a module that is assigned (it refuses to forget it), so the running module goes by a handover of verbs after the merge,
|
||||
never by hand on a machine (novox/hq ADR 0449 §4, ADR 0450 §6):
|
||||
|
||||
1. assign `mesh-delivery-gate` to the machine that holds the seat (`mesh-controller.assign`);
|
||||
2. check that `mesh-controller.seats` still names `mesh-delivery`, held by the running module;
|
||||
3. unassign `mesh-delivery` from that machine (`mesh-controller.unassign`) and push it (`mesh-controller.push`);
|
||||
4. the seat passes: the gate reads the controller's assignments and the node-engine's have keys, finds the module on no machine,
|
||||
carries its deliveries, groups and pull keys over once (the key `carried` in its `deliveries` says it did), reads the controller's
|
||||
walks and the forge's open pull requests, and acts.
|
||||
|
||||
Until then the gate is silent: it takes no event, writes no key, asks nothing and answers no verb but `delivery_status`, which
|
||||
says why (`holds-the-seat`, `seat-waits`).
|
||||
|
||||
The catalogue holds the names of the old module that the handover needs, and no others: the three buckets the gate carries
|
||||
over, and the event the issue tracker and the tool runner hear until the module is forgotten (`catalogue_test.go`).
|
||||
+253
-57
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -8,15 +9,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// A merge into main waits for the operator's Approve (novox/hq ADR 0398 §3, issue 948, ADR 0421).
|
||||
//
|
||||
// This owner asks the operator on the router (the operator channel, as the knowledge base asks for a record) as soon as
|
||||
// a pull request's checks pass: Approve or Decline, each option binding the act "merge these heads". It merges only after
|
||||
// an Approve whose warrant holds for that ask — the same asker, the same ask's digest, an option it offered, in time, and
|
||||
// the act that option bound.
|
||||
// This owner writes the ask into its bucket asks (novox/hq ADR 0437 §6) as soon as a pull request's checks pass:
|
||||
// Approve or Decline, each option binding the act "merge these heads". The teller publishes it to the operator on the
|
||||
// router and, on a warrant that holds the ask (its asker, the ask's digest, an option it offered, in time, and the act
|
||||
// that option bound), writes the answer into its bucket answers. This owner reads the answer and acts on it only while
|
||||
// the heads of the key it was written under are still its group's heads; it asks the router nothing.
|
||||
//
|
||||
// - Asked once per head. A group is asked once, for all its members' heads together, by the first member that reaches
|
||||
// this point; the Approve covers every member at those heads.
|
||||
@@ -27,8 +31,12 @@ import (
|
||||
//
|
||||
// The terminal Approve (ADR 0398 §3) is issue 953; today the proving channel is the operator's phone.
|
||||
|
||||
// Module is this module's name: the one the bus grants its asks under.
|
||||
const Module = "mesh-delivery"
|
||||
// Module is this module's name: the one the bus names its buckets and events under (novox/hq ADR 0437 §3). Seat is
|
||||
// the seat it claims, whose name stays `mesh-delivery` (ADR 0437 §11).
|
||||
const (
|
||||
Module = "mesh-delivery-gate"
|
||||
Seat = "mesh-delivery"
|
||||
)
|
||||
|
||||
// The two answers, both at the level approve.
|
||||
const (
|
||||
@@ -39,14 +47,27 @@ const (
|
||||
// AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
|
||||
const AskLasts = asks.ApproveLasts - 10*time.Minute
|
||||
|
||||
// Operator is the operator channel as this owner reaches it: asker.Client in the running module.
|
||||
// Operator is how this owner asks the operator: through the keys of its bucket asks and the teller's answers. kvOperator in
|
||||
// the running module.
|
||||
type Operator interface {
|
||||
// Ask checks, keeps and publishes an ask under this module's name.
|
||||
Ask(a asks.Ask) error
|
||||
// Cancel takes an ask back.
|
||||
Cancel(id string) error
|
||||
// Where is an ask's state as the router keeps it, and its warrant once there is one.
|
||||
Where(id string) (string, *asks.Warrant, error)
|
||||
// Ask writes the ask under its key (sdk.MergeAskKey); it writes nothing when that key holds the same ask already.
|
||||
Ask(key string, a sdk.Ask) error
|
||||
// Cancel takes the ask at a key away, which the teller reads as the ask withdrawn.
|
||||
Cancel(key string) error
|
||||
// Where is the teller's answer to the ask at a key, or nil when there is none.
|
||||
Where(key string) (*sdk.Answer, error)
|
||||
}
|
||||
|
||||
// askGroup is the name a delivery's ask and merge are kept under: its group's, or, for a lone pull request, a group of one
|
||||
// under its branch name (novox/hq ADR 0437 §6).
|
||||
func askGroup(d *Delivery) string {
|
||||
switch {
|
||||
case d.Group != "":
|
||||
return d.Group
|
||||
case d.Branch != "":
|
||||
return d.Branch
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// headsString is the heads an act names, as an act's value: "<id>=<sha>" for each, sorted, joined by commas.
|
||||
@@ -61,7 +82,7 @@ func headsString(heads map[string]string) string {
|
||||
|
||||
// mergeAct is what an answer binds: this ask, the answer, and the heads it covers.
|
||||
func mergeAct(ask, option string, heads map[string]string) asks.Act {
|
||||
return asks.Act{"verb": Module + ".merge", "ask": ask, "option": option, "heads": headsString(heads)}
|
||||
return asks.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)}
|
||||
}
|
||||
|
||||
var unshownPath = regexp.MustCompile(`[/\\@~$]|\d+\.\d+\.\d+|[A-Za-z0-9+/=_]{24,}|(?i)\b[a-z0-9-]+\.[a-z]{2,12}\b`)
|
||||
@@ -283,6 +304,10 @@ func (h *Holder) endStaleAsks() {
|
||||
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "its head "+shortOf(d.Commit, 12)+" is no longer the pull request's head, or the pull request closed"))
|
||||
case !h.headsHold(d, r):
|
||||
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "a head it covers moved on: the heads it covers are no longer the group's heads"))
|
||||
case r.Approved == nil && r.AskID != "" && !r.AskedAt.IsZero() && !h.Now().Before(r.AskedAt.Add(AskLasts)):
|
||||
// The ask lapsed at the router with nobody's answer, and nothing in the gate's inputs says so: it is taken away and
|
||||
// written again, which the teller asks afresh (ADR 0441 §1).
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, "nobody answered in "+AskLasts.String()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +322,9 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
r.Approved, r.Warrant, r.AskID, r.Ask, r.Heads = c.Approved, c.Warrant, c.AskID, c.Ask, c.Heads
|
||||
return "", nil
|
||||
}
|
||||
if !c.AskedAt.IsZero() {
|
||||
h.lookAt(c.AskedAt.Add(AskLasts), "the operator's Approve, unanswered until the ask lapses")
|
||||
}
|
||||
return "the operator's Approve is asked (" + c.AskID + ")", nil
|
||||
}
|
||||
if r.Refused != "" {
|
||||
@@ -311,31 +339,117 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
group = d.Group
|
||||
}
|
||||
heads := map[string]string{}
|
||||
var covered []sdk.Head
|
||||
for _, m := range members {
|
||||
heads[m.ID] = m.Commit
|
||||
covered = append(covered, sdk.Head{Repository: m.Repository, Number: m.Number, Head: m.Commit})
|
||||
}
|
||||
name := askGroup(d)
|
||||
id, err := sdk.MergeAskKey(name, covered)
|
||||
if err != nil {
|
||||
return "the operator cannot be asked: " + err.Error(), nil
|
||||
}
|
||||
r.Asks++
|
||||
id := fmt.Sprintf("merge-%d-%s-%d", d.Number, shortOf(d.Commit, 12), r.Asks)
|
||||
ask := composeMergeAsk(id, d, group, members, heads, now)
|
||||
// Each asking at these heads has its own router id (sdk.AskingID): the router keeps an ask by its id and drops a second ask
|
||||
// under it as a redelivery, so an ask written again at unchanged heads (a lapse, a status or the review record changing) is
|
||||
// the next asking, with a new id (review round 1, G2; issue 1370).
|
||||
asking := 1
|
||||
for _, m := range members {
|
||||
if m.Ready != nil && m.Ready.AskedKey == id {
|
||||
asking = max(asking, m.Ready.Asking+1)
|
||||
}
|
||||
}
|
||||
ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, now)
|
||||
taken := []*Delivery{}
|
||||
var reads []memberRead
|
||||
for _, m := range members {
|
||||
h.runOf(m, now)
|
||||
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks)
|
||||
m.Ready.AskedKey, m.Ready.Asking = id, asking
|
||||
h.keep(m)
|
||||
taken = append(taken, m)
|
||||
reads = append(reads, readOf(m, m.Commit))
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s passed its checks: the operator is asked to Approve its merge into main (%s, ask %d)",
|
||||
d.ID, id, r.Asks)
|
||||
gid := ""
|
||||
if group != "" {
|
||||
gid = d.Group
|
||||
}
|
||||
return "the operator's Approve is asked (" + id + ")", func() {
|
||||
if err := h.Operator.Ask(ask); err != nil {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
// A group is not asked while a member's required status is not a success at its head (issue 1275): the group
|
||||
// says which, and is asked once every member may merge.
|
||||
var holdMember, holdSays string
|
||||
var readErr error
|
||||
for _, m := range reads {
|
||||
if gid == "" {
|
||||
break
|
||||
}
|
||||
says, _, err := h.notASuccess(m)
|
||||
if err != nil {
|
||||
readErr = err
|
||||
break
|
||||
}
|
||||
if says != "" {
|
||||
holdMember, holdSays = m.id, says
|
||||
break
|
||||
}
|
||||
}
|
||||
undo := func() {
|
||||
for _, m := range taken {
|
||||
if m.Ready != nil && m.Ready.AskID == id {
|
||||
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads = "", nil, nil
|
||||
h.keep(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
// An earlier asking of this key may still stand in the bucket: not written again, it is taken away.
|
||||
stale := func() {
|
||||
h.mu.Lock()
|
||||
standing := h.standing(id)
|
||||
h.mu.Unlock()
|
||||
if standing {
|
||||
if err := h.Operator.Cancel(id); err != nil {
|
||||
h.Logf("[mesh-delivery] the operator's ask %s could not be withdrawn (%v)", id, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.markWritten(id, false)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if readErr != nil || holdSays != "" {
|
||||
stale()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
undo()
|
||||
if g := h.groups[gid]; g != nil && holdSays != "" {
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Says != holdSays || g.Hold.Member != holdMember {
|
||||
h.Logf("[mesh-delivery] group %s is not asked for its Approve: %s waits on a required status: %s", gid, holdMember, holdSays)
|
||||
g.Hold = &GroupHold{Kind: "unasked", Member: holdMember, Says: holdSays, Since: h.Now()}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
h.Logf("[mesh-delivery] group %s: the required statuses cannot be read, so the operator is not asked yet: %v", gid, readErr)
|
||||
}
|
||||
h.again("a group's Approve held for a required status")
|
||||
return
|
||||
}
|
||||
if g := func() *Group { h.mu.Lock(); defer h.mu.Unlock(); return h.groups[gid] }(); g != nil && g.Hold != nil && g.Hold.Kind == "unasked" {
|
||||
h.mu.Lock()
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
if err := h.Operator.Ask(id, sdk.Ask{Group: name, Heads: covered, Asking: asking, Ask: ask}); err == nil {
|
||||
h.mu.Lock()
|
||||
h.markWritten(id, true)
|
||||
h.mu.Unlock()
|
||||
} else {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
undo()
|
||||
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)
|
||||
}
|
||||
@@ -376,7 +490,11 @@ func (h *Holder) dropAsk(id, why string) func() {
|
||||
}
|
||||
if err := h.Operator.Cancel(id); err != nil {
|
||||
h.Logf("[mesh-delivery] the operator's ask %s could not be cancelled (%v)", id, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.markWritten(id, false)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,15 +508,37 @@ func (h *Holder) withdrawAsk(id, why string) func() {
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
// The key is not taken away: the next look writes it again with the next asking, if the group may be asked, and takes it away
|
||||
// only if it may not (readyAsks, withdrawnKeys), so the key is never absent between the two.
|
||||
h.withdrawn[id] = true
|
||||
h.Logf("[mesh-delivery] the operator's ask %s is withdrawn to be asked again: %s", id, why)
|
||||
return func() {
|
||||
if h.Operator == nil {
|
||||
return
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// withdrawnKeys are, after a look, the closures that take away the asks withdrawn and not written again by it. Under the lock.
|
||||
func (h *Holder) withdrawnKeys() []func() {
|
||||
var out []func()
|
||||
for key := range h.withdrawn {
|
||||
delete(h.withdrawn, key)
|
||||
again := false
|
||||
for _, d := range h.deliveries {
|
||||
again = again || (d.Ready != nil && d.Ready.AskID == key)
|
||||
}
|
||||
if err := h.Operator.Cancel(id); err != nil {
|
||||
h.Logf("[mesh-delivery] the operator's ask %s could not be withdrawn (%v)", id, err)
|
||||
if again || h.Operator == nil {
|
||||
continue
|
||||
}
|
||||
key := key
|
||||
out = append(out, func() {
|
||||
if err := h.Operator.Cancel(key); err != nil {
|
||||
h.Logf("[mesh-delivery] the operator's ask %s could not be withdrawn (%v)", key, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.markWritten(key, false)
|
||||
h.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pendingAsks are the asks waiting for an answer, by id; under the lock.
|
||||
@@ -415,25 +555,25 @@ func (h *Holder) pendingAsks() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// hearAsks reads the router's record of every pending ask and applies the warrant of each that closed.
|
||||
// hearAsks reads the teller's answer to every pending ask and applies each that is there.
|
||||
func (h *Holder) hearAsks() {
|
||||
if h.Operator == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
ids := h.pendingAsks()
|
||||
keys := h.pendingAsks()
|
||||
h.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
state, warrant, err := h.Operator.Where(id)
|
||||
for _, key := range keys {
|
||||
answer, err := h.Operator.Where(key)
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the router's record of the ask %s cannot be read: %v", id, err)
|
||||
h.Logf("[mesh-delivery] the teller's answer to the ask %s cannot be read: %v", key, err)
|
||||
continue
|
||||
}
|
||||
if state == "" || state == "open" || state == "held" {
|
||||
if answer == nil {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
said := h.decided(id, state, warrant)
|
||||
said := h.answered(key, *answer)
|
||||
h.mu.Unlock()
|
||||
for _, s := range said {
|
||||
s()
|
||||
@@ -441,57 +581,61 @@ func (h *Holder) hearAsks() {
|
||||
}
|
||||
}
|
||||
|
||||
// decided applies one closed ask's warrant to the deliveries it covers; under the lock. It returns what to say on the
|
||||
// pull requests.
|
||||
func (h *Holder) decided(id, state string, w *asks.Warrant) []func() {
|
||||
// answered applies the teller's answer to the ask at a key to the deliveries it covers; under the lock. The teller has
|
||||
// held the warrant to the ask (novox/hq ADR 0437 §6); this owner acts on the answer only if the key it was written under is
|
||||
// still the key of the heads asked: an answer to an ask taken away, or to other heads, merges nothing (ADR 0441 §1). It
|
||||
// returns what to say on the pull requests.
|
||||
func (h *Holder) answered(key string, ans sdk.Answer) []func() {
|
||||
var covered []*Delivery
|
||||
for _, d := range h.deliveries {
|
||||
if d.Ready != nil && d.Ready.AskID == id {
|
||||
if d.Ready != nil && d.Ready.AskID == key {
|
||||
covered = append(covered, d)
|
||||
}
|
||||
}
|
||||
if len(covered) == 0 {
|
||||
h.Logf("[mesh-delivery] the answer to the ask %s names no ask that is asked now: nothing is merged on it", key)
|
||||
return nil
|
||||
}
|
||||
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
|
||||
first, now := covered[0].Ready, h.Now()
|
||||
end := func(why string) {
|
||||
refuse := func(why string) {
|
||||
h.Logf("[mesh-delivery] REFUSED the answer to the ask %s: %s", key, why)
|
||||
for _, d := range covered {
|
||||
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
|
||||
d.Ready.Refused = "was answered, and the answer does not hold here: " + clip(oneLine(why), 300)
|
||||
h.keep(d)
|
||||
}
|
||||
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
|
||||
if w != nil {
|
||||
why = "ended " + string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
why += " (" + w.Words + ")"
|
||||
}
|
||||
var heads []sdk.Head
|
||||
for id, sha := range first.Heads {
|
||||
m := h.deliveries[id]
|
||||
if m == nil {
|
||||
refuse("the head " + id + " it covers is no delivery of this owner")
|
||||
return nil
|
||||
}
|
||||
end(why)
|
||||
heads = append(heads, sdk.Head{Repository: m.Repository, Number: m.Number, Head: sha})
|
||||
}
|
||||
if want, err := sdk.MergeAskKey(askGroup(covered[0]), heads); err != nil || want != key {
|
||||
refuse(fmt.Sprintf("its key %s is not the key %s of the heads asked", key, want))
|
||||
return nil
|
||||
}
|
||||
option, err := w.For(Module, *first.Ask)
|
||||
if err == nil {
|
||||
err = option.Performs(mergeAct(id, option.ID, first.Heads))
|
||||
// The answer is to the asking asked now: the teller names the router id it was given in the warrant's id (`<ask id>@<time>`),
|
||||
// and an answer to an earlier asking is never taken for a later one (issue 1370).
|
||||
if now, at, ok := strings.Cut(ans.Warrant, "@"); ok && at != "" && now != sdk.AskingID(key, first.Asking) {
|
||||
h.Logf("[mesh-delivery] the answer to the ask %s was given to an earlier asking (%s), not the one asked now (%s): not taken",
|
||||
key, now, sdk.AskingID(key, first.Asking))
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] REFUSED the answer to the ask %s: %v", id, err)
|
||||
for _, d := range covered {
|
||||
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
|
||||
d.Ready.Refused = "was answered, and the answer does not hold here: " + clip(oneLine(err.Error()), 300)
|
||||
h.keep(d)
|
||||
}
|
||||
if ans.Option != OptionApprove && ans.Option != OptionDecline {
|
||||
refuse("it chose " + ans.Option + ", which the ask did not offer")
|
||||
return nil
|
||||
}
|
||||
var said []func()
|
||||
for _, d := range covered {
|
||||
r := d.Ready
|
||||
r.Warrant = w.Says()
|
||||
r.Warrant = ans.Words
|
||||
h.mergeAgainNow(d) // the operator chose: the merge is asked at once
|
||||
switch option.ID {
|
||||
switch ans.Option {
|
||||
case OptionApprove:
|
||||
r.Approved = &now
|
||||
h.Logf("[mesh-delivery] %s: the operator approved its merge into main (%s)", d.ID, r.Warrant)
|
||||
@@ -505,3 +649,55 @@ func (h *Holder) decided(id, state string, w *asks.Warrant) []func() {
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// kvOperator is the operator over the runtime: the ask is a key of the gate's bucket asks, the answer a key of the teller's
|
||||
// bucket answers.
|
||||
type kvOperator struct{}
|
||||
|
||||
func (kvOperator) Ask(key string, a sdk.Ask) error {
|
||||
raw, err := sdk.EncodeAsk(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e, err := stdio.State("asks").Get(key); err == nil && e != nil {
|
||||
if kept, err := sdk.DecodeAsk(e.Value); err == nil && sdk.SameAsk(kept, a) {
|
||||
return nil // the same ask is there: the teller has it
|
||||
}
|
||||
}
|
||||
_, err = stdio.State("asks").Put(key, json.RawMessage(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
func (kvOperator) Cancel(key string) error { return stdio.State("asks").Delete(key) }
|
||||
|
||||
func (kvOperator) Where(key string) (*sdk.Answer, error) {
|
||||
e, err := stdio.State(sdk.TellerModule + ".answers").Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := sdk.DecodeAnswer(e.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the answer %s is not readable: %w", key, err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// standing is whether an ask is written under a key in the gate's asks, as this owner last knew. Under the lock.
|
||||
func (h *Holder) standing(key string) bool {
|
||||
for _, d := range h.deliveries {
|
||||
if d.Ready != nil && d.Ready.AskedKey == key && d.Ready.Written {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// markWritten keeps whether an ask stands under a key, on every delivery that asked it. Under the lock.
|
||||
func (h *Holder) markWritten(key string, written bool) {
|
||||
for _, d := range h.deliveries {
|
||||
if d.Ready != nil && d.Ready.AskedKey == key && d.Ready.Written != written {
|
||||
d.Ready.Written = written
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-17
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **A merge into main waits for the operator's Approve** (novox/hq ADR 0398 §3, issue 948): a marked, green delivery
|
||||
@@ -84,19 +85,39 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerThatIsNotForThisAskMergesNothing(t *testing.T) {
|
||||
// AC3 of issue 1348: an answer naming old heads merges nothing. The teller's answer to an ask that was withdrawn when the head
|
||||
// moved is still in its bucket; the gate acts on the key of the heads asked now alone.
|
||||
func TestAnAnswerNamingOldHeadsMergesNothing(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
id := w.op.open()[0]
|
||||
w.op.answer(id, OptionApprove)
|
||||
w.op.mu.Lock()
|
||||
w.op.warrant[id].AskDigest = "sha256:not-the-ask-that-was-shown"
|
||||
w.op.mu.Unlock()
|
||||
old := w.op.open()[0]
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
w.op.answer(old, OptionApprove) // the teller wrote it after the ask was taken away
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 || len(w.merges.puts) != 0 {
|
||||
t.Fatalf("an answer to old heads merged: %v, handed %d", w.forge.merges, len(w.merges.puts))
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready != nil && d.Ready.Approved != nil {
|
||||
t.Fatalf("an answer to old heads approved the old delivery: %+v", d.Ready)
|
||||
}
|
||||
}
|
||||
|
||||
// An answer under a key that is not the key of the heads the ask covers is refused and said: the gate acts on its own
|
||||
// ask's key alone (the heads hash is the key's last part).
|
||||
func TestAnAnswerUnderAKeyThatIsNotTheHeadsKeyIsRefused(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
key := w.op.open()[0]
|
||||
w.h.mu.Lock()
|
||||
w.h.deliveries[id].Ready.Heads[id] = strings.Repeat("9", 40) // the heads no longer give the key
|
||||
w.h.mu.Unlock()
|
||||
w.op.answer(key, OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a warrant for another ask merged: %v", w.forge.merges)
|
||||
t.Fatalf("an answer under another key merged: %v", w.forge.merges)
|
||||
}
|
||||
d := w.delivery(IDOf("novox/app", readyHead))
|
||||
if !strings.Contains(d.Ready.Refused, "digest") || d.Ready.Approved != nil {
|
||||
d := w.delivery(id)
|
||||
if !strings.Contains(d.Ready.Refused, "does not hold") || d.Ready.Approved != nil {
|
||||
t.Fatalf("the refused answer is not kept: %+v", d.Ready)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "answer") {
|
||||
@@ -142,13 +163,20 @@ func TestAPushAfterTheAskCancelsItAndTheNewHeadIsAskedOnItsOwnWhenItsChecksPass(
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredAskIsAskedAgainOnce(t *testing.T) {
|
||||
// The ask lapses at the router after AskLasts with nobody's answer, and the gate has no word of it: it takes its key away and
|
||||
// writes it again, once, which the teller asks afresh (it asks again under the same id when the gate writes its key again).
|
||||
func TestAnUnansweredAskIsWrittenAgainOnceWhenItLapses(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
first := w.op.open()[0]
|
||||
w.op.end(first)
|
||||
w.later(AskLasts + time.Minute)
|
||||
w.settleAll()
|
||||
if w.op.count() != 2 || len(w.op.open()) != 1 || w.op.open()[0] == first {
|
||||
t.Fatalf("an expired ask was not asked again once: %d asks, open %v", w.op.count(), w.op.open())
|
||||
if w.op.count() != 2 || len(w.op.open()) != 1 || w.op.open()[0] != first || len(w.op.cancelled) != 0 {
|
||||
t.Fatalf("a lapsed ask was not written again once, over the same key: %d asks, open %v, taken away %v", w.op.count(), w.op.open(), w.op.cancelled)
|
||||
}
|
||||
// Review round 1, G2: the router keeps an ask by its id and drops a second under it, so the second asking has an id of its own.
|
||||
again := w.op.byKey[first]
|
||||
if again.Asking != 2 || again.Ask.ID != sdk.AskingID(first, 2) || again.Ask.ID == sdk.AskID(first) {
|
||||
t.Fatalf("the second asking is %d under the id %s; wanted asking 2 under %s", again.Asking, again.Ask.ID, sdk.AskingID(first, 2))
|
||||
}
|
||||
for range 3 {
|
||||
w.later(2 * time.Minute)
|
||||
@@ -176,12 +204,13 @@ func TestAGroupIsAskedOnceForAllItsHeadsAndMergedInOnePassOnOneApprove(t *testin
|
||||
if open := w.op.open(); len(open) != 1 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a group is asked once before any merge: open %v, merges %v", w.op.open(), w.forge.merges)
|
||||
}
|
||||
a := w.op.ask(w.op.open()[0])
|
||||
key := w.op.open()[0]
|
||||
a := w.op.ask(key)
|
||||
if !strings.Contains(a.Headline, "group feat") || !strings.Contains(a.Details, "mesh-controller#1") ||
|
||||
!strings.Contains(a.Details, "mesh-catalog#2") {
|
||||
t.Fatalf("the ask does not name the group and its members: %+v", a)
|
||||
}
|
||||
w.op.answer(a.ID, OptionApprove)
|
||||
w.op.answer(key, OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || w.op.count() != 1 {
|
||||
t.Fatalf("both members were not merged in one pass on the one Approve: merges %v, asks %d", w.forge.merges, w.op.count())
|
||||
@@ -263,7 +292,7 @@ func TestIssue1025AMergeOnTheApproveIsAnnouncedWithItsWarrant(t *testing.T) {
|
||||
}
|
||||
m := got[0]
|
||||
if m["owner"] != "novox" || m["repo"] != "app" || m["number"] != float64(7) || m["head_sha"] != readyHead ||
|
||||
m["ask_id"] != open[0] || m["option"] != OptionApprove {
|
||||
m["ask_id"] != sdk.AskID(open[0]) || m["asker"] != sdk.TellerModule || m["option"] != OptionApprove {
|
||||
t.Fatalf("the event does not name the pull request and its warrant: %v", m)
|
||||
}
|
||||
hs, _ := m["heads"].(map[string]any)
|
||||
@@ -324,7 +353,7 @@ func TestIssue1025AMergeInAGroupCarriesTheGroupAndItsHeads(t *testing.T) {
|
||||
}
|
||||
for _, m := range got {
|
||||
hs, _ := m["heads"].(map[string]any)
|
||||
if m["group"] != "feat/x" || m["ask_id"] != open[0] || m["option"] != OptionApprove ||
|
||||
if m["group"] != "feat/x" || m["ask_id"] != sdk.AskID(open[0]) || m["asker"] != sdk.TellerModule || m["option"] != OptionApprove ||
|
||||
hs[ctl] != ctlHead || hs[cat] != catHead || len(hs) != 2 {
|
||||
t.Fatalf("the group's merge event: %v", m)
|
||||
}
|
||||
@@ -352,3 +381,28 @@ func TestIssue1025AnOwedMergedIsSaidAfterARestartAndOnce(t *testing.T) {
|
||||
t.Fatalf("said %d times after the restart; wanted once", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, G2: an answer given to the first asking is not taken for the second, and the second's Approve is announced
|
||||
// under the second asking's id.
|
||||
func TestAnAnswerToAnEarlierAskingIsNotTakenAndTheMergeNamesTheAnsweredAsking(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
key := w.op.open()[0]
|
||||
w.later(AskLasts + time.Minute)
|
||||
w.settleAll() // asked again: the second asking
|
||||
w.op.mu.Lock()
|
||||
w.op.answers[key] = &sdk.Answer{Option: OptionApprove, Warrant: sdk.AskingID(key, 1) + "@2026-10-06T12:00:00Z", Words: "the operator, via telegram, chose Approve"}
|
||||
w.op.mu.Unlock()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 || len(w.merges.puts) != 0 {
|
||||
t.Fatalf("an answer to the first asking merged on the second: %v", w.forge.merges)
|
||||
}
|
||||
w.op.answer(key, OptionApprove) // the answer to the asking asked now
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the answer to the second asking did not merge: %v", w.forge.merges)
|
||||
}
|
||||
say := mergedSaid(w)
|
||||
if len(say) != 1 || say[0]["ask_id"] != sdk.AskingID(key, 2) {
|
||||
t.Fatalf("the merge is announced %v; wanted the ask id of asking 2, %s", say, sdk.AskingID(key, 2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// fakeMerges is the gate's bucket merges with the forge bridge behind it. Every pull request the gate writes in the state
|
||||
// merging is merged on the fake forge, as the bridge's merge does (mesh-delivery modules/mesh-delivery-forge-bridge
|
||||
// internal/bridge acts.go MergeActs): the forge's merge tool is asked at the head with the approved heads of the whole
|
||||
// group, `<repository>@<full sha>` sorted, and what the fake forge answers is written on FORGE as the bridge writes it, a
|
||||
// merged message or a refused one with the forge's words. With manual set the bridge says nothing.
|
||||
type fakeMerges struct {
|
||||
mu sync.Mutex
|
||||
forge *fakeForge
|
||||
h *Holder
|
||||
values map[string]sdk.Merge
|
||||
puts []sdk.Merge
|
||||
manual bool
|
||||
failed error
|
||||
// said are the messages the bridge wrote on FORGE, in order.
|
||||
said []sdk.Forge
|
||||
}
|
||||
|
||||
func newFakeMerges(f *fakeForge) *fakeMerges {
|
||||
return &fakeMerges{forge: f, values: map[string]sdk.Merge{}}
|
||||
}
|
||||
|
||||
func (m *fakeMerges) Get(group string) (*sdk.Merge, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if v, ok := m.values[group]; ok {
|
||||
return &v, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *fakeMerges) Put(group string, v sdk.Merge) error {
|
||||
if _, err := sdk.EncodeMerge(v); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.failed != nil {
|
||||
defer m.mu.Unlock()
|
||||
return m.failed
|
||||
}
|
||||
m.values[group] = v
|
||||
m.puts = append(m.puts, v)
|
||||
manual := m.manual
|
||||
m.mu.Unlock()
|
||||
if manual {
|
||||
return nil
|
||||
}
|
||||
for _, p := range v.Pulls {
|
||||
if p.State != sdk.MergeMerging {
|
||||
continue
|
||||
}
|
||||
owner, repo, _ := strings.Cut(p.Repository, "/")
|
||||
var approved []string
|
||||
for key, sha := range v.Approved {
|
||||
repository, _, _ := strings.Cut(key, "@")
|
||||
approved = append(approved, repository+"@"+sha)
|
||||
}
|
||||
sortStrings(approved)
|
||||
f := m.forge
|
||||
f.mu.Lock()
|
||||
f.merges = append(f.merges, owner+"/"+repo+"#"+itoa(p.Number)+"@"+p.Head.Head)
|
||||
f.approvedHeads = append(f.approvedHeads, approved)
|
||||
err := f.refuseRepo[owner+"/"+repo]
|
||||
if err == nil {
|
||||
err = f.refuse
|
||||
}
|
||||
f.mu.Unlock()
|
||||
msg := sdk.Forge{Repository: p.Repository, Number: p.Number, Head: p.Head.Head}
|
||||
if err != nil {
|
||||
msg.Kind, msg.Act, msg.Words = sdk.ForgeRefused, "merge", err.Error()
|
||||
} else {
|
||||
msg.Kind, msg.Merge, msg.Root = sdk.ForgeMerged, strings.Repeat("c", 40), "root"
|
||||
msg.Trees = map[string]string{"app": "tree"}
|
||||
}
|
||||
m.say(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// say writes a message on FORGE and has the gate hear it, as the runtime does.
|
||||
func (m *fakeMerges) say(msg sdk.Forge) {
|
||||
body, err := sdk.EncodeForge(msg)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
event, err := sdk.ForgeEvent(msg.Repository, msg.Kind)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.said = append(m.said, msg)
|
||||
m.mu.Unlock()
|
||||
m.h.Forged("mesh-delivery-forge-bridge."+event, body)
|
||||
}
|
||||
|
||||
// last is the newest value written for a group.
|
||||
func (m *fakeMerges) last(group string) sdk.Merge {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.values[group]
|
||||
}
|
||||
|
||||
// fakeSource is the gate's bucket source with its one key.
|
||||
type fakeSource struct {
|
||||
mu sync.Mutex
|
||||
main *sdk.Source
|
||||
moves []sdk.Source
|
||||
}
|
||||
|
||||
func (s *fakeSource) Move(update func(prev *sdk.Source) (sdk.Source, bool)) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next, write := update(s.main)
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
if _, err := sdk.EncodeSource(next); err != nil {
|
||||
return err
|
||||
}
|
||||
s.main = &next
|
||||
s.moves = append(s.moves, next)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortStrings(s []string) { sort.Strings(s) }
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// fakeOwed is the gate's bucket owed with the forge bridge behind it: every act written is performed on the fake forge, as the
|
||||
// bridge performs it (mesh-delivery modules/mesh-delivery-forge-bridge internal/bridge acts.go OwedAct), once for each
|
||||
// revision. With down set the bucket cannot be written (the bus is away); the forge's own refusals are the bridge's to say,
|
||||
// not the gate's to retry.
|
||||
type fakeOwed struct {
|
||||
mu sync.Mutex
|
||||
forge *fakeForge
|
||||
down bool
|
||||
// keys are the latest value of each key written, and writes every revision in order.
|
||||
keys map[string]sdk.Owed
|
||||
writes []string
|
||||
}
|
||||
|
||||
func newFakeOwed(f *fakeForge) *fakeOwed { return &fakeOwed{forge: f, keys: map[string]sdk.Owed{}} }
|
||||
|
||||
func (o *fakeOwed) Put(key string, v sdk.Owed) error {
|
||||
if _, err := sdk.EncodeOwed(v); err != nil {
|
||||
return err
|
||||
}
|
||||
repository, number, head, act, err := sdk.ParseOwedKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.mu.Lock()
|
||||
if o.down {
|
||||
defer o.mu.Unlock()
|
||||
return errors.New("the bus is away")
|
||||
}
|
||||
if kept, ok := o.keys[key]; ok && sdk.SameOwed(kept, v) {
|
||||
o.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
o.keys[key] = v
|
||||
o.writes = append(o.writes, key)
|
||||
o.mu.Unlock()
|
||||
owner, repo, _ := strings.Cut(repository, "/")
|
||||
f := o.forge
|
||||
switch v.Act {
|
||||
case sdk.OwedNote:
|
||||
_ = f.Note(owner, repo, head, v.Text)
|
||||
case sdk.OwedView:
|
||||
_ = f.View(owner, repo, number, v.Text)
|
||||
case sdk.OwedStatus:
|
||||
_ = f.Status(owner, repo, head, v.Context, v.State, v.Text, v.Target)
|
||||
case sdk.OwedComment:
|
||||
_ = f.Comment(owner, repo, number, v.Text)
|
||||
}
|
||||
_ = act
|
||||
return nil
|
||||
}
|
||||
|
||||
// acts are the keys written whose act is of a kind, in order.
|
||||
func (o *fakeOwed) acts(kind string) []string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
var out []string
|
||||
for _, k := range o.writes {
|
||||
if _, _, _, act, err := sdk.ParseOwedKey(k); err == nil && actKind(act) == kind {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+375
-101
@@ -11,6 +11,8 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -75,16 +78,30 @@ func newTestBus(t *testing.T) *testBus {
|
||||
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})
|
||||
for _, name := range m.buckets() {
|
||||
kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: m.Module + "_" + name, History: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.kvs[name] = kv
|
||||
}
|
||||
// The buckets of other modules the manifest reads (`<module>.<name>` is the bucket `<module>_<name>`): the gate reads
|
||||
// who has the seat there, and today's state to carry (seat.go).
|
||||
for _, name := range m.Reads {
|
||||
kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: strings.Replace(name, ".", "_", 1), History: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.kvs[name] = kv
|
||||
}
|
||||
// The node-engine has said today's module removed on the one machine: the gate may hold the seat (ADR 0450 §6, issue 1369).
|
||||
if _, err := b.kvs["node-engine.have"].Put(context.Background(), "test.mesh-delivery", []byte(`{"v":1,"phase":"removed","health":"unknown"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 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 {
|
||||
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + ControllerSeat + ".event.>", "mesh.seat." + asks.Seat + ".event.>",
|
||||
"mesh.mod." + sdk.ForgeBridgeModule + ".event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
@@ -121,7 +138,7 @@ type askedOfMesh struct {
|
||||
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")
|
||||
cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_SERVED_MODULE=mesh-delivery-gate", "MESH_MODULE=mesh-delivery-gate", "MESH_NODE=test")
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -191,6 +208,15 @@ func (r *runtime) read(out io.Reader) {
|
||||
}
|
||||
}
|
||||
|
||||
// untilHolding waits for the gate to hold the seat: it holds it when it reads the controller's walks once, the first thing it
|
||||
// does after (readOnHolding), and it takes no event before.
|
||||
func (r *runtime) untilHolding() {
|
||||
r.t.Helper()
|
||||
r.until("the gate to hold the seat", 20*time.Second, func() bool {
|
||||
return len(r.asksOf("seat:"+ControllerSeat+".delivery-walks")) > 0
|
||||
})
|
||||
}
|
||||
|
||||
func (r *runtime) ping() {
|
||||
select {
|
||||
case r.notify <- struct{}{}:
|
||||
@@ -284,6 +310,17 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
return map[string]any{}, nil
|
||||
case "mesh/seat.record":
|
||||
return nil, nil
|
||||
case "mesh/state.watch":
|
||||
// The runtime's watch (ADR 0201): each current value, then each change, handed to the bundle as mesh/state under the
|
||||
// id the watch was asked with.
|
||||
var id string
|
||||
_ = json.Unmarshal(m.ID, &id)
|
||||
kv := r.b.kvs[p.State]
|
||||
if kv == nil {
|
||||
return nil, fmt.Errorf("the state %q is not declared", p.State)
|
||||
}
|
||||
go r.watch(kv, id, p.State)
|
||||
return map[string]any{}, nil
|
||||
case "mesh/ask":
|
||||
var a struct {
|
||||
Key string `json:"key"`
|
||||
@@ -306,6 +343,17 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
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_commit_statuses":
|
||||
// Green: both merge checks a success at the head asked, as the gitea module answers.
|
||||
sha, _ := a.Body["sha"].(string)
|
||||
if sha == "" {
|
||||
sha = "0000000"
|
||||
}
|
||||
return toolAnswer(map[string]any{"commit": sha, "statuses": []any{
|
||||
map[string]any{"context": "mesh/merge-gate", "state": "success"},
|
||||
map[string]any{"context": "mesh/repo-check", "state": "success"}},
|
||||
"merge": map[string]any{"branch": "main", "protected": true, "required": []string{"mesh/merge-gate", "mesh/repo-check"},
|
||||
"blocking": []any{}, "mergeable": true, "says": "main requires mesh/merge-gate, mesh/repo-check"}}), 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
|
||||
@@ -358,6 +406,42 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
r.say(map[string]any{"id": m.ID, "result": result})
|
||||
}
|
||||
|
||||
// watch hands the bundle each key of a bucket and each change of it, as the runtime does for a state watch.
|
||||
func (r *runtime) watch(kv jetstream.KeyValue, id, state string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.t.Cleanup(cancel)
|
||||
w, err := kv.Watch(ctx, ">")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer w.Stop()
|
||||
current := true
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case e, ok := <-w.Updates():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e == nil {
|
||||
current = false
|
||||
continue
|
||||
}
|
||||
op := "put"
|
||||
if e.Operation() != jetstream.KeyValuePut {
|
||||
op = "delete"
|
||||
}
|
||||
r.mu.Lock()
|
||||
n := r.next
|
||||
r.next++
|
||||
r.mu.Unlock()
|
||||
r.say(map[string]any{"id": fmt.Sprintf("watch-%d", n), "method": "mesh/state", "params": map[string]any{"watch": id,
|
||||
"change": stdio.StateChange{State: state, Key: e.Key(), Op: op, Value: e.Value(), Revision: e.Revision(), Current: current}}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -408,16 +492,18 @@ func (b *testBus) publish(t *testing.T, subject string, body any) time.Time {
|
||||
|
||||
func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealBus(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
rt := b.start(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
evctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
rt.deliverEvents(ctx)
|
||||
// The module has started when it hears what it follows, and the operator's answers.
|
||||
rt.deliverEvents(evctx)
|
||||
// The module has started when it hears what it follows.
|
||||
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
|
||||
return len(rt.subs) >= len(followed)
|
||||
})
|
||||
rt.untilHolding()
|
||||
for _, k := range followed {
|
||||
if strings.Contains(k, "labeled") || strings.Contains(k, "commented") {
|
||||
t.Fatalf("the module follows %s: a label or a comment starts nothing here (ADR 0421 §4)", k)
|
||||
@@ -433,67 +519,75 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
passed := b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
|
||||
|
||||
// The operator is asked, within seconds of the verdict's event, with no label, comment or mark written by anyone.
|
||||
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]
|
||||
asked := time.Now()
|
||||
rt.mu.Unlock()
|
||||
if !strings.Contains(ask.Explanation, "not reviewed at this head") {
|
||||
t.Fatalf("the first ask does not say the record is missing: %s", ask.Explanation)
|
||||
// The ask is written into the gate's bucket asks within seconds of the verdict's event, with no label, comment or mark
|
||||
// written by anyone, and nothing is asked of a router.
|
||||
var key string
|
||||
rt.until("the ask to be written", 5*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
ask := askAt(t, b, key)
|
||||
t.Logf("the ask was written %s after the verdict's event", time.Since(passed))
|
||||
if !strings.Contains(ask.Ask.Explanation, "not reviewed at this head") || ask.Ask.ID != sdk.AskID(key) {
|
||||
t.Fatalf("the first ask does not say the record is missing: %s", ask.Ask.Explanation)
|
||||
}
|
||||
t.Logf("the operator was asked %s after the verdict's event", asked.Sub(passed))
|
||||
// The description is edited to add the review record (issue 1079): the forge module's edit event carries it. The unanswered
|
||||
// ask is withdrawn from the router and asked again, once, with the record read; nothing else is asked of the mesh.
|
||||
first := ask
|
||||
// ask is taken away and written again, once, with the record read (the heads are the same, so is the key).
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.edited", map[string]any{"owner": "novox", "repo": "app", "number": 7,
|
||||
"title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head})
|
||||
rt.until("the first ask to be withdrawn and the operator asked again", 5*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.operatorCancels) >= 1 && len(rt.operatorAsks) >= 2
|
||||
rt.until("the ask to be written again with the record", 5*time.Second, func() bool {
|
||||
a, ok := askNow(b, key) // the key may be absent for an instant while the ask is taken away and written again
|
||||
return ok && strings.Contains(a.Ask.Explanation, "reviewed by lead on shanks")
|
||||
})
|
||||
rt.mu.Lock()
|
||||
if len(rt.operatorCancels) != 1 || rt.operatorCancels[0] != first.ID || len(rt.operatorAsks) != 2 {
|
||||
t.Fatalf("withdrawn %v, asked %d; wanted the first ask %s withdrawn once and exactly one new ask", rt.operatorCancels, len(rt.operatorAsks), first.ID)
|
||||
// The ask is overwritten, never taken away: the key is there at every revision, and the second asking has an id of its own
|
||||
// (review round 1, G2), which the router would take as new.
|
||||
if h, err := b.kvs["asks"].History(ctx, key); err != nil || len(h) != 2 || h[1].Operation() != jetstream.KeyValuePut {
|
||||
t.Fatalf("the ask's history is %d revisions (%v); wanted written and written again", len(h), err)
|
||||
}
|
||||
ask = rt.operatorAsks[1]
|
||||
rt.mu.Unlock()
|
||||
if ask.ID == first.ID || !strings.Contains(ask.Explanation, "reviewed by lead on shanks") || strings.Contains(ask.Explanation, "not reviewed") {
|
||||
t.Fatalf("the new ask is not a fresh one naming the reviewer: %s %s", ask.ID, ask.Explanation)
|
||||
if again := askAt(t, b, key); again.Asking != 2 || again.Ask.ID != sdk.AskingID(key, 2) || again.Ask.ID == ask.Ask.ID {
|
||||
t.Fatalf("the second asking is %d under %s, the first was %s", again.Asking, again.Ask.ID, ask.Ask.ID)
|
||||
}
|
||||
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 {
|
||||
t.Fatalf("merged before the Approve")
|
||||
}
|
||||
// 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")
|
||||
e, err := b.kvs["pulls"].Get(ctx, "novox.app.7")
|
||||
var k PullKey
|
||||
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
|
||||
})
|
||||
|
||||
// The operator approves (a person's Approve, proven by the router): the merge is asked within seconds.
|
||||
opt, _ := ask.Option(OptionApprove)
|
||||
approved := 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 approved head", merges)
|
||||
// What the transitions owe the forge is written as owed keys; the gate calls no tool of the forge that writes (issue 1349).
|
||||
for _, tool := range []string{"gitea.gitea_commit_status", "gitea.gitea_delivery_view", "gitea.gitea_note_append", "gitea.gitea_add_comment"} {
|
||||
if n := len(rt.asksOf(tool)); n != 0 {
|
||||
t.Fatalf("the gate asked %s %d times: the bridge writes to the forge", tool, n)
|
||||
}
|
||||
}
|
||||
if got := fmt.Sprint(merges[0].Body["approved_heads"]); got != "[novox/app@"+head+"]" {
|
||||
t.Fatalf("the merge tool was handed the approved heads %s", got)
|
||||
rt.until("the owed keys to be written", 5*time.Second, func() bool {
|
||||
keys, err := b.kvs["owed"].Keys(ctx)
|
||||
return err == nil && len(keys) > 0
|
||||
})
|
||||
// The operator approves; the teller holds the warrant and writes the answer: the merge is handed to the bridge within
|
||||
// seconds, with the Approve's heads, and the gate asks the forge nothing.
|
||||
approved := b.answer(t, key, OptionApprove)
|
||||
var handed sdk.Merge
|
||||
rt.until("the merge to be handed to the bridge", 5*time.Second, func() bool { handed = mergeAt(b, "fix/x"); return len(handed.Pulls) > 0 })
|
||||
if len(handed.Pulls) != 1 || handed.Pulls[0].Head.Head != head || handed.Pulls[0].Number != 7 || handed.Pulls[0].State != sdk.MergeMerging ||
|
||||
handed.Approved[sdk.ApprovedKey("novox/app", 7)] != head || handed.Ask != key {
|
||||
t.Fatalf("the merge handed is %+v; wanted pull request 7 at the approved head, merging, under the ask %s", handed, key)
|
||||
}
|
||||
if took := merges[0].At.Sub(approved); took > 5*time.Second {
|
||||
t.Fatalf("the merge was asked %s after the Approve's event; wanted within 5s", took)
|
||||
} else {
|
||||
t.Logf("the merge was asked %s after the Approve's event", took)
|
||||
t.Logf("the merge was handed %s after the answer", time.Since(approved))
|
||||
if n := len(rt.asksOf("gitea.gitea_merge_pull_request")); n != 0 {
|
||||
t.Fatalf("the gate asked the forge to merge %d times: the bridge merges", n)
|
||||
}
|
||||
// The bridge says it merged; the mesh's source then holds the merge, in one write.
|
||||
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40),
|
||||
Root: "r", Trees: map[string]string{"app": "t"}})
|
||||
rt.until("source.main to hold the merge", 5*time.Second, func() bool {
|
||||
e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
src, err := sdk.DecodeSource(e.Value())
|
||||
return err == nil && src.Group == "fix/x" && src.Heads["novox/app"] == strings.Repeat("b", 40)
|
||||
})
|
||||
// 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)
|
||||
@@ -505,21 +599,103 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
}
|
||||
}
|
||||
|
||||
// **The acceptance of one group, one Approve** (novox/hq ADR 0418 §4, ADR 0421, design 47 milestone 3, issues 978 and
|
||||
// 1022): a group of two pull requests on one branch name, in two repositories, with green checks, gets one ask of the
|
||||
// operator — no label, comment or mark written by anyone — and on the Approve both merge in one pass, each handed the
|
||||
// approved heads of both.
|
||||
func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeInOnePassOverARealBus(t *testing.T) {
|
||||
// onlyAsk is the key of the one ask in the gate's bucket asks, or empty.
|
||||
func onlyAsk(b *testBus) string {
|
||||
keys, err := b.kvs["asks"].Keys(context.Background())
|
||||
if err != nil || len(keys) != 1 {
|
||||
return ""
|
||||
}
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
// askNow is the ask under a key at this moment; false in the instant a rewrite has taken the key away and not yet written it.
|
||||
func askNow(b *testBus, key string) (sdk.Ask, bool) {
|
||||
e, err := b.kvs["asks"].Get(context.Background(), key)
|
||||
if err != nil {
|
||||
return sdk.Ask{}, false
|
||||
}
|
||||
a, err := sdk.DecodeAsk(e.Value())
|
||||
return a, err == nil
|
||||
}
|
||||
|
||||
func askNowOrFatal(t *testing.T, b *testBus, key string) sdk.Ask {
|
||||
t.Helper()
|
||||
a, ok := askNow(b, key)
|
||||
if !ok {
|
||||
t.Fatalf("no ask under %s to answer", key)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func askAt(t *testing.T, b *testBus, key string) sdk.Ask {
|
||||
t.Helper()
|
||||
e, err := b.kvs["asks"].Get(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatalf("the ask %s: %v", key, err)
|
||||
}
|
||||
a, err := sdk.DecodeAsk(e.Value())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func mergeAt(b *testBus, group string) sdk.Merge {
|
||||
e, err := b.kvs["merges"].Get(context.Background(), group)
|
||||
if err != nil {
|
||||
return sdk.Merge{}
|
||||
}
|
||||
m, _ := sdk.DecodeMerge(e.Value())
|
||||
return m
|
||||
}
|
||||
|
||||
// answer is the teller's answer to an ask, written under the ask's own key in its bucket answers.
|
||||
func (b *testBus) answer(t *testing.T, key, option string) time.Time {
|
||||
t.Helper()
|
||||
// The warrant's id is the router id of the asking answered and the time (the teller's warrantID).
|
||||
raw, err := sdk.EncodeAnswer(sdk.Answer{Option: option, Warrant: askNowOrFatal(t, b, key).Ask.ID + "@2026-10-20T03:00:00Z", Words: "the operator, via telegram, chose " + option})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.kvs["mesh-delivery-teller.answers"].Put(context.Background(), key, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// forgeSays is the forge bridge's message on FORGE, as its stream carries it.
|
||||
func (b *testBus) forgeSays(t *testing.T, f sdk.Forge) {
|
||||
t.Helper()
|
||||
raw, err := sdk.EncodeForge(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, err := sdk.ForgeEvent(f.Repository, f.Kind)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.js.Publish(context.Background(), "mesh.mod."+sdk.ForgeBridgeModule+".event."+event, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// **The acceptance of one group, one Approve** (novox/hq ADR 0418 §4, ADR 0421, ADR 0441 §5, design 47 milestone 3, issues 978
|
||||
// and 1022): a group of two pull requests on one branch name, in two repositories, with green checks, gets one ask of the
|
||||
// operator — no label, comment or mark written by anyone — and on the Approve the members are handed to the bridge one at a time,
|
||||
// the second after the first merged, each with the approved heads of both.
|
||||
func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterTheOtherOverARealBus(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
rt := b.start(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
evctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
rt.deliverEvents(ctx)
|
||||
rt.deliverEvents(evctx)
|
||||
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
|
||||
return len(rt.subs) >= len(followed)
|
||||
})
|
||||
rt.untilHolding()
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
for _, p := range []PullEvent{pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"),
|
||||
pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} {
|
||||
@@ -543,61 +719,62 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeInOnePassOv
|
||||
if n := len(rt.asksOf("seat:" + ControllerSeat + ".delivery-order")); n != 0 {
|
||||
t.Fatalf("the controller's delivery-order was asked %d times: no order is asked for", n)
|
||||
}
|
||||
rt.mu.Lock()
|
||||
nothing := len(rt.operatorAsks)
|
||||
rt.mu.Unlock()
|
||||
if nothing != 0 {
|
||||
t.Fatalf("the operator was asked before the group's composed check passed")
|
||||
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 0 {
|
||||
t.Fatalf("the operator was asked before the group's composed check passed: %v", keys)
|
||||
}
|
||||
// The composed check passes at both heads: the operator is asked once, for the group.
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", map[string]any{"group": "feat/x", "id": "check-1", "verdict": "pass",
|
||||
"summary": "both compose", "members": []map[string]any{
|
||||
{"owner": "novox", "repo": "mesh-controller", "commit": ctlHead, "trunk": "t1t1t1t1t1t1"},
|
||||
{"owner": "novox", "repo": "mesh-catalog", "commit": catHead, "trunk": "t1t1t1t1t1t1"}}})
|
||||
rt.until("the operator to be asked once for the group", 10*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.operatorAsks) == 1
|
||||
})
|
||||
var key string
|
||||
rt.until("the operator to be asked once for the group", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
rt.mu.Lock()
|
||||
asked, ask := len(rt.operatorAsks), rt.operatorAsks[0]
|
||||
rt.mu.Unlock()
|
||||
if asked != 1 {
|
||||
t.Fatalf("the operator was asked %d times for one group", asked)
|
||||
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 1 {
|
||||
t.Fatalf("the operator was asked %d times for one group", len(keys))
|
||||
}
|
||||
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 {
|
||||
if g := askAt(t, b, key).Group; g != "feat/x" {
|
||||
t.Fatalf("the ask is for the group %q", g)
|
||||
}
|
||||
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 || mergeAt(b, "feat/x").Group != "" {
|
||||
t.Fatalf("merged before the Approve")
|
||||
}
|
||||
opt, _ := ask.Option(OptionApprove)
|
||||
approved := 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()})
|
||||
approved := b.answer(t, key, OptionApprove)
|
||||
|
||||
// Both merge on that one Approve, in one pass: asked within seconds of the Approve's event, each with both heads.
|
||||
rt.until("both merges to be asked of the forge", 5*time.Second, func() bool { return len(rt.asksOf("gitea.gitea_merge_pull_request")) >= 2 })
|
||||
merges := rt.asksOf("gitea.gitea_merge_pull_request")
|
||||
if len(merges) != 2 {
|
||||
t.Fatalf("the merge was asked %d times; wanted once for each member: %+v", len(merges), merges)
|
||||
// One member is handed, with both heads approved, within seconds of the answer; the second only after the bridge says the
|
||||
// first merged.
|
||||
var first sdk.Merge
|
||||
rt.until("the first member to be handed", 5*time.Second, func() bool { first = mergeAt(b, "feat/x"); return len(first.Pulls) > 0 })
|
||||
if len(first.Pulls) != 1 || first.Pulls[0].State != sdk.MergeMerging || len(first.Approved) != 2 ||
|
||||
first.Approved[sdk.ApprovedKey("novox/mesh-controller", 1)] != ctlHead || first.Approved[sdk.ApprovedKey("novox/mesh-catalog", 2)] != catHead {
|
||||
t.Fatalf("the first merge handed is %+v", first)
|
||||
}
|
||||
heads := map[string]bool{}
|
||||
for _, m := range merges {
|
||||
heads[fmt.Sprint(m.Body["repo"], "@", m.Body["head"])] = true
|
||||
if got := fmt.Sprint(m.Body["approved_heads"]); got != "[novox/mesh-catalog@"+catHead+" novox/mesh-controller@"+ctlHead+"]" {
|
||||
t.Fatalf("a merge was handed the approved heads %s", got)
|
||||
t.Logf("the first member was handed %s after the answer", time.Since(approved))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if again := mergeAt(b, "feat/x"); len(again.Pulls) != 1 {
|
||||
t.Fatalf("the second member was handed before the first merged: %+v", again.Pulls)
|
||||
}
|
||||
one := first.Pulls[0]
|
||||
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: one.Repository, Number: one.Number, Head: one.Head.Head,
|
||||
Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}})
|
||||
var both sdk.Merge
|
||||
rt.until("the second member to be handed", 5*time.Second, func() bool { both = mergeAt(b, "feat/x"); return len(both.Pulls) == 2 })
|
||||
if both.Pulls[0].State != sdk.MergeMerged || both.Pulls[1].State != sdk.MergeMerging || both.Pulls[1].Repository == one.Repository {
|
||||
t.Fatalf("the second merge handed is %+v", both.Pulls)
|
||||
}
|
||||
two := both.Pulls[1]
|
||||
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: two.Repository, Number: two.Number, Head: two.Head.Head,
|
||||
Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"x": "t"}})
|
||||
rt.until("source.main to hold both merges", 5*time.Second, func() bool {
|
||||
e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if took := m.At.Sub(approved); took > 5*time.Second {
|
||||
t.Fatalf("a merge was asked %s after the Approve's event; wanted within 5s", took)
|
||||
}
|
||||
}
|
||||
if !heads["mesh-controller@"+ctlHead] || !heads["mesh-catalog@"+catHead] {
|
||||
t.Fatalf("the merges were asked for %v", heads)
|
||||
}
|
||||
rt.mu.Lock()
|
||||
again := len(rt.operatorAsks)
|
||||
rt.mu.Unlock()
|
||||
if again != 1 {
|
||||
t.Fatalf("a second ask was made: %d", again)
|
||||
src, err := sdk.DecodeSource(e.Value())
|
||||
return err == nil && src.Group == "feat/x" && len(src.Heads) == 2
|
||||
})
|
||||
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 1 {
|
||||
t.Fatalf("a second ask was made: %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,8 +792,9 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA
|
||||
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
|
||||
return len(rt.subs) >= len(followed)
|
||||
})
|
||||
rt.untilHolding()
|
||||
const head, merge = "3333333333333333333333333333333333333333", "4444444444444444444444444444444444444444"
|
||||
announced := pr("novox/app", 9, head, "fix/y", "src/a.go")
|
||||
announced.BaseSHA = "t1t1t1t1t1t1"
|
||||
@@ -664,3 +842,99 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA
|
||||
t.Fatalf("the controller was asked %d times to let the held walk go or take its backups again", n)
|
||||
}
|
||||
}
|
||||
|
||||
// **AC3 of issue 1347 (ADR 0437 §7's check): the gate carries a live mesh's open deliveries and groups, once.** The buckets of
|
||||
// today's module are filled from testdata/live-capture-2026-10-20.json (its source is said in the file), the gate starts with
|
||||
// today's module on no machine, and every delivery and group arrives in its own buckets unchanged but for the ask an Approve
|
||||
// waited on; a second start copies nothing.
|
||||
func TestTheGateCarriesALiveCapturesDeliveriesAndGroupsOnceOverARealBus(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "live-capture-2026-10-20.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var capture struct {
|
||||
Deliveries []json.RawMessage `json:"deliveries"`
|
||||
Groups []json.RawMessage `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &capture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
want := map[string]Delivery{}
|
||||
for _, one := range capture.Deliveries {
|
||||
var d Delivery
|
||||
if err := json.Unmarshal(one, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.kvs["mesh-delivery.deliveries"].Put(ctx, kvKey(d.ID), one); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Ready != nil { // what the carry clears: the ask nobody answered
|
||||
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
|
||||
}
|
||||
want[kvKey(d.ID)] = d
|
||||
}
|
||||
for _, one := range capture.Groups {
|
||||
var g Group
|
||||
if err := json.Unmarshal(one, &g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.kvs["mesh-delivery.groups"].Put(ctx, kvKey(g.ID), one); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rt := b.start(t)
|
||||
rt.untilHolding()
|
||||
for key, d := range want {
|
||||
e, err := b.kvs["deliveries"].Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("the delivery %s did not arrive: %v", key, err)
|
||||
}
|
||||
var got Delivery
|
||||
if err := json.Unmarshal(e.Value(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g, w := string(mustJSON(got)), string(mustJSON(d)); g != w {
|
||||
t.Fatalf("the delivery %s changed in the carry:\n got %s\nwant %s", key, g, w)
|
||||
}
|
||||
}
|
||||
for _, one := range capture.Groups {
|
||||
var g Group
|
||||
_ = json.Unmarshal(one, &g)
|
||||
e, err := b.kvs["groups"].Get(ctx, kvKey(g.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("the group %s did not arrive: %v", g.ID, err)
|
||||
}
|
||||
// The carry copies the group byte for byte (TestTheCarryCopiesWhatItDoesNotClearByteForByte); by the time this reads, the gate
|
||||
// holds the seat and acts, and writes a group's said state as it does, so what is compared is what only the carry could change.
|
||||
got := mustDecode[Group](t, e.Value())
|
||||
if got.ID != g.ID || !slices.Equal(got.Members, g.Members) || !got.Created.Equal(g.Created) {
|
||||
t.Fatalf("the group %s changed in the carry: %+v", g.ID, got)
|
||||
}
|
||||
}
|
||||
if _, err := b.kvs["deliveries"].Get(ctx, carriedKey); err != nil {
|
||||
t.Fatalf("the mark carried was not written: %v", err)
|
||||
}
|
||||
// A second start copies nothing: a delivery today's module gains meanwhile stays where it is.
|
||||
_ = rt.in.Close()
|
||||
_ = rt.cmd.Wait()
|
||||
late := mustJSON(&Delivery{ID: "novox/app@999999999999", Repository: "novox/app", Commit: "999999999999", Number: 99, State: Proposed})
|
||||
if _, err := b.kvs["mesh-delivery.deliveries"].Put(ctx, kvKey("novox/app@999999999999"), late); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := b.start(t)
|
||||
again.untilHolding()
|
||||
if _, err := b.kvs["deliveries"].Get(ctx, kvKey("novox/app@999999999999")); !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
t.Fatalf("a second start copied a key (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustDecode[T any](t *testing.T, raw []byte) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **Today's module is gone from the catalogue** (novox/hq ADR 0437 §1, issue 1351; how it is checked): no `modules/mesh-delivery`
|
||||
// is held, no manifest declares the module `mesh-delivery`, and no manifest names the module in what it reads, hears or
|
||||
// calls but through the seat's name (`mesh-delivery` as a seat or a repository, `seat:mesh-delivery.<verb>`) and through
|
||||
// the names below, each of which stays until today's module is forgotten by the controller, because it is the only way to
|
||||
// reach what that module left: the three buckets the gate carries over (ADR 0450 §6) and the event the trackers hear
|
||||
// (issue 1350). Removing the module from the catalogue does not stop the running module: the controller keeps it until it is
|
||||
// unassigned (mesh-controller upgrades.go retireDeleted), and the handover follows the merge by verbs.
|
||||
var oldModuleNames = map[string][]string{
|
||||
"mesh-delivery-gate": {"mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls"},
|
||||
"mesh-issues": {"mesh-delivery.transition"},
|
||||
}
|
||||
|
||||
func TestTheCatalogueHoldsNoModuleMeshDeliveryButThroughTheNamesItsHandoverNeeds(t *testing.T) {
|
||||
modules := filepath.Join("..", "..", "..")
|
||||
if _, err := os.Stat(filepath.Join(modules, "mesh-delivery")); err == nil {
|
||||
t.Fatal("modules/mesh-delivery is still in the catalogue: its code is the gate's now")
|
||||
}
|
||||
dirs, err := os.ReadDir(modules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
read := 0
|
||||
for _, dir := range dirs {
|
||||
raw, err := os.ReadFile(filepath.Join(modules, dir.Name(), "module.json"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
read++
|
||||
var manifest any
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatalf("%s: %v", dir.Name(), err)
|
||||
}
|
||||
if m, _ := manifest.(map[string]any); m["module"] == "mesh-delivery" {
|
||||
t.Errorf("%s declares the module mesh-delivery", dir.Name())
|
||||
}
|
||||
var walk func(v any)
|
||||
walk = func(v any) {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
if strings.HasPrefix(x, "mesh-delivery.") && !slices.Contains(oldModuleNames[dir.Name()], x) {
|
||||
t.Errorf("%s names the module mesh-delivery in %q: through the seat, or the gate's name", dir.Name(), x)
|
||||
}
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
walk(e)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, e := range x {
|
||||
walk(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(manifest)
|
||||
}
|
||||
if read < 20 {
|
||||
t.Fatalf("only %d manifests were read: the catalogue is not where the test looks", read)
|
||||
}
|
||||
}
|
||||
+23
-42
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -243,8 +242,9 @@ func (h *Holder) Flush() {
|
||||
group bool
|
||||
e Effect
|
||||
body string
|
||||
repo [2]string
|
||||
repo string
|
||||
num int
|
||||
head string
|
||||
url string
|
||||
}
|
||||
h.mu.Lock()
|
||||
@@ -274,7 +274,7 @@ func (h *Holder) Flush() {
|
||||
if e.Next != nil && now.Before(*e.Next) {
|
||||
continue
|
||||
}
|
||||
j := job{id: id, e: e, repo: [2]string{d.Owner(), d.Repo()}, num: d.Number, url: d.HTMLURL}
|
||||
j := job{id: id, e: e, repo: d.Repository, num: d.Number, head: d.Commit, url: d.HTMLURL}
|
||||
if e.Kind == EffectView {
|
||||
j.body = ViewBody(d, h.groups[d.Group])
|
||||
}
|
||||
@@ -295,15 +295,11 @@ func (h *Holder) Flush() {
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, j := range jobs {
|
||||
err := h.do(j.e, j.repo, j.num, j.body, j.url, j.group)
|
||||
err := h.do(j.e, j.repo, j.num, j.head, j.body, j.url, j.group)
|
||||
h.mu.Lock()
|
||||
// Why it is given up, when it is: the forge's word that what it is for does not exist is final, and
|
||||
// so is a failure tried retryLimit times, the last ones retryCap apart.
|
||||
// Why it is given up, when it is: a failure of the bus tried retryLimit times, the last ones retryCap apart.
|
||||
dropped := ""
|
||||
switch {
|
||||
case err != nil && forgeKinds[j.e.Kind] && doesNotExist(err):
|
||||
dropped = "the forge answers that what it is for does not exist"
|
||||
case err != nil && j.e.Tries+1 >= retryLimit:
|
||||
if err != nil && j.e.Tries+1 >= retryLimit {
|
||||
dropped = fmt.Sprintf("tried %d times since %s", j.e.Tries+1, j.e.Since.UTC().Format(time.RFC3339))
|
||||
}
|
||||
owed := func(list []Effect) []Effect {
|
||||
@@ -314,7 +310,9 @@ func (h *Holder) Flush() {
|
||||
return append(list[:i:i], list[i+1:]...)
|
||||
}
|
||||
list[i].Tries++
|
||||
list[i].Last = err.Error()
|
||||
if o.Kind != EffectStatus || !j.group {
|
||||
list[i].Last = err.Error() // a group's status names its member in Last, which stays
|
||||
}
|
||||
next := h.Now().Add(retryAfter(list[i].Tries))
|
||||
list[i].Next = &next
|
||||
return list
|
||||
@@ -401,17 +399,8 @@ func retryAfter(tries int) time.Duration {
|
||||
return min(wait, retryCap)
|
||||
}
|
||||
|
||||
// forgeKinds are the effects done by the forge's holder.
|
||||
var forgeKinds = map[string]bool{EffectNote: true, EffectView: true, EffectStatus: true}
|
||||
|
||||
// notThere is the forge's own answer that what was asked of does not exist: a pull request whose number the
|
||||
// forge no longer holds, a commit it does not know. Tried again it answers the same, for ever.
|
||||
var notThere = regexp.MustCompile(`(?i)Gitea API \S+: (404\b|\d{3}\b.*\bdoes not exist\b)`)
|
||||
|
||||
func doesNotExist(err error) bool { return err != nil && notThere.MatchString(err.Error()) }
|
||||
|
||||
// do is one owed effect.
|
||||
func (h *Holder) do(e Effect, repo [2]string, number int, body, url string, group bool) error {
|
||||
// do is one owed effect: said on the bus, or written as the key the forge bridge performs.
|
||||
func (h *Holder) do(e Effect, repository string, number int, head, body, url string, group bool) error {
|
||||
switch e.Kind {
|
||||
case EffectEmit:
|
||||
var payload any
|
||||
@@ -426,28 +415,20 @@ func (h *Holder) do(e Effect, repo [2]string, number int, body, url string, grou
|
||||
event = e.Event
|
||||
}
|
||||
return h.Emit(event, payload)
|
||||
case EffectNote:
|
||||
if h.Forge == nil {
|
||||
return fmt.Errorf("no forge")
|
||||
}
|
||||
return h.Forge.Note(repo[0], repo[1], e.Commit, e.Line)
|
||||
case EffectView:
|
||||
if h.Forge == nil || number == 0 {
|
||||
if number == 0 {
|
||||
return nil
|
||||
}
|
||||
return h.Forge.View(repo[0], repo[1], number, body)
|
||||
case EffectStatus:
|
||||
if h.Forge == nil {
|
||||
return fmt.Errorf("no forge")
|
||||
}
|
||||
if group {
|
||||
// A group's status names its member's repository and page in Last.
|
||||
where, page, _ := strings.Cut(e.Last, " ")
|
||||
ownerRepo, _, _ := strings.Cut(where, "#")
|
||||
owner, name, _ := strings.Cut(ownerRepo, "/")
|
||||
return h.Forge.Status(owner, name, e.Commit, e.Context, e.State, e.Line, page)
|
||||
}
|
||||
return h.Forge.Status(repo[0], repo[1], e.Commit, e.Context, e.State, e.Line, url)
|
||||
}
|
||||
return nil
|
||||
page := url
|
||||
if group && e.Kind == EffectStatus {
|
||||
// A group's status names its member's repository and page in Last.
|
||||
repository, number, page = memberOfLast(e.Last)
|
||||
head = e.Commit
|
||||
}
|
||||
act, value, num, hd := owedOf(e, repository, number, head, body, page)
|
||||
if act == "" {
|
||||
return nil
|
||||
}
|
||||
return h.owe(repository, num, hd, act, value)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Events that start a step (novox/hq ADR 0406 §1, issue 975, ADR 0421 issue 1022).
|
||||
//
|
||||
// This owner acts on events: a verdict, a commit status, an Approve. The operator is asked within the event that makes
|
||||
// the Approve possible (the checks passing), with no label or comment written by anyone: the forge's label and comment
|
||||
// events start nothing here.
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// wantForge says the forge's open pull requests are to be read at the next look: a check may have answered.
|
||||
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
|
||||
}
|
||||
|
||||
// AnswersChanged takes a change of the teller's answers: the answers to the asks pending are read at the look it asks for.
|
||||
func (h *Holder) AnswersChanged() {
|
||||
if h.Holding() {
|
||||
h.Poke()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// A merge check turning success for the head an approved, ready, not-yet-merged delivery's merge was refused at is
|
||||
// itself an event that decides whether it may merge (issue 1144): the merge tool may have refused it on a check still
|
||||
// pending, which this status answers. The merge is asked again at once (mergeAgainNow), under the lock, so the next
|
||||
// look asks it; no timer does this (issue 1115). A status of another context, another commit or a state other than
|
||||
// success changes nothing.
|
||||
func (h *Holder) CommitStatus(e ForgeStatusEvent) {
|
||||
if !slices.Contains(mergeChecks, e.Context) {
|
||||
return
|
||||
}
|
||||
h.wantForge()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if e.State != "success" {
|
||||
h.withdrawForStatus(e)
|
||||
return
|
||||
}
|
||||
repository := strings.ToLower(e.Owner + "/" + e.Repo)
|
||||
for _, d := range h.deliveries {
|
||||
if !strings.EqualFold(d.Repository, repository) || d.Ready == nil || d.Ready.Merged != nil || d.MergedAs != "" ||
|
||||
d.State.Final() {
|
||||
continue
|
||||
}
|
||||
head := d.Commit
|
||||
if sha := d.Ready.Heads[d.ID]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
if sameCommit(head, e.SHA) {
|
||||
h.mergeAgainNow(d) // its approved head's check turned success on the forge: the merge is asked again at once
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawForStatus takes back the ask of an Approve nobody answered yet when a merge check of a head it covers is no
|
||||
// longer a success (issue 1275, AC5): the answer to a withdrawn ask merges nothing, and the operator is asked again once
|
||||
// every member may merge. Under the lock.
|
||||
func (h *Holder) withdrawForStatus(e ForgeStatusEvent) {
|
||||
repository := strings.ToLower(e.Owner + "/" + e.Repo)
|
||||
done := map[string]bool{}
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if !strings.EqualFold(d.Repository, repository) || r == nil || r.AskID == "" || r.Approved != nil || r.Declined != nil || done[r.AskID] {
|
||||
continue
|
||||
}
|
||||
head := d.Commit
|
||||
if sha := r.Heads[d.ID]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
if sameCommit(head, e.SHA) {
|
||||
done[r.AskID] = true
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, fmt.Sprintf("%s of %s is %s at its head %s", e.Context, d.ID, e.State, shortOf(head, 12))))
|
||||
h.Poke()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -66,9 +66,7 @@ func TestAFailedVerdictAsksNothing(t *testing.T) {
|
||||
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()
|
||||
w.settleAll()
|
||||
}
|
||||
|
||||
func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
|
||||
@@ -157,7 +155,7 @@ 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"
|
||||
return "the forge refused gitea_merge_pull_request: novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both statuses pass. Nothing was merged"
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §5): the review record is read and put in the ask; it is never a gate.
|
||||
+32
-44
@@ -62,7 +62,10 @@ func (s *memStore) Deliveries() ([]*Delivery, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []*Delivery
|
||||
for _, raw := range s.deliveries {
|
||||
for key, raw := range s.deliveries {
|
||||
if key == carriedKey { // the gate's mark that today's state was carried (seat.go), as the real store skips it
|
||||
continue
|
||||
}
|
||||
var d Delivery
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return nil, err
|
||||
@@ -504,17 +507,17 @@ func (f *fakeForge) Statuses(owner, repo string, number int, commit, base string
|
||||
f.asked = append(f.asked, fmt.Sprintf("%s/%s#%d@%s base=%s", owner, repo, number, commit, base))
|
||||
out := &ForgeChecks{}
|
||||
if number > 0 {
|
||||
p, ok := f.pulls[fmt.Sprintf("%s/%s#%d", owner, repo, number)]
|
||||
if !ok {
|
||||
if p, ok := f.pulls[fmt.Sprintf("%s/%s#%d", owner, repo, number)]; ok {
|
||||
out.Pull = &p
|
||||
if commit == "" {
|
||||
commit = p.HeadSHA
|
||||
}
|
||||
if base == "" {
|
||||
base = p.Base
|
||||
}
|
||||
} else if commit == "" {
|
||||
return nil, fmt.Errorf("no pull request %d", number)
|
||||
}
|
||||
out.Pull = &p
|
||||
if commit == "" {
|
||||
commit = p.HeadSHA
|
||||
}
|
||||
if base == "" {
|
||||
base = p.Base
|
||||
}
|
||||
}
|
||||
for key, said := range f.statuses {
|
||||
at, context, _ := strings.Cut(key, " ")
|
||||
@@ -588,12 +591,17 @@ func (f *fakeForge) notesOn(commit string) []string {
|
||||
|
||||
// world is a holder over fakes, with a clock a test moves.
|
||||
type world struct {
|
||||
t *testing.T
|
||||
h *Holder
|
||||
store *memStore
|
||||
ctl *fakeController
|
||||
forge *fakeForge
|
||||
op *fakeOperator
|
||||
t *testing.T
|
||||
h *Holder
|
||||
store *memStore
|
||||
ctl *fakeController
|
||||
forge *fakeForge
|
||||
op *fakeOperator
|
||||
merges *fakeMerges
|
||||
source *fakeSource
|
||||
owed *fakeOwed
|
||||
// asked is the key of the ask a test answered.
|
||||
asked string
|
||||
now time.Time
|
||||
said []map[string]any
|
||||
mu sync.Mutex
|
||||
@@ -621,6 +629,11 @@ func newWorldOver(t *testing.T, store *memStore, ctl *fakeController, forge *fak
|
||||
w.op = newFakeOperator(w.h.Now)
|
||||
w.op.auto = true
|
||||
w.h.Operator = w.op
|
||||
w.merges, w.source = newFakeMerges(forge), &fakeSource{}
|
||||
w.h.Merges, w.h.Source = w.merges, w.source
|
||||
w.merges.h = w.h
|
||||
w.owed = newFakeOwed(forge)
|
||||
w.h.Owed = w.owed
|
||||
w.h.init()
|
||||
if err := w.h.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -634,28 +647,14 @@ func (w *world) later(d time.Duration) {
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// settleAll lets the router's answers reach the holder as its `decided` events, and takes a look, a few times.
|
||||
// settleAll takes a look a few times: the answers the teller wrote are read at each, and each member of a group handed to the
|
||||
// forge bridge is merged by the fake bridge, whose word is heard by the next look.
|
||||
func (w *world) settleAll() {
|
||||
for range 4 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *world) delivery(id string) *Delivery {
|
||||
w.t.Helper()
|
||||
w.h.mu.Lock()
|
||||
@@ -712,18 +711,7 @@ func aWalk(id, repo, commit string, waits bool) Walk {
|
||||
return w
|
||||
}
|
||||
|
||||
// Merge and Comment record what was asked (novox/hq issue 722); Merge answers refuse when set.
|
||||
func (f *fakeForge) Merge(owner, repo string, number int, head string, approved []string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head))
|
||||
f.approvedHeads = append(f.approvedHeads, approved)
|
||||
if err := f.refuseRepo[owner+"/"+repo]; err != nil {
|
||||
return err
|
||||
}
|
||||
return f.refuse
|
||||
}
|
||||
|
||||
// Comment records what was asked (novox/hq issue 722).
|
||||
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A group is merged whole or not at all, in its order (issue 1275, novox/hq ADR 0418).
|
||||
//
|
||||
// - Before the operator is asked, and again before any member is merged, every member's required statuses are read at
|
||||
// its head on the forge; one that is not a success holds the group, and nothing is merged.
|
||||
// - The members are merged one after the other in the group's order, and the pass stops at the first refusal: no later
|
||||
// member is merged.
|
||||
// - A group held or half merged is said as a stalled line, urgent, until every member is merged or a person decides.
|
||||
|
||||
// memberRead is what a status read of one member needs, taken under the lock.
|
||||
type memberRead struct {
|
||||
id, owner, repo, base, head string
|
||||
number int
|
||||
}
|
||||
|
||||
// readOf is a delivery as a status read needs it, at the head the Approve or the ask covers.
|
||||
func readOf(d *Delivery, head string) memberRead {
|
||||
if head == "" {
|
||||
head = d.Commit
|
||||
}
|
||||
return memberRead{id: d.ID, owner: d.Owner(), repo: d.Repo(), base: d.Base, head: head, number: d.Number}
|
||||
}
|
||||
|
||||
// notAsSuccess reads a member's required statuses at its head and says, in the forge's words, which are not a success;
|
||||
// empty when all are. ownOnly is whether only the member's own repository check is missing or pending. An error is a
|
||||
// read that could not be made: the member then counts as not mergeable.
|
||||
func (h *Holder) notASuccess(m memberRead) (says string, ownOnly bool, err error) {
|
||||
fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if fc.Merge.Mergeable == nil {
|
||||
return "", false, fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
}
|
||||
if *fc.Merge.Mergeable {
|
||||
return "", false, nil
|
||||
}
|
||||
var parts []string
|
||||
ownOnly = len(fc.Merge.Blocking) > 0
|
||||
for _, b := range fc.Merge.Blocking {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State))
|
||||
if b.Context != ctxRepo || (b.State != "pending" && b.State != "missing") {
|
||||
ownOnly = false
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s requires %s; not a success: %s", fc.Merge.Branch, strings.Join(fc.Merge.Required, ", "),
|
||||
strings.Join(parts, ", ")), ownOnly, nil
|
||||
}
|
||||
|
||||
// unmergedLive are a group's members not yet merged and not over, in the group's order. Under the lock.
|
||||
func (h *Holder) unmergedLive(g *Group) []*Delivery {
|
||||
var out []*Delivery
|
||||
for _, d := range h.membersOf(g) {
|
||||
if d.MergedAs == "" && !d.State.Final() && (d.Ready == nil || d.Ready.Merged == nil) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// groupMergeable reads every member's required statuses at the head the Approve covers, and says whether all are a success.
|
||||
// When one is not, or cannot be read, nothing is handed to the bridge: the group is held, said urgent (ADR 0441 §2, §4), and
|
||||
// the target's turn is given back so the next event that decides whether it may merge asks it again. Outside the lock.
|
||||
func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
release := func() {
|
||||
if d := h.deliveries[target]; d != nil && d.Ready != nil {
|
||||
d.Ready.merging = false
|
||||
d.Ready.LastTry = time.Time{}
|
||||
}
|
||||
}
|
||||
for _, m := range reads {
|
||||
says, ownOnly, err := h.notASuccess(m)
|
||||
if err == nil && says == "" {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s: the required statuses of %s cannot be read, so nothing is merged yet: %v", gid, m.id, err)
|
||||
release()
|
||||
h.again("a group's merge whose required statuses could not be read")
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
var own func()
|
||||
if g := h.groups[gid]; g != nil {
|
||||
if g.Hold == nil || g.Hold.Kind != "blocked" || g.Hold.Says != says || g.Hold.Member != m.id {
|
||||
h.Logf("[mesh-delivery] group %s is approved and NOT merged: %s at %s: %s", gid, m.id, shortOf(m.head, 12), says)
|
||||
}
|
||||
since := time.Time{}
|
||||
if g.Hold != nil && g.Hold.Kind == "blocked" {
|
||||
since = g.Hold.Since
|
||||
}
|
||||
if since.IsZero() {
|
||||
since = h.Now()
|
||||
}
|
||||
g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
if d := h.deliveries[m.id]; ownOnly && d != nil && d.Ready != nil {
|
||||
own = h.askOwnAfterRefusal(d) // its own repository check is what is missing: asked, once for the head
|
||||
}
|
||||
release()
|
||||
h.again("a group's merge held for a required status")
|
||||
h.mu.Unlock()
|
||||
if own != nil {
|
||||
own()
|
||||
}
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
if g := h.groups[gid]; g != nil && g.Hold != nil {
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// groupLines are the groups that are held or half merged, as urgent stalled lines the controller raises as conditions:
|
||||
// one for each, on its first member not merged, until every member is merged or a person decides (issue 1275). A member
|
||||
// the merge tool refused is said by readyLines, with its group; this adds a group held for a required status, and a
|
||||
// group half merged with no refusal. Under the lock.
|
||||
func (h *Holder) groupLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, g := range h.groups {
|
||||
live := h.unmergedLive(g)
|
||||
if len(live) == 0 {
|
||||
continue
|
||||
}
|
||||
first, refused, running, merged := live[0], false, false, 0
|
||||
for _, d := range live {
|
||||
if d.Ready != nil && (d.Ready.RefusedSince != nil || d.Ready.Failed != "" || d.Ready.Refused != "") {
|
||||
refused = true
|
||||
}
|
||||
if d.Ready != nil && d.Ready.merging {
|
||||
running = true
|
||||
}
|
||||
}
|
||||
for _, d := range h.membersOf(g) {
|
||||
if d.MergedAs != "" || (d.Ready != nil && d.Ready.Merged != nil) {
|
||||
merged++
|
||||
}
|
||||
}
|
||||
heldOn := h.deliveries[holdMember(g)]
|
||||
switch {
|
||||
// A held member the merge tool refused is said by readyLines already: one line for an id, not two.
|
||||
case g.Hold != nil && g.Hold.Kind == "blocked" && !running && !(heldOn != nil && heldOn.Ready != nil && heldOn.Ready.RefusedSince != nil):
|
||||
out = append(out, StalledLine{ID: g.Hold.Member, Number: numberOf(h.deliveries[g.Hold.Member]), State: first.State,
|
||||
For: now.Sub(g.Hold.Since).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
H2: "none: the required status turns success, which asks the merge again — or the operator's decision",
|
||||
Says: fmt.Sprintf("group %s is approved and nothing is merged (merged %d of %d): %s waits on a required status "+
|
||||
"at its head: %s. Needed: that status turns success (its check asked again), or the operator decides.",
|
||||
g.ID, merged, len(h.membersOf(g)), g.Hold.Member, g.Hold.Says)})
|
||||
case merged > 0 && !refused && !running:
|
||||
out = append(out, StalledLine{ID: first.ID, Number: first.Number, State: first.State,
|
||||
For: now.Sub(g.Updated).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
H2: "none: every member merged, or the operator decides",
|
||||
Says: fmt.Sprintf("group %s is half merged (%d of %d members merged): %s is not merged. Needed: it merges "+
|
||||
"(its Approve and checks hold at its head), or the operator decides.", g.ID, merged, len(h.membersOf(g)), first.ID)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func numberOf(d *Delivery) int {
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
return d.Number
|
||||
}
|
||||
|
||||
func holdMember(g *Group) string {
|
||||
if g.Hold == nil {
|
||||
return ""
|
||||
}
|
||||
return g.Hold.Member
|
||||
}
|
||||
+8
-7
@@ -89,25 +89,26 @@ func TestAnApprovedGroupWhoseTrunkMovedIsCheckedAgainAndMergedOnTheSameApprove(t
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0418 §4: a member the forge refuses is merged again on its fresh check, while the other merged in the same pass.
|
||||
func TestAMemberTheForgeRefusesIsMergedAgainWhileTheOtherMergedInTheSamePass(t *testing.T) {
|
||||
// ADR 0418 §4, issue 1275/AC3: a member the forge refuses stops the pass (the members after it are not asked), and is
|
||||
// merged again on its fresh check, the others after it in the same pass.
|
||||
func TestAMemberTheForgeRefusesStopsThePassAndIsMergedAgainOnItsFreshCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("both members are asked in the same pass: %v", w.forge.merges)
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the pass stops at the first refusal: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("a refused member is asked again with no event, on a timer or at each look: %v", w.forge.merges)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: w.h.groups["feat/g"].Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 4 {
|
||||
t.Fatalf("a refused member is asked again on the next composed verdict: %v", w.forge.merges)
|
||||
if len(w.forge.merges) != 3 {
|
||||
t.Fatalf("a refused member is asked again on the next composed verdict, then the next: %v", w.forge.merges)
|
||||
}
|
||||
if d := w.delivery(IDOf(gCat, gHk)); d.Ready.Merged == nil {
|
||||
t.Fatalf("not merged: %+v", d.Ready)
|
||||
+27
-33
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -177,41 +178,31 @@ func TestRetireHistoryIsRefusedForARealHeldDelivery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedWorkTheForgeSaysCanNeverBeDoneIsDropped(t *testing.T) {
|
||||
// What the bus cannot take is owed and tried again later each time, and given up after retryLimit tries, saying so. What the
|
||||
// forge refuses is the bridge's to say (issue 1349): the gate has no retry for it.
|
||||
func TestOwedWorkTheBusCannotTakeIsTriedAgainLaterAndGivenUpAfterRetryLimit(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.forge.viewErr = errors.New(`Gitea API /repos/novox/app/issues/7/comments: 500 {"message":"issue does not ` +
|
||||
`exist [id: 0, repo_id: 53, index: 7]"}`)
|
||||
w.owed.down = true
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Flush()
|
||||
if w.forge.viewCalls != 1 {
|
||||
t.Fatalf("the view was asked %d time(s)", w.forge.viewCalls)
|
||||
tries := func() int {
|
||||
for _, e := range w.delivery(id).Owed {
|
||||
if e.Kind == EffectView {
|
||||
return e.Tries
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if d := w.delivery(id); len(d.Owed) != 0 {
|
||||
t.Fatalf("an answer that its pull request does not exist left owed %+v", d.Owed)
|
||||
}
|
||||
w.settleAll()
|
||||
if w.forge.viewCalls != 1 {
|
||||
t.Fatalf("a dropped view was asked again: %d", w.forge.viewCalls)
|
||||
}
|
||||
if st := w.h.Status("listening"); !strings.Contains(strings.Join(st["refused"].([]string), "\n"), "given up") {
|
||||
t.Fatalf("its drop was not said: %v", st["refused"])
|
||||
}
|
||||
|
||||
// Any other failure is tried again later each time, and given up after retryLimit tries.
|
||||
w.forge.viewErr = errors.New("the forge is away")
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
before := w.forge.viewCalls
|
||||
w.h.Flush()
|
||||
w.h.Flush()
|
||||
w.h.Flush()
|
||||
if w.forge.viewCalls != before+1 {
|
||||
t.Fatalf("a failed view was tried %d time(s) at once", w.forge.viewCalls-before)
|
||||
if tries() != 1 {
|
||||
t.Fatalf("a view the bus could not take was tried %d time(s) at once", tries())
|
||||
}
|
||||
w.later(retryFirst + time.Second)
|
||||
w.h.Flush()
|
||||
if w.forge.viewCalls != before+2 {
|
||||
t.Fatalf("a failed view was not tried again after its wait: %d", w.forge.viewCalls-before)
|
||||
if tries() != 2 {
|
||||
t.Fatalf("a failed view was not tried again after its wait: %d", tries())
|
||||
}
|
||||
if retryAfter(1) != retryFirst || retryAfter(2) != 2*retryFirst || retryAfter(100) != retryCap {
|
||||
t.Fatalf("the waits: %s %s %s", retryAfter(1), retryAfter(2), retryAfter(100))
|
||||
@@ -220,14 +211,17 @@ func TestOwedWorkTheForgeSaysCanNeverBeDoneIsDropped(t *testing.T) {
|
||||
w.later(retryCap)
|
||||
w.h.Flush()
|
||||
}
|
||||
for _, e := range w.delivery(id).Owed {
|
||||
if e.Kind == EffectView {
|
||||
t.Fatalf("a view failing %d times is still owed: %+v", e.Tries, e)
|
||||
}
|
||||
if tries() != -1 {
|
||||
t.Fatalf("a view failing %d times is still owed", tries())
|
||||
}
|
||||
if !doesNotExist(errors.New("Gitea API /repos/a/b/issues/1/comments: 404 Not Found")) ||
|
||||
doesNotExist(errors.New("tool gitea.gitea_delivery_view does not exist on this machine")) ||
|
||||
doesNotExist(errors.New("the forge is away")) {
|
||||
t.Fatal("doesNotExist reads the forge's answer wrong")
|
||||
if st := w.h.Status("listening"); !strings.Contains(strings.Join(st["refused"].([]string), "\n"), "given up") {
|
||||
t.Fatalf("its drop was not said: %v", st["refused"])
|
||||
}
|
||||
// The bus back: what is owed anew is written.
|
||||
w.owed.down = false
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.Flush()
|
||||
if len(w.owed.acts(sdk.OwedView)) == 0 {
|
||||
t.Fatal("nothing was written once the bus was back")
|
||||
}
|
||||
}
|
||||
+25
-4
@@ -9,6 +9,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -27,13 +28,30 @@ type Holder struct {
|
||||
Emit func(event string, body any) error
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
// Operator is the operator channel, asked for the Approve of a merge into main (approval.go).
|
||||
// Operator keeps the ask of the Approve of a merge into main in the gate's asks and reads the teller's answers
|
||||
// (approval.go); Merges and Source are the gate's merges and source (merges.go).
|
||||
Operator Operator
|
||||
Merges MergeStore
|
||||
Source SourceStore
|
||||
// Owed is the gate's bucket owed: what a transition owes the forge, written as keys (owed.go).
|
||||
Owed OwedStore
|
||||
// Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none.
|
||||
Pulls PullKeys
|
||||
// Seat says where today's module still stands; the gate holds the seat only when it stands nowhere (seat.go). Carry
|
||||
// copies today's state over at the first holding, OnHold takes the reads of a holder just come.
|
||||
Seat Occupancy
|
||||
Carry func() (CarryReport, error)
|
||||
OnHold func()
|
||||
// cancels are asks to take back, gathered under the lock and run with the other asks of the look.
|
||||
cancels []func()
|
||||
|
||||
// withdrawn are the ask keys withdrawn since the last look (approval.go withdrawAsk).
|
||||
withdrawn map[string]bool
|
||||
|
||||
seatHeld atomic.Bool
|
||||
seatMu sync.Mutex
|
||||
seatWhy string
|
||||
|
||||
mu sync.Mutex
|
||||
deliveries map[string]*Delivery
|
||||
groups map[string]*Group
|
||||
@@ -139,6 +157,7 @@ func (h *Holder) init() {
|
||||
h.deliveries = map[string]*Delivery{}
|
||||
h.groups = map[string]*Group{}
|
||||
h.dirty = map[string]bool{}
|
||||
h.withdrawn = map[string]bool{}
|
||||
h.written = map[string][]byte{}
|
||||
h.dirtyGroup = map[string]bool{}
|
||||
h.unmatched = map[string]unmatched{}
|
||||
@@ -1943,7 +1962,8 @@ func (h *Holder) askOwnCheck(id string, m Member, why string) {
|
||||
return
|
||||
}
|
||||
if asked == "" {
|
||||
h.Logf("[mesh-delivery] %s's check was asked and the controller's answer held no id: taken as refused, asked again in a minute at most", id)
|
||||
h.Logf("[mesh-delivery] ERROR: %s's check was asked and the controller's answer held no check id: it is not taken as asked, "+
|
||||
"and asked again in a minute at most", id)
|
||||
return
|
||||
}
|
||||
delete(h.askTried, id)
|
||||
@@ -2670,11 +2690,12 @@ func refusedAfter(d *Delivery, at time.Time) bool {
|
||||
}
|
||||
|
||||
// ownStatusPending is whether the merge tool refused a merge because a status of the member's own merge check is
|
||||
// pending while its gate is not: the forge's "not a success: mesh/repo-check (pending)" (gitea merge.ts, mergeableUnder).
|
||||
// pending or missing (issue 1276) while its gate is not: the forge's "not a success: mesh/repo-check (pending)" (gitea merge.ts, mergeableUnder).
|
||||
// A refusal that also names mesh/merge-gate is the group's composed check's to answer, as is a verdict out of date.
|
||||
func ownStatusPending(answer string) bool {
|
||||
_, listed, ok := strings.Cut(answer, "not a success:")
|
||||
return ok && strings.Contains(listed, "mesh/repo-check (pending)") && !strings.Contains(listed, "mesh/merge-gate")
|
||||
return ok && (strings.Contains(listed, "mesh/repo-check (pending)") || strings.Contains(listed, "mesh/repo-check (missing)")) &&
|
||||
!strings.Contains(listed, "mesh/merge-gate")
|
||||
}
|
||||
|
||||
// askOwnAfterRefusal asks a member's own check once for its head, after the merge tool refused it for a pending status of
|
||||
+5
-5
@@ -250,19 +250,19 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) {
|
||||
if len(w.said) != 1 || len(w.forge.notesOn(head)) != 2 {
|
||||
t.Fatalf("once kept, said %d and noted %d", len(w.said), len(w.forge.notesOn(head)))
|
||||
}
|
||||
// A forge that is away is asked again; a note is appended once however often it is asked.
|
||||
w.forge.down = true
|
||||
// A bucket the bus cannot take is written again; a note is appended once however often it is asked.
|
||||
w.owed.down = true
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.settleAll()
|
||||
if len(w.forge.notesOn(head)) != 2 {
|
||||
t.Fatal("noted while the forge was away")
|
||||
t.Fatal("noted while the bus was away")
|
||||
}
|
||||
w.forge.down = false
|
||||
w.owed.down = false
|
||||
w.later(time.Hour) // each failure put its next try later: past every one of them
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if notes := w.forge.notesOn(head); len(notes) != 4 {
|
||||
t.Fatalf("after the forge came back the note has %d line(s): %q", len(notes), notes)
|
||||
t.Fatalf("after the bus came back the note has %d line(s): %q", len(notes), notes)
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", head)); len(d.Owed) != 0 {
|
||||
t.Fatalf("still owed: %+v", d.Owed)
|
||||
+6
-4
@@ -39,11 +39,13 @@ func TestAReviewedByLineWrittenAfterTheAskWithdrawsTheUnansweredAskAndAsksAgain(
|
||||
w.h.PullEdited(edited("Reviewed-by: reviewer@shanks at " + readyHead))
|
||||
w.settleAll()
|
||||
open := w.op.open()
|
||||
if len(open) != 1 || open[0] == first || w.op.count() != 2 {
|
||||
t.Fatalf("after the edit the open asks are %v (first %s), %d asked; wanted the first withdrawn and one new", open, first, w.op.count())
|
||||
// The heads are the same, so the key is: the ask is taken away and written again with the record in its words, which the
|
||||
// teller reads as the ask to withdraw and ask afresh.
|
||||
if len(open) != 1 || open[0] != first || w.op.count() != 2 {
|
||||
t.Fatalf("after the edit the open asks are %v (first %s), %d asked; wanted the first withdrawn and written again once", open, first, w.op.count())
|
||||
}
|
||||
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != first {
|
||||
t.Fatalf("the first ask was not withdrawn: %v", w.op.cancelled)
|
||||
if len(w.op.cancelled) != 0 || w.op.byKey[first].Asking != 2 {
|
||||
t.Fatalf("the first ask was taken away (%v) or not written again as asking 2 (%d)", w.op.cancelled, w.op.byKey[first].Asking)
|
||||
}
|
||||
if e := w.op.ask(open[0]).Explanation; !strings.Contains(e, "reviewed by reviewer on shanks at "+shortOf(readyHead, 12)) ||
|
||||
strings.Contains(e, "not reviewed") {
|
||||
@@ -0,0 +1,413 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const time30s = 31 * time.Second
|
||||
|
||||
// Issue 1275 (the 2026-10-19 18:05 group): a group is merged whole or not at all, in its order, and a group that is held or
|
||||
// half merged is said loudly. Issue 1276/AC1: a refusal naming the member's own repository check as missing asks that check.
|
||||
|
||||
const (
|
||||
gate, repoCheck = "mesh/merge-gate", "mesh/repo-check"
|
||||
)
|
||||
|
||||
// statusOf sets a member head's two merge checks on the forge, and makes both required.
|
||||
func setChecks(w *world, repository, sha, gateState, repoState string) {
|
||||
w.forge.required = []string{gate, repoCheck}
|
||||
for ctx, state := range map[string]string{gate: gateState, repoCheck: repoState} {
|
||||
key := repository + "@" + sha + " " + ctx
|
||||
if state == "" {
|
||||
delete(w.forge.statuses, key) // no status at all: the forge says it is missing
|
||||
continue
|
||||
}
|
||||
w.forge.statuses[key] = state + " said → x"
|
||||
}
|
||||
}
|
||||
|
||||
func allGreen(w *world) {
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
}
|
||||
|
||||
func refusedWords(repository string) error {
|
||||
return errors.New("the forge refused gitea_merge_pull_request: " + repository + " is not merged: main requires " +
|
||||
"mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing). A pull request merges once its checks succeed")
|
||||
}
|
||||
|
||||
func urgentLines(w *world) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.Severity == "urgent" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1275/AC2: an approved group whose member has a required status that is not a success at its head when the merge starts
|
||||
// is merged not at all, though the first member in order is fine; the group says which member and which status.
|
||||
func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing after the ask
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged with a required status missing: %v", w.forge.merges)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || !strings.Contains(lines[0].Says, "feat/g") || !strings.Contains(lines[0].Says, IDOf(gCtl, gHc)) ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
||||
t.Fatalf("the held group is not said as one urgent line naming group, member, status and need: %+v", lines)
|
||||
}
|
||||
// The status turns success: the whole group merges.
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"})
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || len(urgentLines(w)) != 0 {
|
||||
t.Fatalf("merges %v, lines %+v; wanted both merged and no line", w.forge.merges, urgentLines(w))
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC2 with 1276/AC1: a member whose own repository check is the missing status has that check asked, once.
|
||||
func TestAHeldGroupWhoseMemberLacksItsOwnRepositoryCheckAsksThatCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
setChecks(w, gCtl, gHc, "success", "")
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed %v; wanted the member's own check once", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said urgent,
|
||||
// naming the group, the refused member and the forge's words.
|
||||
func TestARefusedFirstMemberStopsTheGroupAndIsSaidUrgent(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
|
||||
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
||||
t.Fatalf("not said as one urgent line: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC4: a group half merged (the second member refused after the first merged) stays raised, pass after pass, until
|
||||
// the second is merged; then it goes.
|
||||
func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
for range 3 {
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCtl, gHc) || !strings.Contains(lines[0].Says, "feat/g") {
|
||||
t.Fatalf("the half merged group is not raised: %+v", lines)
|
||||
}
|
||||
}
|
||||
// A half merged group with no refusal on record (the pass died between members) is raised too.
|
||||
d := w.delivery(IDOf(gCtl, gHc))
|
||||
d.Ready.RefusedSince = nil
|
||||
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "half merged") {
|
||||
t.Fatalf("a half merged group without a refusal is not raised: %+v", lines)
|
||||
}
|
||||
w.forge.refuseRepo = nil
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
||||
w.settleAll()
|
||||
if lines := urgentLines(w); len(lines) != 0 {
|
||||
t.Fatalf("the group is whole and still raised: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC1: a group with a member whose required status is not a success is not asked its Approve; the group says which
|
||||
// member and status; it is asked once every member may merge.
|
||||
func TestAGroupIsNotAskedWhileAMembersRequiredStatusIsNotASuccess(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
setChecks(w, gCat, gHk, "success", "success") // the composed verdict set every member's gate
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked with a required status pending: %d asks", w.op.count())
|
||||
}
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Member != IDOf(gCtl, gHc) || !strings.Contains(g.Hold.Says, "mesh/repo-check (pending)") {
|
||||
t.Fatalf("the group does not say which member and status it waits for: %+v", g.Hold)
|
||||
}
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || g.Hold != nil {
|
||||
t.Fatalf("asks %d, hold %+v; wanted the group asked once its statuses are green", w.op.count(), g.Hold)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC5: a member's required status turning not-success while the ask waits withdraws the ask at the router; the
|
||||
// answer to the withdrawn ask merges nothing; the group is asked again once every member may merge.
|
||||
func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
ask := w.op.open()[0]
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "pending"})
|
||||
setChecks(w, gCat, gHk, "success", "pending")
|
||||
w.settleAll()
|
||||
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != ask {
|
||||
t.Fatalf("the ask was not withdrawn at the router: %v", w.op.cancelled)
|
||||
}
|
||||
w.op.answer(ask, OptionApprove) // an answer that arrives late
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("the answer to a withdrawn ask merged: %v", w.forge.merges)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("asked again while a status was still pending: %d", w.op.count())
|
||||
}
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if w.op.count() != 2 {
|
||||
t.Fatalf("not asked again once every member may merge: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// 1276/AC1: given a closed group whose member's merge was refused with "not a success: mesh/repo-check (missing)", then
|
||||
// the member's own check is asked, not the composed check.
|
||||
func TestAClosedGroupsMemberRefusedForItsOwnMissingStatusAsksItsOwnCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed checks %v; wanted the member's own check once and no composed check", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC3 (review round 1): a member that is both refused and held for a required status is said by one stalled line,
|
||||
// not two with the same id.
|
||||
func TestAMemberBothRefusedAndHeldForAStatusIsOneStalledLine(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
setChecks(w, gCat, gHk, "success", "") // now its status is missing too; the next pass is held
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "success"})
|
||||
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if g := w.h.groups["feat/g"]; g.Hold == nil {
|
||||
t.Fatalf("the group was not held for the missing status")
|
||||
}
|
||||
n := 0
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == IDOf(gCat, gHk) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("%d stalled lines for one member: %+v", n, w.h.Stalled())
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6: a member refused because its verdict is out of date (the trunk moved; its fresh check runs) is a warning at
|
||||
// once, and urgent only when the group is not merged within ten minutes; the pass stops at the refusal all the same.
|
||||
func TestAnOutOfDateRefusalIsAWarningAtOnceAndUrgentAfterTenMinutes(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the pass did not stop at the refusal: %v", w.forge.merges)
|
||||
}
|
||||
sev := func() []string {
|
||||
var out []string
|
||||
for _, l := range w.h.Stalled() {
|
||||
out = append(out, l.Severity)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := sev(); len(got) != 1 || got[0] != "warning" {
|
||||
t.Fatalf("an out of date refusal is %v at once; wanted one warning", got)
|
||||
}
|
||||
w.later(9 * time.Minute)
|
||||
if got := sev(); len(got) != 1 || got[0] != "warning" {
|
||||
t.Fatalf("at nine minutes it is %v; wanted still a warning", got)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
if got := sev(); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6: every other refusal (here a missing status) is urgent at once.
|
||||
func TestAMissingStatusRefusalIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || lines[0].Severity != "urgent" {
|
||||
t.Fatalf("a missing status refusal is not urgent at once: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func severities(w *world) []string {
|
||||
var out []string
|
||||
for _, l := range w.h.Stalled() {
|
||||
out = append(out, l.Severity)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1275/AC6 (b): the same out of date refusal on a LATER member, after the earlier one merged, is a warning at once and
|
||||
// urgent after ten minutes: the half merged group does not say it urgent at once.
|
||||
func TestAnOutOfDateRefusalOnALaterMemberIsAWarningThenUrgent(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 1 || got[0] != "warning" {
|
||||
t.Fatalf("a later member's out of date refusal is %v at once; wanted one warning", got)
|
||||
}
|
||||
w.later(11 * time.Minute)
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (c): words naming an out of date check and also a missing status are urgent at once.
|
||||
func TestARefusalNamingOutOfDateAndAMissingStatusIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-catalog#2 is not merged: its verdict was " +
|
||||
"judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccc merged into trunk bbbb was " +
|
||||
"asked. main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing)")
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("a refusal with two causes is %v; wanted urgent at once", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (d): words mesh-delivery cannot classify are urgent at once.
|
||||
func TestARefusalInUnknownWordsIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: something nobody foresaw")
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("an unclassified refusal is %v; wanted urgent at once", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The merge tool's refusals, copied from gitea merge.ts with their line, `${...}` filled in.
|
||||
var mergeToolRefusals = []struct {
|
||||
line string
|
||||
words string
|
||||
outOfDated bool
|
||||
}{
|
||||
{"merge.ts:121", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
||||
"Its check is already asked and not yet answered (building), so it is not asked again; took 3s. Ask again at cccccccccccc once " +
|
||||
"both statuses pass: this tool never merges by itself (novox/hq ADR 0323). A check that never answers is asked again with " +
|
||||
"mesh-delivery.recheck. Nothing was merged", true},
|
||||
{"merge.ts:147", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
||||
"The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both " +
|
||||
"statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may move the trunk " +
|
||||
"again first; this is then refused and asked once more. A check that never answers is asked again with " +
|
||||
"mesh-delivery.recheck. Nothing was merged", true},
|
||||
{"merge.ts:144", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb, " +
|
||||
"and its check could not be asked: the forge is away. Nothing was merged", false},
|
||||
{"merge.ts:156", "novox/app#7 is not merged: main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check " +
|
||||
"(pending). A pull request merges once its checks succeed", false},
|
||||
{"merge.ts:165", "novox/app#7 is not merged: its verdict read siblings, and which ones is not held here, so the group cannot " +
|
||||
"be weighed. Ask its check again at cccccccccccc. Nothing was merged", false},
|
||||
{"merge.ts:172", "novox/app#7 is not merged: its verdict was judged beside 1 sibling(s), and a sibling is not ready. A member " +
|
||||
"judged beside its siblings merges once its group passed at the heads that verdict read (novox/hq ADR 0382, 0421). " +
|
||||
"Nothing was merged", false},
|
||||
}
|
||||
|
||||
// 1275/AC6 (e): only the merge tool's two out of date refusals (:121, :147) are warnings; the one saying its check could
|
||||
// not be asked (:144) and every other is urgent at once.
|
||||
func TestOnlyTheMergeToolsTwoOutOfDateRefusalsAreWarnings(t *testing.T) {
|
||||
for _, c := range mergeToolRefusals {
|
||||
if got := outOfDateRefusal(c.words); got != c.outOfDated {
|
||||
t.Errorf("%s: outOfDateRefusal = %v, wanted %v", c.line, got, c.outOfDated)
|
||||
}
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: " + c.words)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
want := "urgent"
|
||||
if c.outOfDated {
|
||||
want = "warning"
|
||||
}
|
||||
if got := severities(w); len(got) != 1 || got[0] != want {
|
||||
t.Errorf("%s: severity %v at once, wanted %s", c.line, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (f): the severity never steps down: a missing status refusal, then an out of date one, stays urgent.
|
||||
func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.forge.refuseRepo = map[string]error{gCat: errAskedAgain}
|
||||
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if d := w.delivery(IDOf(gCat, gHk)); !outOfDateRefusal(d.Ready.Answer) {
|
||||
t.Fatalf("the second refusal was not the out of date one: %q", d.Ready.Answer)
|
||||
}
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("severity %v after an out of date refusal that followed an urgent one; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **The gate asks through its bucket asks and merges through the bridge** (novox/hq ADR 0437 §6, §8, ADR 0441 §1 to §5,
|
||||
// ADR 0450 §3, issue 1348). Fixture sources: the key and the id of an ask and the layout of a merge are mesh-sdk go/delivery's
|
||||
// (MergeAskKey, AskID, Merge); the act an ask binds is spelled as mesh-controller cmd/mesh-controller/moved.go:98-106 verifies
|
||||
// it and as the teller checks it (mesh-delivery modules/mesh-delivery-teller teller.go mergeAct, headsOf); the bridge's
|
||||
// messages are the ones mesh-delivery modules/mesh-delivery-forge-bridge internal/bridge acts.go writes (RefusedMessage) and
|
||||
// bridge.go merged writes, and the fake bridge here (bridge_test.go) writes them in that layout.
|
||||
|
||||
// AC1: a ready group writes one ask key, with today's words, and the gate calls no router.
|
||||
func TestAReadyGroupWritesOneAskKeyWithTodaysWordsAndCallsNoRouter(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("%d asks written, wanted one", w.op.count())
|
||||
}
|
||||
key := w.op.open()[0]
|
||||
kind, group, hash, err := sdk.ParseAskKey(key)
|
||||
if err != nil || kind != "merge" || group != "feat/g" {
|
||||
t.Fatalf("the key %q is not merge.<group>.<heads hash>: %q %q %v", key, kind, group, err)
|
||||
}
|
||||
ask := w.op.byKey[key]
|
||||
if ask.Group != "feat/g" || len(ask.Heads) != 2 {
|
||||
t.Fatalf("the ask value %+v does not name the group and both heads", ask)
|
||||
}
|
||||
if want, err := sdk.MergeAskKey("feat/g", ask.Heads); err != nil || want != key || !strings.HasSuffix(key, hash) {
|
||||
t.Fatalf("the heads %v give the key %q, the ask is under %q (%v)", ask.Heads, want, key, err)
|
||||
}
|
||||
if ask.Ask.ID != sdk.AskID(key) {
|
||||
t.Fatalf("the router's id of the ask is %q, wanted %q", ask.Ask.ID, sdk.AskID(key))
|
||||
}
|
||||
// Today's words (approval.go composeMergeAsk): the headline names the group, the details its members.
|
||||
if !strings.Contains(ask.Ask.Headline, "group feat") || !strings.Contains(ask.Ask.Details, "mesh-controller#1") ||
|
||||
!strings.Contains(ask.Ask.Details, "mesh-catalog#2") || len(ask.Ask.Options) != 2 || ask.Ask.Who != asks.Operator {
|
||||
t.Fatalf("the ask is not today's merge ask: %+v", ask.Ask)
|
||||
}
|
||||
// The act the Approve binds is the one the teller checks and the controller verifies: this ask's router id, the option, and
|
||||
// the heads as `owner/repo@<first 12 characters>` to the whole commit.
|
||||
approve, _ := ask.Ask.Option(OptionApprove)
|
||||
heads := map[string]string{IDOf(gCtl, gHc): gHc, IDOf(gCat, gHk): gHk}
|
||||
want, _ := asks.ActDigest(asks.Act{"verb": "mesh-delivery.merge", "ask": sdk.AskID(key), "option": "approve",
|
||||
"heads": "novox/mesh-catalog@d2d2d2d2d2d2=" + gHk + ",novox/mesh-controller@c1c1c1c1c1c1=" + gHc})
|
||||
if approve.Binds != want {
|
||||
t.Fatalf("the Approve binds %s, wanted the act %v of %v", approve.Binds, mergeAct(sdk.AskID(key), "approve", heads), heads)
|
||||
}
|
||||
if len(w.forge.calls) != 0 || len(w.merges.puts) != 0 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("something was written beside the ask: forge %v, merges %d", w.forge.calls, len(w.merges.puts))
|
||||
}
|
||||
}
|
||||
|
||||
// answeredFirst is a group approved by the teller's answer, with the bridge saying nothing yet.
|
||||
func answeredFirst(t *testing.T) *world {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.merges.manual = true
|
||||
w.asked = w.op.open()[0]
|
||||
w.op.answer(w.asked, OptionApprove)
|
||||
w.settleAll()
|
||||
return w
|
||||
}
|
||||
|
||||
// AC2: an answer naming the heads asked now hands the first member only; the bridge's merged hands the next; its refused
|
||||
// hands none.
|
||||
func TestAnAnswerHandsTheFirstMemberOnlyAndTheBridgesMergedHandsTheNext(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
if len(w.merges.puts) != 1 {
|
||||
t.Fatalf("the gate wrote %d merges, wanted the first member handed once", len(w.merges.puts))
|
||||
}
|
||||
first := w.merges.last("feat/g")
|
||||
if len(first.Pulls) != 1 || first.Pulls[0].Repository != gCat || first.Pulls[0].State != sdk.MergeMerging || first.Pulls[0].Head.Head != gHk {
|
||||
t.Fatalf("the first member is not handed alone, merging, at its head: %+v", first.Pulls)
|
||||
}
|
||||
if first.Approved[sdk.ApprovedKey(gCat, 2)] != gHk || first.Approved[sdk.ApprovedKey(gCtl, 1)] != gHc || len(first.Approved) != 2 {
|
||||
t.Fatalf("the merge does not carry the Approve's heads of both members: %v", first.Approved)
|
||||
}
|
||||
if first.Ask != w.asked {
|
||||
t.Fatalf("the merge names no ask: %+v", first)
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.merges.puts) != 1 {
|
||||
t.Fatalf("the next member was handed before the bridge said the first merged: %d merges written", len(w.merges.puts))
|
||||
}
|
||||
w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}})
|
||||
w.settleAll()
|
||||
if len(w.merges.puts) != 2 {
|
||||
t.Fatalf("the second member was not handed after the first merged: %d merges written", len(w.merges.puts))
|
||||
}
|
||||
second := w.merges.last("feat/g")
|
||||
if len(second.Pulls) != 2 || second.Pulls[0].State != sdk.MergeMerged || second.Pulls[0].Commit != strings.Repeat("a", 40) ||
|
||||
second.Pulls[1].Repository != gCtl || second.Pulls[1].State != sdk.MergeMerging {
|
||||
t.Fatalf("the next merge is not the first merged and the second merging: %+v", second.Pulls)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2: the bridge's refused hands no member, and the group is said urgent, naming the member and the forge's words.
|
||||
func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
w.merges.say(sdk.Forge{Kind: sdk.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge",
|
||||
Words: "the forge refused gitea_merge_pull_request: mesh/repo-check (missing)"})
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if len(w.merges.puts) != 2 {
|
||||
t.Fatalf("%d merges written; wanted the first handed and then written refused, and none handed after", len(w.merges.puts))
|
||||
}
|
||||
refused := w.merges.last("feat/g")
|
||||
if len(refused.Pulls) != 1 || refused.Pulls[0].State != sdk.MergeRefused || !strings.Contains(refused.Pulls[0].Words, "mesh/repo-check (missing)") {
|
||||
t.Fatalf("the refusal is not written into the merge: %+v", refused.Pulls)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") {
|
||||
t.Fatalf("the refusal is not said as one urgent line: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0441 §2: before a member is handed, every member's required statuses are read again; one that is not a success holds
|
||||
// the group, even after the first member merged.
|
||||
func TestTheNextMemberIsNotHandedWhileARequiredStatusOfTheGroupIsNotASuccess(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}})
|
||||
setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing meanwhile
|
||||
w.settleAll()
|
||||
if len(w.merges.puts) != 1 {
|
||||
t.Fatalf("the second member was handed with a required status missing: %d merges written", len(w.merges.puts))
|
||||
}
|
||||
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "mesh/repo-check (missing)") {
|
||||
t.Fatalf("the held group is not said urgent: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// AC4: once every member is merged the gate writes source.main once, and each merge is announced with the ask, its asker the
|
||||
// teller, and the heads.
|
||||
func TestAGroupMergedWholeMovesSourceMainOnceAndAnnouncesWithTheTeller(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("merges %v", w.forge.merges)
|
||||
}
|
||||
if len(w.source.moves) != 1 {
|
||||
t.Fatalf("source.main moved %d times, wanted once", len(w.source.moves))
|
||||
}
|
||||
main := w.source.moves[0]
|
||||
commit := strings.Repeat("c", 40)
|
||||
if main.Group != "feat/g" || main.Heads[gCat] != commit || main.Heads[gCtl] != commit || len(main.Heads) != 2 {
|
||||
t.Fatalf("source.main is %+v; wanted both repositories at their merge commits, moved by the group", main)
|
||||
}
|
||||
final := w.merges.last("feat/g")
|
||||
if len(final.Pulls) != 2 || final.Pulls[0].State != sdk.MergeMerged || final.Pulls[1].State != sdk.MergeMerged {
|
||||
t.Fatalf("the merge is not written as all merged: %+v", final.Pulls)
|
||||
}
|
||||
say := mergedSaid(w)
|
||||
if len(say) != 2 {
|
||||
t.Fatalf("announced %d merges, wanted each member's once: %v", len(say), say)
|
||||
}
|
||||
for _, m := range say {
|
||||
if m["asker"] != sdk.TellerModule || !strings.HasPrefix(m["ask_id"].(string), "merge-feat_g-") || m["option"] != OptionApprove {
|
||||
t.Fatalf("the announcement does not name the ask and its asker the teller: %v", m)
|
||||
}
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != 1 {
|
||||
t.Fatalf("source.main moved again: %d", len(w.source.moves))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **The gate writes nothing to the forge: its statuses, views, notes and comments are owed keys, and its plan is read from
|
||||
// `plans`** (novox/hq ADR 0437 §2, §8, ADR 0450 §2, §4, §7, issue 1349; the empty check answer is the mesh-delivery half of
|
||||
// issue 1311). Fixture sources: the key and the value of an owed act are mesh-sdk go/delivery's (OwedKey, Owed); the arguments
|
||||
// of `plans` are mesh-controller cmd/mesh-controller/seatverbs.go's (repository, paths, module-dirs, head, base, removed, data)
|
||||
// and its answer the document of deliveryPlanDocument (plan, reach, mesh, gated).
|
||||
|
||||
// AC1: a transition that today sets a status, a view, a note or a comment writes the matching owed key.
|
||||
func TestATransitionWritesItsStatusViewAndNoteAsOwedKeys(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
page := "https://forge.invalid/novox/app/pulls/7"
|
||||
statusKey, _ := sdk.OwedKey("novox/app", 7, head, "status=mesh/delivery")
|
||||
viewKey, _ := sdk.OwedKey("novox/app", 7, head, "view")
|
||||
noteKey, _ := sdk.OwedKey("novox/app", 7, head, "note")
|
||||
for _, key := range []string{statusKey, viewKey, noteKey} {
|
||||
if _, there := w.owed.keys[key]; !there {
|
||||
t.Fatalf("the key %s was not written; the keys are %v", key, w.owed.writes)
|
||||
}
|
||||
}
|
||||
if s := w.owed.keys[statusKey]; s.Act != sdk.OwedStatus || s.Context != "mesh/delivery" || s.State != "pending" || s.Target != page || s.Text == "" {
|
||||
t.Fatalf("the owed status is %+v; wanted mesh/delivery pending, pointing at %s", s, page)
|
||||
}
|
||||
if v := w.owed.keys[viewKey]; v.Act != sdk.OwedView || !strings.Contains(v.Text, id) {
|
||||
t.Fatalf("the owed view is %+v", v)
|
||||
}
|
||||
if n := w.owed.keys[noteKey]; n.Act != sdk.OwedNote || n.Ref != "mesh-plan" || !strings.Contains(n.Text, "proposed") {
|
||||
t.Fatalf("the owed note is %+v", n)
|
||||
}
|
||||
// What the bridge would perform is what the forge holds.
|
||||
if len(w.forge.notesOn(head)) == 0 || !strings.HasPrefix(statusOn(w, "novox/app", head), "pending") {
|
||||
t.Fatalf("the fake bridge did not perform what was owed: %v", w.forge.notesOn(head))
|
||||
}
|
||||
}
|
||||
|
||||
// AC1: a note on the merge commit, which no pull request names, is keyed with the number 0.
|
||||
func TestANoteOnTheMergeCommitIsOwedWithNumberZero(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/app", merge, true)))
|
||||
w.settleAll()
|
||||
key, _ := sdk.OwedKey("novox/app", 0, merge, "note")
|
||||
if n, there := w.owed.keys[key]; !there || n.Act != sdk.OwedNote {
|
||||
t.Fatalf("no note was owed on the merge commit under %s; the keys are %v", key, w.owed.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// AC1: two comments on one head are two acts: the sequence number is in the act.
|
||||
func TestTwoCommentsOnOneHeadAreTwoOwedKeys(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.mu.Lock()
|
||||
d := w.h.deliveries[id]
|
||||
w.h.sayOn(d, "the first thing said")
|
||||
w.h.sayOn(d, "the second thing said")
|
||||
w.h.mu.Unlock()
|
||||
w.settleAll()
|
||||
one, _ := sdk.OwedKey("novox/app", 7, head, "comment.1")
|
||||
two, _ := sdk.OwedKey("novox/app", 7, head, "comment.2")
|
||||
if w.owed.keys[one].Text != "the first thing said" || w.owed.keys[two].Text != "the second thing said" {
|
||||
t.Fatalf("the comments are %+v and %+v", w.owed.keys[one], w.owed.keys[two])
|
||||
}
|
||||
if len(w.forge.comments) != 2 {
|
||||
t.Fatalf("the bridge would say %d comments: %v", len(w.forge.comments), w.forge.comments)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2: the gate's code calls no gitea write tool and no `delivery-plan`, and no controller verb beyond the ones its manifest
|
||||
// names (ADR 0450 §7). Tests are left out: they name the words to prove them gone.
|
||||
func TestTheGatesCodeCallsNoForgeWriteToolAndNoDeliveryPlan(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forbidden := []string{"delivery-plan", "gitea_commit_status\"", "gitea_delivery_view", "gitea_note_append", "gitea_add_comment",
|
||||
"gitea_merge_pull_request\"", "gitea_open_pulls_write"}
|
||||
// The controller's verbs the gate calls: ADR 0450 §7.
|
||||
allowed := []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "forget-checks", "plans"}
|
||||
called := map[string]bool{}
|
||||
fset := token.NewFileSet()
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fset, f, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.BasicLit:
|
||||
if x.Kind == token.STRING {
|
||||
for _, word := range forbidden {
|
||||
if strings.Contains(x.Value, word) {
|
||||
t.Errorf("%s holds the string %s", f, x.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
if sel, ok := x.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "verb" && len(x.Args) > 0 {
|
||||
if lit, ok := x.Args[0].(*ast.BasicLit); ok {
|
||||
verb, _ := strconv.Unquote(lit.Value)
|
||||
called[verb] = true
|
||||
if !slices.Contains(allowed, verb) {
|
||||
t.Errorf("%s calls the controller's verb %q", f, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
for _, verb := range []string{"plans", "delivery-check"} {
|
||||
if !called[verb] {
|
||||
t.Errorf("the gate calls no %s: the test reads nothing", verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stubController is a controller that answers every verb with one document, and keeps what it was asked.
|
||||
type stubController struct {
|
||||
mu sync.Mutex
|
||||
keys []string
|
||||
bodies []map[string]any
|
||||
answer string
|
||||
}
|
||||
|
||||
func (s *stubController) ask(key string, body any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.keys = append(s.keys, key)
|
||||
b, _ := body.(map[string]any)
|
||||
s.bodies = append(s.bodies, b)
|
||||
return json.RawMessage(s.answer), nil
|
||||
}
|
||||
|
||||
func (s *stubController) called(key string) []map[string]any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []map[string]any
|
||||
for i, k := range s.keys {
|
||||
if k == key {
|
||||
out = append(out, s.bodies[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AC3: a delivery with no plan gets it from `plans`, asked as data, with the change's diffset.
|
||||
func TestADeliveryWithNoPlanGetsItFromPlansAsData(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
stub := &stubController{answer: `{"ok":true,"output":"","answer":{"asked":"check-1","held":true,"walks":[],
|
||||
"plan":{"repository":"novox/app","base":"main","head":"` + head + `","moved":["app"],"summary":"builds app, sent to the node"}}}`}
|
||||
w.h.Controller = seatController{ask: stub.ask}
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
c := verdict("novox/app", 7, head, "pass")
|
||||
c.Plan = nil
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
asked := stub.called("seat:mesh-controller.plans")
|
||||
if len(asked) == 0 {
|
||||
t.Fatalf("`plans` was not asked; the controller was asked %v", stub.keys)
|
||||
}
|
||||
if len(stub.called("seat:mesh-controller.delivery-plan")) != 0 {
|
||||
t.Fatal("delivery-plan was asked")
|
||||
}
|
||||
got := asked[0]
|
||||
if got["repository"] != "novox/app" || got["paths"] != "src/a.go" || got["data"] != "true" || got["head"] != head || got["base"] != "main" {
|
||||
t.Fatalf("plans was asked %v; wanted the repository, the paths, the head, the base and data", got)
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", head)); d.Plan == nil || d.Plan.Summary != "builds app, sent to the node" {
|
||||
t.Fatalf("the delivery's plan is %+v", d.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
// AC4: a delivery-check answer that holds no id is an error said aloud, naming the delivery, and not taken as asked.
|
||||
func TestAnEmptyDeliveryCheckAnswerIsAnErrorNamingTheDelivery(t *testing.T) {
|
||||
stub := &stubController{answer: `{"ok":true,"output":"","answer":{}}`}
|
||||
if id, err := (seatController{ask: stub.ask}).Check("", []Member{{ID: "novox/app@x"}}); err == nil || id != "" ||
|
||||
!strings.Contains(err.Error(), "no check id") {
|
||||
t.Fatalf("an empty check answer gave %q and %v", id, err)
|
||||
}
|
||||
w := newWorld(t)
|
||||
var said []string
|
||||
var mu sync.Mutex
|
||||
w.h.Logf = func(format string, a ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
said = append(said, strings.TrimSpace(fmt.Sprintf(format, a...)))
|
||||
}
|
||||
w.h.Controller = seatController{ask: stub.ask}
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
found := false
|
||||
for _, line := range said {
|
||||
if strings.Contains(line, id) && strings.Contains(line, "no check id") && !strings.Contains(line, "taken as refused") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no error naming %s and the empty answer was logged: %q", id, said)
|
||||
}
|
||||
if d := w.delivery(id); d.CheckAsk != nil {
|
||||
t.Fatalf("an empty answer was kept as an ask: %+v", d.CheckAsk)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -57,15 +57,15 @@ func TestDeliveryStatusCountsTheOwedEffectsTried(t *testing.T) {
|
||||
t.Fatalf("last try said %v, tried at %s", tried["last"], w.now.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// The forge away: its effects fail and are counted failed; quiet passes try nothing more.
|
||||
w.forge.down = true
|
||||
// The bus away: its effects fail and are counted failed; quiet passes try nothing more.
|
||||
w.owed.down = true
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.Flush()
|
||||
tried = triedSaid(t, w)
|
||||
failed := countOf(t, tried, "failed")
|
||||
if failed == 0 {
|
||||
t.Fatalf("the forge away failed nothing: %v", tried)
|
||||
t.Fatalf("the bus away failed nothing: %v", tried)
|
||||
}
|
||||
if countOf(t, tried, "tried") != countOf(t, tried, "done")+failed+countOf(t, tried, "given-up") {
|
||||
t.Fatalf("tries do not add up: %v", tried)
|
||||
@@ -80,7 +80,7 @@ func TestDeliveryStatusCountsTheOwedEffectsTried(t *testing.T) {
|
||||
}
|
||||
|
||||
// The forge back, past every next try: the failed effects are done.
|
||||
w.forge.down = false
|
||||
w.owed.down = false
|
||||
w.later(time.Hour)
|
||||
w.h.Flush()
|
||||
tried = triedSaid(t, w)
|
||||
+52
-44
@@ -1,4 +1,4 @@
|
||||
// mesh-delivery: the holder of the mesh-delivery seat (novox/hq ADR 0239, to-be 47). A Go bundle the node's
|
||||
// mesh-delivery-gate: the holder of the mesh-delivery seat (novox/hq ADR 0239, to-be 47). A Go bundle the node's
|
||||
// runtime launches. It owns the delivery — one commit in one repository, from its pull request's head to
|
||||
// every machine — and the delivery group above it: their states in one compiled table, kept in its own state
|
||||
// on the bus, every transition said as an event, noted on the commit and shown on the pull request through
|
||||
@@ -16,8 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
@@ -34,43 +33,48 @@ func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.n
|
||||
// The events this owner follows: the forge's pull requests, and the controller's verdicts and walks.
|
||||
// The forge's status events say that a check answered (events.go).
|
||||
var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed",
|
||||
"gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".checked", ControllerSeat + ".plan-moved"}
|
||||
"gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".checked", ControllerSeat + ".plan-moved",
|
||||
forgePattern}
|
||||
|
||||
// forgePattern is the forge bridge's messages on FORGE (novox/hq ADR 0437 §8): the gate follows a merge and the refusal of one.
|
||||
const forgePattern = "mesh-delivery-forge-bridge.forge.**"
|
||||
|
||||
func main() {
|
||||
operator := &asker.Client{Module: Module, State: stdio.State("asked")}
|
||||
occupancy := &busOccupancy{}
|
||||
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: operator,
|
||||
Operator: kvOperator{},
|
||||
Merges: kvMerges{},
|
||||
Source: kvSource{},
|
||||
Owed: kvOwed{},
|
||||
Pulls: kvPulls{},
|
||||
// The gate acts only while it holds the seat, and carries today's state over when it first does (seat.go).
|
||||
Seat: occupancy,
|
||||
Carry: carryFromBus,
|
||||
}
|
||||
h.OnHold = func() { go readOnHolding(h) }
|
||||
h.init()
|
||||
l := &listening{now: "not yet: starting"}
|
||||
go run(h, l, operator)
|
||||
go run(h, l, occupancy)
|
||||
if err := stdio.Serve("", tools(h, l)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// run takes the events and the operator's answers, which a gate that does not hold the seat acknowledges and leaves
|
||||
// alone, and watches who has the seat (watchSeat). The state is read back, after today's is carried, when the gate first
|
||||
// holds the seat (SeatChanged), and what it may have missed meanwhile is read once then (readOnHolding): the controller's
|
||||
// walks, the forge's open pull requests, the operator's asks — and it 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, occupancy *busOccupancy) {
|
||||
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()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
logf("[mesh-delivery] cannot read back its deliveries yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
go h.Work()
|
||||
go watchSeat(h, l, occupancy)
|
||||
for _, pattern := range followed {
|
||||
pattern := pattern
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
@@ -83,34 +87,27 @@ func run(h *Holder, l *listening, operator *asker.Client) {
|
||||
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
|
||||
// The teller's answers: a change of its bucket asks for a look, which reads the answers to the asks pending.
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.State(sdkdelivery.TellerModule+".answers").Watch("", func(stdio.StateChange) error { h.AnswersChanged(); return nil })
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
logf("[mesh-delivery] not hearing the teller's answers yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
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.
|
||||
}
|
||||
|
||||
// readOnHolding reads once, now that the gate holds the seat and events arrive, what happened while it was away: the
|
||||
// forge's open pull requests (heads nobody checks) are read by the first look, the asks the router closed meanwhile are read
|
||||
// here, and the controller's walks, until they have been read once; plan-moved events carry every move after.
|
||||
func readOnHolding(h *Holder) {
|
||||
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 {
|
||||
@@ -125,12 +122,19 @@ func run(h *Holder, l *listening, operator *asker.Client) {
|
||||
// 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 {
|
||||
if !h.Holding() {
|
||||
return nil // not the holder of the seat: the event is acknowledged and taken by no one (seat.go)
|
||||
}
|
||||
taken := take(h, e)
|
||||
h.Poke()
|
||||
return taken
|
||||
}
|
||||
|
||||
func take(h *Holder, e stdio.Envelope) error {
|
||||
if strings.HasPrefix(e.Key, "mesh-delivery-forge-bridge.forge.") {
|
||||
h.Forged(e.Key, e.Body)
|
||||
return nil
|
||||
}
|
||||
switch e.Key {
|
||||
case "gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed":
|
||||
var p PullEvent
|
||||
@@ -246,7 +250,7 @@ func need(a map[string]any, keys ...string) error {
|
||||
}
|
||||
|
||||
func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
seat := "mesh-delivery."
|
||||
seat := Seat + "."
|
||||
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 " +
|
||||
@@ -412,7 +416,11 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
// 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
|
||||
ofSeat := strings.HasPrefix(list[i].Name, seat)
|
||||
list[i].Run = func(a map[string]any) (any, error) {
|
||||
if ofSeat && !h.Holding() {
|
||||
return nil, h.notHolder() // a verb of the seat is answered by its holder only
|
||||
}
|
||||
out, err := run(a)
|
||||
h.Poke()
|
||||
return out, err
|
||||
+40
-16
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -26,10 +27,25 @@ type manifest struct {
|
||||
Consumes []string `json:"consumes"`
|
||||
Emits []string `json:"emits"`
|
||||
Invokes []string `json:"invokes"`
|
||||
State []string `json:"state"`
|
||||
State []any `json:"state"`
|
||||
Reads []string `json:"reads"`
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
|
||||
// buckets are the names of the state the manifest declares, each a bare name or an object with a name and a history.
|
||||
func (m manifest) buckets() []string {
|
||||
var out []string
|
||||
for _, s := range m.State {
|
||||
switch v := s.(type) {
|
||||
case string:
|
||||
out = append(out, v)
|
||||
case map[string]any:
|
||||
out = append(out, v["name"].(string))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
@@ -45,35 +61,43 @@ func readManifest(t *testing.T) (manifest, string) {
|
||||
|
||||
func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "mesh-delivery" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-delivery" || m.Claims[0].Scope != "mesh" {
|
||||
if m.Module != "mesh-delivery-gate" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-delivery" || m.Claims[0].Scope != "mesh" {
|
||||
t.Fatalf("claims %+v", m.Claims)
|
||||
}
|
||||
// The seat is the mesh's own (mesh-*): a module claims it and never declares it.
|
||||
if len(m.Seats) != 0 {
|
||||
t.Fatal("the manifest declares a seat; mesh-delivery is the mesh's own")
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, followed) {
|
||||
t.Fatalf("consumes %v, follows %v", m.Consumes, followed)
|
||||
// The thirteen verbs of the seat (ADR 0437 §3, §11).
|
||||
if len(m.Claims[0].Serves) != 13 {
|
||||
t.Fatalf("serves %d verbs, wanted 13: %v", len(m.Claims[0].Serves), m.Claims[0].Serves)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, followed) || !slices.Contains(m.Consumes, "mesh-delivery-forge-bridge.forge.**") {
|
||||
t.Fatalf("consumes %v, follows %v (the bridge's forge messages among them)", m.Consumes, followed)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged"}) {
|
||||
t.Fatalf("emits %v", m.Emits)
|
||||
}
|
||||
if !reflect.DeepEqual(m.State, []string{"deliveries", "groups", "asked", "pulls"}) {
|
||||
t.Fatalf("state %v", m.State)
|
||||
// Seven buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3.
|
||||
buckets := m.buckets()
|
||||
if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source"}) {
|
||||
t.Fatalf("state %v", buckets)
|
||||
}
|
||||
// It calls exactly what its ports ask: the controller's verbs and the forge's tools.
|
||||
// What it reads: the carry from today's module's three buckets, the teller's answers, the seat's holder and the
|
||||
// machines' haves (ADR 0450 §6 and its insight of 2026-10-20).
|
||||
if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls",
|
||||
"mesh-delivery-teller.answers", "mesh-controller.assignments", "node-engine.have"}) {
|
||||
t.Fatalf("reads %v", m.Reads)
|
||||
}
|
||||
// It calls exactly the controller's seven verbs (ADR 0450 §7) and reads the forge with three tools; the forge's
|
||||
// writes are the bridge's (ADR 0437 §8). It uses no operator-channel: the teller asks (ADR 0437 §6).
|
||||
want := []string{}
|
||||
for _, v := range []string{"delivery-plan", "delivery-check", "deliver", "delivery-backups-again", "delivery-stop", "delivery-walks", "forget-checks"} {
|
||||
for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "forget-checks", "plans"} {
|
||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||
}
|
||||
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls",
|
||||
// The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off.
|
||||
"gitea.gitea_merge_pull_request", "gitea.gitea_add_comment")
|
||||
// A merge into main asks the operator for the Approve (novox/hq ADR 0398 §3, issue 948), as the knowledge base asks.
|
||||
want = append(want, "seat:operator-channel.notify")
|
||||
if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
|
||||
t.Fatalf("uses %v", m.Uses)
|
||||
want = append(want, "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
|
||||
if len(m.Uses) != 0 {
|
||||
t.Fatalf("uses %v: the teller asks, not the gate", m.Uses)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
|
||||
@@ -0,0 +1,406 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The gate merges through the forge bridge, never itself (novox/hq ADR 0437 §8, ADR 0441 §5, ADR 0450 §3). It writes into
|
||||
// `merges.<group>` the Approve's heads and ONE member at a time, the next only after the bridge's message on FORGE says the
|
||||
// one before it merged, and none after a refusal. It re-reads every member's required statuses before it hands a member
|
||||
// (ADR 0441 §2). Once every member is merged it moves `source.main` in one compare-and-set write and the mesh's source has
|
||||
// the group as one revision. What it announces for each merge (`merged`) names the ask, its asker the teller, and the heads.
|
||||
|
||||
// MergeStore is the gate's bucket merges.
|
||||
type MergeStore interface {
|
||||
Get(group string) (*sdk.Merge, error)
|
||||
Put(group string, m sdk.Merge) error
|
||||
}
|
||||
|
||||
// SourceStore is the gate's bucket source, one key, moved by compare-and-set.
|
||||
type SourceStore interface {
|
||||
// Move reads source.main and writes what update makes of it, unless update says nothing is to be written; a write
|
||||
// another writer won is read again and tried again.
|
||||
Move(update func(prev *sdk.Source) (sdk.Source, bool)) error
|
||||
}
|
||||
|
||||
// kvMerges is merges over the runtime.
|
||||
type kvMerges struct{}
|
||||
|
||||
func (kvMerges) Get(group string) (*sdk.Merge, error) {
|
||||
key, err := sdk.MergesKey(group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, err := stdio.State("merges").Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := sdk.DecodeMerge(e.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the merge of %s is not readable: %w", group, err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (kvMerges) Put(group string, m sdk.Merge) error {
|
||||
key, err := sdk.MergesKey(group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := sdk.EncodeMerge(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stdio.State("merges").Put(key, json.RawMessage(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
// kvSource is source over the runtime.
|
||||
type kvSource struct{}
|
||||
|
||||
func (kvSource) Move(update func(prev *sdk.Source) (sdk.Source, bool)) error {
|
||||
state := stdio.State("source")
|
||||
for range changeAttempts {
|
||||
e, err := state.Get(sdk.SourceMainKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var prev *sdk.Source
|
||||
if e != nil {
|
||||
p, err := sdk.DecodeSource(e.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source.main is not readable: %w", err)
|
||||
}
|
||||
prev = &p
|
||||
}
|
||||
next, write := update(prev)
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
raw, err := sdk.EncodeSource(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e == nil {
|
||||
_, err = state.Create(sdk.SourceMainKey, json.RawMessage(raw))
|
||||
} else {
|
||||
_, err = state.Update(sdk.SourceMainKey, json.RawMessage(raw), e.Revision)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, stdio.ErrChanged) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("source.main was written by another writer first %d times: %w", changeAttempts, stdio.ErrChanged)
|
||||
}
|
||||
|
||||
// approvedOf are the heads an Approve covers as a merge carries them: `<repository>@<number>` to the full head.
|
||||
func (h *Holder) approvedOf(heads map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for id, sha := range heads {
|
||||
if m := h.deliveries[id]; m != nil {
|
||||
out[sdk.ApprovedKey(m.Repository, m.Number)] = sha
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handTurn is d's turn to be handed to the bridge, when it is: the first member of its group not merged (a lone pull request
|
||||
// is a group of one), every member approved at its head and ready to merge. Nil when it is not. Under the lock; the hand
|
||||
// runs outside it.
|
||||
func (h *Holder) handTurn(g *Group, d *Delivery, now time.Time) func() {
|
||||
var reads []memberRead
|
||||
c := h.covering(d)
|
||||
if g != nil {
|
||||
order := h.unmergedLive(g)
|
||||
if len(order) == 0 || order[0] != d {
|
||||
return nil
|
||||
}
|
||||
for _, m := range order {
|
||||
mc := h.covering(m)
|
||||
if m.Ready == nil || m.Ready.Declined != nil || mc == nil || mc.Approved == nil || mc.Heads[m.ID] != m.Commit || h.readyWaits(m) != "" {
|
||||
return nil // the others are not approved at their heads yet: nothing is merged before they are
|
||||
}
|
||||
reads = append(reads, readOf(m, mc.Heads[m.ID]))
|
||||
}
|
||||
}
|
||||
if c == nil || c.Approved == nil || c.Heads[d.ID] == "" {
|
||||
return nil
|
||||
}
|
||||
d.Ready.Tries++
|
||||
d.Ready.LastTry, d.Ready.merging = now, true
|
||||
h.keep(d)
|
||||
gid, name, key, target, approved := "", askGroup(d), c.AskID, d.ID, h.approvedOf(c.Heads)
|
||||
if g != nil {
|
||||
gid = g.ID
|
||||
}
|
||||
return func() { h.hand(gid, name, key, target, reads, approved) }
|
||||
}
|
||||
|
||||
// hand reads a group's required statuses and, when all are a success, writes the target as the member being merged.
|
||||
func (h *Holder) hand(gid, name, key, target string, reads []memberRead, approved map[string]string) {
|
||||
if gid != "" && !h.groupMergeable(gid, target, reads) {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
value, ok := h.mergeValue(name, key, approved, target, sdk.MergeMerging)
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
h.giveBack(target, nil)
|
||||
return
|
||||
}
|
||||
cur, err := h.Merges.Get(name)
|
||||
if err == nil && cur != nil && sdk.SameMerge(*cur, value) {
|
||||
return // handed already, the bridge has the revision
|
||||
}
|
||||
if err == nil {
|
||||
err = h.Merges.Put(name, value)
|
||||
}
|
||||
if err != nil {
|
||||
h.giveBack(target, err)
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s is handed to the forge bridge to merge, at the heads the operator approved", target)
|
||||
}
|
||||
|
||||
// giveBack gives a target its turn back after a hand that could not be made, to be asked again after a while.
|
||||
func (h *Holder) giveBack(target string, err error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if d := h.deliveries[target]; d != nil && d.Ready != nil {
|
||||
d.Ready.merging = false
|
||||
d.Ready.LastTry = time.Time{}
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s could not be handed to the forge bridge: %v; asked again in %s", target, err, askAgainAfter)
|
||||
}
|
||||
h.again("a merge that could not be handed to the forge bridge")
|
||||
}
|
||||
|
||||
// mergeValue is the value of a group's merges key with these members merged, and the target in the state asked (merging, or
|
||||
// refused with the forge's words): the group's members in order, those the Approve covers. Under the lock. False when it
|
||||
// cannot be made (the target is not among the heads the Approve covers).
|
||||
func (h *Holder) mergeValue(name, key string, approved map[string]string, target string, state sdk.MergeState) (sdk.Merge, bool) {
|
||||
t := h.deliveries[target]
|
||||
if t == nil {
|
||||
return sdk.Merge{}, false
|
||||
}
|
||||
members := []*Delivery{t}
|
||||
if g := h.groups[t.Group]; t.Group != "" && g != nil {
|
||||
members = h.membersOf(g)
|
||||
}
|
||||
m := sdk.Merge{Group: name, Ask: key, Approved: approved}
|
||||
for _, o := range members {
|
||||
head := approved[sdk.ApprovedKey(o.Repository, o.Number)]
|
||||
if head == "" {
|
||||
continue
|
||||
}
|
||||
pull := sdk.MergePull{Head: sdk.Head{Repository: o.Repository, Number: o.Number, Head: head}}
|
||||
switch {
|
||||
case o.MergedAs != "" || (o.Ready != nil && o.Ready.Merged != nil):
|
||||
commit := o.MergedAs
|
||||
if o.Ready != nil && o.Ready.MergeCommit != "" {
|
||||
commit = o.Ready.MergeCommit
|
||||
}
|
||||
if commit == "" {
|
||||
continue
|
||||
}
|
||||
pull.State, pull.Commit = sdk.MergeMerged, commit
|
||||
case o == t:
|
||||
pull.State = state
|
||||
if state == sdk.MergeRefused {
|
||||
pull.Words = o.Ready.Answer
|
||||
}
|
||||
m.Pulls = append(m.Pulls, pull)
|
||||
return m, true
|
||||
default:
|
||||
return m, false
|
||||
}
|
||||
m.Pulls = append(m.Pulls, pull)
|
||||
}
|
||||
return m, len(m.Pulls) > 0 && state == sdk.MergeMerged
|
||||
}
|
||||
|
||||
// Forged takes a message of the forge bridge on FORGE: the gate follows a merge and the refusal of a merge, and nothing else.
|
||||
func (h *Holder) Forged(event string, body []byte) {
|
||||
f, err := sdk.DecodeForge(body)
|
||||
if err != nil {
|
||||
h.Refuse(fmt.Errorf("a message of the forge bridge is not readable: %v", err))
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case f.Kind == sdk.ForgeMerged:
|
||||
h.ForgeMerged(f)
|
||||
case f.Kind == sdk.ForgeRefused && f.Act == "merge":
|
||||
h.ForgeRefused(f)
|
||||
}
|
||||
}
|
||||
|
||||
// memberOfForge is the delivery a forge message is about: at the head it names.
|
||||
func (h *Holder) memberOfForge(f sdk.Forge) *Delivery {
|
||||
if d := h.deliveries[IDOf(f.Repository, f.Head)]; d != nil && d.Number == f.Number {
|
||||
return d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForgeMerged takes the bridge's word that a pull request the gate handed to it merged: the merge is announced once,
|
||||
// with the ask, its asker the teller and the heads (issue 1025), and the next member's turn comes at the next look.
|
||||
func (h *Holder) ForgeMerged(f sdk.Forge) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.memberOfForge(f)
|
||||
if d == nil || d.Ready == nil || d.Ready.Approved == nil || d.Ready.Merged != nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, f.Head))), Since: now})
|
||||
r.Merged, r.Answer, r.RefusedSince, r.merging, r.MergeCommit = &now, "merged", nil, false, f.Merge
|
||||
h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s, its check passed against its trunk's head", d.ID, shortOf(f.Head, 12))
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// ForgeRefused takes the bridge's word that the forge refused a merge: nothing after it is merged, and the refusal is said
|
||||
// as a stalled line (ADR 0441 §3, §4). The merge is handed again only at the next event that decides whether it may
|
||||
// merge (mergeAgainNow).
|
||||
func (h *Holder) ForgeRefused(f sdk.Forge) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.memberOfForge(f)
|
||||
if d == nil || d.Ready == nil || d.Ready.Merged != nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
r.merging, r.refusedNoted = false, false
|
||||
answer := oneLine(f.Words)
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince = &now
|
||||
}
|
||||
if !outOfDateRefusal(answer) {
|
||||
r.Urgent = true // never a warning again for this head
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", d.ID,
|
||||
answer)
|
||||
}
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// settleMerges works out, under the lock, what a merge owes its key and the source once the bridge has spoken: a refused
|
||||
// member written refused, and a group whose members are all merged written merged and moved onto source.main in one write.
|
||||
// The writing happens outside the lock.
|
||||
func (h *Holder) settleMerges() []func() {
|
||||
var out []func()
|
||||
done := map[string]bool{}
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
r := d.Ready
|
||||
if r == nil || r.Approved == nil || r.AskID == "" || len(r.Heads) == 0 {
|
||||
continue
|
||||
}
|
||||
name, key, approved := askGroup(d), r.AskID, h.approvedOf(r.Heads)
|
||||
switch {
|
||||
case r.Merged == nil && r.RefusedSince != nil && !r.refusedNoted && r.Answer != "" && r.Answer != "merged":
|
||||
r.refusedNoted = true
|
||||
target := d.ID
|
||||
out = append(out, func() {
|
||||
h.mu.Lock()
|
||||
value, ok := h.mergeValue(name, key, approved, target, sdk.MergeRefused)
|
||||
h.mu.Unlock()
|
||||
if ok {
|
||||
if err := h.Merges.Put(name, value); err != nil {
|
||||
h.Logf("[mesh-delivery] the refusal of %s could not be written to its merge: %v", target, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
case r.Merged != nil && !r.SourceMoved && !done[key]:
|
||||
if !h.allMerged(r.Heads) {
|
||||
continue
|
||||
}
|
||||
done[key] = true
|
||||
heads := map[string]string{}
|
||||
var members []string
|
||||
for hid := range r.Heads {
|
||||
m := h.deliveries[hid]
|
||||
commit := m.MergedAs
|
||||
if m.Ready != nil && m.Ready.MergeCommit != "" {
|
||||
commit = m.Ready.MergeCommit
|
||||
}
|
||||
heads[m.Repository] = commit
|
||||
members = append(members, hid)
|
||||
}
|
||||
sort.Strings(members)
|
||||
last := h.deliveries[members[len(members)-1]]
|
||||
out = append(out, func() { h.finish(name, key, approved, last.ID, heads, members) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allMerged is whether every delivery an Approve covers is merged.
|
||||
func (h *Holder) allMerged(heads map[string]string) bool {
|
||||
for id := range heads {
|
||||
m := h.deliveries[id]
|
||||
if m == nil || (m.MergedAs == "" && (m.Ready == nil || m.Ready.Merged == nil)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// finish writes a group's merge as all merged and moves source.main to the merge commits in one compare-and-set write: the
|
||||
// group is one revision of the mesh's source (ADR 0437 §1, §8).
|
||||
func (h *Holder) finish(name, key string, approved map[string]string, last string, heads map[string]string, members []string) {
|
||||
h.mu.Lock()
|
||||
value, ok := h.mergeValue(name, key, approved, last, sdk.MergeMerged)
|
||||
h.mu.Unlock()
|
||||
var err error
|
||||
if ok {
|
||||
err = h.Merges.Put(name, value)
|
||||
}
|
||||
if err == nil {
|
||||
err = h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) {
|
||||
next := sdk.Source{Group: name, Heads: map[string]string{}}
|
||||
if prev != nil {
|
||||
for repository, head := range prev.Heads {
|
||||
next.Heads[repository] = head
|
||||
}
|
||||
}
|
||||
for repository, commit := range heads {
|
||||
next.Heads[repository] = commit
|
||||
}
|
||||
return next, prev == nil || !reflect.DeepEqual(prev.Heads, next.Heads)
|
||||
})
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s merged, and its place in the source cannot be written yet: %v; asked again in %s", name, err, askAgainAfter)
|
||||
h.again("source.main, moved once every member is merged")
|
||||
return
|
||||
}
|
||||
for _, id := range members {
|
||||
if m := h.deliveries[id]; m != nil && m.Ready != nil {
|
||||
m.Ready.SourceMoved = true
|
||||
h.keep(m)
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] group %s is merged whole: source.main holds %s", name, strings.Join(members, ", "))
|
||||
}
|
||||
+17
@@ -38,6 +38,8 @@ type Delivery struct {
|
||||
// that was said, for this pull request.
|
||||
After []string `json:"after,omitempty"`
|
||||
AfterSaid bool `json:"after_said,omitempty"`
|
||||
// Comments is how many comments this owner owed on the pull request at this head, which numbers the next.
|
||||
Comments int `json:"comments,omitempty"`
|
||||
// Issues are the issues its title and body name ("issue 948"), shown to the operator who is asked to approve its merge.
|
||||
Issues []int `json:"issues,omitempty"`
|
||||
// Group is the delivery group it is in: its head branch name. Empty for a lone delivery.
|
||||
@@ -321,6 +323,9 @@ const (
|
||||
EffectNote = "note"
|
||||
EffectView = "view"
|
||||
EffectStatus = "status"
|
||||
// EffectComment is a comment on the pull request; its Context is the sequence number of the comment on the head, which
|
||||
// keeps two comments on one head two acts (mesh-sdk go/delivery OwedCommentAct).
|
||||
EffectComment = "comment"
|
||||
)
|
||||
|
||||
// FromForgesWord is whether the delivery was made from the forge's word at its merge: said by FromForge, or —
|
||||
@@ -968,12 +973,24 @@ type Group struct {
|
||||
Closed bool `json:"closed,omitempty"`
|
||||
// Said is the derived state last said, so a change of it is said once.
|
||||
Said string `json:"said,omitempty"`
|
||||
// Hold is why the group's merge or its Approve's ask is held back by a member's required status that is not a
|
||||
// success (issue 1275); nil when nothing is.
|
||||
Hold *GroupHold `json:"hold,omitempty"`
|
||||
// SaidStatus is the mesh/delivery-group status last written for it: a change of the status a state takes is
|
||||
// written too, though the state did not change (novox/hq issue 902).
|
||||
SaidStatus string `json:"said_status,omitempty"`
|
||||
Owed []Effect `json:"owed,omitempty"`
|
||||
}
|
||||
|
||||
// GroupHold is a group held back from its Approve or its merge because a member's required status is not a success.
|
||||
type GroupHold struct {
|
||||
// Kind is "unasked" (the Approve is not asked) or "blocked" (the approved merge is not started: nothing is merged).
|
||||
Kind string `json:"kind"`
|
||||
Member string `json:"member"`
|
||||
Says string `json:"says"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// GroupCheck is a group's composed check.
|
||||
type GroupCheck struct {
|
||||
Asked string `json:"asked"`
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **Where today's module stands** (novox/hq ADR 0450 §6 as corrected, issues 1347 and 1369; review round 1, G1). Fixture
|
||||
// sources: an assignment is mesh-controller internal/chain/records.go Assignment (a key is never removed: an unassignment writes
|
||||
// `ended`, a pending one has a `state`); a have is mesh-sdk go/delivery Have, whose phase `removed` the node-engine writes for a
|
||||
// module its declaration no longer holds.
|
||||
const (
|
||||
standing = `{"node":"novox","module":"mesh-delivery","assigned":"2026-10-01T10:00:00Z","build":"b1","since":"2026-10-01T10:00:00Z"}`
|
||||
ended = `{"node":"novox","module":"mesh-delivery","assigned":"2026-10-01T10:00:00Z","build":"b1","since":"2026-10-01T10:00:00Z","ended":"2026-10-20T03:00:00Z"}`
|
||||
pending = `{"node":"novox","module":"mesh-delivery","state":"waiting"}`
|
||||
haveRun = `{"v":1,"build":"b1","phase":"running","health":"ok"}`
|
||||
haveGone = `{"v":1,"build":"b1","phase":"removed","health":"unknown"}`
|
||||
haveFailed = `{"v":1,"build":"b1","phase":"failed","health":"failed"}`
|
||||
)
|
||||
|
||||
func bucket(current bool, kv ...string) seatBucket {
|
||||
b := seatBucket{values: map[string][]byte{}, current: current}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
b.values[kv[i]] = []byte(kv[i+1])
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestAnEndedAssignmentKeepsItsKeyAndIsNoOccupant(t *testing.T) {
|
||||
who, err := occupantsOf(bucket(true, "novox.mesh-delivery", ended), bucket(true, "novox.mesh-delivery", haveGone))
|
||||
if err != nil || len(who) != 0 {
|
||||
t.Fatalf("an ended assignment and a removed have gave %v, %v; wanted a free seat", who, err)
|
||||
}
|
||||
if who, _ := occupantsOf(bucket(true, "novox.mesh-delivery", standing), bucket(true, "novox.mesh-delivery", haveGone)); len(who) != 1 || !strings.Contains(who[0], "assigned on novox") {
|
||||
t.Fatalf("a standing assignment gave %v", who)
|
||||
}
|
||||
// A pending assignment, waiting for its build, is no assignment (every reader of the chain says so).
|
||||
if who, _ := occupantsOf(bucket(true, "novox.mesh-delivery", pending), bucket(true, "novox.mesh-delivery", haveGone)); len(who) != 0 {
|
||||
t.Fatalf("a pending assignment gave %v", who)
|
||||
}
|
||||
// Other modules' keys are no business of the gate's.
|
||||
if who, _ := occupantsOf(bucket(true, "novox.mesh-delivery-gate", standing), bucket(true, "novox.mesh-delivery", haveGone)); len(who) != 0 {
|
||||
t.Fatalf("another module's assignment gave %v", who)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHaveKeyCountsUnlessItsPhaseIsRemoved(t *testing.T) {
|
||||
for _, have := range []string{haveRun, haveFailed, `{"v":1,"phase":"applying","health":"unknown"}`, `not json`} {
|
||||
who, err := occupantsOf(bucket(true), bucket(true, "novox.mesh-delivery", have))
|
||||
if err != nil || len(who) != 1 || !strings.Contains(who[0], "node-engine of novox") {
|
||||
t.Fatalf("the have %s gave %v, %v; wanted an occupant", have, who, err)
|
||||
}
|
||||
}
|
||||
// One machine removed it, another runs it still.
|
||||
who, _ := occupantsOf(bucket(true), bucket(true, "novox.mesh-delivery", haveGone, "ace.mesh-delivery", haveRun))
|
||||
if len(who) != 1 || !strings.Contains(who[0], "ace") {
|
||||
t.Fatalf("a module removed on one machine and running on another gave %v", who)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty read says nothing of the module: the seat is not free until a node-engine said the module removed.
|
||||
func TestAnEmptyReadOfTheHaveKeysDoesNotFreeTheSeat(t *testing.T) {
|
||||
who, err := occupantsOf(bucket(true), bucket(true))
|
||||
if err != nil || len(who) != 1 || !strings.Contains(who[0], "no have key") {
|
||||
t.Fatalf("an empty read gave %v, %v; wanted the seat held back", who, err)
|
||||
}
|
||||
if who, _ := occupantsOf(bucket(true), bucket(true, "novox.node", `{"v":1}`, "novox.mesh-delivery-gate", haveRun)); len(who) != 1 {
|
||||
t.Fatalf("have keys of other modules gave %v", who)
|
||||
}
|
||||
}
|
||||
|
||||
// A watch that has not delivered its current values yet says nothing: the seat is not judged.
|
||||
func TestTheSeatIsNotJudgedBeforeBothWatchesAreCurrent(t *testing.T) {
|
||||
for _, c := range [][2]bool{{false, true}, {true, false}, {false, false}} {
|
||||
who, err := occupantsOf(bucket(c[0]), bucket(c[1], "novox.mesh-delivery", haveGone))
|
||||
if err == nil || len(who) != 0 {
|
||||
t.Fatalf("watches current %v gave %v, %v; wanted an error", c, who, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// fakeOperator is the gate's bucket asks and the teller behind it: it keeps the asks written, and answers as the test says
|
||||
// (the teller's answer under the ask's key). In auto mode it approves every ask the moment it is read (the tests of other
|
||||
// behaviour, which are not about the Approve). An ask is known here by its key (mesh-sdk go/delivery MergeAskKey), as the
|
||||
// delivery keeps it.
|
||||
type fakeOperator struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
auto bool
|
||||
asked []asks.Ask
|
||||
byKey map[string]sdk.Ask
|
||||
order []string
|
||||
seen map[string]bool
|
||||
cancelled []string
|
||||
state map[string]string
|
||||
answers map[string]*sdk.Answer
|
||||
refuse error
|
||||
}
|
||||
|
||||
func newFakeOperator(now func() time.Time) *fakeOperator {
|
||||
return &fakeOperator{now: now, byKey: map[string]sdk.Ask{}, seen: map[string]bool{}, state: map[string]string{}, answers: map[string]*sdk.Answer{}}
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Ask(key string, a sdk.Ask) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.refuse != nil {
|
||||
return o.refuse
|
||||
}
|
||||
if err := a.Ask.Check(o.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sdk.EncodeAsk(a); err != nil {
|
||||
return err
|
||||
}
|
||||
if o.state[key] == "open" && sdk.SameAsk(o.byKey[key], a) {
|
||||
return nil // the same ask is there, as kvOperator leaves it
|
||||
}
|
||||
o.asked = append(o.asked, a.Ask)
|
||||
o.byKey[key] = a
|
||||
if _, known := o.seen[key]; !known {
|
||||
o.order = append(o.order, key)
|
||||
o.seen[key] = true
|
||||
}
|
||||
o.state[key] = "open"
|
||||
delete(o.answers, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Cancel(key string) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.cancelled = append(o.cancelled, key)
|
||||
o.state[key] = "cancelled"
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Where(key string) (*sdk.Answer, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.auto && o.state[key] == "open" {
|
||||
o.answerLocked(key, OptionApprove)
|
||||
}
|
||||
return o.answers[key], nil
|
||||
}
|
||||
|
||||
// ask is the router's ask at a key, as the gate composed it.
|
||||
func (o *fakeOperator) ask(key string) asks.Ask {
|
||||
if a, ok := o.byKey[key]; ok {
|
||||
return a.Ask
|
||||
}
|
||||
panic("no ask " + key)
|
||||
}
|
||||
|
||||
func (o *fakeOperator) answerLocked(key, option string) {
|
||||
a := o.ask(key)
|
||||
opt, _ := a.Option(option)
|
||||
o.state[key] = "answered"
|
||||
o.answers[key] = &sdk.Answer{Option: option, Warrant: fmt.Sprintf("%s@%s", a.ID, o.now().UTC().Format(time.RFC3339)),
|
||||
Words: "the operator, via telegram, chose " + opt.Label}
|
||||
}
|
||||
|
||||
// answer is the operator's choice, proven to the teller by the router and written by it.
|
||||
func (o *fakeOperator) answer(key, option string) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.answerLocked(key, option)
|
||||
}
|
||||
|
||||
// open is the keys of the asks waiting for an answer, in the order asked.
|
||||
func (o *fakeOperator) open() []string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
var out []string
|
||||
for _, k := range o.order {
|
||||
if o.state[k] == "open" {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (o *fakeOperator) count() int { o.mu.Lock(); defer o.mu.Unlock(); return len(o.asked) }
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The gate writes nothing to the forge: what a transition owes it — a status, the view on the pull request, a line of the
|
||||
// commit's note, a comment — is one key of the gate's bucket owed, `<repository>.<number>.<head>.<act>` (mesh-sdk go/delivery
|
||||
// OwedKey), which the forge bridge performs once for each revision (novox/hq ADR 0437 §8, ADR 0450 §4). The number is 0 for
|
||||
// a commit no pull request names; a comment's act carries a sequence number, so two comments on one head are two acts.
|
||||
// What the gate keeps is the effect owed until its key is written (effects.go); the forge's own refusal is the bridge's
|
||||
// to say on FORGE, and the gate has no retry for it.
|
||||
|
||||
// noPage is the target of a status whose delivery has no page to point at.
|
||||
const noPage = "none"
|
||||
|
||||
// notesRef is the notes ref the commit's note is kept under.
|
||||
const notesRef = "mesh-plan"
|
||||
|
||||
// OwedStore is the gate's bucket owed.
|
||||
type OwedStore interface {
|
||||
// Put writes the act under its key, unless that key holds the same act already (a revision is an act to perform).
|
||||
Put(key string, o sdk.Owed) error
|
||||
}
|
||||
|
||||
// kvOwed is owed over the runtime.
|
||||
type kvOwed struct{}
|
||||
|
||||
func (kvOwed) Put(key string, o sdk.Owed) error {
|
||||
raw, err := sdk.EncodeOwed(o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e, err := stdio.State("owed").Get(key); err == nil && e != nil {
|
||||
if kept, err := sdk.DecodeOwed(e.Value); err == nil && sdk.SameOwed(kept, o) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
_, err = stdio.State("owed").Put(key, json.RawMessage(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
// owe writes one owed act as its key. An act that has no key (a name the key refuses, a status with no page to point at) is
|
||||
// said and dropped, since writing it again would fail the same.
|
||||
func (h *Holder) owe(repository string, number int, head, act string, o sdk.Owed) error {
|
||||
if h.Owed == nil {
|
||||
return errors.New("no owed bucket")
|
||||
}
|
||||
key, err := sdk.OwedKey(repository, number, head, act)
|
||||
if err == nil {
|
||||
o.Act = actKind(act)
|
||||
_, err = sdk.EncodeOwed(o)
|
||||
}
|
||||
if err != nil {
|
||||
h.Refuse(fmt.Errorf("the act %q on %s#%d at %s cannot be owed and is dropped: %v", act, repository, number, shortOf(head, 12), err))
|
||||
return nil
|
||||
}
|
||||
return h.Owed.Put(key, o)
|
||||
}
|
||||
|
||||
// actKind is the kind of an owed act: `status=<context>` is a status, `comment.<seq>` a comment.
|
||||
func actKind(act string) string {
|
||||
kind, _, _ := sdk.ParseOwedAct(act)
|
||||
return kind
|
||||
}
|
||||
|
||||
// statusAct is the act of a status of a context.
|
||||
func statusAct(context string) string { return sdk.OwedStatus + "=" + context }
|
||||
|
||||
// commentAct is the act of the seq-th comment on a head.
|
||||
func commentAct(seq int) string {
|
||||
act, _ := sdk.OwedCommentAct(seq)
|
||||
return act
|
||||
}
|
||||
|
||||
// owedOf is an effect as the act its key names and its value: the repository and number are the delivery's (or, for a
|
||||
// group's status, the member's the effect names in Last), the body the view's.
|
||||
func owedOf(e Effect, repository string, number int, head, body, page string) (act string, value sdk.Owed, num int, hd string) {
|
||||
num, hd = number, head
|
||||
switch e.Kind {
|
||||
case EffectNote:
|
||||
hd = e.Commit
|
||||
if e.Commit != head {
|
||||
num = 0 // the merge commit is named by no pull request
|
||||
}
|
||||
return sdk.OwedNote, sdk.Owed{Text: e.Line, Ref: notesRef}, num, hd
|
||||
case EffectView:
|
||||
return sdk.OwedView, sdk.Owed{Text: body}, num, hd
|
||||
case EffectStatus:
|
||||
// The owed status must name where it points; a delivery with no pull request page points at none, which the forge's
|
||||
// tool leaves out (it links only an http or https address).
|
||||
if page == "" {
|
||||
page = noPage
|
||||
}
|
||||
return statusAct(e.Context), sdk.Owed{Context: e.Context, State: e.State, Text: e.Line, Target: page}, num, e.Commit
|
||||
case EffectComment:
|
||||
seq, _ := strconv.Atoi(e.Context)
|
||||
return commentAct(seq), sdk.Owed{Text: e.Line}, num, hd
|
||||
}
|
||||
return "", sdk.Owed{}, 0, ""
|
||||
}
|
||||
|
||||
// memberOfLast reads the repository, pull request number and page a group's status effect names in Last
|
||||
// (`owner/repo#N page`).
|
||||
func memberOfLast(last string) (repository string, number int, page string) {
|
||||
where, page, _ := strings.Cut(last, " ")
|
||||
repository, n, _ := strings.Cut(where, "#")
|
||||
number, _ = strconv.Atoi(n)
|
||||
return repository, number, page
|
||||
}
|
||||
+16
-42
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -70,12 +71,6 @@ type Controller interface {
|
||||
|
||||
// Forge is what the forge's holder does for a delivery.
|
||||
type Forge interface {
|
||||
// Note appends one line to a commit's note under refs/notes/mesh-plan; a line already there is not added.
|
||||
Note(owner, repo, commit, line string) error
|
||||
// View keeps the delivery's view current on its pull request: one comment, edited in place.
|
||||
View(owner, repo string, number int, body string) error
|
||||
// Status sets one status of a commit, linking to the view.
|
||||
Status(owner, repo, commit, context, state, description, target string) error
|
||||
// Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch
|
||||
// it merges into would let it merge. Reads only.
|
||||
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
|
||||
@@ -84,13 +79,6 @@ type Forge interface {
|
||||
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
|
||||
// 347). Reads only.
|
||||
OpenPulls() ([]OpenPull, error)
|
||||
// Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq
|
||||
// issue 722): the one path every merge takes. approved are the heads the operator's Approve covers, each
|
||||
// `owner/repo@<full sha>` (ADR 0421 §9); the tool refuses a pull request whose head differs. Its refusal is the
|
||||
// error, in its words.
|
||||
Merge(owner, repo string, number int, head string, approved []string) error
|
||||
// Comment adds a comment to a pull request.
|
||||
Comment(owner, repo string, number int, body string) error
|
||||
}
|
||||
|
||||
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
|
||||
@@ -210,8 +198,10 @@ func (c seatController) verb(name string, args map[string]any) (json.RawMessage,
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// Plan asks the controller's `plans` for the plan of a change as data (novox/hq ADR 0450 §2): the document the old
|
||||
// per-change verb answered, now the what-if of the plans every person reads as text.
|
||||
func (c seatController) Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error) {
|
||||
args := map[string]any{"repository": repository, "paths": strings.Join(paths, ",")}
|
||||
args := map[string]any{"repository": repository, "paths": strings.Join(paths, ","), "data": "true"}
|
||||
if base != "" {
|
||||
args["base"] = base
|
||||
}
|
||||
@@ -224,7 +214,7 @@ func (c seatController) Plan(repository, base, head string, paths, moduleDirs, r
|
||||
if len(removed) > 0 {
|
||||
args["removed"] = strings.Join(removed, ",")
|
||||
}
|
||||
raw, err := c.verb("delivery-plan", args)
|
||||
raw, err := c.verb("plans", args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -232,7 +222,7 @@ func (c seatController) Plan(repository, base, head string, paths, moduleDirs, r
|
||||
Plan *DeliveryPlan `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Plan == nil {
|
||||
return nil, errors.New("the controller's delivery-plan answered no plan")
|
||||
return nil, errors.New("the controller's plans answered no plan as data")
|
||||
}
|
||||
return a.Plan, nil
|
||||
}
|
||||
@@ -254,7 +244,10 @@ func (c seatController) Check(group string, members []Member) (string, error) {
|
||||
if a.Asked != "" {
|
||||
return a.Asked, nil
|
||||
}
|
||||
return a.Rechecked, nil
|
||||
if a.Rechecked != "" {
|
||||
return a.Rechecked, nil
|
||||
}
|
||||
return "", fmt.Errorf("the controller's delivery-check answered no check id (%s): it is not taken as asked", clip(string(raw), 200))
|
||||
}
|
||||
|
||||
func (c seatController) Deliver(walk, why string) error {
|
||||
@@ -345,20 +338,6 @@ func (f toolForge) tool(name string, args map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f toolForge) Note(owner, repo, commit, line string) error {
|
||||
return f.tool("gitea_note_append", map[string]any{"owner": owner, "repo": repo, "sha": commit,
|
||||
"ref": "mesh-plan", "line": line})
|
||||
}
|
||||
|
||||
func (f toolForge) View(owner, repo string, number int, body string) error {
|
||||
return f.tool("gitea_delivery_view", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body})
|
||||
}
|
||||
|
||||
func (f toolForge) Status(owner, repo, commit, context, state, description, target string) error {
|
||||
return f.tool("gitea_commit_status", map[string]any{"owner": owner, "repo": repo, "sha": commit,
|
||||
"context": context, "state": state, "description": description, "target_url": target})
|
||||
}
|
||||
|
||||
func (f toolForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
|
||||
args := map[string]any{"owner": owner, "repo": repo}
|
||||
if number > 0 {
|
||||
@@ -385,15 +364,6 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (f toolForge) Merge(owner, repo string, number int, head string, approved []string) error {
|
||||
return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head,
|
||||
"approved_heads": approved})
|
||||
}
|
||||
|
||||
func (f toolForge) Comment(owner, repo string, number int, body string) error {
|
||||
return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body})
|
||||
}
|
||||
|
||||
func (f toolForge) OpenPulls() ([]OpenPull, error) {
|
||||
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
|
||||
if err != nil {
|
||||
@@ -458,7 +428,8 @@ func (kvStore) PutGroup(g *Group) error {
|
||||
}
|
||||
func (kvStore) DeleteGroup(id string) error { return stdio.State("groups").Delete(kvKey(id)) }
|
||||
|
||||
func readAll[T any](state string) ([]*T, error) {
|
||||
// readAll reads every key of a state but the ones it is told to leave (the gate's mark `carried` is no delivery).
|
||||
func readAll[T any](state string, leave ...string) ([]*T, error) {
|
||||
keys, err := stdio.State(state).Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -466,6 +437,9 @@ func readAll[T any](state string) ([]*T, error) {
|
||||
sort.Strings(keys)
|
||||
var out []*T
|
||||
for _, k := range keys {
|
||||
if slices.Contains(leave, k) {
|
||||
continue
|
||||
}
|
||||
e, err := stdio.State(state).Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -483,5 +457,5 @@ func readAll[T any](state string) ([]*T, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (kvStore) Deliveries() ([]*Delivery, error) { return readAll[Delivery]("deliveries") }
|
||||
func (kvStore) Deliveries() ([]*Delivery, error) { return readAll[Delivery]("deliveries", carriedKey) }
|
||||
func (kvStore) Groups() ([]*Group, error) { return readAll[Group]("groups") }
|
||||
+3
@@ -61,6 +61,9 @@ func (h *Holder) Work() {
|
||||
func (h *Holder) React() {
|
||||
h.reactMu.Lock()
|
||||
defer h.reactMu.Unlock()
|
||||
if !h.Holding() {
|
||||
return // not the holder of the seat: no look, no write, no ask (seat.go)
|
||||
}
|
||||
h.wanted.mu.Lock()
|
||||
h.wanted.at, h.wanted.step = time.Time{}, ""
|
||||
h.wanted.mu.Unlock()
|
||||
+104
-78
@@ -3,10 +3,12 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The Approve is asked as soon as the checks pass (novox/hq ADR 0421, issue 1022).
|
||||
@@ -57,11 +59,26 @@ 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 is whether the member is handed to the forge bridge and not merged or refused yet: not handed twice
|
||||
// meanwhile. Not kept.
|
||||
merging bool
|
||||
// refusedNoted is whether the refusal was written into the group's merge key. Not kept.
|
||||
refusedNoted bool
|
||||
// AskedKey and Asking are the ask key last asked for and how many times it was asked at those heads; they stay when the ask
|
||||
// is withdrawn, so the next asking is the next number (sdk.AskingID). Written is whether that ask stands in the gate's asks.
|
||||
AskedKey string `json:"asked_key,omitempty"`
|
||||
Asking int `json:"asking,omitempty"`
|
||||
Written bool `json:"written,omitempty"`
|
||||
// MergeCommit is the commit the forge bridge reported the merge as, and SourceMoved whether source.main holds it
|
||||
// (merges.go).
|
||||
MergeCommit string `json:"merge_commit,omitempty"`
|
||||
SourceMoved bool `json:"source_moved,omitempty"`
|
||||
// OwnAskedAt is when its own check was asked after the merge tool refused it for a pending status of its own (issue
|
||||
// 1049): once for the head this run is for.
|
||||
OwnAskedAt time.Time `json:"own_asked_at,omitempty"`
|
||||
// Urgent is set once a refusal of this head was one that is urgent at once: its severity never steps down to a
|
||||
// warning after (issue 1275, AC6).
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
// 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.
|
||||
@@ -202,11 +219,18 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
if !r.LastTry.IsZero() {
|
||||
continue // refused: asked again at the next event that decides whether it may merge (mergeAgainNow), with no timer
|
||||
}
|
||||
r.Tries++
|
||||
r.LastTry, r.merging = now, true
|
||||
h.keep(d)
|
||||
asks = append(asks, h.askMerge(d.ID, r.Heads))
|
||||
// A group is merged one member at a time, handed to the forge bridge, and the pass stops at the first refusal (ADR
|
||||
// 0441 §3, §5); a lone pull request is a group of one.
|
||||
g := h.groups[d.Group]
|
||||
if d.Group == "" {
|
||||
g = nil
|
||||
}
|
||||
if a := h.handTurn(g, d, now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
}
|
||||
asks = append(asks, h.settleMerges()...)
|
||||
asks = append(asks, h.withdrawnKeys()...)
|
||||
asks = append(h.cancels, asks...)
|
||||
h.cancels = nil
|
||||
return asks
|
||||
@@ -326,73 +350,13 @@ func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// askMerge asks the forge's merge tool to merge a head the operator approved, and keeps its answer. The tool is handed
|
||||
// the heads the Approve covers, each `owner/repo@<full sha>` (novox/hq ADR 0421 §9), and refuses a pull request whose
|
||||
// head differs from the one approved.
|
||||
func (h *Holder) askMerge(id string, heads map[string]string) func() {
|
||||
return func() {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
owner, repo, number, head := d.Owner(), d.Repo(), d.Number, d.Commit
|
||||
if sha := heads[id]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
h.mu.Unlock()
|
||||
err := h.Forge.Merge(owner, repo, number, head, approvedHeads(heads))
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
r.merging = false
|
||||
if err == nil {
|
||||
// Issue 1025: the announcement is owed, kept with the delivery, so a death right after the merge
|
||||
// still says it once.
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, head))), Since: now})
|
||||
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
|
||||
h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s, its check passed against its trunk's head", id,
|
||||
shortOf(head, 12))
|
||||
h.keep(d)
|
||||
return
|
||||
}
|
||||
answer := oneLine(err.Error())
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince = &now
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", id,
|
||||
answer)
|
||||
}
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
|
||||
// approvedHeads are the heads an Approve covers as the merge tool is handed them: `owner/repo@<full sha>`, sorted.
|
||||
func approvedHeads(heads map[string]string) []string {
|
||||
var out []string
|
||||
for id, sha := range heads {
|
||||
repository, _, _ := strings.Cut(id, "@")
|
||||
out = append(out, repository+"@"+sha)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// sayOn comments on a delivery's pull request.
|
||||
// sayOn owes a comment on a delivery's pull request: an owed act numbered among the comments on its head, written as a key
|
||||
// with the delivery's other effects (owed.go). Under the lock; what it returns has nothing left to do.
|
||||
func (h *Holder) sayOn(d *Delivery, body string) func() {
|
||||
owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID
|
||||
return func() {
|
||||
if err := h.Forge.Comment(owner, repo, number, body); err != nil {
|
||||
h.Logf("[mesh-delivery] %s: could not say on its pull request: %v", id, err)
|
||||
}
|
||||
}
|
||||
d.Comments++
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectComment, Commit: d.Commit, Line: body, Context: strconv.Itoa(d.Comments), Since: h.Now()})
|
||||
h.keep(d)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// readyLines are the approved heads that will not merge: a check that failed at once, a merge refused past its bound;
|
||||
@@ -420,16 +384,19 @@ func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
// again after each composed check. Said after the merge window, with the group and the tool's last answer.
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: groupRefusedFor.String(),
|
||||
H2: "none: the group's composed check is asked again after each refusal, and the merge each minute — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is approved at %s with its group %s closed (a sibling merged), and the merge tool has "+
|
||||
"refused it since %s: %s", d.Repository, d.Number, shortOf(r.Head, 12), d.Group,
|
||||
r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
H2: "none: the group's composed check is asked again after each refusal, and the merge each minute — the operator's",
|
||||
Severity: refusalSeverity(r, now),
|
||||
Says: fmt.Sprintf("group %s is half merged: %s#%d is approved at %s and the merge tool has refused it since %s: %s. "+
|
||||
"Needed: the refusal's cause is met (the merge is asked again at each verdict, status or Approve), or the operator decides.",
|
||||
d.Group, d.Repository, d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
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 after each verdict, Approve or composed verdict, and each minute — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is approved 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)})
|
||||
H2: "none: the merge is asked again on a verdict, an Approve, a group's composed check or a merge check's " +
|
||||
"status turning success — the operator's",
|
||||
Says: fmt.Sprintf("%s%s#%d is approved at %s, and the merge tool has refused it since %s: %s%s", heldInGroup(d),
|
||||
d.Repository, d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer, neededOf(d)),
|
||||
Severity: severityOf(d, now)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -448,6 +415,65 @@ func mergedEvent(d *Delivery, head string) map[string]any {
|
||||
"ask_id": "", "option": "", "heads": heads}
|
||||
if r.Approved != nil {
|
||||
ev["ask_id"], ev["option"] = r.AskID, OptionApprove
|
||||
if _, _, _, err := sdk.ParseAskKey(r.AskID); err == nil {
|
||||
// The ask of the gate: the router knows it by the id its key gives, and the teller asked it (ADR 0437 §6).
|
||||
ev["ask_id"], ev["asker"] = sdk.AskingID(r.AskID, r.Asking), sdk.TellerModule
|
||||
} // an Approve carried over from today's module keeps its router id, and the asker the controller takes by default
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// heldInGroup is the words that put a refused member in its group's pass: none for a lone delivery.
|
||||
func heldInGroup(d *Delivery) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return "group " + d.Group + " is held, and no member after this one is merged: "
|
||||
}
|
||||
|
||||
// neededOf is what a refused member needs, for a member of a group (issue 1275).
|
||||
func neededOf(d *Delivery) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return ". Needed: the refusal's cause is met (the merge is asked again at each verdict, status or Approve), or the operator decides."
|
||||
}
|
||||
|
||||
// outOfDateFor is how long a refusal for an out of date verdict is a warning before it is urgent (issue 1275, AC6).
|
||||
const outOfDateFor = 10 * time.Minute
|
||||
|
||||
// outOfDateRefusal is whether the merge tool refused because the member's verdict is judged against an earlier trunk: the
|
||||
// forge's "its verdict was judged against trunk <t>, and main is now <t>. The check of head <h> merged into trunk <t> was
|
||||
// asked" (gitea merge.ts), whose fresh check is already running.
|
||||
//
|
||||
// Words that also name another cause (a status not a success, missing or failed) are not this: the other cause is urgent
|
||||
// at once (ADR 0442, issue 1275 AC6).
|
||||
func outOfDateRefusal(answer string) bool {
|
||||
for _, other := range []string{"not a success", "(missing)", "(failure)", "(error)", "could not be asked"} {
|
||||
if strings.Contains(answer, other) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// gitea merge.ts: "Its check is already asked and not yet answered" (on its way), or "The check of head <h> merged into
|
||||
// trunk <t> was asked" (asked now). Its other refusals, among them "its check could not be asked", are not this.
|
||||
return strings.Contains(answer, "its verdict ") && strings.Contains(answer, " is now ") &&
|
||||
(strings.Contains(answer, "Its check is already asked and not yet answered") ||
|
||||
strings.Contains(answer, "merged into trunk") && strings.Contains(answer, "was asked"))
|
||||
}
|
||||
|
||||
// refusalSeverity is "warning" while a member's refusal for an out of date verdict is younger than ten minutes, as its
|
||||
// fresh check runs, and "urgent" for it after that and for every other refusal at once.
|
||||
func refusalSeverity(r *ReadyRun, now time.Time) string {
|
||||
if !r.Urgent && r.RefusedSince != nil && outOfDateRefusal(r.Answer) && now.Sub(*r.RefusedSince) < outOfDateFor {
|
||||
return "warning"
|
||||
}
|
||||
return "urgent"
|
||||
}
|
||||
|
||||
// severityOf is the severity of a refused member of a group, empty for a lone delivery.
|
||||
func severityOf(d *Delivery, now time.Time) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return refusalSeverity(d.Ready, now)
|
||||
}
|
||||
+84
@@ -67,6 +67,8 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC3: a refused merge is not asked again by any timer; only an event (here, mergeAgainNow called directly, as a
|
||||
// verdict, an Approve or a composed check does) asks it again.
|
||||
func TestAMergeRefusedIsSaidAtOnceAndAskedAgainOnlyOnAnEvent(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
@@ -93,6 +95,88 @@ func TestAMergeRefusedIsSaidAtOnceAndAskedAgainOnlyOnAnEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC1: a refused merge, approved and ready, is asked again at once when the forge's forge.status says one of
|
||||
// the merge checks (mesh/merge-gate, mesh/repo-check) is success for the commit the merge was refused at — the
|
||||
// delivery's approved head — and merges once the tool accepts (novox/hq mesh-catalog#366: the verdict reached this
|
||||
// owner before the forge wrote the statuses, the merge asked then was refused on a pending check, and nothing asked
|
||||
// it again when the statuses turned success).
|
||||
func TestAMergeRefusedIsAskedAgainWhenItsCheckTurnsSuccessOnTheForge(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: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("not asked once, refused: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: readyHead, Context: "mesh/repo-check", State: "success"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("not asked again when its check turned success: %v", w.forge.merges)
|
||||
}
|
||||
if w.delivery(IDOf("novox/app", readyHead)).Ready.Merged == nil {
|
||||
t.Fatalf("it did not merge once the tool accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC2: a status of another context, of another commit, or a state other than success changes nothing for a
|
||||
// refused merge: it is not asked again.
|
||||
func TestAMergeRefusedIsNotAskedAgainOnAStatusThatDoesNotMatch(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: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("not asked once, refused: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
for _, e := range []ForgeStatusEvent{
|
||||
{Owner: "novox", Repo: "app", SHA: readyHead, Context: "some/other-check", State: "success"}, // another context
|
||||
{Owner: "novox", Repo: "app", SHA: pushed, Context: "mesh/repo-check", State: "success"}, // another commit
|
||||
{Owner: "novox", Repo: "app", SHA: readyHead, Context: "mesh/repo-check", State: "pending"}, // another state
|
||||
} {
|
||||
w.h.CommitStatus(e)
|
||||
w.settleAll()
|
||||
}
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked again on a status that should change nothing: %v", w.forge.merges)
|
||||
}
|
||||
if w.delivery(IDOf("novox/app", readyHead)).Ready.Merged != nil {
|
||||
t.Fatalf("merged on a status that should change nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC4: the stalled line for a refused approved head names the events that ask the merge again, and no longer
|
||||
// says "each minute" (removed by issue 1115; false since then — there is no timer).
|
||||
func TestTheStalledLineForARefusedApprovedHeadNamesTheEventsThatAskTheMergeAgain(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: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("wanted one stalled line, got %+v", lines)
|
||||
}
|
||||
h2 := lines[0].H2
|
||||
if strings.Contains(h2, "each minute") {
|
||||
t.Fatalf("the stalled line still says 'each minute', false since issue 1115: %q", h2)
|
||||
}
|
||||
for _, want := range []string{"verdict", "Approve", "composed check", "status"} {
|
||||
if !strings.Contains(h2, want) {
|
||||
t.Fatalf("the stalled line does not name %q as asking the merge again: %q", want, h2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
+3
-3
@@ -80,12 +80,12 @@ func TestARecheckLeavesAStatusTheHeadNeverHadAlone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A forge that cannot be told refuses the recheck whole: nothing is asked on a stale verdict, and the
|
||||
// A bus that cannot take the pending statuses refuses the recheck whole: nothing is asked on a stale verdict, and the
|
||||
// delivery stays where it stood, for the person to ask again.
|
||||
func TestARecheckTheForgeCannotTakeIsRefused(t *testing.T) {
|
||||
func TestARecheckTheBusCannotTakeIsRefused(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := heldGreen(t, w, "novox/app", 9)
|
||||
w.forge.down = true
|
||||
w.owed.down = true
|
||||
if _, err := w.h.Recheck(id, "main moved on", "a person"); err == nil {
|
||||
t.Fatal("a recheck whose statuses could not be put back was taken")
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The gate acts only while it holds the seat, and carries today's state over when it first holds it (novox/hq ADR 0450
|
||||
// §6 and its insight of 2026-10-20; ADR 0437 §7). It holds the seat when no machine is assigned today's module
|
||||
// (mesh-controller.assignments, keys `<node>.<module>`) and no machine's node-engine says it has the module (node-engine.have,
|
||||
// the same keys): the seat passes by ADR 0449 §4, so the gate never has to take it. While it does not hold it, it takes
|
||||
// no event, writes no key, asks nothing and answers no verb, and says in its log why it waits.
|
||||
|
||||
// OldModule is today's module, whose place the gate takes: the module `mesh-delivery`.
|
||||
const OldModule = "mesh-delivery"
|
||||
|
||||
// Occupancy is where today's module still stands.
|
||||
type Occupancy interface {
|
||||
// Occupants says, one line each, where the module is assigned ("assigned on <node>") or has a have key ("on <node>
|
||||
// the node-engine has it"); none when no machine has it.
|
||||
Occupants() ([]string, error)
|
||||
}
|
||||
|
||||
// Bucket is a bucket of the bus: today's module's, read, or the gate's own, written.
|
||||
type Bucket interface {
|
||||
Keys() ([]string, error)
|
||||
// Get is a key's value, or nil when it has none.
|
||||
Get(key string) ([]byte, error)
|
||||
Put(key string, raw []byte) error
|
||||
}
|
||||
|
||||
// carriedKey is the key, in the gate's bucket `deliveries`, that says today's state was carried: no later start or holding
|
||||
// copies again.
|
||||
const carriedKey = "carried"
|
||||
|
||||
// CarryReport says what a carry did.
|
||||
type CarryReport struct {
|
||||
// Already is true when the key `carried` was there: nothing was copied.
|
||||
Already bool
|
||||
// Copied is, by bucket, how many keys were copied.
|
||||
Copied map[string]int
|
||||
// Cleared is how many deliveries had the id of an ask of today's module taken off.
|
||||
Cleared int
|
||||
}
|
||||
|
||||
// SeatChanged reads who has today's module and moves the gate to or from holding the seat. The first time it finds the seat
|
||||
// free it carries today's state over (Carry), reads it back (Load) and only then holds; a failure of either is returned and
|
||||
// leaves the gate silent, to be tried again.
|
||||
func (h *Holder) SeatChanged() error {
|
||||
if h.Seat == nil {
|
||||
return nil
|
||||
}
|
||||
who, err := h.Seat.Occupants()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] cannot tell who has the seat yet: %v", err)
|
||||
return err
|
||||
}
|
||||
if len(who) > 0 {
|
||||
why := "today's module is not gone yet: " + strings.Join(who, "; ")
|
||||
if h.seatHeld.Swap(false) {
|
||||
h.Logf("[mesh-delivery] the gate no longer holds the seat and is silent: %s", why)
|
||||
} else if h.setSeatWhy(why) {
|
||||
h.Logf("[mesh-delivery] the gate waits for the seat and is silent: %s", why)
|
||||
}
|
||||
h.setSeatWhy(why)
|
||||
return nil
|
||||
}
|
||||
if h.seatHeld.Load() {
|
||||
return nil
|
||||
}
|
||||
if h.Carry != nil {
|
||||
report, err := h.Carry()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] today's state cannot be carried yet: %v", err)
|
||||
return err
|
||||
}
|
||||
if report.Already {
|
||||
h.Logf("[mesh-delivery] today's state was carried before: nothing is copied")
|
||||
} else {
|
||||
h.Logf("[mesh-delivery] today's state is carried: %v copied, %d ask id(s) cleared", report.Copied, report.Cleared)
|
||||
}
|
||||
}
|
||||
if err := h.Load(); err != nil {
|
||||
h.Logf("[mesh-delivery] the carried state cannot be read back yet: %v", err)
|
||||
return err
|
||||
}
|
||||
h.setSeatWhy("")
|
||||
h.seatHeld.Store(true)
|
||||
h.Logf("[mesh-delivery] the gate holds the seat: today's module is on no machine")
|
||||
if h.OnHold != nil {
|
||||
h.OnHold()
|
||||
}
|
||||
h.Poke()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Holding is whether the gate acts. A holder with no Seat to ask always holds (the tests'; main sets one).
|
||||
func (h *Holder) Holding() bool { return h.Seat == nil || h.seatHeld.Load() }
|
||||
|
||||
// setSeatWhy keeps why the gate waits and says whether it changed.
|
||||
func (h *Holder) setSeatWhy(why string) bool {
|
||||
h.seatMu.Lock()
|
||||
defer h.seatMu.Unlock()
|
||||
changed := h.seatWhy != why
|
||||
h.seatWhy = why
|
||||
return changed
|
||||
}
|
||||
|
||||
// SeatWhy is why the gate is silent, or empty when it holds.
|
||||
func (h *Holder) SeatWhy() string {
|
||||
h.seatMu.Lock()
|
||||
defer h.seatMu.Unlock()
|
||||
return h.seatWhy
|
||||
}
|
||||
|
||||
// notHolder is the answer of a verb asked of a gate that does not hold the seat.
|
||||
func (h *Holder) notHolder() error {
|
||||
return fmt.Errorf("not the holder: the gate does not hold the seat mesh-delivery yet, and answers nothing until it does (%s)", h.SeatWhy())
|
||||
}
|
||||
|
||||
// CarryOver copies today's state into the gate's own, once. It does nothing when `carried` is in the gate's deliveries.
|
||||
//
|
||||
// For each of today's module's buckets `deliveries`, `groups` and `pulls` it copies every key the gate's bucket lacks, and a
|
||||
// key the gate has is never touched. A delivery whose Approve nobody answered has the ask of today's module taken off, since
|
||||
// that ask lives in a router the gate does not ask under: the gate asks again through the teller, as an ask that ended without
|
||||
// a choice is asked again (ADR 0421 §6). A delivery whose Approve was given keeps its answer, which the merge still needs.
|
||||
// The mark `carried` is written last, so a carry that stopped halfway is done again and a finished one never is.
|
||||
func CarryOver(old, own func(bucket string) Bucket, at time.Time) (CarryReport, error) {
|
||||
report := CarryReport{Copied: map[string]int{}}
|
||||
marks := own("deliveries")
|
||||
if mark, err := marks.Get(carriedKey); err != nil {
|
||||
return report, fmt.Errorf("the mark that today's state was carried cannot be read: %w", err)
|
||||
} else if mark != nil {
|
||||
report.Already = true
|
||||
return report, nil
|
||||
}
|
||||
for _, bucket := range []string{"deliveries", "groups", "pulls"} {
|
||||
keys, err := old(bucket).Keys()
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("the keys of %s.%s cannot be read: %w", OldModule, bucket, err)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if bucket == "deliveries" && key == carriedKey {
|
||||
continue
|
||||
}
|
||||
have, err := own(bucket).Get(key)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%s %s of the gate cannot be read: %w", bucket, key, err)
|
||||
}
|
||||
if have != nil {
|
||||
continue
|
||||
}
|
||||
raw, err := old(bucket).Get(key)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%s.%s %s cannot be read: %w", OldModule, bucket, key, err)
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
if bucket == "deliveries" {
|
||||
var d Delivery
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return report, fmt.Errorf("the delivery %s of %s.deliveries is not readable: %w", key, OldModule, err)
|
||||
}
|
||||
if r := d.Ready; r != nil && r.AskID != "" && r.Approved == nil && r.Declined == nil {
|
||||
r.AskID, r.Ask, r.Heads = "", nil, nil
|
||||
report.Cleared++
|
||||
if raw, err = json.Marshal(&d); err != nil {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := own(bucket).Put(key, raw); err != nil {
|
||||
return report, fmt.Errorf("%s %s cannot be written: %w", bucket, key, err)
|
||||
}
|
||||
report.Copied[bucket]++
|
||||
}
|
||||
}
|
||||
mark, err := json.Marshal(map[string]any{"v": 1, "at": at.UTC().Format(time.RFC3339), "from": OldModule,
|
||||
"deliveries": report.Copied["deliveries"], "groups": report.Copied["groups"], "pulls": report.Copied["pulls"], "cleared": report.Cleared})
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if err := marks.Put(carriedKey, mark); err != nil {
|
||||
return report, fmt.Errorf("the mark that today's state was carried cannot be written: %w", err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// What a bucket says of where today's module stands: the values by key, `<node>.<module>`, and whether the watch of the bucket
|
||||
// has delivered its current values.
|
||||
type seatBucket struct {
|
||||
values map[string][]byte
|
||||
current bool
|
||||
}
|
||||
|
||||
// occupantsOf says where today's module stands from what the controller's assignments and the node-engine's have keys hold
|
||||
// (novox/hq ADR 0450 §6 as corrected). Neither bucket loses a key: an unassignment writes `ended` on the assignment and keeps
|
||||
// its key (mesh-controller internal/chain/records.go Assignment), and the node-engine writes the phase `removed` on the have
|
||||
// key of a module its declaration no longer holds (issue 1369). So an assignment counts only while it stands (no `ended`, no
|
||||
// `state`), a have key counts unless its phase is `removed`, and the module is gone only once both watches have delivered
|
||||
// their current values and at least one have key of it exists and every one says removed. A value that cannot be read counts.
|
||||
func occupantsOf(assignments, haves seatBucket) ([]string, error) {
|
||||
if !assignments.current || !haves.current {
|
||||
return nil, errors.New("the watches of the assignments and the have keys have not both delivered their current values yet")
|
||||
}
|
||||
var who []string
|
||||
for key, raw := range assignments.values {
|
||||
node, module, ok := strings.Cut(key, ".")
|
||||
if !ok || module != OldModule {
|
||||
continue
|
||||
}
|
||||
var a struct {
|
||||
Ended time.Time `json:"ended,omitzero"`
|
||||
State string `json:"state,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil || (a.Ended.IsZero() && a.State == "") {
|
||||
who = append(who, "assigned on "+node)
|
||||
}
|
||||
}
|
||||
seen := 0
|
||||
for key, raw := range haves.values {
|
||||
node, module, ok := strings.Cut(key, ".")
|
||||
if !ok || module != OldModule {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
var h struct {
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &h); err != nil || h.Phase != string(sdk.PhaseRemoved) {
|
||||
who = append(who, "the node-engine of "+node+" has it")
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
who = append(who, "no node-engine has said the module removed yet: no have key of it")
|
||||
}
|
||||
sort.Strings(who)
|
||||
return who, nil
|
||||
}
|
||||
|
||||
// busOccupancy reads the controller's assignments and the node-engine's have keys through the runtime. It answers nothing until
|
||||
// the watches of both have delivered their current values (watchSeat says so).
|
||||
type busOccupancy struct {
|
||||
assignmentsCurrent, haveCurrent atomic.Bool
|
||||
}
|
||||
|
||||
func (b *busOccupancy) Occupants() ([]string, error) {
|
||||
read := func(bucket string, current bool) (seatBucket, error) {
|
||||
out := seatBucket{values: map[string][]byte{}, current: current}
|
||||
if !current {
|
||||
return out, nil
|
||||
}
|
||||
keys, err := stdio.State(bucket).Keys()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s cannot be read: %w", bucket, err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if _, module, ok := strings.Cut(key, "."); !ok || module != OldModule {
|
||||
continue
|
||||
}
|
||||
e, err := stdio.State(bucket).Get(key)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s %s cannot be read: %w", bucket, key, err)
|
||||
}
|
||||
if e != nil {
|
||||
out.values[key] = e.Value
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
a, err := read("mesh-controller.assignments", b.assignmentsCurrent.Load())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h, err := read("node-engine.have", b.haveCurrent.Load())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return occupantsOf(a, h)
|
||||
}
|
||||
|
||||
// keptBucket is a bucket of the bus through the runtime: today's module's by `<module>.<name>`, the gate's by its name.
|
||||
type keptBucket struct{ name string }
|
||||
|
||||
func (b keptBucket) Keys() ([]string, error) { return stdio.State(b.name).Keys() }
|
||||
func (b keptBucket) Get(key string) ([]byte, error) {
|
||||
e, err := stdio.State(b.name).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value, nil
|
||||
}
|
||||
func (b keptBucket) Put(key string, raw []byte) error {
|
||||
_, err := stdio.State(b.name).Put(key, json.RawMessage(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
// carryFromBus is the carry over the runtime: today's module's buckets into the gate's own.
|
||||
func carryFromBus() (CarryReport, error) {
|
||||
return CarryOver(func(b string) Bucket { return keptBucket{OldModule + "." + b} }, func(b string) Bucket { return keptBucket{b} }, time.Now())
|
||||
}
|
||||
|
||||
// watchSeat reads who has the seat at once, and again at every change of the assignments or the have keys, for ever. A read
|
||||
// that fails is said and tried again in ten seconds; a change read meanwhile is not lost.
|
||||
func watchSeat(h *Holder, l *listening, occ *busOccupancy) {
|
||||
changed := make(chan struct{}, 1)
|
||||
note := func(stdio.StateChange) error {
|
||||
select {
|
||||
case changed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, w := range []struct {
|
||||
bucket string
|
||||
current *atomic.Bool
|
||||
}{{"mesh-controller.assignments", &occ.assignmentsCurrent}, {"node-engine.have", &occ.haveCurrent}} {
|
||||
bucket := w.bucket
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.State(bucket).Watch("", note)
|
||||
if err == nil {
|
||||
w.current.Store(true) // the current values are in: the seat may be judged
|
||||
note(stdio.StateChange{})
|
||||
return
|
||||
}
|
||||
logf("[mesh-delivery] not hearing changes of %s yet (%v); asking again in %s", bucket, err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for {
|
||||
err := h.SeatChanged()
|
||||
switch {
|
||||
case err != nil:
|
||||
l.set("not yet: " + err.Error())
|
||||
select {
|
||||
case <-changed:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
default:
|
||||
if h.Holding() {
|
||||
l.set("listening")
|
||||
} else {
|
||||
l.set("waiting: not the holder of the seat (" + h.SeatWhy() + ")")
|
||||
}
|
||||
<-changed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// **The gate acts only while it holds the seat, and carries today's state over once** (novox/hq ADR 0450 §6 with its
|
||||
// insight of 2026-10-20, ADR 0437 §7, issue 1347). Fixture sources: the keys of mesh-controller.assignments and
|
||||
// node-engine.have are `<node>.<module>` (mesh-controller internal/inventory/chain_assignments.go assignmentKey,
|
||||
// mesh-sdk go/delivery HaveKey); a delivery is the module's own Delivery, written as ports.go kvStore writes it.
|
||||
|
||||
// fakeOccupancy is where today's module stands, as the test says.
|
||||
type fakeOccupancy struct {
|
||||
mu sync.Mutex
|
||||
who []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (o *fakeOccupancy) set(who ...string) { o.mu.Lock(); o.who = who; o.mu.Unlock() }
|
||||
func (o *fakeOccupancy) Occupants() ([]string, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return slices.Clone(o.who), o.err
|
||||
}
|
||||
|
||||
// mapBucket is a bucket over a map, shared with the store a test reads.
|
||||
type mapBucket struct {
|
||||
mu *sync.Mutex
|
||||
m map[string][]byte
|
||||
}
|
||||
|
||||
func (b mapBucket) Keys() ([]string, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range b.m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
func (b mapBucket) Get(key string) ([]byte, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.m[key], nil
|
||||
}
|
||||
func (b mapBucket) Put(key string, raw []byte) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.m[key] = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// seatTest is a world whose holder asks where today's module stands.
|
||||
func seatTest(t *testing.T, who ...string) (*world, *fakeOccupancy) {
|
||||
t.Helper()
|
||||
w := newWorld(t)
|
||||
occ := &fakeOccupancy{who: who}
|
||||
w.h.Seat = occ
|
||||
return w, occ
|
||||
}
|
||||
|
||||
// anEvent is the runtime handing the holder a pull request's head, as handle takes it.
|
||||
func anEvent(t *testing.T, w *world) {
|
||||
t.Helper()
|
||||
hear(t, w, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
}
|
||||
|
||||
// nothingWritten says the forge, the controller and the store were left alone.
|
||||
func nothingWritten(t *testing.T, w *world) {
|
||||
t.Helper()
|
||||
w.forge.mu.Lock()
|
||||
forge := len(w.forge.notes) + len(w.forge.views) + len(w.forge.statuses) + w.forge.statusWrites + len(w.forge.calls) + len(w.forge.comments)
|
||||
w.forge.mu.Unlock()
|
||||
w.ctl.mu.Lock()
|
||||
ctl := len(w.ctl.checks) + w.ctl.asked + len(w.ctl.delivers) + len(w.ctl.stops)
|
||||
w.ctl.mu.Unlock()
|
||||
w.store.mu.Lock()
|
||||
keys := len(w.store.deliveries) + len(w.store.groups)
|
||||
w.store.mu.Unlock()
|
||||
if forge != 0 || ctl != 0 || keys != 0 {
|
||||
t.Fatalf("a gate that does not hold the seat wrote: %d on the forge, %d to the controller, %d keys", forge, ctl, keys)
|
||||
}
|
||||
if n := len(w.h.deliveries); n != 0 {
|
||||
t.Fatalf("a gate that does not hold the seat took the event: %d deliveries", n)
|
||||
}
|
||||
}
|
||||
|
||||
func toolNamed(t *testing.T, h *Holder, name string) stdio.Tool {
|
||||
t.Helper()
|
||||
for _, tool := range tools(h, &listening{}) {
|
||||
if tool.Name == name {
|
||||
return tool
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return stdio.Tool{}
|
||||
}
|
||||
|
||||
// AC1: with the module assigned on a machine, or its have key present, an event changes no key and a verb answers that
|
||||
// the gate is not the holder.
|
||||
func TestTheGateIsSilentWhileTodaysModuleIsAssignedOrHasAHaveKey(t *testing.T) {
|
||||
for _, who := range []string{"assigned on novox", "the node-engine of ace has it"} {
|
||||
t.Run(who, func(t *testing.T) {
|
||||
w, _ := seatTest(t, who)
|
||||
if err := w.h.SeatChanged(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.h.Holding() {
|
||||
t.Fatalf("the gate holds the seat while %s", who)
|
||||
}
|
||||
anEvent(t, w)
|
||||
nothingWritten(t, w)
|
||||
// Every verb of the seat answers that it is not the holder, and none changes anything.
|
||||
for _, name := range []string{"mesh-delivery.deliveries", "mesh-delivery.release", "mesh-delivery.stop", "mesh-delivery.recheck"} {
|
||||
_, err := toolNamed(t, w.h, name).Run(map[string]any{"id": "novox/app@111111111111", "why": "a test"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not the holder") || !strings.Contains(err.Error(), who) {
|
||||
t.Fatalf("%s answered %v, wanted not the holder, naming %q", name, err, who)
|
||||
}
|
||||
}
|
||||
nothingWritten(t, w)
|
||||
// Its own status still answers, and says why it is silent.
|
||||
out, err := toolNamed(t, w.h, "delivery_status").Run(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := out.(map[string]any)
|
||||
if status["holds-the-seat"] != false || !strings.Contains(status["seat-waits"].(string), who) {
|
||||
t.Fatalf("status %v", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The seat free, the gate holds it and acts; asked again with the module back, it is silent again.
|
||||
func TestTheGateActsOnceTodaysModuleIsGoneAndStopsIfItComesBack(t *testing.T) {
|
||||
w, occ := seatTest(t)
|
||||
if err := w.h.SeatChanged(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !w.h.Holding() {
|
||||
t.Fatal("the seat is free and the gate does not hold it")
|
||||
}
|
||||
anEvent(t, w)
|
||||
if len(w.h.deliveries) != 1 {
|
||||
t.Fatalf("the gate that holds the seat took %d deliveries, wanted 1", len(w.h.deliveries))
|
||||
}
|
||||
if _, err := toolNamed(t, w.h, "mesh-delivery.deliveries").Run(map[string]any{}); err != nil {
|
||||
t.Fatalf("a verb of the holder: %v", err)
|
||||
}
|
||||
occ.set("assigned on novox")
|
||||
if err := w.h.SeatChanged(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.h.Holding() {
|
||||
t.Fatal("the module is assigned again and the gate still holds")
|
||||
}
|
||||
hear(t, w, "gitea.pull.updated", pr("novox/app", 8, "2222222222222222222222222222222222222222", "feat/y", "src/b.go"))
|
||||
if len(w.h.deliveries) != 1 {
|
||||
t.Fatalf("an event was taken while the gate was silent: %d deliveries", len(w.h.deliveries))
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing owed is flushed, and no look is taken, while the gate is silent.
|
||||
func TestASilentGateFlushesNothingItOwes(t *testing.T) {
|
||||
w, occ := seatTest(t)
|
||||
if err := w.h.SeatChanged(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) // owes a note, a view and a status
|
||||
occ.set("assigned on novox")
|
||||
if err := w.h.SeatChanged(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := func() [3]int {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
w.store.mu.Lock()
|
||||
defer w.store.mu.Unlock()
|
||||
return [3]int{len(w.forge.notes) + len(w.forge.views) + len(w.forge.statuses) + w.forge.statusWrites + len(w.forge.calls), w.store.puts, len(w.said)}
|
||||
}
|
||||
before := counts()
|
||||
w.h.React()
|
||||
if after := counts(); after != before {
|
||||
t.Fatalf("a silent gate flushed what it owes: forge, store and events %v before, %v after", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat that cannot be read leaves a gate that did not hold silent, and says so.
|
||||
func TestTheGateStaysSilentWhenTheSeatCannotBeRead(t *testing.T) {
|
||||
w, occ := seatTest(t)
|
||||
occ.err = errors.New("the bus is away")
|
||||
if err := w.h.SeatChanged(); err == nil {
|
||||
t.Fatal("a seat that cannot be read was taken as free")
|
||||
}
|
||||
if w.h.Holding() {
|
||||
t.Fatal("holding without knowing")
|
||||
}
|
||||
}
|
||||
|
||||
// --- AC2: the carry ---------------------------------------------------------------------------------
|
||||
|
||||
func aDelivery(id string, number int, ready *ReadyRun) []byte {
|
||||
return mustJSON(&Delivery{ID: id, Repository: "novox/app", Commit: strings.Repeat("1", 40), Number: number, Base: "main",
|
||||
State: Proposed, Ready: ready})
|
||||
}
|
||||
|
||||
type carryFixture struct {
|
||||
oldD, oldG, oldP map[string][]byte
|
||||
mu sync.Mutex
|
||||
own map[string]mapBucket
|
||||
w *world
|
||||
occ *fakeOccupancy
|
||||
approvedAt time.Time
|
||||
}
|
||||
|
||||
// aCarry is a world whose holder carries from these buckets into its store's: the old module's deliveries, one with an ask
|
||||
// waiting, one approved and one the gate already has under another value; one group; one pull key.
|
||||
func aCarry(t *testing.T) *carryFixture {
|
||||
f := &carryFixture{oldD: map[string][]byte{}, oldG: map[string][]byte{}, oldP: map[string][]byte{},
|
||||
approvedAt: time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)}
|
||||
f.w, f.occ = seatTest(t)
|
||||
ask := &asks.Ask{ID: "merge-7-aaaaaaaaaaaa-1"}
|
||||
f.oldD["novox/app@aaaaaaaaaaaa"] = aDelivery("novox/app@aaaaaaaaaaaa", 7, &ReadyRun{Head: strings.Repeat("a", 40), AskID: "merge-7-aaaaaaaaaaaa-1",
|
||||
Ask: ask, Heads: map[string]string{"novox/app@aaaaaaaaaaaa": strings.Repeat("a", 40)}, Asks: 1})
|
||||
f.oldD["novox/app@bbbbbbbbbbbb"] = aDelivery("novox/app@bbbbbbbbbbbb", 8, &ReadyRun{Head: strings.Repeat("b", 40), AskID: "merge-8-bbbbbbbbbbbb-1",
|
||||
Ask: &asks.Ask{ID: "merge-8-bbbbbbbbbbbb-1"}, Heads: map[string]string{"novox/app@bbbbbbbbbbbb": strings.Repeat("b", 40)},
|
||||
Approved: &f.approvedAt, Warrant: "the operator on the phone"})
|
||||
f.oldD["novox/app@cccccccccccc"] = aDelivery("novox/app@cccccccccccc", 9, &ReadyRun{Head: strings.Repeat("c", 40), AskID: "merge-9-cccccccccccc-1", Ask: ask})
|
||||
f.oldG["feat_x"] = []byte(`{"id":"feat/x","members":["novox/app@aaaaaaaaaaaa"]}`)
|
||||
f.oldP["novox_app_7"] = []byte(`{"v":1,"number":7,"head":"` + strings.Repeat("a", 40) + `"}`)
|
||||
// The gate already has one of the old module's deliveries, otherwise.
|
||||
f.w.store.deliveries["novox/app@cccccccccccc"] = aDelivery("novox/app@cccccccccccc", 9, &ReadyRun{Head: strings.Repeat("c", 40)})
|
||||
ownG, ownP := map[string][]byte{}, map[string][]byte{}
|
||||
f.own = map[string]mapBucket{
|
||||
"deliveries": {mu: &f.w.store.mu, m: f.w.store.deliveries},
|
||||
"groups": {mu: &f.mu, m: ownG},
|
||||
"pulls": {mu: &f.mu, m: ownP},
|
||||
}
|
||||
old := map[string]mapBucket{"deliveries": {mu: &f.mu, m: f.oldD}, "groups": {mu: &f.mu, m: f.oldG}, "pulls": {mu: &f.mu, m: f.oldP}}
|
||||
f.w.h.Carry = func() (CarryReport, error) {
|
||||
return CarryOver(func(b string) Bucket { return old[b] }, func(b string) Bucket { return f.own[b] }, f.approvedAt)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// AC2: when both go, the gate carries once (old ask ids cleared, keys it already has untouched) and then acts; a restart
|
||||
// copies nothing.
|
||||
func TestTheGateCarriesTodaysStateOnceWhenItFirstHoldsTheSeat(t *testing.T) {
|
||||
f := aCarry(t)
|
||||
before := slices.Clone(f.w.store.deliveries["novox/app@cccccccccccc"])
|
||||
f.occ.set("assigned on novox")
|
||||
if err := f.w.h.SeatChanged(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.w.store.deliveries) != 1 || f.w.h.Holding() {
|
||||
t.Fatalf("the gate carried while the module was still assigned: %d keys", len(f.w.store.deliveries))
|
||||
}
|
||||
f.occ.set()
|
||||
if err := f.w.h.SeatChanged(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.w.h.Holding() {
|
||||
t.Fatal("the gate does not hold the seat after the module went")
|
||||
}
|
||||
d := f.w.h.deliveries
|
||||
for _, id := range []string{"novox/app@aaaaaaaaaaaa", "novox/app@bbbbbbbbbbbb", "novox/app@cccccccccccc"} {
|
||||
if d[id] == nil {
|
||||
t.Fatalf("%s was not carried and read back: %v", id, f.w.store.deliveries)
|
||||
}
|
||||
}
|
||||
// The ask nobody answered is cleared, so the gate asks again; the one answered keeps its answer.
|
||||
if r := d["novox/app@aaaaaaaaaaaa"].Ready; r.AskID != "" || r.Ask != nil || r.Heads != nil || r.Asks != 1 {
|
||||
t.Fatalf("the unanswered ask was not cleared: %+v", r)
|
||||
}
|
||||
if r := d["novox/app@bbbbbbbbbbbb"].Ready; r.Approved == nil || r.AskID == "" || r.Warrant == "" {
|
||||
t.Fatalf("an answered Approve lost its answer: %+v", r)
|
||||
}
|
||||
// A key the gate had is untouched.
|
||||
if !bytes.Equal(f.w.store.deliveries["novox/app@cccccccccccc"], before) {
|
||||
t.Fatal("a key the gate already had was overwritten")
|
||||
}
|
||||
for bucket, key := range map[string]string{"groups": "feat_x", "pulls": "novox_app_7"} {
|
||||
if got, _ := f.own[bucket].Get(key); got == nil {
|
||||
t.Fatalf("%s %s was not carried", bucket, key)
|
||||
}
|
||||
}
|
||||
if got, _ := f.own["deliveries"].Get(carriedKey); got == nil {
|
||||
t.Fatal("the key carried was not written")
|
||||
}
|
||||
// A restart (a holder that holds again, or starts again) copies nothing.
|
||||
f.oldD["novox/app@dddddddddddd"] = aDelivery("novox/app@dddddddddddd", 10, nil)
|
||||
report, err := f.w.h.Carry()
|
||||
if err != nil || !report.Already {
|
||||
t.Fatalf("a second carry: %+v %v", report, err)
|
||||
}
|
||||
if got, _ := f.own["deliveries"].Get("novox/app@dddddddddddd"); got != nil {
|
||||
t.Fatal("a second carry copied a key")
|
||||
}
|
||||
}
|
||||
|
||||
// A carry that fails leaves the gate silent and writes no mark, so it is tried again.
|
||||
func TestAFailedCarryLeavesTheGateSilentAndUnmarked(t *testing.T) {
|
||||
f := aCarry(t)
|
||||
f.w.h.Carry = func() (CarryReport, error) { return CarryReport{}, errors.New("the old bucket cannot be read") }
|
||||
if err := f.w.h.SeatChanged(); err == nil {
|
||||
t.Fatal("a failed carry was not returned")
|
||||
}
|
||||
if f.w.h.Holding() {
|
||||
t.Fatal("the gate holds the seat on state it did not carry")
|
||||
}
|
||||
if got, _ := f.own["deliveries"].Get(carriedKey); got != nil {
|
||||
t.Fatal("a failed carry wrote its mark")
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, try failure: the carry copies a group, a pull key and a delivery without an unanswered ask byte for byte; what
|
||||
// the gate writes after it holds the seat and acts is its own (a group's said state), not the carry's.
|
||||
func TestTheCarryCopiesWhatItDoesNotClearByteForByte(t *testing.T) {
|
||||
f := aCarry(t)
|
||||
group := []byte(`{"id":"feat/x","members":["novox/app@aaaaaaaaaaaa"],"created":"2026-10-09T07:00:00Z","updated":"2026-10-09T07:00:00Z","said":"delivering"}`)
|
||||
f.oldG["feat_x"] = group
|
||||
plain := aDelivery("novox/app@eeeeeeeeeeee", 11, nil)
|
||||
f.oldD["novox/app@eeeeeeeeeeee"] = plain
|
||||
if err := f.w.h.SeatChanged(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
bucket, key string
|
||||
want []byte
|
||||
}{{"groups", "feat_x", group}, {"pulls", "novox_app_7", f.oldP["novox_app_7"]}, {"deliveries", "novox/app@eeeeeeeeeeee", plain}} {
|
||||
if got, _ := f.own[c.bucket].Get(c.key); !bytes.Equal(got, c.want) {
|
||||
t.Fatalf("%s %s changed in the carry:\n got %s\nwant %s", c.bucket, c.key, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"source": "mesh-delivery.deliveries and mesh-delivery.groups, called on the live mesh at 2026-10-20 about 02:45 (local) by fix-r3e-gate@shanks; there is no tool that reads a key's value (issue 1333), so each value is built from what the verbs show: id, pull request number, state, since, group, and for the group's members base, head and state. What the verbs do not show is filled with the least that reads: the commit of a delivery is its id's twelve characters (the verbs show no full head), the base is main, created is since. The two ask ids under ready are synthetic (the verbs do not show an ask id): they stand for an Approve asked and not answered, which the carry must clear.",
|
||||
"deliveries": [
|
||||
{"id": "novox/mesh-catalog@b9104454f9a5", "repository": "novox/mesh-catalog", "commit": "b9104454f9a5", "number": 378, "base": "main", "state": "delivering", "since": "2026-10-19T22:58:32Z", "created": "2026-10-19T22:58:32Z"},
|
||||
{"id": "novox/mesh-sdk@1c20b9fb9b25", "repository": "novox/mesh-sdk", "commit": "1c20b9fb9b25", "number": 43, "base": "main", "state": "proposed", "since": "2026-10-19T20:39:11Z", "created": "2026-10-19T20:39:11Z"},
|
||||
{"id": "novox/de-spiegel@fdcbaefb3123", "repository": "novox/de-spiegel", "commit": "fdcbaefb3123", "number": 4, "base": "main", "state": "ready", "since": "2026-10-16T20:10:46Z", "created": "2026-10-16T20:10:46Z", "ready": {"head": "fdcbaefb3123", "seen": "2026-10-20T00:00:00Z", "last_try": "0001-01-01T00:00:00Z", "ask_id": "merge-4-fdcbaefb3123-1", "asked_at": "2026-10-20T00:00:00Z", "asks": 1, "heads": {"fdcbaefb3123": "fdcbaefb3123"}}},
|
||||
{"id": "novox/hq@1552b0e0a3ec", "repository": "novox/hq", "commit": "1552b0e0a3ec", "number": 384, "base": "main", "state": "proposed", "since": "2026-10-16T15:58:05Z", "created": "2026-10-16T15:58:05Z"},
|
||||
{"id": "novox/mesh-controller@0350b4e22276", "repository": "novox/mesh-controller", "commit": "0350b4e22276", "number": 325, "base": "main", "state": "proposed", "since": "2026-10-14T13:22:26Z", "created": "2026-10-14T13:22:26Z"},
|
||||
{"id": "novox/mesh-catalog@1fe6ea4ee473", "repository": "novox/mesh-catalog", "commit": "1fe6ea4ee473", "number": 152, "base": "main", "state": "ready", "since": "2026-10-09T07:54:44Z", "created": "2026-10-09T07:54:44Z", "ready": {"head": "1fe6ea4ee473", "seen": "2026-10-20T00:00:00Z", "last_try": "0001-01-01T00:00:00Z", "ask_id": "merge-152-1fe6ea4ee473-1", "asked_at": "2026-10-20T00:00:00Z", "asks": 1, "heads": {"1fe6ea4ee473": "1fe6ea4ee473"}}},
|
||||
{"id": "novox/mesh-catalog@795806efa7ad", "repository": "novox/mesh-catalog", "commit": "795806efa7ad95fa5cb3210b527117ad0316f4b9", "number": 350, "base": "main", "state": "stopped", "since": "2026-10-09T07:00:00Z", "created": "2026-10-09T07:00:00Z", "group": "feat/whole-modules"},
|
||||
{"id": "novox/mesh-controller@874a91a3c64f", "repository": "novox/mesh-controller", "commit": "874a91a3c64ff4a4dfa6b55b85101622e0e2e550", "number": 370, "base": "main", "state": "failed", "since": "2026-10-09T07:00:00Z", "created": "2026-10-09T07:00:00Z", "group": "feat/whole-modules"}
|
||||
],
|
||||
"groups": [
|
||||
{"id": "feat/whole-modules", "members": ["novox/mesh-catalog@795806efa7ad", "novox/mesh-controller@874a91a3c64f"], "created": "2026-10-09T07:00:00Z", "updated": "2026-10-09T07:00:00Z"}
|
||||
]
|
||||
}
|
||||
+4
-3
@@ -55,6 +55,7 @@ func TestAProposedDeliveryWhoseHeadHoldsAPassingVerdictAtTheForgeAdoptsItAndAsks
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC3: no timer asks a refused merge again; only an event does (here, mergeAgainNow called directly).
|
||||
func TestAMergeTheForgeRefusedIsAskedAgainOnlyOnAnEventWithNoTimer(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
@@ -108,16 +109,16 @@ func TestAMergeNoWalkOpenedForReadsTheControllersWalksOnceAtItsBound(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEffectTheForgeCouldNotTakeIsTriedAgainAtItsTimeByTheTimerAlone(t *testing.T) {
|
||||
func TestAnEffectTheBusCouldNotTakeIsTriedAgainAtItsTimeByTheTimerAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.down = true
|
||||
w.owed.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.owed.down = false
|
||||
w.later(retryFirst + time.Second)
|
||||
w.h.React()
|
||||
if len(w.forge.notes) == 0 {
|
||||
+12
-6
@@ -226,6 +226,8 @@ type StalledLine struct {
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
// Severity is "urgent" for a group that is held or half merged (issue 1275); empty otherwise.
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
|
||||
@@ -269,6 +271,7 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
out = append(out, h.withChecks(h.uncheckedLines(now))...)
|
||||
out = append(out, h.orphanLines(now)...)
|
||||
out = append(out, h.readyLines(now)...)
|
||||
out = append(out, h.groupLines(now)...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -294,9 +297,9 @@ func (h *Holder) Recheck(id, why, by string) (string, error) {
|
||||
h.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
owner, repo, commit, page := d.Owner(), d.Repo(), d.Commit, d.HTMLURL
|
||||
owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number
|
||||
h.mu.Unlock()
|
||||
if err := h.checkingAgain(owner, repo, commit, page, why); err != nil {
|
||||
if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil {
|
||||
return "", fmt.Errorf("%s is not rechecked: %v; its merge check must say pending before it is asked again, "+
|
||||
"so nothing merges on the old verdict — ask again once the forge answers", id, err)
|
||||
}
|
||||
@@ -333,10 +336,11 @@ func (h *Holder) Recheck(id, why, by string) (string, error) {
|
||||
// verdict sets it; the repository's own check only when the head holds one, since a verdict that says none
|
||||
// would leave a pending one standing for ever. Asked of the forge's holder, which keeps them; it takes
|
||||
// pending on them and nothing else, a verdict being the controller's alone (novox/hq issue 293).
|
||||
func (h *Holder) checkingAgain(owner, repo, commit, page, why string) error {
|
||||
func (h *Holder) checkingAgain(owner, repo string, number int, commit, page, why string) error {
|
||||
if h.Forge == nil {
|
||||
return errors.New("no forge")
|
||||
}
|
||||
// The statuses are owed as keys the forge bridge performs, on the pull request number the delivery has.
|
||||
contexts := []string{ctxGate, ctxRepo}
|
||||
if held, err := h.Forge.Statuses(owner, repo, 0, commit, ""); err == nil {
|
||||
contexts = []string{ctxGate}
|
||||
@@ -348,8 +352,10 @@ func (h *Holder) checkingAgain(owner, repo, commit, page, why string) error {
|
||||
}
|
||||
line := clip(oneLine("checking again: "+why), 140)
|
||||
for _, c := range contexts {
|
||||
if err := h.Forge.Status(owner, repo, commit, c, "pending", line, page); err != nil {
|
||||
return fmt.Errorf("the forge did not set %s pending on %s: %w", c, shortOf(commit, 8), err)
|
||||
act, value, num, head := owedOf(Effect{Kind: EffectStatus, Commit: commit, Context: c, State: "pending", Line: line},
|
||||
owner+"/"+repo, number, commit, "", page)
|
||||
if err := h.owe(owner+"/"+repo, num, head, act, value); err != nil {
|
||||
return fmt.Errorf("%s could not be owed as pending on %s: %w", c, shortOf(commit, 8), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -702,7 +708,7 @@ func (h *Holder) Status(listening string) map[string]any {
|
||||
}
|
||||
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
|
||||
"walks-unmatched": len(h.unmatched), "events": listening, "started": h.started.Format(time.RFC3339),
|
||||
"seat-on-record": h.held}
|
||||
"seat-on-record": h.held, "holds-the-seat": h.Holding(), "seat-waits": h.SeatWhy()}
|
||||
// The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again,
|
||||
// or given up, and when the last was tried. A count since the start, said with that start: two readings in
|
||||
// one run give a window's tries.
|
||||
@@ -1,22 +1,22 @@
|
||||
module mesh-delivery
|
||||
module mesh-delivery-gate
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.51.0
|
||||
github.com/nats-io/nats.go v1.54.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/klauspost/compress v1.20.0 // 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/nkeys v0.4.16 // 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/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
@@ -1,11 +1,17 @@
|
||||
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=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b h1:U7++vBnHNWRYpwdbO+Tx03HUcEzDFLBciNDe0fNiXYc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020011849-ba684e54976b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
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/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
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=
|
||||
@@ -14,14 +20,22 @@ github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnY
|
||||
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/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
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/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
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/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
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/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"module": "mesh-delivery",
|
||||
"module": "mesh-delivery-gate",
|
||||
"version": "1",
|
||||
"slug": "deliver",
|
||||
"slug": "gate",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-delivery",
|
||||
@@ -31,7 +31,8 @@
|
||||
"gitea.forge.pull-request.edited",
|
||||
"gitea.forge.pull-request.closed",
|
||||
"mesh-controller.checked",
|
||||
"mesh-controller.plan-moved"
|
||||
"mesh-controller.plan-moved",
|
||||
"mesh-delivery-forge-bridge.forge.**"
|
||||
],
|
||||
"emits": [
|
||||
"transition",
|
||||
@@ -39,31 +40,45 @@
|
||||
"merged"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.delivery-plan",
|
||||
"seat:mesh-controller.delivery-check",
|
||||
"seat:mesh-controller.deliver",
|
||||
"seat:mesh-controller.delivery-backups-again",
|
||||
"seat:mesh-controller.delivery-stop",
|
||||
"seat:mesh-controller.delivery-backups-again",
|
||||
"seat:mesh-controller.delivery-walks",
|
||||
"seat:mesh-controller.delivery-check",
|
||||
"seat:mesh-controller.forget-checks",
|
||||
"gitea.gitea_note_append",
|
||||
"gitea.gitea_delivery_view",
|
||||
"gitea.gitea_commit_status",
|
||||
"seat:mesh-controller.plans",
|
||||
"gitea.gitea_commit_statuses",
|
||||
"gitea.gitea_contains",
|
||||
"gitea.gitea_open_pulls",
|
||||
"gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_add_comment",
|
||||
"seat:operator-channel.notify"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
"gitea.gitea_open_pulls"
|
||||
],
|
||||
"state": [
|
||||
"deliveries",
|
||||
"groups",
|
||||
"asked",
|
||||
"pulls"
|
||||
"pulls",
|
||||
{
|
||||
"name": "asks",
|
||||
"history": 5
|
||||
},
|
||||
{
|
||||
"name": "merges",
|
||||
"history": 10
|
||||
},
|
||||
{
|
||||
"name": "owed",
|
||||
"history": 5
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"history": 64
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery.deliveries",
|
||||
"mesh-delivery.groups",
|
||||
"mesh-delivery.pulls",
|
||||
"mesh-delivery-teller.answers",
|
||||
"mesh-controller.assignments",
|
||||
"node-engine.have"
|
||||
],
|
||||
"tools": [
|
||||
"delivery_status"
|
||||
@@ -84,10 +99,10 @@
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-delivery",
|
||||
"binary": "mesh-delivery",
|
||||
"from": "cmd/mesh-delivery-gate",
|
||||
"binary": "mesh-delivery-gate",
|
||||
"loads": [
|
||||
"mesh-delivery"
|
||||
"mesh-delivery-gate"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
# mesh-delivery
|
||||
|
||||
The `mesh-delivery` module of the mesh catalogue: claims the seat `mesh-delivery`.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
## A backup that failed before the send
|
||||
|
||||
The controller backs up the data of the modules a delivery moves before it asks or sends anything (ADR 0404). When its
|
||||
walk's record, in `plan-moved`, names a backup that failed or stayed silent and nothing of the plan was sent, the
|
||||
delivery goes from delivering to held (the observed row `backup failed`); the held reason names the machine, the
|
||||
module and the backup's words, and the pull request's comment shows it. The hold does not lift by itself. `release`
|
||||
of such a delivery asks the controller (`delivery-backups-again`) to take the backups again and the delivery goes on
|
||||
delivering; a record from before the controller cleared the failure does not hold it again. A held delivery already
|
||||
ends as covered or delivered when a later merge covers it.
|
||||
@@ -1,58 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Events that start a step (novox/hq ADR 0406 §1, issue 975, ADR 0421 issue 1022).
|
||||
//
|
||||
// This owner acts on events: a verdict, a commit status, an Approve. The operator is asked within the event that makes
|
||||
// the Approve possible (the checks passing), with no label or comment written by anyone: the forge's label and comment
|
||||
// events start nothing here.
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// wantForge says the forge's open pull requests are to be read at the next look: a check may have answered.
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// fakeOperator is the operator channel: it keeps the asks made, and answers as the test says. In auto mode it
|
||||
// approves every ask the moment it is read (the tests of other behaviour, which are not about the Approve).
|
||||
type fakeOperator struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
auto bool
|
||||
asked []asks.Ask
|
||||
cancelled []string
|
||||
state map[string]string
|
||||
warrant map[string]*asks.Warrant
|
||||
refuse error
|
||||
}
|
||||
|
||||
func newFakeOperator(now func() time.Time) *fakeOperator {
|
||||
return &fakeOperator{now: now, state: map[string]string{}, warrant: map[string]*asks.Warrant{}}
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Ask(a asks.Ask) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.refuse != nil {
|
||||
return o.refuse
|
||||
}
|
||||
if err := a.Check(o.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
o.asked = append(o.asked, a)
|
||||
o.state[a.ID] = "open"
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Cancel(id string) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.cancelled = append(o.cancelled, id)
|
||||
o.state[id] = "cancelled"
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Where(id string) (string, *asks.Warrant, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.auto && o.state[id] == "open" {
|
||||
o.answerLocked(id, OptionApprove)
|
||||
}
|
||||
return o.state[id], o.warrant[id], nil
|
||||
}
|
||||
|
||||
func (o *fakeOperator) ask(id string) asks.Ask {
|
||||
for _, a := range o.asked {
|
||||
if a.ID == id {
|
||||
return a
|
||||
}
|
||||
}
|
||||
panic("no ask " + id)
|
||||
}
|
||||
|
||||
func (o *fakeOperator) answerLocked(id, option string) {
|
||||
a := o.ask(id)
|
||||
opt, _ := a.Option(option)
|
||||
o.state[id] = "answered"
|
||||
o.warrant[id] = &asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: option, Label: opt.Label,
|
||||
Level: opt.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Verified: "user id verified"},
|
||||
At: o.now(), AskDigest: a.Digest()}
|
||||
}
|
||||
|
||||
// answer is the operator's choice, proven by the router.
|
||||
func (o *fakeOperator) answer(id, option string) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.answerLocked(id, option)
|
||||
}
|
||||
|
||||
// end is an ask that closed without a choice.
|
||||
func (o *fakeOperator) end(id string) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.state[id] = "expired"
|
||||
o.warrant[id] = &asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeExpired, At: o.now()}
|
||||
}
|
||||
|
||||
// open is the ids of the asks waiting for an answer, in the order asked.
|
||||
func (o *fakeOperator) open() []string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
var out []string
|
||||
for _, a := range o.asked {
|
||||
if o.state[a.ID] == "open" {
|
||||
out = append(out, a.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (o *fakeOperator) count() int { o.mu.Lock(); defer o.mu.Unlock(); return len(o.asked) }
|
||||
@@ -587,7 +587,8 @@ func TestFortyReservationsAtOnceFromTwoInstancesGetFortyDistinctConsecutiveNumbe
|
||||
}
|
||||
|
||||
// The carry over the bus (novox/hq ADR 0300 §6, ADR 0307): values the bucket kept before the switch are read
|
||||
// through the runtime's history, oldest first, and carried before the write that touches the issue.
|
||||
// through the runtime's history, oldest first, and carried before the write that touches the issue. The bucket
|
||||
// keeps the two values the manifest declares (novox/hq issue 1221), so of three written the last two are carried.
|
||||
func TestAWriteOverTheBusCarriesEveryValueTheBucketKeptFirst(t *testing.T) {
|
||||
b := newBus(t)
|
||||
var events []event
|
||||
@@ -617,7 +618,7 @@ func TestAWriteOverTheBusCarriesEveryValueTheBucketKeptFirst(t *testing.T) {
|
||||
titles = append(titles, op.Issue.Title)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(ops) != "[carried carried carried noted]" || fmt.Sprint(titles) != "[first second third]" {
|
||||
if fmt.Sprint(ops) != "[carried carried noted]" || fmt.Sprint(titles) != "[second third]" {
|
||||
t.Fatalf("the log holds %v of %v", ops, titles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,16 @@ func (n *notified) notify(key, severity, summary, headline, explanation, resolve
|
||||
}
|
||||
|
||||
func transition(t *testing.T, h *harness, number int, to, why, mergedAs string, at time.Time) {
|
||||
t.Helper()
|
||||
transitionOn(t, h, "mesh-delivery.transition", number, to, why, mergedAs, at)
|
||||
}
|
||||
|
||||
// transitionOn is a delivery's transition said under an event's name: today's module's, or the gate's.
|
||||
func transitionOn(t *testing.T, h *harness, event string, number int, to, why, mergedAs string, at time.Time) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(map[string]any{"repository": "novox/mesh-catalog", "number": number, "to": to, "why": why,
|
||||
"merged_as": mergedAs, "at": at})
|
||||
if err := h.OnTransition(stdio.Envelope{Key: "mesh-delivery.transition", Body: raw}); err != nil {
|
||||
if err := h.OnTransition(stdio.Envelope{Key: event, Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -333,3 +339,26 @@ func TestAResolvedIssueEndsItsStuckWarningAndIsNeverSaidStuck(t *testing.T) {
|
||||
t.Fatalf("a resolved issue said stuck: %q", note.said)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1350 AC1: the handler takes a transition under either name, today's module's or the gate's, and keeps the same fact.
|
||||
func TestATransitionIsTakenUnderEitherNameOfTheDeliverysOwner(t *testing.T) {
|
||||
for _, event := range transitionEvents {
|
||||
t.Run(event, func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := h.open(t, "the counter skips a number")
|
||||
if _, err := h.Move(MoveAsk{Number: n, Status: StatusLocated, LocatedIn: []string{"mesh-catalog modules/records"},
|
||||
FixedBy: []string{"mesh-catalog#9"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
|
||||
transitionOn(t, h, event, 9, "delivered", "the walk is done", "abc123abc123", at)
|
||||
v, err := h.View(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Delivery != "delivered to every machine it should reach" {
|
||||
t.Fatalf("the issue's delivery line is %q, heard as %s; wanted delivered", v.Delivery, event)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user