gitea: mesh-tools administers a user's delivered repository and puts main's rule right, permissions read as the account sees them; knowledge-base.progress: minimal answers leave items to the tree, failed calls warn before parse faults (issues 1945, 1947)
This commit is contained in:
+43
-5
@@ -6,7 +6,7 @@
|
||||
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";
|
||||
import { MAIN_BRANCH, mainMergers, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
import { Refused, checkSentPath, ruleCovers, pathSegment, pathSegments } from "./guard.js";
|
||||
import { isCommit } from "./checks.js";
|
||||
|
||||
@@ -1047,12 +1047,19 @@ export class GiteaAdmin {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What the forge itself says an account may do on a repository: none, read, write, admin or owner. */
|
||||
/** What the forge gives an account on a repository, effective: none, read, write, admin or owner. It is the repository as
|
||||
* that account sees it (the site administrator acts as the account with `sudo`), so access through an organisation's team
|
||||
* counts. The collaborator endpoint is not used: it names only a collaborator's own permission, and says "read" of an
|
||||
* account that writes through a team (novox/hq issue 1945). */
|
||||
async repositoryPermission(owner: string, repo: string, user: string): Promise<string> {
|
||||
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}/permission`;
|
||||
const path = `${repoPath(owner, repo)}?sudo=${encodeURIComponent(user)}`;
|
||||
const res = await this.request(path);
|
||||
if (res.status === 404) return "none";
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
return String(res.body?.permission ?? "none");
|
||||
const p = res.body?.permissions;
|
||||
if (p?.admin === true) return res.body?.owner?.login === user ? "owner" : "admin";
|
||||
if (p?.push === true) return "write";
|
||||
return p?.pull === true ? "read" : "none";
|
||||
}
|
||||
|
||||
/** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for
|
||||
@@ -1067,7 +1074,38 @@ export class GiteaAdmin {
|
||||
await this.addCollaborator(owner, repo, user, "write");
|
||||
}
|
||||
|
||||
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> {
|
||||
/** Make an account an administrator collaborator of a repository (writes.ts: a repository of a user that builds a
|
||||
* registered module, where the tools' account must also put main's rule right). */
|
||||
async addAdminCollaborator(owner: string, repo: string, user: string): Promise<void> {
|
||||
await this.addCollaborator(owner, repo, user, "admin");
|
||||
}
|
||||
|
||||
/** Put main's rule right in one repository: no push, the merge whitelist (the tools' account first), no status required,
|
||||
* and no administrator override. A rule the operator already set that is stronger is kept, never made weaker (ADR 0266):
|
||||
* a whitelist that holds the tools' account and only logins the wanted one names, more approvals, a push already refused.
|
||||
* Answers the rule as the forge holds it after, read back. With the administrator's login. */
|
||||
async ensureMainRule(owner: string, repo: string, mergers: string[] = mainMergers()): Promise<{ created: boolean; rule: BranchProtection }> {
|
||||
const rules = await this.branchProtections(owner, repo);
|
||||
const own = rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH);
|
||||
const kept = own?.enable_merge_whitelist === true ? (own.merge_whitelist_usernames ?? []) : [];
|
||||
const whitelist = kept.length > 0 && kept.includes(mergers[0]) && kept.every((u) => mergers.includes(u)) ? kept : mergers;
|
||||
const body = { ...mainRuleBody(mergers), merge_whitelist_usernames: whitelist, block_admin_merge_override: true };
|
||||
const path = `${repoPath(owner, repo)}/branch_protections`;
|
||||
if (own) {
|
||||
const res = await this.request(`${path}/${MAIN_BRANCH}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||
if (res.status !== 200) GiteaAdmin.fail(`${path}/${MAIN_BRANCH}`, res);
|
||||
} else {
|
||||
const covering = rules.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH));
|
||||
const approvals = Math.max(0, ...covering.map((r) => Number(r.required_approvals ?? 0)));
|
||||
const res = await this.request(path, { method: "POST", body: JSON.stringify({ rule_name: MAIN_BRANCH, ...(approvals > 0 ? { required_approvals: approvals } : {}), ...body }) });
|
||||
if (res.status !== 201) GiteaAdmin.fail(path, res);
|
||||
}
|
||||
const after = (await this.branchProtections(owner, repo)).find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH);
|
||||
if (!after) throw new Error(`main's rule of ${owner}/${repo} was written, but the forge does not list it`);
|
||||
return { created: !own, rule: after };
|
||||
}
|
||||
|
||||
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write" | "admin"): Promise<void> {
|
||||
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`;
|
||||
const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) });
|
||||
if (res.status === 204 || res.status === 200) return;
|
||||
|
||||
@@ -28,6 +28,7 @@ interface Team { id: number; name: string; units: any; members: Set<string> }
|
||||
|
||||
interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map<string, string> }
|
||||
const forgeRepos: ForgeRepo[] = [];
|
||||
const userRules = new Map<string, any>();
|
||||
const repoFaults = { refuse: new Set<string>(), permission: new Set<string>() };
|
||||
function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo {
|
||||
const [owner, name] = full.split("/");
|
||||
@@ -84,7 +85,7 @@ function fakeForge() {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://fake");
|
||||
const p = url.pathname.replace(/^\/api\/v1/, "");
|
||||
const me = who(req);
|
||||
const me = url.searchParams.has("sudo") && isAdmin(who(req)) ? url.searchParams.get("sudo") : who(req);
|
||||
calls.push(`${req.method} ${p} as ${me}`);
|
||||
if (me === null) return json(res, 401, { message: "unauthorized" });
|
||||
|
||||
@@ -168,6 +169,20 @@ function fakeForge() {
|
||||
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
|
||||
return json(res, 404, { message: "branch does not exist" });
|
||||
}
|
||||
// A repository of a user: its rules are its administrator's (a site administrator, or a collaborator with admin).
|
||||
m = p.match(/^\/repos\/(?!novox\/)([^/]+)\/([^/]+)\/branch_protections(?:\/(.*))?$/);
|
||||
if (m) {
|
||||
const key = `${m[1]}/${m[2]}`;
|
||||
const repo = forgeRepos.find((r) => r.full === key);
|
||||
if (!repo || !["admin", "owner"].includes(permissionOf(me, repo))) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
if (req.method === "GET" && m[3] === undefined) return json(res, 200, userRules.has(key) ? [userRules.get(key)] : []);
|
||||
if (req.method === "PATCH" && m[3] === "main") {
|
||||
if (!userRules.has(key)) return json(res, 404, { message: "branch protection does not exist" });
|
||||
Object.assign(userRules.get(key), await read(req));
|
||||
return json(res, 200, userRules.get(key));
|
||||
}
|
||||
if (req.method === "POST") { const b = await read(req); userRules.set(key, b); return json(res, 201, b); }
|
||||
}
|
||||
// gitea's reqAdmin on the whole group: reading the rules is administration too.
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/);
|
||||
if (m) {
|
||||
@@ -205,6 +220,7 @@ function fakeForge() {
|
||||
m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/);
|
||||
if (m) {
|
||||
const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2]));
|
||||
if (repo && req.method === "GET" && !m[3] && url.searchParams.has("sudo") && repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
|
||||
if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" });
|
||||
if (req.method === "GET" && !m[3]) {
|
||||
const mine = permissionOf(me, repo);
|
||||
@@ -214,7 +230,8 @@ function fakeForge() {
|
||||
if (req.method === "GET" && m[4]) {
|
||||
if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
|
||||
if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" });
|
||||
return json(res, 200, { permission: permissionOf(user, repo) });
|
||||
// As gitea answers: the collaborator's own permission, not what a team gives the account.
|
||||
return json(res, 200, { permission: repo.collaborators.get(user) ?? "read" });
|
||||
}
|
||||
if (req.method === "PUT" && !m[4]) {
|
||||
// gitea's reqAdmin: the repository's administration, which on a repository of another user the site administrator alone holds.
|
||||
@@ -608,7 +625,9 @@ test("the merging account is given write on each repository the indexer's module
|
||||
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } });
|
||||
assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
|
||||
const repo = (full: string) => forgeRepos.find((x) => x.full === full)!;
|
||||
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write");
|
||||
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "admin", "a user's repository is administered, not only written");
|
||||
assert.deepEqual(userRules.get("jschoubben/car-hunter"), { rule_name: "main", enable_push: false, enable_merge_whitelist: true,
|
||||
merge_whitelist_usernames: [TOOLS_ACCOUNT], enable_status_check: false, status_check_contexts: [], block_admin_merge_override: true });
|
||||
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator");
|
||||
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true);
|
||||
assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
|
||||
@@ -636,8 +655,14 @@ test("the merging account is given write on each repository the indexer's module
|
||||
assert.ok(!forge.calls.slice(puts).some((c) => c.startsWith("PUT ")), "no write is asked on a repository no module builds from");
|
||||
assert.equal(repo("jschoubben/dotfiles").collaborators.get(TOOLS_ACCOUNT), "read");
|
||||
repoFaults.refuse.clear();
|
||||
// A stronger rule the operator set (a whitelist of the tools' account alone, two approvals) is kept, never weakened.
|
||||
userRules.set("jschoubben/snake", { rule_name: "main", enable_push: true, enable_merge_whitelist: true,
|
||||
merge_whitelist_usernames: [TOOLS_ACCOUNT], required_approvals: 2, block_admin_merge_override: false });
|
||||
const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any;
|
||||
assert.deepEqual(after.granted, ["jschoubben/snake"]);
|
||||
assert.deepEqual(after.main_rule.map((x: any) => [x.repository, x.created, x.rule.enable_push, x.rule.required_approvals, x.rule.block_admin_merge_override]),
|
||||
[["jschoubben/snake", false, false, 2, true]]);
|
||||
assert.equal(repo("jschoubben/snake").collaborators.get(TOOLS_ACCOUNT), "admin");
|
||||
assert.deepEqual(((await unwritableTool.run({})) as any).unwritable, []);
|
||||
// A module gone from the indexer leaves its repository out of the delivered list.
|
||||
await writes.take({ key: "snake", op: "delete" });
|
||||
|
||||
+54
-23
@@ -18,10 +18,14 @@
|
||||
// `repo.writable` {owner, repo, account}: mesh-delivery-gate, which does not ask the operator's Approve of a pull request
|
||||
// whose merge the forge would refuse, reads that repository again on it.
|
||||
//
|
||||
// **A repository of a user is administered, not only written** (the operator, 2026-10-26: "mesh-tools should get all the
|
||||
// permissions I give him"): the account is an admin collaborator there and puts main's rule right (client.ts ensureMainRule).
|
||||
//
|
||||
// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it;
|
||||
// gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
|
||||
|
||||
import type { ForgeRepository } from "./client.js";
|
||||
import type { BranchProtection } from "./protection.js";
|
||||
|
||||
/** The indexer's state this module reads: one key per registered module, its value naming the repository. */
|
||||
export const INDEXER_MODULES = "mesh-delivery-indexer.modules";
|
||||
@@ -33,7 +37,8 @@ export interface WriteForge {
|
||||
everyRepository(): Promise<ForgeRepository[]>;
|
||||
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
|
||||
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
|
||||
addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>;
|
||||
addAdminCollaborator(owner: string, repo: string, user: string): Promise<void>;
|
||||
ensureMainRule(owner: string, repo: string): Promise<{ created: boolean; rule: BranchProtection }>;
|
||||
}
|
||||
|
||||
/** One change of the indexer's modules state, as the SDK's state watch hands it. */
|
||||
@@ -51,8 +56,12 @@ export interface Written {
|
||||
delivered: string[];
|
||||
/** Made writable now, each read back from the forge. */
|
||||
granted: string[];
|
||||
/** Written already (the organisation's team, or a collaborator that writes). */
|
||||
/** Administered already (a repository of a user), or written through the organisation's team. */
|
||||
already: string[];
|
||||
/** Each repository of a user whose main's rule was put right (no push, the merge whitelist, no status, no administrator
|
||||
* override), read back: what the forge holds for it now. */
|
||||
main_rule: { repository: string; created: boolean; rule: { enable_push?: boolean; merge_whitelist_usernames?: string[] | null;
|
||||
block_admin_merge_override?: boolean; enable_status_check?: boolean; required_approvals?: number } }[];
|
||||
/** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */
|
||||
refused: { repository: string; why: string }[];
|
||||
/** Repositories the account cannot write that are left so, and why: they build no registered module. */
|
||||
@@ -60,6 +69,7 @@ export interface Written {
|
||||
}
|
||||
|
||||
const WRITES = new Set(["write", "admin", "owner"]);
|
||||
const ADMINS = new Set(["admin", "owner"]);
|
||||
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
function split(full: string): [string, string] {
|
||||
@@ -118,7 +128,7 @@ export class DeliveredWrites {
|
||||
"(the watch is not granted or not answered); nothing is granted");
|
||||
}
|
||||
const delivered = this.delivered();
|
||||
const result: Written = { account: this.account, delivered, granted: [], already: [], refused: [], left_read_only: [] };
|
||||
const result: Written = { account: this.account, delivered, granted: [], already: [], main_rule: [], refused: [], left_read_only: [] };
|
||||
const named = only && only.length ? only.map((x) => String(x).trim()) : null;
|
||||
const targets: string[] = [];
|
||||
for (const full of named ?? delivered) {
|
||||
@@ -140,36 +150,55 @@ export class DeliveredWrites {
|
||||
result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (WRITES.has(permission)) {
|
||||
this.writable.add(full);
|
||||
result.already.push(full);
|
||||
continue;
|
||||
}
|
||||
if (owner === this.org) {
|
||||
if (WRITES.has(permission)) {
|
||||
this.writable.add(full);
|
||||
result.already.push(full);
|
||||
continue;
|
||||
}
|
||||
// The organisation's repositories are written through its team (tools-account.ts), never a collaborator.
|
||||
result.refused.push({ repository: full, why: `a repository of ${this.org}, which ${this.account} writes through the ` +
|
||||
`team ${this.org}/${this.account}; the forge gives it "${permission}", so the team is not as it should be (it is ` +
|
||||
"set again at the next mint of the tools' token)" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.forge.addWriteCollaborator(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: said(err) });
|
||||
continue;
|
||||
// A repository of a user: the account administers it (the operator, 2026-10-26: "mesh-tools should get all the
|
||||
// permissions I give him"), so it also puts main's rule right, which only an administrator may.
|
||||
let changed = false;
|
||||
if (!ADMINS.has(permission)) {
|
||||
try {
|
||||
await this.forge.addAdminCollaborator(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: said(err) });
|
||||
continue;
|
||||
}
|
||||
let after: string;
|
||||
try {
|
||||
after = await this.forge.repositoryPermission(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (!ADMINS.has(after)) {
|
||||
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
let after: string;
|
||||
try {
|
||||
after = await this.forge.repositoryPermission(owner, name, this.account);
|
||||
const { created, rule } = await this.forge.ensureMainRule(owner, name);
|
||||
result.main_rule.push({ repository: full, created, rule: { enable_push: rule.enable_push,
|
||||
merge_whitelist_usernames: rule.merge_whitelist_usernames, block_admin_merge_override: rule.block_admin_merge_override,
|
||||
enable_status_check: rule.enable_status_check, required_approvals: rule.required_approvals } });
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (!WRITES.has(after)) {
|
||||
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
|
||||
result.refused.push({ repository: full, why: `${this.account} administers it, but main's rule could not be put right — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
this.writable.add(full);
|
||||
if (!changed) {
|
||||
result.already.push(full);
|
||||
continue;
|
||||
}
|
||||
result.granted.push(full);
|
||||
await this.emit(WRITABLE_EVENT, { owner, repo: name, account: this.account }).catch((err) =>
|
||||
this.log(`${this.account} writes ${full} now, and that could not be said on the bus — ${said(err)}`));
|
||||
@@ -230,12 +259,14 @@ export function writeTools(writes: () => DeliveredWrites): Tool[] {
|
||||
},
|
||||
{
|
||||
name: "gitea_grant_write",
|
||||
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository that " +
|
||||
"builds a registered module and that it cannot write now, or on the repositories named (each must build one). " +
|
||||
description: "Give the mesh's merging forge account (mesh-tools) administration, as a collaborator, on every repository " +
|
||||
"of a user that builds a registered module, and put main's rule right there (no push, the merge whitelist, no status " +
|
||||
"required, no administrator override; a stronger rule the operator set is kept), or on the repositories named (each " +
|
||||
"must build one). The answer's main_rule says each rule as the forge holds it after, read back. " +
|
||||
"The module does this itself at its start and when a module of a new repository is registered; this is the same " +
|
||||
"act by hand, such as when a merge or a commit status was refused 403. A repository of the organisation novox " +
|
||||
"is written through its team and needs nothing. Answers what was granted (each read back from the forge), what " +
|
||||
"was written already, what the forge refused in its own words, and which other repositories it left read-only " +
|
||||
"was administered already, what the forge refused in its own words, and which other repositories it left read-only " +
|
||||
"and why (they build no registered module). Needs the site administrator's login, used for this grant alone.",
|
||||
input: {
|
||||
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every delivered one" },
|
||||
|
||||
@@ -167,6 +167,8 @@ func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A cut takes the warnings from the last: the calls that failed are said before the answers that did not parse.
|
||||
callsFirst(p.Warnings)
|
||||
fitProgress(p, boundOf(a.Bound))
|
||||
return p, nil
|
||||
}
|
||||
@@ -822,8 +824,21 @@ func movedSay(t *time.Time) string {
|
||||
return " · moved " + when(t)
|
||||
}
|
||||
|
||||
// callsFirst puts the warnings of calls that failed before those of answers that did not parse, in place.
|
||||
func callsFirst(w []string) {
|
||||
sort.SliceStable(w, func(i, j int) bool {
|
||||
return !strings.Contains(w[i], " answered something else") && strings.Contains(w[j], " answered something else")
|
||||
})
|
||||
}
|
||||
|
||||
// fitProgress keeps an answer within its bound: the items go first, then the tree's last lines, saying what was cut.
|
||||
// A minimal answer never carries the items: its tree says the same, one line per milestone, so leaving them out is how
|
||||
// the minimal form is made (the items of design 47 alone passed 8192 bytes) and no cut. A detailed answer's items, and
|
||||
// everything else that goes, are said in cut.
|
||||
func fitProgress(p *Progress, bound int) {
|
||||
if p.Detail == DetailMinimal && p.Tree != "" {
|
||||
p.Items = nil
|
||||
}
|
||||
if size(p) <= bound {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -306,12 +306,23 @@ func TestProgressOfADesignMinimalAndDetailed(t *testing.T) {
|
||||
if len(raw) > DefaultBound || p.Detail != DetailMinimal || !strings.Contains(p.Source, "issue 1781") {
|
||||
t.Fatalf("%d bytes, %s, %s", len(raw), p.Detail, p.Source)
|
||||
}
|
||||
if p.Cut != "" || len(p.Items) != 0 {
|
||||
t.Errorf("a minimal answer leaves the items to its tree and is not a cut: cut %q, %d items", p.Cut, len(p.Items))
|
||||
}
|
||||
warns := []string{"mesh-delivery.show a answered something else: x", "issue-tracker.get 1: refused", "the tracker did not name 2", "issue-tracker.get 3 answered something else: y"}
|
||||
callsFirst(warns)
|
||||
if strings.Join(warns, "|") != "issue-tracker.get 1: refused|the tracker did not name 2|mesh-delivery.show a answered something else: x|issue-tracker.get 3 answered something else: y" {
|
||||
t.Errorf("call failures do not come first: %q", warns)
|
||||
}
|
||||
if seats.asked["seat:issue-tracker.list"] != 1 || seats.asked["seat:mesh-delivery.deliveries"] != 0 || seats.asked["seat:issue-tracker.get"] != 0 {
|
||||
t.Fatalf("minimal asked %v", seats.asked)
|
||||
}
|
||||
if w := strings.Join(p.Warnings, "\n"); !strings.Contains(w, "the tracker neither answered nor named missing") || !strings.Contains(w, "973, 525") {
|
||||
t.Errorf("a row the tracker did not answer is not said: %v", p.Warnings)
|
||||
}
|
||||
if big, _ := k.Progress(ProgressAsk{Of: "design 47", Bound: 8192}); big.Cut != "" || big.Tree != p.Tree {
|
||||
t.Errorf("a minimal answer at bound 8192 is cut: %q", big.Cut)
|
||||
}
|
||||
lines := strings.Split(p.Tree, "\n")
|
||||
if !strings.HasPrefix(lines[0], "✗ design 47 · Delivery: from a commit to delivered — ") {
|
||||
t.Fatalf("first line %q", lines[0])
|
||||
|
||||
Reference in New Issue
Block a user