Forgejo repo onboarding #266
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#266
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
There is no way to onboard a repository to the Forgejo integration. Onboarding should:
Today both halves are entirely manual. A repository becomes known to the deck only implicitly:
matchWorkspaces(apps/server/src/forgejo/workspace-index.ts:69) resolvesowner/repofrom a registered workspace's gitorigin, and there is no repository registry. Per-repo behaviour lives in.omp/forgejo.yamlon the repo's default branch (REPO_CONFIG_PATH,apps/server/src/forgejo/repo-config.ts:40), read live through the Forgejo API (readRepoFile,apps/server/src/forgejo/dispatch-api.ts:535); when the file is absent,parseRepoConfig(null)yieldsDEFAULT_REPO_CONFIGand the agent is reactive-only. Every label name the automation keys on exceptStatus/Blocked(repo-config.ts:43) and the protectedagent/prefix comes from that file, so the file and its labels must be created together. The server currently has no write path for file contents, branches, pull requests or labels:DispatchApican only comment, replace issue labels, create issues and close PRs; PR creation exists solely as an agent tool (apps/server/src/forgejo/tools/prs.ts:396).A third failure mode is invisible today: a repository can have the config and the labels and still be inert because no webhook reaches the deck, or because the webhook's secret does not match the broker's.
Plan
Context
Add a deterministic, server-side onboarding action: a dialog on each Forgejo-matched workspace row in Settings → Workspaces collects which behaviours to enable and under which label names, then one endpoint creates the missing labels, opens a pull request adding
.omp/forgejo.yaml, and reports what it did. Sixty seconds after the PR is opened, the deck checks its own webhook delivery log — the PR itself is the probe — and comments on the PR when no delivery for that repository arrived, naming the concrete misconfiguration.The deck never creates or inspects the Forgejo webhook: the webhook secret lives only in the broker's config file under a separate UID (
apps/broker/src/config.ts; migration016-drop-forgejo-webhook-secret.sqlremoved it from the deck database), and the server has no access to it. The probe uses only data the deck already records.Approach
Steps 1–3 are independent of each other; 4 depends on 1–3; 5 depends on 1; 6–7 depend on 4 and 1. The tree builds and existing tests pass after each step.
1. Protocol types for onboarding
In
packages/protocol/src/index.ts, Forgejo REST section (around lines 2054–2290, before the// Git SSHheader):Extend the matched variant of
WorkspaceForgejoInfo(:78-87) with the onboarding flag:Add:
2. Forgejo write methods on
DispatchApiIn
apps/server/src/forgejo/dispatch-api.ts, add four methods to theDispatchApiinterface (:15) and implement them increateDispatchApi(:266), copying the shape ofcreateIssue(:481) —client.json(...)plus a mapped result.repoApiPath(forgejo/client.ts:180) builds the path; callers pre-encode path segments, asreadRepoFiledoes withencodePath(dispatch-api.ts:535).Implementations (field names verified against Forgejo v16
modules/structs/repo_file.goandmodules/structs/issue_label.go):readDefaultBranch:GET repoApiPath(owner, repo), mapdefault_branch. Do not change the existingreadRepoPrivate(:387), which hits the same endpoint — onboarding is the only caller and one extra request per onboarding is irrelevant.createFileOnBranch:POST repoApiPath(owner, repo, "contents", encodePath(input.path))with body{ content: Buffer.from(input.content, "utf8").toString("base64"), message: input.message, new_branch: input.branch, force_overwrite_new_branch: true }.contentmust be base64;new_branchcreates the branch off the default branch in the same call, so no separate branch-creation request exists.createPull:POST repoApiPath(owner, repo, "pulls")with{ head, base, title, body }; map{ number, url: typeof data.html_url === "string" ? data.html_url : null }.createLabel:POST repoApiPath(owner, repo, "labels")with{ name, color, description }(coloris required by Forgejo); map{ id }.3. Config rendering and selection validation
New file
apps/server/src/forgejo/onboarding-config.ts— no equivalent exists;repo-config.tsonly parses, and theyamlpackage's serializer cannot emit the commented hint blocks this template needs.validateOnboardSelection, in this order, returning the first failure:name.trim()non-empty, else`label name for <field> must not be empty`.sameLabelfromrepo-config.ts:432(trim, lowercase,-/_/space folded), else`label names must be distinct: "<a>" and "<b>"`. This mirrors the ruleparseRepoConfigenforces atrepo-config.ts:172-186; reusesameLabel, do not re-implement the folding.ciWatchdogBranchesnon-empty after trim, else`branch globs must not be empty`.Label fields per section (unused fields are ignored entirely, including when empty):
issueHandlertriageissue_handler.labelimplementationintake,progress,planning,plannedimplementation.intake_label,progress_label,planning_label,planned_labelciWatchdogOthersPrsignoreci_watchdog.others_prs.exclude_labelsautoReviewreview.auto_reviewothersPrsPushothers_prs.pushciWatchdogBranchesnon-emptyci_watchdog.branchesonboardingLabelsreturns the referenced names with these fixed colors and descriptions, then alwaysStatus/Blocked(the server acts on it regardless of config,repo-config.ts:43):triage#d97706Starts an omp-deck triage session on the issueintake#16a34aStarts an omp-deck implementation of the issueprogress#2563ebAn omp-deck implementation is runningplanning#7c3aedAn omp-deck planning session is runningplanned#9333eaAn approved plan has been written to the issueignore#6b7280omp-deck skips this pull requestStatus/Blocked#dc2626omp-deck never starts an implementation on this issuerenderRepoConfigbuilds the text by concatenation, emitting only enabled sections. Label names are emitted double-quoted with\and"escaped.implementationalways includesmax_concurrent: 1;ci_watchdog.own_prs.enabled: trueis emitted whenever anyci_watchdogkey is emitted. After the active sections, always append the commentedsystem_promptsandcompactionhint blocks, copied verbatim from this repository's own.omp/forgejo.yaml(lines 26–48), so the PR documents the remaining keys. Shape for a fully enabled selection:detectLabelStyledecides which casing the dialog prefills, so the created labels match the repository's existing convention instead of always being lowercase. Only two conventions are distinguished: all-lowercase (kind/bug) and PascalCase (Kind/Bug). Rule: for each name, take the first character; skip names whose first character is not an ASCII letter; an uppercase first character votespascal, a lowercase one voteslower. The majority wins; an exact tie, or no voting name at all, yieldslower(this repository's own convention). Evaluate every name the label list returns — it is already fetched, and a larger sample is strictly more stable than sampling a few.defaultLabelNamesreturns these, and nothing else is configurable from the style:lowerpascaltriageagent/triageAgent/Triageintakeagent/readyAgent/Readyprogressagent/in progressAgent/In Progressplanningagent/planningAgent/Planningplannedagent/plannedAgent/Plannedignoreagent/ignoreAgent/IgnoreStatus/Blockedis unaffected by the style: the server matches it against theBLOCKED_LABELconstant, so it is always created under that exact name. PascalCase names keep the fallback protection inisAutomationLabel(repo-config.ts:461), which lowercases throughlabelKeybefore testing theagent/prefix.4. The onboarding endpoints
In
apps/server/src/routes-forgejo.ts, add two routes insidebuildForgejoRouter(:95), copying the validation and error style ofPOST /forgejo/implement(:198) — hand-rolledtypeofchecks,{ error }bodies.GET /forgejo/onboard-defaults?workspaceId=…supplies the dialog's prefilled label names. Steps 2–5 below apply unchanged (workspaceIdfromc.req.query, then workspace, config and repo resolution), thenapi.listLabels(owner, repo)→ 502`could not list labels: ${res.message}`, and finallyconst style = detectLabelStyle(labels.map((l) => l.name))→ 200 with{ style, labels: defaultLabelNames(style) } satisfies OnboardDefaultsResponse. The name table lives on the server only; the web package never hard-codes label names.POST /forgejo/onboarddoes the work, in this order, with the exact failure responses:await c.req.json()in try/catch → 400invalid json body.typeof req.workspaceId !== "string"→ 400workspaceId is required. Each boolean field must be a boolean andciWatchdogBranchesan array of strings → 400invalid onboarding request.listWorkspaces().find(...)→ 404workspace not found.readForgejoConfig()→ 422Forgejo is not configured.resolveRepoFromCwd(workspace.path, cfg.url)(forgejo/repo.ts:172) → 422workspace origin is not on the configured Forgejo instance.validateOnboardSelection(req)→ 400 with the returned message.forgejoApi()→ 422Forgejo is not configured.api.readRepoFile(owner, repo, REPO_CONFIG_PATH):!ok→ 502`could not read ${REPO_CONFIG_PATH}: ${res.message}`;data !== null→ 409`${REPO_CONFIG_PATH} already exists on the default branch`.renderRepoConfig(req), thenparseRepoConfig(rendered)as a self-check:!ok→ 500`generated config is invalid: ${result.error}`. This guards a renderer bug from ever reaching a PR.api.listLabels(owner, repo)(returns repo and org labels,dispatch-api.ts:466viafetchAvailableLabels):!ok→ 502`could not list labels: ${res.message}`. For each spec fromonboardingLabels(req), existing when some label matches underlabelKey(repo-config.ts:427) — an org match counts as existing, because an org label is already usable on the repo's issues and a repo-level copy would shadow it. Create the rest sequentially withapi.createLabel; first failure → 502`could not create label "${name}": ${res.message}`. Labels created before the failure stay; a retry skips them.api.readDefaultBranch(owner, repo)→ 502`could not read the default branch: ${res.message}`.api.createFileOnBranch(owner, repo, { path: REPO_CONFIG_PATH, content: rendered, branch: ONBOARDING_BRANCH, message: ONBOARDING_TITLE })→ 502`could not commit ${REPO_CONFIG_PATH}: ${res.message}`.api.createPull(owner, repo, { head: ONBOARDING_BRANCH, base: defaultBranch, title: ONBOARDING_TITLE, body })→ 502`could not open the pull request: ${res.message}`.scheduleWebhookProbe({ owner, repo, number })(step 5), then 200 with theOnboardRepoResponse.Module constants in
routes-forgejo.ts, next toISSUE_PICKER_LIMIT(:58):The PR body is built in the handler and states, in this order: one line per enabled behaviour and what triggers it; the labels created and the ones that already existed; and a closing line that the repository's Forgejo webhook must point at the deck for any of it to run, with the secret taken from the broker's
config.json— the deck cannot configure the webhook itself.5. Webhook probe
New file
apps/server/src/forgejo/onboarding-probe.ts.webhookProbeVerdict, evaluated in order againstlistRecentForgejoDeliveries(FORGEJO_DELIVERY_RETENTION)(the existing query,db/forgejo-deliveries.ts:101; no new DB function is needed — retention is 200 rows and the window is 60 s):receivedAt >= sincewhoserepoequalsowner/repocase-insensitively →"ok".receivedAt >= sincewhoseoutcomeis"invalid_signature"or"not_configured"→"signature". This is decisive because the webhook receiver fillsrepoonly after signature verification (routes-forgejo-webhook.ts,deliveryContext(parsed)runs post-verify), so a wrong secret necessarily produces repo-less rows.repo→"other_repos"."none".scheduleWebhookProbe:sinceat call time and reads the delivery log immediately: if a row already carries thisrepo, return without scheduling — the webhook is demonstrably working and a comment would be noise.setTimeout(..., delayMs ?? WEBHOOK_PROBE_DELAY_MS)and.unref()on the handle so the timer never holds the process open. The timer is in-memory only; a restart within the window silently drops the probe, which is acceptable for a diagnostic."ok"posts nothing. Otherwiseapi.postComment(owner, repo, number, body)(dispatch-api.ts:29), usingdeps.api ?? createDispatchApi(); a null api or a failed post logs a warning vialogger("forgejo:onboarding-probe")and does nothing else — a diagnostic must never throw into a timer.Comment bodies, one per verdict, each ending with the concrete next step:
"signature": deliveries reached the deck while this PR was created but their signature did not verify; the secret on the Forgejo webhook does not match the one in the broker'sconfig.json. Fix the secret on either side so they match."other_repos": no delivery for this repository reached the deck, though other repositories on the instance do deliver; the webhook most likely does not cover this repository. Add a repository webhook, or extend the instance/organisation webhook to it."none": no webhook delivery reached the deck at all. Add a webhook with content typeapplication/jsonand the secret from the broker'sconfig.json; Settings → Forgejo in the deck shows the exact payload URL to use. Do not synthesise the deck's public URL in the comment — the server does not know it (the panel derives it fromwindow.location.origin,ForgejoPanel.tsx:271-284).6. Onboarded flag in the workspaces feed
In
apps/server/src/workspace-details.ts, extendreadForgejoInfo(:39): aftermatchRemoteToInstancesucceeds, callcreateDispatchApi()andreadRepoFile(owner, repo, REPO_CONFIG_PATH);okwith non-null data →onboarded: true,okwith null →false, a null api or!ok→null. The function is already async and already called per workspace, so no caller changes. This adds one Forgejo request per matched workspace to the Settings → Workspaces load only.7. Dialog and row action
apps/web/src/lib/forgejo-api.ts: add to theforgejoApiobject (:38), importing the three types in theimport typeblock at:1-11:apps/web/src/components/settings/OnboardRepoDialog.tsx, built on the existingModal(already imported byWorkspacesPanel.tsx:6) and thebtn-primary/btn-ghostclasses used there. Props:{ workspace: WorkspaceDetails; onClose(): void; onDone(msg: string): void }. Controls:onboardDefaults, fetched once when the dialog opens: until it resolves the dialog renders a "loading…" line instead of the inputs and disables submit, so a late response can never overwrite something the user typed. If the fetch fails, render the error and fall back to thelowernames (agent/triage,agent/ready,agent/in progress,agent/planning,agent/planned,agent/ignore) so onboarding stays possible.onDonewith a message naming the PR and the created/skipped labels; on error render the thrown message inline in the dialog (the sharedrequesthelper already unwraps{ error }), keeping the dialog open so the user can retry.WorkspacesPanel.tsx: inForgejoCell(:304), thematchedbranch renders the existing link plus anOnboardbtn-ghostbutton, disabled wheninfo.onboarded === truewithtitle="already onboarded";nullleaves it enabled (the endpoint's 409 is then the backstop). Clicking opens the dialog for that row;onDonesets the panel notice and bumps the existingreloadTickso the row refetches itsonboardedstate.Critical files & anchors
apps/server/src/forgejo/repo-config.ts—REPO_CONFIG_PATH(:40),BLOCKED_LABEL(:43), the pairwise-distinct label rule to mirror (:172-186),labelKey/sameLabel(:427,:432).apps/server/src/forgejo/dispatch-api.ts—DispatchApi(:15),createDispatchApi(:266),createIssueas the write-method template (:481),readRepoFile(:535),listLabels(:466).apps/server/src/routes-forgejo.ts—buildForgejoRouter(:95) andPOST /forgejo/implement(:198) as the validation/error template; injectableforgejoApiis what tests stub.apps/server/src/routes-forgejo-webhook.ts— provesrepois recorded only after signature verification and that every outcome, includingignored, is logged; this is what makes the probe's three verdicts separable..omp/forgejo.yaml(repository root) — the commentedsystem_promptsandcompactionblocks (lines 26–48) thatrenderRepoConfigappends verbatim.Verification
Run from
apps/serverunless stated otherwise.bun test src/forgejo/onboarding-config.test.ts:["main", "release/*"], default label names) →renderRepoConfigoutput fed toparseRepoConfigreturnsokwithconfig.issueHandler.label === "agent/triage",config.implementation.intakeLabel === "agent/ready",config.review !== null,config.ciWatchdogOthersPrs.excludeLabels["agent/ignore"],config.ciWatchdogBranches["main", "release/*"],config.othersPrsPush === true.okand equals the shape ofDEFAULT_REPO_CONFIG.validateOnboardSelectionwithintake: "agent/ready"andplanning: "Agent/Ready"returns a distinctness message (proves thesameLabelfolding is used, not plain equality).detectLabelStyle(["Kind/Bug", "Kind/Feature", "wontfix"])→"pascal";detectLabelStyle(["kind/bug", "Kind/Feature"])→"lower"(tie);detectLabelStyle([])anddetectLabelStyle(["42-blocked"])→"lower";defaultLabelNames("pascal").progress === "Agent/In Progress".bun test src/forgejo/onboarding-probe.test.ts:webhookProbeVerdict("owner/repo", since, rows)over four fixture sets →"ok","signature","other_repos","none"respectively.bun test src/routes-forgejo.test.ts, extending the existing file's pattern (bootDb()plusbuildForgejoRouter({ forgejoApi: () => stub }),:~128-136) with aDispatchApistub recording calls:readRepoFileresolving non-null content → 409, and nocreateLabel/createPullcall recorded.listLabelsreturns[{ id: 1, name: "Agent/Ready" }]; request usesintake: "agent/ready"→ nocreateLabelfor it (case/space-folded match),createLabelcalled for the remaining names plusStatus/Blocked; thecreateFileOnBranchcall carriesbranch: "omp-deck/onboarding"and acontentthatparseRepoConfigaccepts;createPullcalled withhead: "omp-deck/onboarding"and the stubbed default branch asbase; response body is{ pullNumber, pullUrl, branch, labels }withcreated: falseforagent/ready.createLabelfailing on the second label → 502 whose message names that label, and nocreatePullcall.GET /forgejo/onboard-defaults:listLabelsreturning[{ id: 1, name: "Kind/Bug" }, { id: 2, name: "Status/Blocked" }]→ 200{ style: "pascal", labels: { triage: "Agent/Triage", … } }; missingworkspaceId→ 400.bun testinapps/server, andbun run typecheckat the repository root.originmatches): start the deck, open Settings → Workspaces, press Onboard on a matched row, submit with defaults. Expect the PR to appear on the instance adding.omp/forgejo.yaml, the labels to exist on the repository, and the row's button to become disabled after the PR merges and the panel reloads. With the webhook deliberately removed, expect a comment on the PR within ~60 s naming the missing webhook; with the webhook present, expect no comment.Assumptions & contingencies
force_overwrite_new_branchon the contents API (present in Forgejo v16modules/structs/repo_file.go). If an older instance rejects it and the call fails becauseomp-deck/onboardingalready exists, do not add branch-deletion: let the 502 carry Forgejo's message so the user deletes the stale branch and retries.#rrggbb. If the instance rejects the leading#, send the six hex digits without it; both forms appear in Forgejo's own examples.nullunconditionally and rely on the endpoint's 409 rather than introducing a cache.