Forgejo: Issue Handler + per-repo config (.omp/forgejo.yaml) #78
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#78
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
When a configured label is added to an issue, the agent should analyze the issue and reply with a comment (plan step 18 of the Forgejo integration). This also introduces the per-repo config file
.omp/forgejo.yamland the label event path that auto-review (step 19) and label-driven implementer intake (step 21,implementation.intake_label) will build on..omp/forgejo.yamlin the repo root with a typed result and safe defaults. A missing file means reactive behavior only: @mentions, review requests and replies keep working. This change reads onlyissue_handler.label; other sections (implementation,review,ci_watchdog) are added by the steps that use them. The file is read from the repository's default branch via the Forgejo API, fresh on every label event (no cache). An invalid file is surfaced on the existing dispatcher failure path (public issue comment naming the file as invalid; the parse error stays in the trigger row / inbox fallback), never silently defaulted.issue_label(Eventissues) with actionlabel_updated(orlabel_clearedwhen all labels are removed). Verified in Forgejoservices/webhook/notifier.goIssueChangeLabels: the payload carries only the issue's full current label set (issue.labels), no added/removed diff. "The configured label was added" is therefore decided from the issue timeline API: label timeline entries aretype: "label"withbody: "1"for an add and""for a removal (Forgejomodels/issues/issue_label.go); one change shares acreated_atsecond. Rule: trigger iff the newest batch of label entries (maxcreated_at) contains an add of the configured label. If another label change lands before the dispatcher processes the delivery, the analysis is skipped (accepted). Label changes by the agent account are ignored. The sender needs repo write access (same broker check as other triggers). PR label changes (pull_request_label) stay ignored.forgejo_sessions) using the existingforgejo_conversationsession kind unchanged (it keeps its read-only code access, so later @mentions continue the same session). The seed asks for analysis only: read the issue, search for similar issues (forgejo_search_issues), reply with analysis, clarifying questions or feedback; no labeling. Seed contents per the design's Context Assembly: issue id, title, description, current labels.References: KB
omp-deck/forgejo-integration-plan.mdstep 18; designomp-deck/forgejo-integration.md(Issue Handler / Analyzer, Issue labeled, Context Assembly, Per-Repo Configuration).Plan
Context
Add the
.omp/forgejo.yamlparser, acceptissue_labelwebhooks, and make the dispatcher run aforgejo_conversationturn with an issue-handler seed whenissue_handler.labelwas just added by a user with write access. All code is inapps/server/src. Steps are ordered so the tree builds andbun testpasses after each.Approach
1. Per-repo config parser (new file
forgejo/repo-config.ts)No equivalent exists (
forgejo/config.tsis the deck-wide Forgejo connection config). Createapps/server/src/forgejo/repo-config.tswith a module doc comment explaining: the file is optional, read from the default branch, unknown keys are ignored so later steps can add sections.parseRepoConfigrules, in order:text === null→{ ok: true, config: DEFAULT_REPO_CONFIG }.parsefromyaml(already a dependency,apps/server/package.json; same import style asroutes-routines.ts:import { parse as parseYaml } from "yaml") inside try/catch; a thrown error →{ ok: false, error: \invalid YAML: ${message}` }`.null/undefined(empty file) → defaults.top level must be a mapping.issue_handlerabsent ornull→issueHandler: null. Present but not a plain object → error`issue_handler` must be a mapping.issue_handler.labelnot a string or blank aftertrim()→ error`issue_handler.label` must be a non-empty string. OtherwiseissueHandler: { label: label.trim() }.issue_handler) are ignored.labelTriggerForreturns"issue_handler"whenconfig.issueHandler?.label === name, else null.2. Dispatch API: raw file and label timeline
In
apps/server/src/forgejo/dispatch-api.tsextendDispatchApiandcreateDispatchApi:readRepoFile:client.text(repoApiPath(owner, repo, "raw", encodePath(path)), { maxBytes: 64 * 1024, signal })with norefquery (Forgejo serves the default branch), same call shape astools/prs.ts:261. ImportencodePathfrom./client.ts.!ok && status === 404→{ ok: true, data: null, meta: { totalCount: null, hasNext: false } }; other failures returned as-is;truncated→{ ok: false, status: 413, message: "file exceeds 64 KiB" }; else{ ok: true, data: text, meta: { totalCount: null, hasNext: false } }.listLabelEvents:fetchAllPages<RawTimelineComment>(client, repoApiPath(owner, repo, "issues", String(number), "timeline"), { signal })(same aslistComments), keep entries withtype === "label"and a non-emptylabel.name, map to{ label: label.name, added: body === "1", author: user?.login ?? "", createdAt: created_at ?? "" }. Addinterface RawTimelineComment { type?: string; body?: string | null; created_at?: string; user?: { login?: string } | null; label?: { name?: string } | null }.Add the two methods to
FakeApiinforgejo/dispatcher.test.ts(configurable responses, defaulting to{ ok: true, data: null }and{ ok: true, data: [] }) so existing tests still compile.3. Pure helpers: labels added by the latest change, and the seed
In
apps/server/src/forgejo/conversation-detect.ts:Compare
Date.parse(createdAt); ignore entries whose date does not parse; return thelabelofaddedentries whose time equals the max.Seed lines (follow
buildConversationSeedstyle, description fenced withfenceFor):Forgejo issue ${repo} #${number}: "${title ?? "?"}"Labels: ${labels joined by ", " or "none"}Trigger: @${sender ?? "?"} added the label "${label}", which this repository's .omp/forgejo.yaml configures as issue_handler.label.Task: analyze this issue. Search for similar or duplicate issues with forgejo_search_issues, then post one comment on #${number} with your analysis: related issues (linked as #N), clarifying questions, and initial feedback. Do not change labels or anything else; do not implement.Description:, fence, body ("(empty)"when missing/blank), fence.4. Webhook classification of
issue_labelIn
apps/server/src/forgejo/webhook.ts:classifyIssueLabel(payload: Record<string, unknown>, agentUsername: string): WebhookVerdict: action must belabel_updated(else ignore`unsupported action ${action}`, which coverslabel_cleared);sender.loginmissing → malformedmissing sender.login; sender equal to agent (case-insensitive) → ignoreself-authored;repository.full_nameandissue.numbervalidated exactly as inclassifyLifecycle; accept withdedupeKey: sha256(JSON.stringify(["label_updated", payload])),event: "issue_label",action: "label_updated",isPull: false,commentAuthor: sender.classifyForgejoDelivery: new brancheventType === "issue_label"→ object check thenclassifyIssueLabel. In the existingissuesbranch also routeaction === "label_updated"toclassifyIssueLabel(Forgejo versions withoutX-Forgejo-Event-Typesend Eventissues).pull_request_labelkeeps falling through tounsupported event.issue_label(Eventissues) /pull_request_label, actionlabel_updatedorlabel_cleared, carrying the full currentissue.labelsand no diff (Forgejoservices/webhook/notifier.goIssueChangeLabels).5. Dispatcher: label gate, config failure kind, issue-handler turn
In
apps/server/src/forgejo/dispatcher.ts:ReadyReasongains"issue-labeled".Readygainslabel: string | null(the configured label that fired; null for every other reason — set it in the existingreturnat the end ofgate).FailureKindgains"config";failureLabelreturnsthe repository's `.omp/forgejo.yaml` is invalidfor it.private async resolveLabelTrigger(job: Job, payload: Payload): Promise<string | null>returning the label to handle, or null after finishing the row. Steps:api.readRepoFile(owner, repo, REPO_CONFIG_PATH); failure →this.fail(job, \could not read ${REPO_CONFIG_PATH}: ${status} ${message}`, "forgejo", null)`, return null.parseRepoConfig(data);!ok→this.fail(job, \${REPO_CONFIG_PATH}: ${error}`, "config", null)`, return null.config.issueHandler ? [config.issueHandler.label] : []; empty →finishForgejoTrigger(id, "dropped", "no label trigger configured"), null.payload.issue?.labelsnames; keep configured labels that are present; none → dropconfigured label not present.api.listLabelEvents(...): 404 → drop`agent has no access to ${row.repo}`(same asdetect); other failure →fail(..., "forgejo", null); thenlabelsAddedByLatestChange(events); first configured+present label in that list withlabelTriggerFor(config, l) === "issue_handler"→ return it; none → dropconfigured label was not added by this change.gate, before the existing reason selection:const labeled = row.event === "issue_label";. Add a branchelse if (labeled) { const label = await this.resolveLabelTrigger(job, payload); if (label === null) return null; reason = "issue-labeled"; }placed before thedetectfallback, and keeplabelfor the returnedReady. Everything after (sender check, permission check, workspace matching) runs unchanged;authoris read frompayload.issue?.user?.loginas for comments.runReady, add a seed branchelse if (reason === "issue-labeled") seed = buildIssueHandlerSeed({ repo: row.repo, number: row.number, label: ready.label!, payload });before the conversation fallback.resolveSessionneeds no change: a non-pull row already maps to aforgejo_conversationsession (created or reused viaforgejo_sessions).Payloadtype withIssueLabeledPayloadLikeif needed for typing.6. Conversation policy wording
In
apps/server/src/bridge/session-policy.tsforgejoConversationPolicy.systemContext, changeYou were started by a comment ontoYou were started by a comment or a label change onso the orientation is true for issue-handler sessions. No tool changes.Critical files & anchors
apps/server/src/forgejo/webhook.ts—classifyForgejoDelivery(≈L89–147);issuesbranch currently accepts onlyclosed/reopened.apps/server/src/forgejo/dispatcher.ts—gate(≈L279–390) reason selection;runReadyseed selection (≈L403–413);failureLabel(≈L803).apps/server/src/forgejo/dispatch-api.ts—DispatchApiinterface andcreateDispatchApi.apps/server/src/forgejo/dispatcher.test.ts—FakeApiandharness()(≈L193–321) to extend.Verification
Run from
apps/server:bun test src/forgejothenbun run typecheck.New tests:
src/forgejo/repo-config.test.ts: null → defaults; empty string → defaults;issue_handler:\n label: needs-triage→{ issueHandler: { label: "needs-triage" } }; unknown sections (review: {auto_review: true}) ignored;[1]→ errortop level must be a mapping;issue_handler: x→ mapping error;issue_handler: {label: ""}→ label error;: : :(bad YAML) → error startinginvalid YAML.labelTriggerForexact match vsNeeds-Triage→ null.src/forgejo/webhook.test.ts:issue_label/label_updated→ accept withevent: "issue_label",isPull: false,commentAuthor= sender; Eventissues+label_updated→ same accept;label_cleared→ ignore; sender = agent → ignoreself-authored;pull_request_label→ ignoreunsupported event.src/forgejo/conversation-detect.test.ts:labelsAddedByLatestChangereturns only adds in the newest second (older add ofneeds-triage, newer add ofbug→["bug"]; same-second add of both → both; newest batch only removals →[]).src/forgejo/dispatcher.test.ts, newdescribe("ForgejoDispatcher issue labels")with anissue_labelrow whose payload hasissue.labels[{name:"needs-triage"}]:issue_handler: {label: needs-triage}and latest timeline batch addingneeds-triage→ one session created with trigger kindforgejo_conversation, prompt containsadded the label "needs-triage", rowdonewith reasonissue-labeled.bugonly (needs-triage added earlier) → rowdroppedconfigured label was not added by this change, no session.readRepoFile→ null → droppedno label trigger configured.failed, a posted comment contains.omp/forgejo.yaml` is invalid.read→ droppedauthor lacks write access (read).Manual end-to-end (needs the Forgejo instance and a connected workspace): in the Forgejo repo webhook settings enable the "Issue labeled" event; commit
.omp/forgejo.yamlwithissue_handler:\n label: needs-triageto the default branch; addneeds-triageto an open issue as a write-access user. Expected:GET http://127.0.0.1:8787/api/forgejo/deliveriesshows anissue_labeldeliveryaccepted, and the agent posts one analysis comment on the issue. Then add a different label to the same issue: delivery accepted, trigger dropped withconfigured label was not added by this change, no new comment.Assumptions & contingencies
issue_labeldelivery shows the action under a different name thanlabel_updated, accept that name inclassifyIssueLabelinstead and update the doc comment.typenamed differently on the instance), match on the observedtypestring; if label entries are absent entirely, stop and report on the issue rather than falling back to firing on every label change.