Forgejo repo onboarding #266

Closed
opened 2026-11-12 14:52:28 +01:00 by lucas · 0 comments
Owner

Problem

There is no way to onboard a repository to the Forgejo integration. Onboarding should:

  • open a pull request that adds the repository's config file
  • create the labels that config needs

Today both halves are entirely manual. A repository becomes known to the deck only implicitly: matchWorkspaces (apps/server/src/forgejo/workspace-index.ts:69) resolves owner/repo from a registered workspace's git origin, and there is no repository registry. Per-repo behaviour lives in .omp/forgejo.yaml on 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) yields DEFAULT_REPO_CONFIG and the agent is reactive-only. Every label name the automation keys on except Status/Blocked (repo-config.ts:43) and the protected agent/ 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: DispatchApi can 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; migration 016-drop-forgejo-webhook-secret.sql removed 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 SSH header):

Extend the matched variant of WorkspaceForgejoInfo (:78-87) with the onboarding flag:

	/** `.omp/forgejo.yaml` exists on the default branch; null when the instance could not be reached. */
	| { status: "matched"; owner: string; repo: string; webUrl: string; onboarded: boolean | null };

Add:

/** Label names the generated config references; only the ones its enabled sections use are read. */
export interface OnboardRepoLabels {
	triage: string;
	intake: string;
	progress: string;
	planning: string;
	planned: string;
	ignore: string;
}

/** Which behaviours the generated `.omp/forgejo.yaml` enables. */
export interface OnboardRepoRequest {
	workspaceId: string;
	issueHandler: boolean;
	implementation: boolean;
	autoReview: boolean;
	ciWatchdogOthersPrs: boolean;
	othersPrsPush: boolean;
	/** Branch globs for `ci_watchdog.branches`; empty = key omitted. */
	ciWatchdogBranches: string[];
	labels: OnboardRepoLabels;
}

/** One label the onboarding needed; `created` false = a repo or org label of that name already existed. */
export interface OnboardRepoLabelOutcome {
	name: string;
	created: boolean;
}

export interface OnboardRepoResponse {
	pullNumber: number;
	/** Web page of the pull request; null when the instance reports none. */
	pullUrl: string | null;
	branch: string;
	labels: OnboardRepoLabelOutcome[];
}

/** Default label names for the onboarding dialog, in the repository's own label casing. */
export interface OnboardDefaultsResponse {
	style: "lower" | "pascal";
	labels: OnboardRepoLabels;
}

2. Forgejo write methods on DispatchApi

In apps/server/src/forgejo/dispatch-api.ts, add four methods to the DispatchApi interface (:15) and implement them in createDispatchApi (:266), copying the shape of createIssue (:481) — client.json(...) plus a mapped result. repoApiPath (forgejo/client.ts:180) builds the path; callers pre-encode path segments, as readRepoFile does with encodePath (dispatch-api.ts:535).

	/** Default branch name of the repository. */
	readDefaultBranch(owner: string, repo: string, signal?: AbortSignal): Promise<ApiResult<string>>;
	/**
	 * Create `path` with UTF-8 `content` in one commit on `branch`, branched off the default branch.
	 * An existing `branch` is force-overwritten, so a retry after a failed attempt succeeds.
	 */
	createFileOnBranch(
		owner: string,
		repo: string,
		input: { path: string; content: string; branch: string; message: string },
		signal?: AbortSignal,
	): Promise<ApiResult<void>>;
	/** Open a pull request from `head` into `base`; `url` is its web page when the instance reports one. */
	createPull(
		owner: string,
		repo: string,
		input: { head: string; base: string; title: string; body: string },
		signal?: AbortSignal,
	): Promise<ApiResult<{ number: number; url: string | null }>>;
	/** Create a repository label; resolves with its id. */
	createLabel(
		owner: string,
		repo: string,
		input: { name: string; color: string; description: string },
		signal?: AbortSignal,
	): Promise<ApiResult<{ id: number }>>;

Implementations (field names verified against Forgejo v16 modules/structs/repo_file.go and modules/structs/issue_label.go):

  • readDefaultBranch: GET repoApiPath(owner, repo), map default_branch. Do not change the existing readRepoPrivate (: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 }. content must be base64; new_branch creates 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 } (color is 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.ts only parses, and the yaml package's serializer cannot emit the commented hint blocks this template needs.

/** A label the onboarding creates when the repository (or its org) has no label of that name. */
export interface OnboardingLabelSpec {
	name: string;
	color: string;
	description: string;
}

/** Null when the selection is valid, else a message safe to return as a 400. */
export function validateOnboardSelection(req: OnboardRepoRequest): string | null;

/** Labels the enabled sections reference, plus `Status/Blocked`, in creation order. */
export function onboardingLabels(req: OnboardRepoRequest): OnboardingLabelSpec[];

/** The `.omp/forgejo.yaml` text the onboarding PR adds. */
export function renderRepoConfig(req: OnboardRepoRequest): string;

/** Casing convention the repository's existing labels follow. */
export type LabelStyle = "lower" | "pascal";

/** The convention `names` mostly follows; `lower` on a tie or when no name starts with a letter. */
export function detectLabelStyle(names: string[]): LabelStyle;

/** The dialog's prefilled label names in `style`. */
export function defaultLabelNames(style: LabelStyle): OnboardRepoLabels;

validateOnboardSelection, in this order, returning the first failure:

  • For every label field an enabled section references (see table below): name.trim() non-empty, else `label name for <field> must not be empty`.
  • Those same names pairwise distinct under sameLabel from repo-config.ts:432 (trim, lowercase, -/_/space folded), else `label names must be distinct: "<a>" and "<b>"`. This mirrors the rule parseRepoConfig enforces at repo-config.ts:172-186; reuse sameLabel, do not re-implement the folding.
  • Every entry of ciWatchdogBranches non-empty after trim, else `branch globs must not be empty`.

Label fields per section (unused fields are ignored entirely, including when empty):

section enabled fields used YAML keys
issueHandler triage issue_handler.label
implementation intake, progress, planning, planned implementation.intake_label, progress_label, planning_label, planned_label
ciWatchdogOthersPrs ignore ci_watchdog.others_prs.exclude_labels
autoReview none review.auto_review
othersPrsPush none others_prs.push
ciWatchdogBranches non-empty none ci_watchdog.branches

onboardingLabels returns the referenced names with these fixed colors and descriptions, then always Status/Blocked (the server acts on it regardless of config, repo-config.ts:43):

field color description
triage #d97706 Starts an omp-deck triage session on the issue
intake #16a34a Starts an omp-deck implementation of the issue
progress #2563eb An omp-deck implementation is running
planning #7c3aed An omp-deck planning session is running
planned #9333ea An approved plan has been written to the issue
ignore #6b7280 omp-deck skips this pull request
Status/Blocked #dc2626 omp-deck never starts an implementation on this issue

renderRepoConfig builds the text by concatenation, emitting only enabled sections. Label names are emitted double-quoted with \ and " escaped. implementation always includes max_concurrent: 1; ci_watchdog.own_prs.enabled: true is emitted whenever any ci_watchdog key is emitted. After the active sections, always append the commented system_prompts and compaction hint 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:

# omp-deck Forgejo integration. See the repository's docs for every key.

issue_handler:
  label: "agent/triage"

implementation:
  intake_label: "agent/ready"
  progress_label: "agent/in progress"
  planning_label: "agent/planning"
  planned_label: "agent/planned"
  max_concurrent: 1

review:
  auto_review: true

ci_watchdog:
  own_prs:
    enabled: true
  others_prs:
    enabled: true
    exclude_labels: ["agent/ignore"]
  branches: ["main"]

others_prs:
  push: true

detectLabelStyle decides 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 votes pascal, a lowercase one votes lower. The majority wins; an exact tie, or no voting name at all, yields lower (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.

defaultLabelNames returns these, and nothing else is configurable from the style:

field lower pascal
triage agent/triage Agent/Triage
intake agent/ready Agent/Ready
progress agent/in progress Agent/In Progress
planning agent/planning Agent/Planning
planned agent/planned Agent/Planned
ignore agent/ignore Agent/Ignore

Status/Blocked is unaffected by the style: the server matches it against the BLOCKED_LABEL constant, so it is always created under that exact name. PascalCase names keep the fallback protection in isAutomationLabel (repo-config.ts:461), which lowercases through labelKey before testing the agent/ prefix.

4. The onboarding endpoints

In apps/server/src/routes-forgejo.ts, add two routes inside buildForgejoRouter (:95), copying the validation and error style of POST /forgejo/implement (:198) — hand-rolled typeof checks, { error } bodies.

GET /forgejo/onboard-defaults?workspaceId=… supplies the dialog's prefilled label names. Steps 2–5 below apply unchanged (workspaceId from c.req.query, then workspace, config and repo resolution), then api.listLabels(owner, repo) → 502 `could not list labels: ${res.message}`, and finally const 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/onboard does the work, in this order, with the exact failure responses:

  1. await c.req.json() in try/catch → 400 invalid json body.
  2. typeof req.workspaceId !== "string" → 400 workspaceId is required. Each boolean field must be a boolean and ciWatchdogBranches an array of strings → 400 invalid onboarding request.
  3. listWorkspaces().find(...) → 404 workspace not found.
  4. readForgejoConfig() → 422 Forgejo is not configured.
  5. resolveRepoFromCwd(workspace.path, cfg.url) (forgejo/repo.ts:172) → 422 workspace origin is not on the configured Forgejo instance.
  6. validateOnboardSelection(req) → 400 with the returned message.
  7. forgejoApi() → 422 Forgejo is not configured.
  8. 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`.
  9. renderRepoConfig(req), then parseRepoConfig(rendered) as a self-check: !ok → 500 `generated config is invalid: ${result.error}`. This guards a renderer bug from ever reaching a PR.
  10. api.listLabels(owner, repo) (returns repo and org labels, dispatch-api.ts:466 via fetchAvailableLabels): !ok → 502 `could not list labels: ${res.message}`. For each spec from onboardingLabels(req), existing when some label matches under labelKey (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 with api.createLabel; first failure → 502 `could not create label "${name}": ${res.message}`. Labels created before the failure stay; a retry skips them.
  11. api.readDefaultBranch(owner, repo) → 502 `could not read the default branch: ${res.message}`.
  12. 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}`.
  13. api.createPull(owner, repo, { head: ONBOARDING_BRANCH, base: defaultBranch, title: ONBOARDING_TITLE, body }) → 502 `could not open the pull request: ${res.message}`.
  14. scheduleWebhookProbe({ owner, repo, number }) (step 5), then 200 with the OnboardRepoResponse.

Module constants in routes-forgejo.ts, next to ISSUE_PICKER_LIMIT (:58):

const ONBOARDING_BRANCH = "omp-deck/onboarding";
const ONBOARDING_TITLE = "Add omp-deck Forgejo configuration";

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.

export const WEBHOOK_PROBE_DELAY_MS = 60_000;

export type WebhookVerdict = "ok" | "signature" | "other_repos" | "none";

/** Verdict from the delivery log; `repo` is `owner/repo`, `since` the ISO time the PR was created. */
export function webhookProbeVerdict(
	repo: string,
	since: string,
	deliveries: ForgejoDeliverySummary[],
): WebhookVerdict;

/** Comment once on the onboarding PR when no delivery for `repo` arrives within {@link WEBHOOK_PROBE_DELAY_MS}. */
export function scheduleWebhookProbe(
	target: { owner: string; repo: string; number: number },
	deps?: { api?: DispatchApi; delayMs?: number; now?: () => string },
): void;

webhookProbeVerdict, evaluated in order against listRecentForgejoDeliveries(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):

  1. A row with receivedAt >= since whose repo equals owner/repo case-insensitively → "ok".
  2. Else a row with receivedAt >= since whose outcome is "invalid_signature" or "not_configured" → "signature". This is decisive because the webhook receiver fills repo only after signature verification (routes-forgejo-webhook.ts, deliveryContext(parsed) runs post-verify), so a wrong secret necessarily produces repo-less rows.
  3. Else any row in the window with a non-null repo → "other_repos".
  4. Else → "none".

scheduleWebhookProbe:

  • Captures since at call time and reads the delivery log immediately: if a row already carries this repo, return without scheduling — the webhook is demonstrably working and a comment would be noise.
  • Otherwise 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.
  • On fire: compute the verdict; "ok" posts nothing. Otherwise api.postComment(owner, repo, number, body) (dispatch-api.ts:29), using deps.api ?? createDispatchApi(); a null api or a failed post logs a warning via logger("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's config.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 type application/json and the secret from the broker's config.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 from window.location.origin, ForgejoPanel.tsx:271-284).

6. Onboarded flag in the workspaces feed

In apps/server/src/workspace-details.ts, extend readForgejoInfo (:39): after matchRemoteToInstance succeeds, call createDispatchApi() and readRepoFile(owner, repo, REPO_CONFIG_PATH); ok with non-null data → onboarded: true, ok with 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 the forgejoApi object (:38), importing the three types in the import type block at :1-11:
	/** Prefilled onboarding label names, in the repository's own label casing. */
	onboardDefaults(workspaceId: string): Promise<OnboardDefaultsResponse> {
		return request<OnboardDefaultsResponse>(`/forgejo/onboard-defaults?${new URLSearchParams({ workspaceId })}`);
	},
	/** Create the labels and open the PR that onboards a workspace's repository. */
	onboardRepo(req: OnboardRepoRequest): Promise<OnboardRepoResponse> {
		return request<OnboardRepoResponse>("/forgejo/onboard", { method: "POST", body: JSON.stringify(req) });
	},
  • New apps/web/src/components/settings/OnboardRepoDialog.tsx, built on the existing Modal (already imported by WorkspacesPanel.tsx:6) and the btn-primary/btn-ghost classes used there. Props: { workspace: WorkspaceDetails; onClose(): void; onDone(msg: string): void }. Controls:
    • Checkboxes, defaults: issue handler on, implementation flow on, automatic PR reviews off, CI watchdog for others' PRs on, allow pushing to others' PR branches off.
    • One text input for branch globs, split on commas and whitespace, empty entries dropped.
    • Six label-name inputs, one per field, each shown only while the section that uses it is checked. Their values come from 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 the lower names (agent/triage, agent/ready, agent/in progress, agent/planning, agent/planned, agent/ignore) so onboarding stays possible.
    • Submit button disabled while in flight. On success call onDone with a message naming the PR and the created/skipped labels; on error render the thrown message inline in the dialog (the shared request helper already unwraps { error }), keeping the dialog open so the user can retry.
  • WorkspacesPanel.tsx: in ForgejoCell (:304), the matched branch renders the existing link plus an Onboard btn-ghost button, disabled when info.onboarded === true with title="already onboarded"; null leaves it enabled (the endpoint's 409 is then the backstop). Clicking opens the dialog for that row; onDone sets the panel notice and bumps the existing reloadTick so the row refetches its onboarded state.

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), createIssue as the write-method template (:481), readRepoFile (:535), listLabels (:466).
  • apps/server/src/routes-forgejo.ts — buildForgejoRouter (:95) and POST /forgejo/implement (:198) as the validation/error template; injectable forgejoApi is what tests stub.
  • apps/server/src/routes-forgejo-webhook.ts — proves repo is recorded only after signature verification and that every outcome, including ignored, is logged; this is what makes the probe's three verdicts separable.
  • .omp/forgejo.yaml (repository root) — the commented system_prompts and compaction blocks (lines 26–48) that renderRepoConfig appends verbatim.

Verification

Run from apps/server unless stated otherwise.

  1. New unit tests, bun test src/forgejo/onboarding-config.test.ts:
    • Full selection (all toggles on, branch globs ["main", "release/*"], default label names) → renderRepoConfig output fed to parseRepoConfig returns ok with config.issueHandler.label === "agent/triage", config.implementation.intakeLabel === "agent/ready", config.review !== null, config.ciWatchdogOthersPrs.excludeLabels ["agent/ignore"], config.ciWatchdogBranches ["main", "release/*"], config.othersPrsPush === true.
    • Minimal selection (everything off) → parses ok and equals the shape of DEFAULT_REPO_CONFIG.
    • validateOnboardSelection with intake: "agent/ready" and planning: "Agent/Ready" returns a distinctness message (proves the sameLabel folding is used, not plain equality).
    • detectLabelStyle(["Kind/Bug", "Kind/Feature", "wontfix"]) → "pascal"; detectLabelStyle(["kind/bug", "Kind/Feature"]) → "lower" (tie); detectLabelStyle([]) and detectLabelStyle(["42-blocked"]) → "lower"; defaultLabelNames("pascal").progress === "Agent/In Progress".
  2. New unit test, bun test src/forgejo/onboarding-probe.test.ts: webhookProbeVerdict("owner/repo", since, rows) over four fixture sets → "ok", "signature", "other_repos", "none" respectively.
  3. Route tests, bun test src/routes-forgejo.test.ts, extending the existing file's pattern (bootDb() plus buildForgejoRouter({ forgejoApi: () => stub }), :~128-136) with a DispatchApi stub recording calls:
    • readRepoFile resolving non-null content → 409, and no createLabel/createPull call recorded.
    • Happy path: listLabels returns [{ id: 1, name: "Agent/Ready" }]; request uses intake: "agent/ready" → no createLabel for it (case/space-folded match), createLabel called for the remaining names plus Status/Blocked; the createFileOnBranch call carries branch: "omp-deck/onboarding" and a content that parseRepoConfig accepts; createPull called with head: "omp-deck/onboarding" and the stubbed default branch as base; response body is { pullNumber, pullUrl, branch, labels } with created: false for agent/ready.
    • createLabel failing on the second label → 502 whose message names that label, and no createPull call.
    • GET /forgejo/onboard-defaults: listLabels returning [{ id: 1, name: "Kind/Bug" }, { id: 2, name: "Status/Blocked" }] → 200 { style: "pascal", labels: { triage: "Agent/Triage", … } }; missing workspaceId → 400.
  4. Whole-repo gate: bun test in apps/server, and bun run typecheck at the repository root.
  5. Manual end-to-end (requires a configured Forgejo instance, a stored token with write access, and a registered workspace whose origin matches): 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

  • The target Forgejo supports force_overwrite_new_branch on the contents API (present in Forgejo v16 modules/structs/repo_file.go). If an older instance rejects it and the call fails because omp-deck/onboarding already exists, do not add branch-deletion: let the 502 carry Forgejo's message so the user deletes the stale branch and retries.
  • Label colors are sent as #rrggbb. If the instance rejects the leading #, send the six hex digits without it; both forms appear in Forgejo's own examples.
  • The onboarded flag costs one Forgejo request per matched workspace on each Settings → Workspaces load. If that proves too slow on an instance with many workspaces, drop the flag back to null unconditionally and rely on the endpoint's 409 rather than introducing a cache.
## Problem There is no way to onboard a repository to the Forgejo integration. Onboarding should: - open a pull request that adds the repository's config file - create the labels that config needs Today both halves are entirely manual. A repository becomes known to the deck only implicitly: `matchWorkspaces` (`apps/server/src/forgejo/workspace-index.ts:69`) resolves `owner/repo` from a registered workspace's git `origin`, and there is no repository registry. Per-repo behaviour lives in `.omp/forgejo.yaml` on 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)` yields `DEFAULT_REPO_CONFIG` and the agent is reactive-only. Every label name the automation keys on except `Status/Blocked` (`repo-config.ts:43`) and the protected `agent/` 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: `DispatchApi` can 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`; migration `016-drop-forgejo-webhook-secret.sql` removed 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 SSH` header): Extend the matched variant of `WorkspaceForgejoInfo` (`:78-87`) with the onboarding flag: ```ts /** `.omp/forgejo.yaml` exists on the default branch; null when the instance could not be reached. */ | { status: "matched"; owner: string; repo: string; webUrl: string; onboarded: boolean | null }; ``` Add: ```ts /** Label names the generated config references; only the ones its enabled sections use are read. */ export interface OnboardRepoLabels { triage: string; intake: string; progress: string; planning: string; planned: string; ignore: string; } /** Which behaviours the generated `.omp/forgejo.yaml` enables. */ export interface OnboardRepoRequest { workspaceId: string; issueHandler: boolean; implementation: boolean; autoReview: boolean; ciWatchdogOthersPrs: boolean; othersPrsPush: boolean; /** Branch globs for `ci_watchdog.branches`; empty = key omitted. */ ciWatchdogBranches: string[]; labels: OnboardRepoLabels; } /** One label the onboarding needed; `created` false = a repo or org label of that name already existed. */ export interface OnboardRepoLabelOutcome { name: string; created: boolean; } export interface OnboardRepoResponse { pullNumber: number; /** Web page of the pull request; null when the instance reports none. */ pullUrl: string | null; branch: string; labels: OnboardRepoLabelOutcome[]; } /** Default label names for the onboarding dialog, in the repository's own label casing. */ export interface OnboardDefaultsResponse { style: "lower" | "pascal"; labels: OnboardRepoLabels; } ``` #### 2. Forgejo write methods on `DispatchApi` In `apps/server/src/forgejo/dispatch-api.ts`, add four methods to the `DispatchApi` interface (`:15`) and implement them in `createDispatchApi` (`:266`), copying the shape of `createIssue` (`:481`) — `client.json(...)` plus a mapped result. `repoApiPath` (`forgejo/client.ts:180`) builds the path; callers pre-encode path segments, as `readRepoFile` does with `encodePath` (`dispatch-api.ts:535`). ```ts /** Default branch name of the repository. */ readDefaultBranch(owner: string, repo: string, signal?: AbortSignal): Promise<ApiResult<string>>; /** * Create `path` with UTF-8 `content` in one commit on `branch`, branched off the default branch. * An existing `branch` is force-overwritten, so a retry after a failed attempt succeeds. */ createFileOnBranch( owner: string, repo: string, input: { path: string; content: string; branch: string; message: string }, signal?: AbortSignal, ): Promise<ApiResult<void>>; /** Open a pull request from `head` into `base`; `url` is its web page when the instance reports one. */ createPull( owner: string, repo: string, input: { head: string; base: string; title: string; body: string }, signal?: AbortSignal, ): Promise<ApiResult<{ number: number; url: string | null }>>; /** Create a repository label; resolves with its id. */ createLabel( owner: string, repo: string, input: { name: string; color: string; description: string }, signal?: AbortSignal, ): Promise<ApiResult<{ id: number }>>; ``` Implementations (field names verified against Forgejo v16 `modules/structs/repo_file.go` and `modules/structs/issue_label.go`): - `readDefaultBranch`: `GET repoApiPath(owner, repo)`, map `default_branch`. Do not change the existing `readRepoPrivate` (`: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 }`. `content` must be base64; `new_branch` creates 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 }` (`color` is 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.ts` only parses, and the `yaml` package's serializer cannot emit the commented hint blocks this template needs. ```ts /** A label the onboarding creates when the repository (or its org) has no label of that name. */ export interface OnboardingLabelSpec { name: string; color: string; description: string; } /** Null when the selection is valid, else a message safe to return as a 400. */ export function validateOnboardSelection(req: OnboardRepoRequest): string | null; /** Labels the enabled sections reference, plus `Status/Blocked`, in creation order. */ export function onboardingLabels(req: OnboardRepoRequest): OnboardingLabelSpec[]; /** The `.omp/forgejo.yaml` text the onboarding PR adds. */ export function renderRepoConfig(req: OnboardRepoRequest): string; /** Casing convention the repository's existing labels follow. */ export type LabelStyle = "lower" | "pascal"; /** The convention `names` mostly follows; `lower` on a tie or when no name starts with a letter. */ export function detectLabelStyle(names: string[]): LabelStyle; /** The dialog's prefilled label names in `style`. */ export function defaultLabelNames(style: LabelStyle): OnboardRepoLabels; ``` `validateOnboardSelection`, in this order, returning the first failure: - For every label field an enabled section references (see table below): `name.trim()` non-empty, else `` `label name for <field> must not be empty` ``. - Those same names pairwise distinct under `sameLabel` from `repo-config.ts:432` (trim, lowercase, `-`/`_`/space folded), else `` `label names must be distinct: "<a>" and "<b>"` ``. This mirrors the rule `parseRepoConfig` enforces at `repo-config.ts:172-186`; reuse `sameLabel`, do not re-implement the folding. - Every entry of `ciWatchdogBranches` non-empty after trim, else `` `branch globs must not be empty` ``. Label fields per section (unused fields are ignored entirely, including when empty): | section enabled | fields used | YAML keys | | --- | --- | --- | | `issueHandler` | `triage` | `issue_handler.label` | | `implementation` | `intake`, `progress`, `planning`, `planned` | `implementation.intake_label`, `progress_label`, `planning_label`, `planned_label` | | `ciWatchdogOthersPrs` | `ignore` | `ci_watchdog.others_prs.exclude_labels` | | `autoReview` | none | `review.auto_review` | | `othersPrsPush` | none | `others_prs.push` | | `ciWatchdogBranches` non-empty | none | `ci_watchdog.branches` | `onboardingLabels` returns the referenced names with these fixed colors and descriptions, then always `Status/Blocked` (the server acts on it regardless of config, `repo-config.ts:43`): | field | color | description | | --- | --- | --- | | `triage` | `#d97706` | `Starts an omp-deck triage session on the issue` | | `intake` | `#16a34a` | `Starts an omp-deck implementation of the issue` | | `progress` | `#2563eb` | `An omp-deck implementation is running` | | `planning` | `#7c3aed` | `An omp-deck planning session is running` | | `planned` | `#9333ea` | `An approved plan has been written to the issue` | | `ignore` | `#6b7280` | `omp-deck skips this pull request` | | `Status/Blocked` | `#dc2626` | `omp-deck never starts an implementation on this issue` | `renderRepoConfig` builds the text by concatenation, emitting only enabled sections. Label names are emitted double-quoted with `\` and `"` escaped. `implementation` always includes `max_concurrent: 1`; `ci_watchdog.own_prs.enabled: true` is emitted whenever any `ci_watchdog` key is emitted. After the active sections, always append the commented `system_prompts` and `compaction` hint 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: ```yaml # omp-deck Forgejo integration. See the repository's docs for every key. issue_handler: label: "agent/triage" implementation: intake_label: "agent/ready" progress_label: "agent/in progress" planning_label: "agent/planning" planned_label: "agent/planned" max_concurrent: 1 review: auto_review: true ci_watchdog: own_prs: enabled: true others_prs: enabled: true exclude_labels: ["agent/ignore"] branches: ["main"] others_prs: push: true ``` `detectLabelStyle` decides 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 votes `pascal`, a lowercase one votes `lower`. The majority wins; an exact tie, or no voting name at all, yields `lower` (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. `defaultLabelNames` returns these, and nothing else is configurable from the style: | field | `lower` | `pascal` | | --- | --- | --- | | `triage` | `agent/triage` | `Agent/Triage` | | `intake` | `agent/ready` | `Agent/Ready` | | `progress` | `agent/in progress` | `Agent/In Progress` | | `planning` | `agent/planning` | `Agent/Planning` | | `planned` | `agent/planned` | `Agent/Planned` | | `ignore` | `agent/ignore` | `Agent/Ignore` | `Status/Blocked` is unaffected by the style: the server matches it against the `BLOCKED_LABEL` constant, so it is always created under that exact name. PascalCase names keep the fallback protection in `isAutomationLabel` (`repo-config.ts:461`), which lowercases through `labelKey` before testing the `agent/` prefix. #### 4. The onboarding endpoints In `apps/server/src/routes-forgejo.ts`, add two routes inside `buildForgejoRouter` (`:95`), copying the validation and error style of `POST /forgejo/implement` (`:198`) — hand-rolled `typeof` checks, `{ error }` bodies. `GET /forgejo/onboard-defaults?workspaceId=…` supplies the dialog's prefilled label names. Steps 2–5 below apply unchanged (`workspaceId` from `c.req.query`, then workspace, config and repo resolution), then `api.listLabels(owner, repo)` → 502 `` `could not list labels: ${res.message}` ``, and finally `const 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/onboard` does the work, in this order, with the exact failure responses: 1. `await c.req.json()` in try/catch → 400 `invalid json body`. 2. `typeof req.workspaceId !== "string"` → 400 `workspaceId is required`. Each boolean field must be a boolean and `ciWatchdogBranches` an array of strings → 400 `invalid onboarding request`. 3. `listWorkspaces().find(...)` → 404 `workspace not found`. 4. `readForgejoConfig()` → 422 `Forgejo is not configured`. 5. `resolveRepoFromCwd(workspace.path, cfg.url)` (`forgejo/repo.ts:172`) → 422 `workspace origin is not on the configured Forgejo instance`. 6. `validateOnboardSelection(req)` → 400 with the returned message. 7. `forgejoApi()` → 422 `Forgejo is not configured`. 8. `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` ``. 9. `renderRepoConfig(req)`, then `parseRepoConfig(rendered)` as a self-check: `!ok` → 500 `` `generated config is invalid: ${result.error}` ``. This guards a renderer bug from ever reaching a PR. 10. `api.listLabels(owner, repo)` (returns repo **and** org labels, `dispatch-api.ts:466` via `fetchAvailableLabels`): `!ok` → 502 `` `could not list labels: ${res.message}` ``. For each spec from `onboardingLabels(req)`, existing when some label matches under `labelKey` (`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 with `api.createLabel`; first failure → 502 `` `could not create label "${name}": ${res.message}` ``. Labels created before the failure stay; a retry skips them. 11. `api.readDefaultBranch(owner, repo)` → 502 `` `could not read the default branch: ${res.message}` ``. 12. `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}` ``. 13. `api.createPull(owner, repo, { head: ONBOARDING_BRANCH, base: defaultBranch, title: ONBOARDING_TITLE, body })` → 502 `` `could not open the pull request: ${res.message}` ``. 14. `scheduleWebhookProbe({ owner, repo, number })` (step 5), then 200 with the `OnboardRepoResponse`. Module constants in `routes-forgejo.ts`, next to `ISSUE_PICKER_LIMIT` (`:58`): ```ts const ONBOARDING_BRANCH = "omp-deck/onboarding"; const ONBOARDING_TITLE = "Add omp-deck Forgejo configuration"; ``` 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`. ```ts export const WEBHOOK_PROBE_DELAY_MS = 60_000; export type WebhookVerdict = "ok" | "signature" | "other_repos" | "none"; /** Verdict from the delivery log; `repo` is `owner/repo`, `since` the ISO time the PR was created. */ export function webhookProbeVerdict( repo: string, since: string, deliveries: ForgejoDeliverySummary[], ): WebhookVerdict; /** Comment once on the onboarding PR when no delivery for `repo` arrives within {@link WEBHOOK_PROBE_DELAY_MS}. */ export function scheduleWebhookProbe( target: { owner: string; repo: string; number: number }, deps?: { api?: DispatchApi; delayMs?: number; now?: () => string }, ): void; ``` `webhookProbeVerdict`, evaluated in order against `listRecentForgejoDeliveries(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): 1. A row with `receivedAt >= since` whose `repo` equals `owner/repo` case-insensitively → `"ok"`. 2. Else a row with `receivedAt >= since` whose `outcome` is `"invalid_signature"` or `"not_configured"` → `"signature"`. This is decisive because the webhook receiver fills `repo` only after signature verification (`routes-forgejo-webhook.ts`, `deliveryContext(parsed)` runs post-verify), so a wrong secret necessarily produces repo-less rows. 3. Else any row in the window with a non-null `repo` → `"other_repos"`. 4. Else → `"none"`. `scheduleWebhookProbe`: - Captures `since` at call time and reads the delivery log immediately: if a row already carries this `repo`, return without scheduling — the webhook is demonstrably working and a comment would be noise. - Otherwise `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. - On fire: compute the verdict; `"ok"` posts nothing. Otherwise `api.postComment(owner, repo, number, body)` (`dispatch-api.ts:29`), using `deps.api ?? createDispatchApi()`; a null api or a failed post logs a warning via `logger("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's `config.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 type `application/json` and the secret from the broker's `config.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 from `window.location.origin`, `ForgejoPanel.tsx:271-284`). #### 6. Onboarded flag in the workspaces feed In `apps/server/src/workspace-details.ts`, extend `readForgejoInfo` (`:39`): after `matchRemoteToInstance` succeeds, call `createDispatchApi()` and `readRepoFile(owner, repo, REPO_CONFIG_PATH)`; `ok` with non-null data → `onboarded: true`, `ok` with 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 the `forgejoApi` object (`:38`), importing the three types in the `import type` block at `:1-11`: ```ts /** Prefilled onboarding label names, in the repository's own label casing. */ onboardDefaults(workspaceId: string): Promise<OnboardDefaultsResponse> { return request<OnboardDefaultsResponse>(`/forgejo/onboard-defaults?${new URLSearchParams({ workspaceId })}`); }, /** Create the labels and open the PR that onboards a workspace's repository. */ onboardRepo(req: OnboardRepoRequest): Promise<OnboardRepoResponse> { return request<OnboardRepoResponse>("/forgejo/onboard", { method: "POST", body: JSON.stringify(req) }); }, ``` - New `apps/web/src/components/settings/OnboardRepoDialog.tsx`, built on the existing `Modal` (already imported by `WorkspacesPanel.tsx:6`) and the `btn-primary`/`btn-ghost` classes used there. Props: `{ workspace: WorkspaceDetails; onClose(): void; onDone(msg: string): void }`. Controls: - Checkboxes, defaults: issue handler **on**, implementation flow **on**, automatic PR reviews **off**, CI watchdog for others' PRs **on**, allow pushing to others' PR branches **off**. - One text input for branch globs, split on commas and whitespace, empty entries dropped. - Six label-name inputs, one per field, each shown only while the section that uses it is checked. Their values come from `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 the `lower` names (`agent/triage`, `agent/ready`, `agent/in progress`, `agent/planning`, `agent/planned`, `agent/ignore`) so onboarding stays possible. - Submit button disabled while in flight. On success call `onDone` with a message naming the PR and the created/skipped labels; on error render the thrown message inline in the dialog (the shared `request` helper already unwraps `{ error }`), keeping the dialog open so the user can retry. - `WorkspacesPanel.tsx`: in `ForgejoCell` (`:304`), the `matched` branch renders the existing link plus an `Onboard` `btn-ghost` button, disabled when `info.onboarded === true` with `title="already onboarded"`; `null` leaves it enabled (the endpoint's 409 is then the backstop). Clicking opens the dialog for that row; `onDone` sets the panel notice and bumps the existing `reloadTick` so the row refetches its `onboarded` state. ### 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`), `createIssue` as the write-method template (`:481`), `readRepoFile` (`:535`), `listLabels` (`:466`). - `apps/server/src/routes-forgejo.ts` — `buildForgejoRouter` (`:95`) and `POST /forgejo/implement` (`:198`) as the validation/error template; injectable `forgejoApi` is what tests stub. - `apps/server/src/routes-forgejo-webhook.ts` — proves `repo` is recorded only after signature verification and that every outcome, including `ignored`, is logged; this is what makes the probe's three verdicts separable. - `.omp/forgejo.yaml` (repository root) — the commented `system_prompts` and `compaction` blocks (lines 26–48) that `renderRepoConfig` appends verbatim. ### Verification Run from `apps/server` unless stated otherwise. 1. New unit tests, `bun test src/forgejo/onboarding-config.test.ts`: - Full selection (all toggles on, branch globs `["main", "release/*"]`, default label names) → `renderRepoConfig` output fed to `parseRepoConfig` returns `ok` with `config.issueHandler.label === "agent/triage"`, `config.implementation.intakeLabel === "agent/ready"`, `config.review !== null`, `config.ciWatchdogOthersPrs.excludeLabels` `["agent/ignore"]`, `config.ciWatchdogBranches` `["main", "release/*"]`, `config.othersPrsPush === true`. - Minimal selection (everything off) → parses `ok` and equals the shape of `DEFAULT_REPO_CONFIG`. - `validateOnboardSelection` with `intake: "agent/ready"` and `planning: "Agent/Ready"` returns a distinctness message (proves the `sameLabel` folding is used, not plain equality). - `detectLabelStyle(["Kind/Bug", "Kind/Feature", "wontfix"])` → `"pascal"`; `detectLabelStyle(["kind/bug", "Kind/Feature"])` → `"lower"` (tie); `detectLabelStyle([])` and `detectLabelStyle(["42-blocked"])` → `"lower"`; `defaultLabelNames("pascal").progress === "Agent/In Progress"`. 2. New unit test, `bun test src/forgejo/onboarding-probe.test.ts`: `webhookProbeVerdict("owner/repo", since, rows)` over four fixture sets → `"ok"`, `"signature"`, `"other_repos"`, `"none"` respectively. 3. Route tests, `bun test src/routes-forgejo.test.ts`, extending the existing file's pattern (`bootDb()` plus `buildForgejoRouter({ forgejoApi: () => stub })`, `:~128-136`) with a `DispatchApi` stub recording calls: - `readRepoFile` resolving non-null content → 409, and **no** `createLabel`/`createPull` call recorded. - Happy path: `listLabels` returns `[{ id: 1, name: "Agent/Ready" }]`; request uses `intake: "agent/ready"` → no `createLabel` for it (case/space-folded match), `createLabel` called for the remaining names plus `Status/Blocked`; the `createFileOnBranch` call carries `branch: "omp-deck/onboarding"` and a `content` that `parseRepoConfig` accepts; `createPull` called with `head: "omp-deck/onboarding"` and the stubbed default branch as `base`; response body is `{ pullNumber, pullUrl, branch, labels }` with `created: false` for `agent/ready`. - `createLabel` failing on the second label → 502 whose message names that label, and no `createPull` call. - `GET /forgejo/onboard-defaults`: `listLabels` returning `[{ id: 1, name: "Kind/Bug" }, { id: 2, name: "Status/Blocked" }]` → 200 `{ style: "pascal", labels: { triage: "Agent/Triage", … } }`; missing `workspaceId` → 400. 4. Whole-repo gate: `bun test` in `apps/server`, and `bun run typecheck` at the repository root. 5. Manual end-to-end (requires a configured Forgejo instance, a stored token with write access, and a registered workspace whose `origin` matches): 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 - The target Forgejo supports `force_overwrite_new_branch` on the contents API (present in Forgejo v16 `modules/structs/repo_file.go`). If an older instance rejects it and the call fails because `omp-deck/onboarding` already exists, do not add branch-deletion: let the 502 carry Forgejo's message so the user deletes the stale branch and retries. - Label colors are sent as `#rrggbb`. If the instance rejects the leading `#`, send the six hex digits without it; both forms appear in Forgejo's own examples. - The onboarded flag costs one Forgejo request per matched workspace on each Settings → Workspaces load. If that proves too slow on an instance with many workspaces, drop the flag back to `null` unconditionally and rely on the endpoint's 409 rather than introducing a cache.
lucas closed this issue 2026-11-12 20:25:37 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#266
No description provided.