Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

6 files changed+53−11
Files (6)
modifiedapps/diffinery/docs/collaboration/checks-and-statuses.md+1−1View file
@@ -282,7 +282,7 @@
282282
283283 A branch protection rule's **Required status checks** (`requiredChecks` in the API) lists check names that must pass on a pull request's head commit before it can merge, and on a merge group's commit before the merge queue merges it. Both check runs and commit statuses count: a required check named `ci/external` is satisfied by a check run named `ci/external` or by a commit status with context `ci/external`.
284284
285-To set them in the web UI, open **Settings**, then **Branch protection**, and enter the names, comma-separated, in **Required status checks**, exactly as they appear on pull requests. See [Branch protection](/docs/repositories/branch-protection) for the whole rule.
285+To set them in the web UI, open **Settings**, then **Branch protection**, and enter the names in **Required status checks**, one per line, exactly as they appear on pull requests. A name may contain commas, for example `CI / test (ubuntu, 22)`. See [Branch protection](/docs/repositories/branch-protection) for the whole rule.
286286
287287 ### How a required check is decided
288288
modifiedapps/diffinery/docs/collaboration/merge-queue.md+1−1View file
@@ -24,7 +24,7 @@
2424
2525 1. Open the repository's **Settings** and choose **Branch protection** (`https://diffinery.com/acme/website/settings/branches`).
2626 2. Open the rule for the branch (for example `main`), or fill in **Add a rule** with `main` as the **Branch name pattern**.
27-3. In **Required status checks**, list the checks that must pass, comma-separated and exactly as they appear on pull requests, for example `CI / test, CI / lint`.
27+3. In **Required status checks**, list the checks that must pass, one per line and exactly as they appear on pull requests, for example `CI / test` and `CI / lint`.
2828 4. Tick **Require a merge queue**, then set:
2929 - **Merge method**: **Create a merge commit**, **Squash and merge** or **Rebase and merge**.
3030 - **Build concurrency**: how many merge groups are built and checked at once.
modifiedapps/diffinery/docs/repositories/branch-protection.md+1−1View file
@@ -209,7 +209,7 @@
209209 1. Open the repository, for example `https://diffinery.com/acme/website`.
210210 2. Select **Settings**, then **Branch protection** in the sidebar (`https://diffinery.com/acme/website/settings/branches`).
211211 3. Under **Add a rule**, enter the **Branch name pattern**, for example `main`. The form starts with **Require a pull request before merging** checked and **Required approvals** set to 1.
212-4. Set **Required approvals**, enter **Required status checks** as a comma-separated list of check names, and check the options you want.
212+4. Set **Required approvals**, enter **Required status checks** one check name per line (names may contain commas, as matrix jobs' do), and check the options you want.
213213 5. To use a merge queue, check **Require a merge queue** and choose the **Merge method**, **Build concurrency** and **Check timeout (minutes)**.
214214 6. Select **Create rule**.
215215
modifiedapps/diffinery/src/repo/required.ts+28−0View file
@@ -12,6 +12,34 @@
1212 return (c.creatorLogin ?? c.app).toLowerCase();
1313 }
1414
15+/**
16+ * The required checks typed into the branch protection form: one per line. On a single line, commas
17+ * separate names, except inside parentheses or brackets, so a matrix job such as
18+ * "End-to-end (git, PRs, Actions)" stays one name.
19+ */
20+export function parseCheckList(text: string): string[] {
21+ const names: string[] = [];
22+ for (const line of text.split(/\r?\n/)) {
23+ if (text.includes("\n")) {
24+ names.push(line);
25+ continue;
26+ }
27+ let depth = 0;
28+ let start = 0;
29+ for (let i = 0; i < line.length; i++) {
30+ const ch = line[i];
31+ if (ch === "(" || ch === "[") depth++;
32+ else if ((ch === ")" || ch === "]") && depth > 0) depth--;
33+ else if (ch === "," && depth === 0) {
34+ names.push(line.slice(start, i));
35+ start = i + 1;
36+ }
37+ }
38+ names.push(line.slice(start));
39+ }
40+ return [...new Set(names.map((n) => n.trim()).filter(Boolean))];
41+}
42+
1543 export type RequiredState = { state: "success" | "failure" | "pending" | "missing"; conclusion: string | null; status: string | null };
1644
1745 /**
modifiedapps/diffinery/src/web/repo-settings.tsx+6−7View file
@@ -7,6 +7,7 @@
77 import { HttpError } from "../lib/util.js";
88 import { MAX_REVIEWERS, MAX_WAIT_TIMER, type Environment } from "../repo/deployments.js";
99 import { resolveReviewers } from "../http/environment-reviews.js";
10+import { parseCheckList } from "../repo/required.js";
1011 import type { ProtectionRules, Role } from "../repo/types.js";
1112 import { ICONS } from "./assets.js";
1213 import { KCheckbox, KRadio, KSelect, type KOption } from "./kumo.js";
@@ -123,8 +124,10 @@
123124 </div>
124125 <div class="field">
125126 <label>Required status checks</label>
126- <input name="requiredChecks" type="text" value={(rules.requiredChecks ?? []).join(", ")} placeholder="ci / test, lint" />
127- <div class="hint">Comma-separated check names, exactly as they appear on pull requests.</div>
127+ <textarea name="requiredChecks" class="mono" style="min-height:80px" autocomplete="off" spellcheck={false} placeholder={"CI / test\nlint"}>
128+ {(rules.requiredChecks ?? []).join("\n")}
129+ </textarea>
130+ <div class="hint">One check name per line, exactly as it appears on pull requests. Names may contain commas.</div>
128131 </div>
129132 <div class="field">
130133 <div class="kstack" style="gap:8px">
@@ -186,11 +189,7 @@
186189 for (const [key] of RULE_FLAGS) if (f.b(key)) (rules as Record<string, unknown>)[key] = true;
187190 const approvals = Math.max(0, Math.min(10, parseInt(f.s("requiredApprovals"), 10) || 0));
188191 if (approvals) rules.requiredApprovals = approvals;
189- const checks = f
190- .s("requiredChecks")
191- .split(/[,\n]/)
192- .map((s) => s.trim())
193- .filter(Boolean);
192+ const checks = parseCheckList(f.s("requiredChecks"));
194193 if (checks.length) rules.requiredChecks = checks;
195194 if (f.b("mergeQueue")) {
196195 const method = f.s("mqMethod");
modifiedapps/diffinery/test/unit/required.test.ts+16−1View file
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "vitest";
2-import { ACTIONS_APP, normalizeCheckSources, requiredCheck } from "../../src/repo/required.js";
2+import { ACTIONS_APP, normalizeCheckSources, parseCheckList, requiredCheck } from "../../src/repo/required.js";
33 import type { Check } from "../../src/repo/collab.js";
44
55 type Entry = Partial<Check> & Pick<Check, "name" | "app" | "status">;
@@ -48,3 +48,18 @@
4848 for (const v of [null, [], "x", { ci: 1 }, { ci: "" }, { ci: "a b" }, { "": "x" }]) expect(() => normalizeCheckSources(v)).toThrow();
4949 });
5050 });
51+
52+describe("parseCheckList", () => {
53+ it("takes one name per line, commas and all", () => {
54+ expect(parseCheckList("CI / Typecheck and bundle\r\nEnd-to-end / End-to-end (git, PRs, Actions)\n\n lint \n")).toEqual(["CI / Typecheck and bundle", "End-to-end / End-to-end (git, PRs, Actions)", "lint"]);
55+ });
56+
57+ it("splits a single line on commas outside parentheses and brackets", () => {
58+ expect(parseCheckList("CI / Unit tests (1/2), End-to-end (git, PRs, Actions), build [linux, arm64], lint")).toEqual(["CI / Unit tests (1/2)", "End-to-end (git, PRs, Actions)", "build [linux, arm64]", "lint"]);
59+ });
60+
61+ it("drops blanks and duplicates", () => {
62+ expect(parseCheckList(" , lint,,lint ")).toEqual(["lint"]);
63+ expect(parseCheckList("")).toEqual([]);
64+ });
65+});