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.

Numeric ids for runs, jobs and workflows, so gh run and gh workflow work #325

Mergedtest-botagenton behalf of cnbrkdmrcimerged into main from claude/numeric-run-ids
12 files changed+180−52base 04dacaa → head b86dc2f
Files (12)
modifiedapps/diffinery/docs/actions/overview.md+1−1View file
@@ -310,7 +310,7 @@
310310 | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | Re-runs the run's failed jobs and the jobs that depend on them (`201`, `{"run_id": ...}`). See below. |
311311 | `POST /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/rerun` | Re-runs one job and the jobs that depend on it (`201`, `{"run_id": ...}`). See below. |
312312
313-Run objects use GitHub's field names (`id`, `node_id`, `run_number`, `run_attempt`, `name`, `display_title`, `path`, `workflow_id`, `event`, `head_branch`, `head_sha`, `sha`, `ref`, `status`, `conclusion`, `actor` and `triggering_actor` as user objects, `created_at`, `updated_at`, `run_started_at`, `url`, `html_url`, `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url`, `rerun_url`, `workflow_url`, `previous_attempt_url` and `pull_requests`) plus `pull_request_number`. Job objects carry GitHub's fields (`run_id`, `run_attempt`, `head_sha`, `head_branch`, `html_url`, `started_at`, `completed_at`, `workflow_name`, `steps` with `number`) next to Diffinery's own camelCase fields, which the CLI reads. `head_sha` is the commit checks are reported on (the pull request head for pull request runs) and `sha` is the commit the run checked out. Run and job IDs are 32-character hexadecimal strings. Wherever a path takes `{run_id}`, the run number works too and means its latest attempt; this is what `github.run_id` and `GITHUB_RUN_ID` hold inside a job. A job's `check_run_url` points at `GET /repos/{owner}/{repo}/check-runs/{job_id}`, which returns the job's check run. While a job is running, one logs request returns at most 5,000 lines; page with `after`. The runs, jobs and artifacts lists send a `Link` header with `next`, `prev`, `first` and `last` URLs, so Octokit's `paginate()` works. Errors have a `message` and a `documentation_url`. Every endpoint is listed in the [API reference](/docs/api).
313+Run objects use GitHub's field names (`id`, `node_id`, `run_number`, `run_attempt`, `name`, `display_title`, `path`, `workflow_id`, `event`, `head_branch`, `head_sha`, `sha`, `ref`, `status`, `conclusion`, `actor` and `triggering_actor` as user objects, `created_at`, `updated_at`, `run_started_at`, `url`, `html_url`, `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url`, `rerun_url`, `workflow_url`, `previous_attempt_url` and `pull_requests`) plus `pull_request_number`. Job objects carry GitHub's fields (`run_id`, `run_attempt`, `head_sha`, `head_branch`, `html_url`, `started_at`, `completed_at`, `workflow_name`, `steps` with `number`) next to Diffinery's own camelCase fields, which the CLI reads. `head_sha` is the commit checks are reported on (the pull request head for pull request runs) and `sha` is the commit the run checked out. Run, job, workflow and check suite IDs are numbers, as on GitHub, so `gh run` and `gh workflow` work: a run's or job's `id` has 16 digits, and a run's `check_suite_id` is its `id`. Paths also take the 32-character hexadecimal ids the web UI's addresses use, and a workflow's file name or path in place of `{workflow_id}`. Wherever a path takes `{run_id}`, the run number works too and means its latest attempt; this is what `github.run_id` and `GITHUB_RUN_ID` hold inside a job. A job's `check_run_url` points at `GET /repos/{owner}/{repo}/check-runs/{job_id}`, which returns the job's check run. `GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs` is a zip laid out like GitHub's: a text file per job and a folder per job with a file per step, which `gh run view --log` reads. While a job is running, one logs request returns at most 5,000 lines; page with `after`. The runs, jobs and artifacts lists send a `Link` header with `next`, `prev`, `first` and `last` URLs, so Octokit's `paginate()` works. Errors have a `message` and a `documentation_url`. Every endpoint is listed in the [API reference](/docs/api).
314314
315315 ### From MCP
316316
modifiedapps/diffinery/docs/agents/github-cli.md+5−3View file
@@ -44,13 +44,15 @@
4444 gh issue create --title "The parser drops comments" --label bug
4545 gh issue list --label bug
4646 gh issue close 42 --reason "not planned"
47+gh run list --workflow ci.yml
48+gh run view --log-failed
4749 ```
4850
4951 | Works | Commands |
5052 | --- | --- |
51-| Reading | `gh pr view` (with `--json`), `gh pr list`, `gh pr status`, `gh pr checks`, `gh pr diff`, `gh issue list` (with `--label`, `--assignee`, `--author`, `--mention`, `--search`, `--state`), `gh issue view` (with `--comments` and `--json`), `gh issue status`, `gh repo view`, `gh label list`, `gh release list`, `gh api` (REST and `gh api graphql`) |
52-| Changing | `gh pr create` (with `--draft`, `--reviewer`, `--assignee`, `--label`), `gh pr edit`, `gh pr ready`, `gh pr comment`, `gh pr close`, `gh pr reopen`, `gh pr merge` (`--merge`, `--squash`, `--rebase`, `--auto`, `--disable-auto`), `gh issue create` (with `--label`, `--assignee`, `--milestone`), `gh issue edit`, `gh issue comment`, `gh issue close` (with `--reason`), `gh issue reopen`, `gh label create` |
53-| Not yet | Team reviewers, projects, `gh issue delete`, `lock`, `pin` and `transfer`. Use `gh api` with the REST paths for anything else. |
53+| Reading | `gh pr view` (with `--json`), `gh pr list`, `gh pr status`, `gh pr checks`, `gh pr diff`, `gh issue list` (with `--label`, `--assignee`, `--author`, `--mention`, `--search`, `--state`), `gh issue view` (with `--comments` and `--json`), `gh issue status`, `gh run list`, `gh run view` (with `--job`, `--log`, `--log-failed` and `--json`), `gh run watch`, `gh workflow list`, `gh workflow view`, `gh repo view`, `gh label list`, `gh release list`, `gh api` (REST and `gh api graphql`) |
54+| Changing | `gh pr create` (with `--draft`, `--reviewer`, `--assignee`, `--label`), `gh pr edit`, `gh pr ready`, `gh pr comment`, `gh pr close`, `gh pr reopen`, `gh pr merge` (`--merge`, `--squash`, `--rebase`, `--auto`, `--disable-auto`), `gh issue create` (with `--label`, `--assignee`, `--milestone`), `gh issue edit`, `gh issue comment`, `gh issue close` (with `--reason`), `gh issue reopen`, `gh run rerun` (with `--failed` and `--job`), `gh run cancel`, `gh workflow run` (with `--ref` and `-f` inputs), `gh label create` |
55+| Not yet | Team reviewers, projects, `gh issue delete`, `lock`, `pin` and `transfer`, `gh run delete`, `gh workflow enable` and `disable`. Use `gh api` with the REST paths for anything else. |
5456
5557 gh changes things with your token's permissions and Diffinery's rules: branch protection, required checks and reviews, and the [agent guardrails](/docs/agents/overview) apply as they do on the web and over REST. `gh pr merge --auto` turns on Diffinery's auto-merge, which merges once the required checks pass. When nothing blocks the merge, gh merges at once.
5658
modifiedapps/diffinery/docs/collaboration/checks-and-statuses.md+3−3View file
@@ -249,7 +249,7 @@
249249 "annotations_url": "https://diffinery.com/api/v3/repos/acme/website/check-runs/31/annotations"
250250 },
251251 "name": "ci/external",
252- "check_suite": { "id": "app-ci-bot-9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d" },
252+ "check_suite": { "id": 6145803720560937 },
253253 "app": { "id": "ci-bot", "slug": "ci-bot", "node_id": "QXBwOmNpLWJvdA", "name": "ci-bot", "owner": null },
254254 "pull_requests": [],
255255 "run_id": null,
@@ -257,7 +257,7 @@
257257 }
258258 ```
259259
260-- `app` is the login of the account that reported the check run, or `diffinery-actions` for Actions. `run_id` and `job_id` are set only for Actions checks.
260+- `app` is the login of the account that reported the check run, or `diffinery-actions` for Actions. `run_id` and `job_id` are set only for Actions checks: the `id`s of the workflow run and job behind it.
261261 - A check run is keyed by commit, name and reporter. Posting again with the same `head_sha` and `name` from the same account updates that check run (same `id`) instead of adding a second one.
262262 - Accounts whose login starts with `diffinery-actions` can't report check runs (`this account can't report check runs`).
263263
@@ -276,7 +276,7 @@
276276 | `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}` | One check suite |
277277 | `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs` | The check runs of a suite |
278278
279-Check suites are derived, not created: each Actions workflow run is one suite (its id is the run id), and each external reporter has one suite per commit (id `app-<login>-<sha>`). A suite is `completed` when all its check runs are, with conclusion `failure` if any of them failed, timed out, was cancelled or needs action, and `success` otherwise. You can't create or re-request suites, and commit statuses are not part of any suite.
279+Check suites are derived, not created: each Actions workflow run is one suite (its id is the run's `id`), and each external reporter has one suite per commit, with a number of its own. A suite is `completed` when all its check runs are, with conclusion `failure` if any of them failed, timed out, was cancelled or needs action, and `success` otherwise. You can't create or re-request suites, and commit statuses are not part of any suite.
280280
281281 ## Required checks
282282
modifiedapps/diffinery/docs/reference/rest-api.md+2−2View file
@@ -85,9 +85,9 @@
8585 | Users, organizations, agents, repositories | A positive integer derived from the account's or repository's own ID in Diffinery. It is stable, but nothing looks an account or repository up by it: use the login or `owner/repo` in paths. |
8686 | Issues and pull requests | The `id` equals the `number` (issues and pull requests share one number sequence per repository). |
8787 | Issue comments, review comments, reviews, check runs, releases, release assets, commit statuses, deployments, deployment statuses, reactions, issue events | Integers, as on GitHub. Use them in paths such as `/issues/comments/{id}` and `/check-runs/{id}`. |
88-| Workflow runs and jobs | 32-character hexadecimal strings. Use them as they are in `/actions/runs/{id}` and `/actions/jobs/{id}`. Getting a run, its jobs, logs and attempts, re-running it and listing its artifacts also accept the run's number (`run_number`, which is `github.run_id` inside a workflow): it picks the latest attempt, and for artifacts it covers every attempt. |
88+| Workflow runs, jobs and check suites | 16-digit integers, so clients that expect GitHub's numbers (such as `gh run`) work. Use them in `/actions/runs/{id}`, `/actions/jobs/{id}` and `/check-suites/{id}`; a run's check suite has the run's `id`. These paths also take the 32-character hexadecimal ids in the web UI's addresses (`html_url`) and in webhook payloads. Getting a run, its jobs, logs and attempts, re-running it and listing its artifacts also accept the run's number (`run_number`, which is `github.run_id` inside a workflow): it picks the latest attempt, and for artifacts it covers every attempt. |
8989 | Artifacts and cache entries | Integers, as on GitHub: `/actions/artifacts/{id}`, `/actions/caches/{id}`. An artifact's `workflow_run.id` is the run number. |
90-| Workflows | The workflow's path, such as `.github/workflows/ci.yml`. `/actions/workflows/{id}` also accepts the file name, `ci.yml`. |
90+| Workflows | An integer derived from the workflow's path. `/actions/workflows/{id}` also accepts the path, such as `.github/workflows/ci.yml`, or the file name, `ci.yml`. |
9191 | Webhooks and tokens | Strings. |
9292
9393 `node_id` is an opaque string, stable for an object. The [GraphQL API](/docs/reference/graphql-api) accepts the `node_id` of a repository or pull request in `node(id:)` and in its mutations.
modifiedapps/diffinery/src/http/api.ts+10−6View file
@@ -26,7 +26,7 @@
2626 import { compat } from "./compat.js";
2727 import { appsApi } from "./apps.js";
2828 import { isAppCaller } from "../repo/token-policy.js";
29-import { baseOf, encPath, ghAutoMerge, ghCheckRun, ghComment, ghCommit, ghGitCommit, ghJob, ghRef, ghRun, ghFile, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, normalizeHomepage, numericId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js";
29+import { baseOf, encPath, ghAutoMerge, ghCheckRun, ghComment, ghCommit, ghGitCommit, ghJob, ghRef, ghRun, ghFile, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, normalizeHomepage, numericId, pageOf, paginate, repoApi, repoHtml, setLinks, uuidNumber, workflowNumber } from "./gh.js";
3030 import { decodePath, issueCtx, issuesOut, pullDetail, pullsOut, repoOut } from "./ghctx.js";
3131
3232 /**
@@ -1080,13 +1080,17 @@
10801080 api.get("/repos/:owner/:repo/actions/workflows", async (c) => {
10811081 const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
10821082 const wfs = await stub.workflows();
1083- return c.json({ total_count: wfs.length, workflows: wfs.map((w) => ({ id: w.path, name: w.name, path: w.path, state: w.errors.length ? "invalid" : "active", errors: w.errors, triggers: w.triggers })) });
1083+ return c.json({ total_count: wfs.length, workflows: wfs.map((w) => ({ id: workflowNumber(w.path), name: w.name, path: w.path, state: w.errors.length ? "invalid" : "active", errors: w.errors, triggers: w.triggers })) });
10841084 });
1085+// `run_id` is Diffinery's; `workflow_run_id` and `run_url` are what GitHub answers with `return_run_details`.
10851086 api.post("/repos/:owner/:repo/actions/workflows/:workflow/dispatches", async (c) => {
10861087 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
10871088 const b = await body<{ ref: string; inputs?: Record<string, unknown> }>(c);
1088- const run = await stub.dispatchWorkflow(decodeURIComponent(c.req.param("workflow")), b.ref, b.inputs ?? {}, caller);
1089- return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201);
1089+ let workflow = decodeURIComponent(c.req.param("workflow"));
1090+ if (/^\d+$/.test(workflow)) workflow = (await stub.workflows()).find((w) => workflowNumber(w.path) === Number(workflow))?.path ?? workflow;
1091+ const run = await stub.dispatchWorkflow(workflow, b.ref, b.inputs ?? {}, caller);
1092+ const id = uuidNumber(run.id);
1093+ return c.json({ run_id: id, workflow_run_id: id, run_url: `${repoApi(baseOf(c), P(c).owner, P(c).repo)}/actions/runs/${id}`, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201);
10901094 });
10911095 // Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`).
10921096 // Only a registered runner claims, with its own token and the labels it registered with. A person's
@@ -1163,13 +1167,13 @@
11631167 api.post(`/repos/:owner/:repo/actions/runs/:id/${path}`, async (c) => {
11641168 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
11651169 const r = await stub.rerun(c.req.param("id"), caller, scope);
1166- return c.json({ run_id: r.id }, 201);
1170+ return c.json({ run_id: uuidNumber(r.id) }, 201);
11671171 });
11681172 }
11691173 api.post("/repos/:owner/:repo/actions/jobs/:id/rerun", async (c) => {
11701174 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
11711175 const r = await stub.rerunJob(c.req.param("id"), caller);
1172- return c.json({ run_id: r.id }, 201);
1176+ return c.json({ run_id: uuidNumber(r.id) }, 201);
11731177 });
11741178 api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => {
11751179 const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
modifiedapps/diffinery/src/http/gh.ts+53−14View file
@@ -46,6 +46,33 @@
4646 return ((a >>> 0) % 0x200000) * 0x100000000 + (b >>> 0) || 1;
4747 }
4848
49+/**
50+ * GitHub's numeric id for a workflow run or job, whose Diffinery ids are UUIDs written as 32 hex
51+ * digits. It keeps 52 of the UUID's random bits (the version digit skipped) above 10^15, so it
52+ * always has 16 digits: shorter numbers stay run numbers (`github.run_id`, `diffinery run view 42`),
53+ * and `uuidPattern` turns it back into a lookup. Anything else gets `numericId`'s hash.
54+ */
55+export function uuidNumber(id: string): number {
56+ if (!/^[0-9a-f]{32}$/.test(id)) return numericId(id);
57+ return UUID_NUMBERS + parseInt(id.slice(0, 12) + id[13], 16);
58+}
59+
60+/** The SQLite GLOB that matches the ids `uuidNumber` maps to `raw`, or null when `raw` isn't such a number. */
61+export function uuidPattern(raw: string): string | null {
62+ if (!/^\d{16}$/.test(raw)) return null;
63+ const n = Number(raw) - UUID_NUMBERS;
64+ if (n < 0 || n >= 2 ** 52) return null;
65+ const h = n.toString(16).padStart(13, "0");
66+ return `${h.slice(0, 12)}?${h[12]}*`;
67+}
68+
69+const UUID_NUMBERS = 1e15;
70+
71+/** A workflow's numeric id (GitHub's `workflow_id`): a hash of its path, which the API also takes. */
72+export function workflowNumber(path: string): number {
73+ return numericId(`workflow:${path}`);
74+}
75+
4976 export type Who = { id: string; login: string; kind?: string | null };
5077
5178 export const userType = (kind?: string | null): "User" | "Organization" | "Bot" => (kind === "org" ? "Organization" : kind === "agent" || kind === "system" ? "Bot" : "User");
@@ -598,12 +625,12 @@
598625 completed_at: isoSeconds(r.completedAt),
599626 output: { title: out.title ?? null, summary: out.summary ?? null, text: out.text ?? null, annotations_count: out.annotations?.length ?? 0, annotations_url: `${api}/check-runs/${id}/annotations` },
600627 name: r.name,
601- check_suite: { id: r.runId ?? `app-${r.app}-${r.sha}` },
628+ check_suite: { id: r.runId ? uuidNumber(r.runId) : numericId(`app-${r.app}-${r.sha}`) },
602629 app: { id: r.app, slug: r.app, node_id: nodeId("App", r.app), name: r.app, owner: null },
603630 pull_requests: [],
604631 // Diffinery: the workflow run and job behind an Actions check.
605- run_id: r.runId,
606- job_id: r.jobId,
632+ run_id: r.runId && uuidNumber(r.runId),
633+ job_id: r.jobId && uuidNumber(r.jobId),
607634 };
608635 }
609636
@@ -629,20 +656,24 @@
629656 prNumber: number | null;
630657 };
631658
632-/** A workflow run: GitHub's fields plus Diffinery's `sha`/`ref` (what the run checked out) and `pull_request_number`. */
659+/**
660+ * A workflow run: GitHub's fields plus Diffinery's `sha`/`ref` (what the run checked out) and `pull_request_number`.
661+ * Its `id`, `workflow_id` and `check_suite_id` are numbers, as clients like gh expect; the API takes them back too.
662+ */
633663 export function ghRun(base: string, owner: string, repo: string, r: RunLike) {
634664 const api = repoApi(base, owner, repo);
635- const url = `${api}/actions/runs/${r.id}`;
665+ const id = uuidNumber(r.id);
666+ const url = `${api}/actions/runs/${id}`;
636667 return {
637- id: r.id,
668+ id,
638669 node_id: nodeId("WorkflowRun", r.id),
639670 run_number: r.number,
640671 run_attempt: r.attempt,
641672 name: r.workflowName,
642673 display_title: r.title,
643674 path: r.workflowPath,
644- workflow_id: r.workflowPath,
645- check_suite_id: r.id,
675+ workflow_id: workflowNumber(r.workflowPath),
676+ check_suite_id: id,
646677 event: r.event,
647678 head_branch: r.headBranch,
648679 head_sha: r.headSha,
@@ -663,7 +694,7 @@
663694 artifacts_url: `${url}/artifacts`,
664695 cancel_url: `${url}/cancel`,
665696 rerun_url: `${url}/rerun`,
666- workflow_url: `${api}/actions/workflows/${encodeURIComponent(r.workflowPath)}`,
697+ workflow_url: `${api}/actions/workflows/${workflowNumber(r.workflowPath)}`,
667698 previous_attempt_url: r.attempt > 1 ? `${url}/attempts/${r.attempt - 1}` : null,
668699 pull_requests: r.prNumber ? [{ number: r.prNumber, url: `${api}/pulls/${r.prNumber}` }] : [],
669700 pull_request_number: r.prNumber,
@@ -673,6 +704,7 @@
673704 export type JobLike = {
674705 id: string;
675706 runId: string;
707+ carriedFrom?: string | null;
676708 name: string;
677709 status: string;
678710 conclusion: string | null;
@@ -683,25 +715,32 @@
683715 steps: Array<{ index: number; name: string; status: string; conclusion?: string | null; startedAt?: string; completedAt?: string }>;
684716 };
685717
686-/** A job: GitHub's snake_case fields next to Diffinery's own (camelCase) ones, which the CLI and web UI read. */
718+/**
719+ * A job: GitHub's snake_case fields next to Diffinery's own (camelCase) ones, which the CLI and web UI read.
720+ * `id`, `run_id` and `carriedFrom` are numbers like GitHub's ids (`runId` stays Diffinery's own id).
721+ */
687722 export function ghJob<J extends JobLike>(base: string, owner: string, repo: string, run: RunLike, j: J) {
688723 const api = repoApi(base, owner, repo);
724+ const id = uuidNumber(j.id);
725+ const runId = uuidNumber(j.runId);
689726 return {
690727 ...j,
728+ id,
729+ carriedFrom: j.carriedFrom ? uuidNumber(j.carriedFrom) : null,
691730 // A job held by its environment's reviewers or wait timer is "waiting", as on GitHub.
692731 status: j.status === "approval" ? "waiting" : j.status,
693- run_id: j.runId,
694- run_url: `${api}/actions/runs/${j.runId}`,
732+ run_id: runId,
733+ run_url: `${api}/actions/runs/${runId}`,
695734 run_attempt: run.attempt,
696735 node_id: nodeId("Job", j.id),
697736 head_sha: run.headSha,
698737 head_branch: run.headBranch,
699- url: `${api}/actions/jobs/${j.id}`,
738+ url: `${api}/actions/jobs/${id}`,
700739 html_url: `${repoHtml(base, owner, repo)}/actions/runs/${j.runId}/job/${j.id}`,
701740 started_at: isoSeconds(j.startedAt),
702741 completed_at: isoSeconds(j.completedAt),
703742 workflow_name: run.workflowName,
704- check_run_url: `${api}/check-runs/${j.id}`,
743+ check_run_url: `${api}/check-runs/${id}`,
705744 labels: [] as string[],
706745 runner_name: j.runnerName ?? null,
707746 // Hosted runner size (large, medium, small, tiny); `usage` (from ...j) is what the job used of it.
modifiedapps/diffinery/src/http/misc.ts+5−4View file
@@ -2,7 +2,7 @@
22 import type { Env } from "../env.js";
33 import type { AppContext, AppVars } from "../auth/principal.js";
44 import { HttpError } from "../lib/util.js";
5-import { baseOf, ghRun, isoSeconds, nodeId, normalizeTopics, repoApi, repoHtml } from "./gh.js";
5+import { baseOf, ghRun, isoSeconds, nodeId, normalizeTopics, repoApi, repoHtml, workflowNumber } from "./gh.js";
66 import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js";
77 import { auditRepo, refuseAgent, WHILE_IMPORTING } from "./common.js";
88
@@ -65,7 +65,8 @@
6565 async function findWorkflow(c: AppContext) {
6666 const { stub } = await load(c);
6767 const id = (c.req.param("workflow") ?? "");
68- const wf = (await stub.workflows()).find((w) => w.path === id || w.path.endsWith(`/${id}`));
68+ const wfs = await stub.workflows();
69+ const wf = wfs.find((w) => w.path === id || w.path.endsWith(`/${id}`)) ?? (/^\d+$/.test(id) ? wfs.find((w) => workflowNumber(w.path) === Number(id)) : undefined);
6970 if (!wf) throw new HttpError(404, "Not Found");
7071 return { stub, wf };
7172 }
@@ -76,14 +77,14 @@
7677 const { owner, repo } = repoParams(c);
7778 const now = isoSeconds(new Date().toISOString());
7879 return c.json({
79- id: wf.path,
80+ id: workflowNumber(wf.path),
8081 node_id: nodeId("Workflow", `${owner}/${repo}/${wf.path}`),
8182 name: wf.name,
8283 path: wf.path,
8384 state: wf.errors.length ? "invalid" : "active",
8485 created_at: now,
8586 updated_at: now,
86- url: `${repoApi(b, owner, repo)}/actions/workflows/${encodeURIComponent(wf.path)}`,
87+ url: `${repoApi(b, owner, repo)}/actions/workflows/${workflowNumber(wf.path)}`,
8788 html_url: `${repoHtml(b, owner, repo)}/blob/HEAD/${wf.path}`,
8889 badge_url: `${repoHtml(b, owner, repo)}/workflows/${encodeURIComponent(wf.name)}/badge.svg`,
8990 errors: wf.errors,
modifiedapps/diffinery/src/http/statuses.ts+3−3View file
@@ -103,17 +103,17 @@
103103 const b = baseOf(c);
104104 const { owner, repo } = repoParams(c);
105105 return {
106- id: s.id,
106+ id: s.number,
107107 node_id: nodeId("CheckSuite", s.id),
108108 head_branch: null,
109109 head_sha: s.headSha,
110110 status: s.status,
111111 conclusion: s.conclusion,
112- url: `${repoApi(b, owner, repo)}/check-suites/${s.id}`,
112+ url: `${repoApi(b, owner, repo)}/check-suites/${s.number}`,
113113 app: { id: s.app, slug: s.app, name: s.app },
114114 pull_requests: [],
115115 latest_check_runs_count: s.runs.length,
116- check_runs_url: `${repoApi(b, owner, repo)}/check-suites/${s.id}/check-runs`,
116+ check_runs_url: `${repoApi(b, owner, repo)}/check-suites/${s.number}/check-runs`,
117117 };
118118 }
119119
modifiedapps/diffinery/src/repo/actions.ts+14−7View file
@@ -20,7 +20,7 @@
2020 import { getFirst, headFirst, legacyArtifactKeys, legacyCacheKeys, logKeys } from "../lib/storage-keys.js";
2121 import { permissionsForJob, type JobPermissions } from "../lib/job-permissions.js";
2222 import { signRuntimeToken } from "../lib/runtime-auth.js";
23-import { ghRepo, numericId } from "../http/gh.js";
23+import { ghRepo, numericId, uuidPattern } from "../http/gh.js";
2424 import { HttpError, newId, noreplyEmail, now, randomToken, sha256 } from "../lib/util.js";
2525 import { localOnly } from "../auth/dev.js";
2626 import { egressFor, type Egress } from "../lib/egress.js";
@@ -875,17 +875,24 @@
875875 return why;
876876 }
877877
878- /** A run by id, or by its number (github.run_id; the latest attempt). */
878+ /** A run by id, its REST API number (http/gh.ts uuidNumber), or its run number (github.run_id; the latest attempt). */
879879 private runRow(id: string): RunRow {
880- const r = /^\d+$/.test(id)
881- ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE run_number = ? ORDER BY attempt DESC, created_at DESC LIMIT 1", Number(id)).toArray()[0]
882- : this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", id).toArray()[0];
880+ const pattern = uuidPattern(id);
881+ const r = pattern
882+ ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE id GLOB ? ORDER BY created_at DESC LIMIT 1", pattern).toArray()[0]
883+ : /^\d+$/.test(id)
884+ ? this.sql.exec<RunRow>("SELECT * FROM runs WHERE run_number = ? ORDER BY attempt DESC, created_at DESC LIMIT 1", Number(id)).toArray()[0]
885+ : this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", id).toArray()[0];
883886 if (!r) throw new HttpError(404, "run not found");
884887 return r;
885888 }
886889
890+ /** A job by id or its REST API number (http/gh.ts uuidNumber). */
887891 private jobRow(id: string): JobRow {
888- const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", id).toArray()[0];
892+ const pattern = uuidPattern(id);
893+ const j = pattern
894+ ? this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id GLOB ? ORDER BY rowid DESC LIMIT 1", pattern).toArray()[0]
895+ : this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", id).toArray()[0];
889896 if (!j) throw new HttpError(404, "job not found");
890897 return j;
891898 }
@@ -940,7 +947,7 @@
940947 async logs(jobId: string, afterSeq = -1): Promise<{ lines: Array<LogLine & { seq: number }>; complete: boolean }> {
941948 const j = this.jobRow(jobId);
942949 // A job a partial re-run kept from an earlier attempt shows the log of the job that ran.
943- if (j.carried_from) jobId = j.carried_from;
950+ jobId = j.carried_from ?? j.id;
944951 const rows = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 5000", jobId, afterSeq).toArray();
945952 if (rows.length === 0 && j.status === "completed") {
946953 const archived = await getFirst(this.env.BUCKET, logKeys(this.repo.meta.repoId, jobId));
modifiedapps/diffinery/src/repo/gitdata.ts+47−8View file
@@ -21,6 +21,7 @@
2121 utf8,
2222 } from "@diffinery/git-core";
2323 import type { Env } from "../env.js";
24+import { numericId, uuidNumber, uuidPattern } from "../http/gh.js";
2425 import { scanForSecrets, type SecretHit } from "../git/secret-scan.js";
2526 import { ZipWriter, tarEnd, tarEntry, tarStart, zip, type ArchiveEntry } from "../lib/archive.js";
2627 import { R2Sink } from "../lib/r2-sink.js";
@@ -512,17 +513,28 @@
512513
513514 // ---------------------------------------------------------------- Actions extras
514515
515- /** A run's logs as a zip with one text file per job (GET /actions/runs/:id/logs). */
516+ /**
517+ * A run's logs as a zip (GET /actions/runs/:id/logs), laid out like GitHub's: one text file per
518+ * job, and a folder per job with a file per step (`build/1_Run tests.txt`), which `gh run view
519+ * --log` shows each step's lines from.
520+ */
516521 async runLogsZip(runId: string): Promise<Uint8Array> {
517522 const run = this.repo.actions.getRun(runId);
518523 const entries: ArchiveEntry[] = [];
524+ const text = (lines: Array<{ t: string; text: string }>) => new TextEncoder().encode(lines.map((l) => `${l.t} ${l.text}`).join("\n") + (lines.length ? "\n" : ""));
519525 let total = 0;
520526 for (const [i, j] of run.jobs.entries()) {
521527 const { lines } = await this.repo.actions.logs(j.id);
522- const text = new TextEncoder().encode(lines.map((l) => `${l.t} ${l.text}`).join("\n") + (lines.length ? "\n" : ""));
523- total += text.length;
528+ const all = text(lines);
529+ total += all.length * 2;
524530 if (total > 24 * 1024 * 1024) break;
525- entries.push({ path: `${i + 1}_${j.name.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 100)}.txt`, mode: "100644", content: text });
531+ entries.push({ path: `${i + 1}_${j.name.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 100)}.txt`, mode: "100644", content: all });
532+ // gh finds a step's file by the job's name without "/" and ":", cut to 90 characters.
533+ const dir = j.name.replace(/[\\/:*?"<>|]+/g, "").slice(0, 90);
534+ for (const s of j.steps) {
535+ const mine = lines.filter((l) => l.step === s.index);
536+ entries.push({ path: `${dir}/${s.index + 1}_${s.name.replace(/[\\/:*?"<>|]+/g, "").slice(0, 100)}.txt`, mode: "100644", content: text(mine) });
537+ }
526538 }
527539 return zip(entries, Math.floor(Date.parse(run.updatedAt) / 1000) || Math.floor(Date.now() / 1000));
528540 }
@@ -537,8 +549,13 @@
537549
538550 // ---------------------------------------------------------------- check runs and suites
539551
540- /** A check run by GitHub's numeric id, Diffinery's own, or the Actions job that reports it (a job's `check_run_url`). */
552+ /**
553+ * A check run by GitHub's numeric id, Diffinery's own, or the Actions job that reports it: a job's
554+ * `check_run_url`, or its REST id (http/gh.ts uuidNumber), which `gh run view` asks for annotations by.
555+ */
541556 check(raw: string): Check {
557+ const job = uuidPattern(raw);
558+ if (job) raw = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE id GLOB ? ORDER BY rowid DESC LIMIT 1", job).toArray()[0]?.id ?? raw;
542559 const row = /^\d{1,15}$/.test(raw)
543560 ? this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE seq = ?", Number(raw)).toArray()[0]
544561 : (this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE id = ?", raw).toArray()[0] ??
@@ -600,8 +617,11 @@
600617 return this.check(c.id);
601618 }
602619
603- /** Check suites, synthesised: one per workflow run, or per app for external check runs. */
604- checkSuites(sha: string): Array<{ id: string; app: string; headSha: string; status: string; conclusion: string | null; runs: Check[] }> {
620+ /**
621+ * Check suites, synthesised: one per workflow run, or per app for external check runs. `number` is
622+ * the REST API's id: the run's (http/gh.ts uuidNumber), or a hash of the app suite's id.
623+ */
624+ checkSuites(sha: string): Array<{ id: string; number: number; app: string; headSha: string; status: string; conclusion: string | null; runs: Check[] }> {
605625 const groups = new Map<string, Check[]>();
606626 for (const c of this.repo.collab.checks(sha)) {
607627 const id = c.runId ?? `app-${c.app}-${sha}`;
@@ -610,11 +630,25 @@
610630 return [...groups].map(([id, runs]) => {
611631 const done = runs.every((r) => r.status === "completed");
612632 const failed = runs.some((r) => ["failure", "timed_out", "cancelled", "action_required"].includes(r.conclusion ?? ""));
613- return { id, app: runs[0]!.app, headSha: sha, status: done ? "completed" : runs.some((r) => r.status === "in_progress") ? "in_progress" : "queued", conclusion: done ? (failed ? "failure" : "success") : null, runs };
633+ return { id, number: suiteNumber(id), app: runs[0]!.app, headSha: sha, status: done ? "completed" : runs.some((r) => r.status === "in_progress") ? "in_progress" : "queued", conclusion: done ? (failed ? "failure" : "success") : null, runs };
614634 });
615635 }
616636
637+ /** A check suite by its id or its REST API number (`number` in checkSuites). */
617638 checkSuite(id: string): ReturnType<GitData["checkSuites"]>[number] {
639+ if (/^\d{1,16}$/.test(id)) {
640+ const run = uuidPattern(id);
641+ const n = Number(id);
642+ id =
643+ (run && this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE id GLOB ? ORDER BY created_at DESC LIMIT 1", run).toArray()[0]?.id) ||
644+ // App suites have no table to look their number up in; external check runs are few.
645+ this.sql
646+ .exec<{ app: string; sha: string }>("SELECT DISTINCT app, sha FROM checks WHERE run_id IS NULL")
647+ .toArray()
648+ .map((r) => `app-${r.app}-${r.sha}`)
649+ .find((k) => suiteNumber(k) === n) ||
650+ id;
651+ }
618652 const row = id.startsWith("app-")
619653 ? this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE ? = 'app-' || app || '-' || sha LIMIT 1", id).toArray()[0]
620654 : this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE run_id = ? LIMIT 1", id).toArray()[0];
@@ -623,3 +657,8 @@
623657 return suite;
624658 }
625659 }
660+
661+/** A check suite's REST API id: its workflow run's, or a hash of an app suite's `app-<app>-<sha>`. */
662+function suiteNumber(id: string): number {
663+ return id.startsWith("app-") ? numericId(id) : uuidNumber(id);
664+}
modifiedapps/diffinery/src/repo/runtime.ts+5−1View file
@@ -1,4 +1,5 @@
11 import type { Env } from "../env.js";
2+import { uuidPattern } from "../http/gh.js";
23 import { dropStaged, finalKey, runComposeStep, stagingPrefix, stepKeys, type BlobBucket, type ComposeStep } from "../lib/blob-store.js";
34 import { READ_ALL, roleForPermissions, type JobPermissions } from "../lib/job-permissions.js";
45 import { legacyArtifactKeys } from "../lib/storage-keys.js";
@@ -474,9 +475,12 @@
474475 * Finished, unexpired artifacts of a run (latest first), optionally by name or id;
475476 * with `includeExpired` (the REST API), expired ones too; with `legacy: false`,
476477 * not those uploaded by older runners.
477- * A numeric run id (github.run_id, the run number) covers all of the run's attempts.
478+ * A numeric run id (github.run_id, the run number) covers all of the run's attempts; the REST
479+ * API's 16-digit run id (http/gh.ts uuidNumber) is one attempt.
478480 */
479481 runArtifacts(runId: string, filter: { name?: string; id?: number; includeExpired?: boolean; legacy?: false } = {}): ArtifactEntry[] {
482+ const pattern = uuidPattern(runId);
483+ if (pattern) runId = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE id GLOB ? ORDER BY created_at DESC LIMIT 1", pattern).toArray()[0]?.id ?? "-";
480484 const numeric = /^\d+$/.test(runId);
481485 // An attempt also has the artifacts of the jobs a partial re-run kept from an earlier attempt, as on GitHub.
482486 const where = [
modifiedapps/diffinery/test/unit/rerun.test.ts+32−0View file
@@ -4,6 +4,7 @@
44 import { Actions, canonicalJson } from "../../src/repo/actions.js";
55 import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
66 import { logKeys } from "../../src/lib/storage-keys.js";
7+import { uuidNumber, uuidPattern, workflowNumber } from "../../src/http/gh.js";
78 import { Runtime } from "../../src/repo/runtime.js";
89 import { testD1 } from "./stubs/d1.js";
910 import type { RepoDO } from "../../src/repo/RepoDO.js";
@@ -267,6 +268,8 @@
267268 const logs = await f.actions.logs(kept.id);
268269 expect(logs).toMatchObject({ complete: true, lines: [{ text: "linted" }] });
269270 expect(f.actions.getJob(kept.id).carriedFrom).toBe(lint.id);
271+ // gh asks by the REST API's numeric ids (http/gh.ts uuidNumber).
272+ expect(await f.actions.logs(String(uuidNumber(kept.id)))).toEqual(logs);
270273 });
271274
272275 it("lists the kept jobs' artifacts with the new attempt's", async () => {
@@ -286,9 +289,38 @@
286289 // The failed leg ran again, so its first attempt's artifact isn't the new attempt's.
287290 expect(rt.runArtifacts(second.id).map((a) => a.name).sort()).toEqual(["lint-report", "passed-b"]);
288291 expect(rt.runArtifacts(first).map((a) => a.name).sort()).toEqual(["failed-b", "lint-report"]);
292+ expect(rt.runArtifacts(String(uuidNumber(second.id))).map((a) => a.name).sort()).toEqual(["lint-report", "passed-b"]);
289293 });
290294 });
291295
296+describe("the REST API's numeric run and job ids", () => {
297+ it("are 16 digits that find the run or job again, while shorter numbers stay run numbers", async () => {
298+ const f = fixture({ ".github/workflows/w.yml": WORKFLOW });
299+ const first = await f.firstRun(failB);
300+ const second = await f.actions.rerun(first, ADA, "failed");
301+ const n = uuidNumber(first);
302+ expect(String(n)).toMatch(/^\d{16}$/);
303+ expect(uuidPattern(String(n))).not.toBeNull();
304+ expect(f.actions.getRun(String(n)).id).toBe(first);
305+ expect(f.actions.getRun(String(uuidNumber(second.id))).id).toBe(second.id);
306+ // A run number is the latest attempt, as before.
307+ expect(f.actions.getRun(String(second.number)).id).toBe(second.id);
308+ expect(f.actions.jobs(String(n)).map((j) => j.id)).toEqual(f.jobs(first).map((j) => j.id));
309+ const job = f.jobs(first)[0]!;
310+ expect(f.actions.getJob(String(uuidNumber(job.id))).id).toBe(job.id);
311+ expect(() => f.actions.getJob(String(uuidNumber(job.id) + 1))).toThrow(/job not found/);
312+ expect(() => f.actions.getRun("9999999999999999")).toThrow(/run not found/);
313+ expect(uuidPattern("123")).toBeNull();
314+ expect(uuidPattern("abc")).toBeNull();
315+ });
316+
317+ it("give workflows a stable number of their own", () => {
318+ expect(workflowNumber(".github/workflows/ci.yml")).toBe(workflowNumber(".github/workflows/ci.yml"));
319+ expect(workflowNumber(".github/workflows/ci.yml")).not.toBe(workflowNumber(".github/workflows/cd.yml"));
320+ expect(Number.isSafeInteger(workflowNumber(".github/workflows/ci.yml"))).toBe(true);
321+ });
322+});
323+
292324 describe("re-running failed jobs of a reusable workflow call", () => {
293325 it("treats the call's jobs like any other and re-runs what needs the call", async () => {
294326 const f = fixture({