Forgejo: CI tools (commit status, run jobs, job logs, run logs) #74
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#74
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Agents need tools to inspect CI results on Forgejo. They are useful right away in manual sessions ("why did CI fail on #42"), and the CI watchdogs (plan steps 17, 20, 23) build on them: the PR watchdog for other people's PRs, CI monitoring on the agent's own PRs, and the branch-failure watchdog. This is step 16 of KB
project/forgejo-integration-plan.md. The design is inproject/forgejo-integration.md(Tools → CI; Implementation Notes → Forgejo webhook compatibility).Requested capabilities:
GET /repos/{o}/{r}/actions/runs/{run_id}/jobs).GET /repos/{o}/{r}/actions/jobs/{job_id}/logs).GET /repos/{o}/{r}/actions/runs/{run_id}/logs).Facts checked against git.sclu1034.dev:
16.0.6+gitea-1.22.0(GET /api/v1/version). Its swagger lists every endpoint above, plusGET /repos/{o}/{r}/commits/{ref}/status(CombinedStatus),GET /actions/runs/{run_id}, andGET /actions/runs?run_number=N.attempt(1-based; omitted = latest) and an HTTP Range header. It has nosteporformatparameter, so the plan does not use them.{job-name}-{job-id}-attempt-{N}.log.ActionRunJobfields:id, name, attempt, status, run_id, needs, runs_on, handle, task_id, owner_id, repo_id.ActionRunincludesid, index_in_repo, status, commit_sha, html_url, title, started, stopped.Decisions from grilling:
local://). The tool also returns an inline tail of the last 50 lines, capped at 5 KB. The agent reads or greps the snapshot for more.local://using a new dependency,fflate.forgejo_read_commit_statusresolves each Actions check's run number to the API run id itself and prints that run id on the check's line.details.Plan
Tool surface (exact)
All four tools are read-only (
mutating: false), so they are not inFORGEJO_MUTATING_TOOLS. Like every Forgejo tool, their descriptions start withForgejo:and useREPO_SCOPE.forgejo_read_commit_statusForgejo Read Commit Statusref: z.string().min(1)(commit SHA, branch or tag)forgejo_list_run_jobsForgejo List Run Jobsrun_id: z.number().int().positive()forgejo_read_job_logForgejo Read Job Logrun_id,job_id(both positive ints),attempt?: z.number().int().positive()forgejo_get_run_logsForgejo Get Run Logsrun_id: z.number().int().positive()Every
run_id/job_iddescription says it is the API id shown byforgejo_read_commit_status/forgejo_list_run_jobs, not the run number in the web URL.Terminal status set:
const TERMINAL = new Set(["success", "failure", "cancelled", "skipped"]). Every other Actions run/job status (waiting,running,blocked,unknown, …) counts as non-terminal. Commit-status terminal states aresuccess,failure,error,warning.pendingis non-terminal.Steps
Dependency. Add
fflate(latest 0.8.x) toapps/server/package.jsondependencieswith the repo's package manager (bun add fflateinapps/server). Nothing in the repo unzips today, so there is no existing helper to reuse.Binary download in the client. In
apps/server/src/forgejo/client.ts, add abytesmethod toForgejoClient, next totext():this.send("GET", path, opts, "application/zip, application/octet-stream, */*")and returnshttpErroron a non-2xx response.res.bodychunks into a list untilmaxBytesis reached, then cancels the stream.truncatedis true when data existed beyond the cap. It concatenates the chunks into oneUint8Array. This differs fromreadBodyCapped, which does UTF-8 work; a ZIP has no UTF-8 boundaries.{ ok: false, status: 0, message: "failed reading response: …" }(redacted), the same waysafeReaddoes.New module
apps/server/src/forgejo/tools/ci.ts. Its header doc comment lists the four tools. The module exportsCI_TOOLS = [readCommitStatus, listRunJobs, readJobLog, getRunLogs]and the pure helpers named below, which tests import. API response types go inapps/server/src/forgejo/tools/api-types.ts, following its existing style:ApiCombinedStatus { state; sha; total_count; statuses: ApiCommitStatus[] },ApiCommitStatus { context; status; description; target_url; updated_at },ApiActionRun { id; index_in_repo; status; commit_sha; html_url; title },ApiActionRunList { workflow_runs: ApiActionRun[]; total_count }, andApiActionRunJob { id; name; attempt; status; needs; runs_on }.ActionRunListdefinition inhttps://git.sclu1034.dev/swagger.v1.json. If the array key is notworkflow_runs, use the key the swagger gives.3a.
parseActionsTarget(url: string | null): { runNumber: number; jobIndex: number | null } | null. Match/\/actions\/runs\/(\d+)(?:\/jobs\/(\d+))?\/?(?:[?#].*)?$/on the URL. Return null for a null URL or no match; that is a non-Actions status.3b.
forgejo_read_commit_statusrepoApiPath(o, r, "commits", encodeURIComponent(ref), "status")with{ limit: 50 }forstate,shaand the first statuses. Whentotal_count>statuses.length, take the check list fromfetchAllPages<ApiCommitStatus>(client, repoApiPath(o, r, "commits", encodeURIComponent(ref), "statuses"), { pageSize: 50, maxPages: 10, signal })instead.ref "<ref>" not found.runNumbers fromparseActionsTarget(target_url). Resolve each to a run withmapLimit(numbers, 4, n => client.json<ApiActionRunList>("GET", repoApiPath(o, r, "actions", "runs"), { query: { run_number: n, limit: 1 }, signal })). Takeworkflow_runs[0]. A miss or an API failure on one number leaves that check's run idnull; the tool does not fail.<sha7> combined: <state> · <n> checks · <all terminal|k pending>.- <state> <context> — <description> [run <run_id> (#<runNumber>)[ job index <jobIndex>]] <target_url>.next: forgejo_list_run_jobs run_id=<id> for job ids, then forgejo_read_job_log.<sha7>: no status checks.withCap(text, "pass a commit SHA to narrow").ForgejoCommitStatusResult(step 5).3c.
forgejo_list_run_jobsactions/runs/<run_id>for the run header, then GETactions/runs/<run_id>/jobs(an array;ActionRunJobList). A 404 on either maps torun <run_id> not found.run <id> (#<index_in_repo>) <status> · <title> · <sha7> · <html_url>.- job <id> <status> <name> · attempt <attempt>[ · needs: a, b].logs: forgejo_read_job_log run_id=<id> job_id=<job>; all jobs: forgejo_get_run_logs run_id=<id>.no jobs.withCap(..., "the run has too many jobs to list; use forgejo_get_run_logs").3d.
forgejo_read_job_loglocalRoot(call); on failure returnerrorResult.actions/runs/<run_id>/jobsand findjob_idin it. If it is missing, returnjob <job_id> is not in run <run_id>.attempt = p.attempt ?? job.attempt. Ifp.attempt > job.attempt, returnjob <id> has <job.attempt> attempt(s).rel = \ci/job-${job_id}-attempt-${attempt}.log`. The log is final whenTERMINAL.has(job.status)orattempt < job.attempt` (an earlier attempt is always final).existsSync(join(root, rel)), reuse the file: read it, and setreused: true.client.text(repoApiPath(o, r, "actions", "jobs", String(job_id), "logs"), { query: { attempt }, maxBytes: SNAPSHOT_READ_BYTES, signal })and write the snapshot withwriteFileAtomic(root, rel, text). Always overwrite when the log is not final.logTail(text: string, maxLines = 50, maxBytes = 5 * 1024): string:Buffer.from(line), takesubarray(-5120), skip leading UTF-8 continuation bytes ((b & 0xc0) === 0x80), and decode the rest.job <id> <name> · attempt <a> · <status>[ (still running — log incomplete, call again later)] · <lines> lines · <formatSize(bytes)>snapshot: local://<rel>, plus(reused)when the snapshot was reused.[truncated: the log exceeds the 64 MB read limit; the snapshot holds the first <size>].last <k> lines:followed by a fenced block containing the tail.log is emptyinstead of the tail.readFileAtRefdoes (apps/server/src/forgejo/tools/prs.ts~L273–275). Move that count into an exportedcountLines(text)insnapshot.tsand use it from both tools.3e.
forgejo_get_run_logslocalRoot(call). GETactions/runs/<run_id>; a 404 maps torun <run_id> not found. The run is final whenTERMINAL.has(run.status).ci/run-<run_id>. If the run is final and the directory exists, reuse it: list its files and skip the download. If the run is not final,rm(join(root, "ci", \run-${run_id}`), { recursive: true, force: true })` first, then download.client.bytes(repoApiPath(o, r, "actions", "runs", String(run_id), "logs"), { maxBytes: SNAPSHOT_READ_BYTES, signal }). If the result is truncated, return the errorrun logs ZIP exceeds the 64 MB download limit; use forgejo_read_job_log per job.unzipSync(data, { filter: f => … })fromfflate. The filter keeps only names ending.logand adds upf.originalSize. Once the total would exceedMAX_UNZIPPED_BYTES = 256 * 1024 * 1024, it returns false for the rest and sets askippedcounter. IfunzipSyncthrows, return the errorrun logs ZIP could not be read: <msg>.zipEntryName(name: string): string: take the basename after the last/, then replace every character outside[A-Za-z0-9._-]with_. If two entries collide, append-2,-3, … before.log.new TextDecoder().decode(bytes). Write all entries withwriteSnapshotDir(root, \ci/run-${run_id}`, files). If the result is"exists"` (a concurrent call won the race), treat the snapshot as reused.writeSnapshotDircallsmkdir(root)and thenjoin(root, name). A name containing/needsjoin(root, "ci")to exist, so passjoin(root, "ci")as the root andrun-<id>as the name instead.run <id> (#<index_in_repo>) <status>[ (still running — logs incomplete)] · <n> job logs · <total size>snapshot: local://ci/run-<id>/, plus(reused)when reused.- local://ci/run-<id>/<file> · <lines> lines · <size>.[<skipped> entries skipped: unpacked size limit 256 MB].read or grep the files; search all with grep path=local://ci/run-<id>/.withCap.Registry. In
apps/server/src/forgejo/tools/index.ts:CI_TOOLSand setFORGEJO_TOOL_DEFS = [...ISSUE_TOOLS, ...PR_TOOLS, ...CI_TOOLS].apps/server/src/forgejo/tools/shared.ts, the header comment already says "and later CI tools". Change it to "and CI tools".Protocol types. Append to
packages/protocol/src/forgejo-tools.ts:Export these the same way the existing types are exported. Check
packages/protocol/src/index.tsfor a re-export offorgejo-tools.ts(unverified — confirm first).Web cards. In
apps/web/src/components/tools/forgejo/, addCommitStatusView.tsx,RunJobsView.tsx,JobLogView.tsxandRunLogsView.tsx.ReadFileView.tsx: useForgejoShellwithrows(ArgRows for the args),hasDetails, and a children body. Each card gets anisX(details)guard written likeisReadFileAtRefinReadFileSummary.tsx.ForgejoLinkto the target URL, run id).ForgejoLinktourl, and one row per job.SnapshotLine path={snapshot}, and the tail in a collapsed<details><pre>.SnapshotLine, and the file list.ForgejoTool.tsx, add the fourcases to the main switch, next to theforgejo_read_file_at_refcase. Add header summaries insummarizeForgejoArgs:forgejo_read_commit_status→ref(7 chars when the ref is 40 hex characters)forgejo_list_run_jobs/forgejo_get_run_logs→run <run_id>forgejo_read_job_log→run <run_id> · job <job_id>[ · attempt <n>]apps/web/src/preview-forgejo.tsFORGEJO_FIXTURES, following thefj-readentries:fj-ci-status(mixed pending/failure),fj-ci-jobs,fj-ci-joblog,fj-ci-joblog-running,fj-ci-runlogs, andfj-ci-error(isError).Session policies. In
apps/server/src/bridge/session-policy.ts:"forgejo_read_commit_status", "forgejo_list_run_jobs", "forgejo_read_job_log", "forgejo_get_run_logs"to bothCONVERSATION_TOOL_NAMES(afterforgejo_search_prs) andIMPLEMENTER_TOOL_NAMES(afterforgejo_search_prs). The pull_request policy inherits them throughCONVERSATION_TOOL_NAMES.decidealready returns"default"for listed tools, and read tools never prompt, so no other change is needed.CI_BULLET = "- CI results: \forgejo_read_commit_status` shows a commit's checks with run ids, `forgejo_list_run_jobs` the jobs of a run, `forgejo_read_job_log` / `forgejo_get_run_logs` snapshot logs to local:// for `read`/`grep`.". Insert it right afterWEB_ACCESS_BULLETin the conversation and pull-requestsystemContextarrays, and as a line in the implementer policy'ssystemContext` immediately before its untrusted-data line. If the implementer has no such line, put it at the end.Docs. In
docs/configuration.mdaround L216, change "All 18 tools" to "All 22 tools". Add one bullet describing the four CI tools and theirlocal://ci/…snapshots. In L248 (the trigger-session tool set), mention the CI read tools.Critical files & anchors
apps/server/src/forgejo/client.ts:ForgejoClient.text/send/safeRead(~L223–311). The newbytes()goes beside them.apps/server/src/forgejo/tools/prs.ts:readFileAtRef(~L238–291). This is the template for snapshot tools (localRoot,writeFileAtomic, details plus text).apps/server/src/forgejo/tools/snapshot.ts:writeSnapshotDir(~L53),writeFileAtomic,SNAPSHOT_READ_BYTES,formatSize.countLinesis added here.apps/server/src/bridge/session-policy.ts:CONVERSATION_TOOL_NAMES(~L115),IMPLEMENTER_TOOL_NAMES(~L142), and the systemContext arrays.apps/web/src/components/tools/ForgejoTool.tsx: main switch (~L64) andsummarizeForgejoArgs(~L149).Verification
Run from
/workspace/omp-deck(apps/serverfor server tests).apps/server/src/forgejo/tools/index.test.ts: add the four names toREAD_TOOLS. Without that, the read/mutating split assertion at ~L74 and the registered-tools assertion at ~L128 fail.apps/server/src/bridge/permission-filter.test.ts: add the four names toREAD(~L35), which asserts that read tools don't prompt.apps/server/src/bridge/session-policy.test.ts: assert thatpolicyToolNamesof a conversation, a pull_request and an implementer trigger each containforgejo_read_job_log, and thatdecidePolicy(TRIGGER, "forgejo_read_job_log", {...}, "s")is"default".apps/server/src/forgejo/tools/ci.test.ts, pure helpers only:parseActionsTarget:https://h/o/r/actions/runs/12/jobs/0→{runNumber:12, jobIndex:0}…/actions/runs/12→{12, null}https://ci.example/build/5→ nulllogTail:zipEntryName:"build/test (ubuntu)-17-attempt-1.log"→"test__ubuntu_-17-attempt-1.log".zipSync({"a/x-1-attempt-1.log": strToU8("hi\n"), "readme.txt": …}). The extraction helper, factored as an exportedextractRunLogs(data: Uint8Array): { files: {rel, content}[]; skipped: number }, returns exactlyx-1-attempt-1.logwith contenthi\n.bun test apps/server/src/forgejo apps/server/src/bridge, then the repo typecheck (bun run typecheckat the root — confirm the script name in the rootpackage.json).forgejo_read_commit_status ref=main.target_urlcontains/actions/runs/showing a numericrun <id>.forgejo_list_run_jobswith that id lists jobs.forgejo_read_job_logwriteslocal://ci/job-<id>-attempt-<n>.log, andreadopens it. A second call on a finished job says(reused).forgejo_get_run_logscreateslocal://ci/run-<id>/*.log.Assumptions & contingencies
<repo html>/actions/runs/<run_number>/jobs/<job_index>, where<run_number>equalsActionRun.index_in_repo. The repo is private, so this is unverified. If the live check shows a different format (for example the API run id itself), adaptparseActionsTargetand skip therun_numberlookup when the number already is the id. Confirm by comparing a real status'starget_urlwithGET /actions/runs/{id}'sindex_in_repo.run_numberfilter: ifGET /actions/runs?run_number=Nignores the filter on 16.0.6 (it returns runs whoseindex_in_repo≠ N), fall back to paging/actions/runs(at most 5 pages × 50) and matchingindex_in_repolocally.