Forgejo: Issue Handler + per-repo config (.omp/forgejo.yaml) #78

Closed
opened 2026-10-26 15:04:24 +01:00 by omp-deck · 0 comments
Collaborator

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.yaml and the label event path that auto-review (step 19) and label-driven implementer intake (step 21, implementation.intake_label) will build on.

  • Per-repo config: a parser for .omp/forgejo.yaml in 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 only issue_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.
  • Label event path: Forgejo delivers issue label changes as Event-Type issue_label (Event issues) with action label_updated (or label_cleared when all labels are removed). Verified in Forgejo services/webhook/notifier.go IssueChangeLabels: 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 are type: "label" with body: "1" for an add and "" for a removal (Forgejo models/issues/issue_label.go); one change shares a created_at second. Rule: trigger iff the newest batch of label entries (max created_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.
  • Issue Handler session: routes to the issue's session (find or create via forgejo_sessions) using the existing forgejo_conversation session 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.md step 18; design omp-deck/forgejo-integration.md (Issue Handler / Analyzer, Issue labeled, Context Assembly, Per-Repo Configuration).

Plan

Context

Add the .omp/forgejo.yaml parser, accept issue_label webhooks, and make the dispatcher run a forgejo_conversation turn with an issue-handler seed when issue_handler.label was just added by a user with write access. All code is in apps/server/src. Steps are ordered so the tree builds and bun test passes after each.

Approach

1. Per-repo config parser (new file forgejo/repo-config.ts)

No equivalent exists (forgejo/config.ts is the deck-wide Forgejo connection config). Create apps/server/src/forgejo/repo-config.ts with a module doc comment explaining: the file is optional, read from the default branch, unknown keys are ignored so later steps can add sections.

export const REPO_CONFIG_PATH = ".omp/forgejo.yaml";

/** Automated behaviors a repository opts into; null = disabled. */
export interface RepoConfig {
	issueHandler: { label: string } | null;
}

export const DEFAULT_REPO_CONFIG: RepoConfig = { issueHandler: null };

export type RepoConfigResult = { ok: true; config: RepoConfig } | { ok: false; error: string };

/** `text` null = file absent → defaults. */
export function parseRepoConfig(text: string | null): RepoConfigResult;

/** What a label configured in the repo triggers; step 21 adds "implementation_intake". */
export type LabelTrigger = "issue_handler";

/** The trigger configured for label `name` (exact, case-sensitive match), or null. */
export function labelTriggerFor(config: RepoConfig, name: string): LabelTrigger | null;

parseRepoConfig rules, in order:

  • text === null → { ok: true, config: DEFAULT_REPO_CONFIG }.
  • Parse with parse from yaml (already a dependency, apps/server/package.json; same import style as routes-routines.ts: import { parse as parseYaml } from "yaml") inside try/catch; a thrown error → { ok: false, error: \invalid YAML: ${message}` }`.
  • Parsed null/undefined (empty file) → defaults.
  • Not a plain object (array, scalar) → error top level must be a mapping.
  • issue_handler absent or null → issueHandler: null. Present but not a plain object → error `issue_handler` must be a mapping.
  • issue_handler.label not a string or blank after trim() → error `issue_handler.label` must be a non-empty string. Otherwise issueHandler: { label: label.trim() }.
  • All other keys (top-level and inside issue_handler) are ignored.

labelTriggerFor returns "issue_handler" when config.issueHandler?.label === name, else null.

2. Dispatch API: raw file and label timeline

In apps/server/src/forgejo/dispatch-api.ts extend DispatchApi and createDispatchApi:

/** Text of `path` on the default branch; null when the file does not exist (404). */
readRepoFile(owner: string, repo: string, path: string, signal?: AbortSignal): Promise<ApiResult<string | null>>;
/** Every label add/removal on issue `number`, oldest first. */
listLabelEvents(owner: string, repo: string, number: number, signal?: AbortSignal): Promise<ApiResult<LabelEvent[]>>;

export interface LabelEvent {
	label: string;
	added: boolean;
	author: string;
	createdAt: string;
}
  • readRepoFile: client.text(repoApiPath(owner, repo, "raw", encodePath(path)), { maxBytes: 64 * 1024, signal }) with no ref query (Forgejo serves the default branch), same call shape as tools/prs.ts:261. Import encodePath from ./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 as listComments), keep entries with type === "label" and a non-empty label.name, map to { label: label.name, added: body === "1", author: user?.login ?? "", createdAt: created_at ?? "" }. Add interface RawTimelineComment { type?: string; body?: string | null; created_at?: string; user?: { login?: string } | null; label?: { name?: string } | null }.

Add the two methods to FakeApi in forgejo/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:

/** Labels added by the newest label change: the add entries sharing the max `createdAt`. Empty when none. */
export function labelsAddedByLatestChange(events: { label: string; added: boolean; createdAt: string }[]): string[];

Compare Date.parse(createdAt); ignore entries whose date does not parse; return the label of added entries whose time equals the max.

/** The fields of an `issue_label` webhook payload the seed reads; anything may be missing. */
export interface IssueLabeledPayloadLike { issue?: { title?: string; body?: string | null; labels?: { name?: string }[] | null }; sender?: { login?: string } | null }

/** The turn for an issue whose `issue_handler.label` was just added. */
export function buildIssueHandlerSeed(t: { repo: string; number: number; label: string; payload: IssueLabeledPayloadLike }): string;

Seed lines (follow buildConversationSeed style, description fenced with fenceFor):

  • 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.
  • blank line, Description:, fence, body ("(empty)" when missing/blank), fence.

4. Webhook classification of issue_label

In apps/server/src/forgejo/webhook.ts:

  • Add classifyIssueLabel(payload: Record<string, unknown>, agentUsername: string): WebhookVerdict: action must be label_updated (else ignore `unsupported action ${action}`, which covers label_cleared); sender.login missing → malformed missing sender.login; sender equal to agent (case-insensitive) → ignore self-authored; repository.full_name and issue.number validated exactly as in classifyLifecycle; accept with dedupeKey: sha256(JSON.stringify(["label_updated", payload])), event: "issue_label", action: "label_updated", isPull: false, commentAuthor: sender.
  • In classifyForgejoDelivery: new branch eventType === "issue_label" → object check then classifyIssueLabel. In the existing issues branch also route action === "label_updated" to classifyIssueLabel (Forgejo versions without X-Forgejo-Event-Type send Event issues). pull_request_label keeps falling through to unsupported event.
  • Extend the module doc comment's Forgejo-facts list: label changes arrive as Event-Type issue_label (Event issues) / pull_request_label, action label_updated or label_cleared, carrying the full current issue.labels and no diff (Forgejo services/webhook/notifier.go IssueChangeLabels).

5. Dispatcher: label gate, config failure kind, issue-handler turn

In apps/server/src/forgejo/dispatcher.ts:

  • ReadyReason gains "issue-labeled". Ready gains label: string | null (the configured label that fired; null for every other reason — set it in the existing return at the end of gate).
  • FailureKind gains "config"; failureLabel returns the repository's `.omp/forgejo.yaml` is invalid for it.
  • Add private async resolveLabelTrigger(job: Job, payload: Payload): Promise<string | null> returning the label to handle, or null after finishing the row. Steps:
    1. api.readRepoFile(owner, repo, REPO_CONFIG_PATH); failure → this.fail(job, \could not read ${REPO_CONFIG_PATH}: ${status} ${message}`, "forgejo", null)`, return null.
    2. parseRepoConfig(data); !ok → this.fail(job, \${REPO_CONFIG_PATH}: ${error}`, "config", null)`, return null.
    3. Configured labels = config.issueHandler ? [config.issueHandler.label] : []; empty → finishForgejoTrigger(id, "dropped", "no label trigger configured"), null.
    4. Current labels = payload.issue?.labels names; keep configured labels that are present; none → drop configured label not present.
    5. api.listLabelEvents(...): 404 → drop `agent has no access to ${row.repo}` (same as detect); other failure → fail(..., "forgejo", null); then labelsAddedByLatestChange(events); first configured+present label in that list with labelTriggerFor(config, l) === "issue_handler" → return it; none → drop configured label was not added by this change.
  • In gate, before the existing reason selection: const labeled = row.event === "issue_label";. Add a branch else if (labeled) { const label = await this.resolveLabelTrigger(job, payload); if (label === null) return null; reason = "issue-labeled"; } placed before the detect fallback, and keep label for the returned Ready. Everything after (sender check, permission check, workspace matching) runs unchanged; author is read from payload.issue?.user?.login as for comments.
  • In runReady, add a seed branch else if (reason === "issue-labeled") seed = buildIssueHandlerSeed({ repo: row.repo, number: row.number, label: ready.label!, payload }); before the conversation fallback. resolveSession needs no change: a non-pull row already maps to a forgejo_conversation session (created or reused via forgejo_sessions).
  • Extend the Payload type with IssueLabeledPayloadLike if needed for typing.

6. Conversation policy wording

In apps/server/src/bridge/session-policy.ts forgejoConversationPolicy.systemContext, change You were started by a comment on to You were started by a comment or a label change on so the orientation is true for issue-handler sessions. No tool changes.

Critical files & anchors

  • apps/server/src/forgejo/webhook.ts — classifyForgejoDelivery (≈L89–147); issues branch currently accepts only closed/reopened.
  • apps/server/src/forgejo/dispatcher.ts — gate (≈L279–390) reason selection; runReady seed selection (≈L403–413); failureLabel (≈L803).
  • apps/server/src/forgejo/dispatch-api.ts — DispatchApi interface and createDispatchApi.
  • apps/server/src/forgejo/dispatcher.test.ts — FakeApi and harness() (≈L193–321) to extend.

Verification

Run from apps/server: bun test src/forgejo then bun 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] → error top level must be a mapping; issue_handler: x → mapping error; issue_handler: {label: ""} → label error; : : : (bad YAML) → error starting invalid YAML. labelTriggerFor exact match vs Needs-Triage → null.
  • src/forgejo/webhook.test.ts: issue_label/label_updated → accept with event: "issue_label", isPull: false, commentAuthor = sender; Event issues + label_updated → same accept; label_cleared → ignore; sender = agent → ignore self-authored; pull_request_label → ignore unsupported event.
  • src/forgejo/conversation-detect.test.ts: labelsAddedByLatestChange returns only adds in the newest second (older add of needs-triage, newer add of bug → ["bug"]; same-second add of both → both; newest batch only removals → []).
  • src/forgejo/dispatcher.test.ts, new describe("ForgejoDispatcher issue labels") with an issue_label row whose payload has issue.labels [{name:"needs-triage"}]:
    • config issue_handler: {label: needs-triage} and latest timeline batch adding needs-triage → one session created with trigger kind forgejo_conversation, prompt contains added the label "needs-triage", row done with reason issue-labeled.
    • latest batch adds bug only (needs-triage added earlier) → row dropped configured label was not added by this change, no session.
    • readRepoFile → null → dropped no label trigger configured.
    • invalid YAML → row failed, a posted comment contains .omp/forgejo.yaml` is invalid.
    • permission read → dropped author lacks write access (read).
    • existing mapped conversation session for the issue → reused (no second session).

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.yaml with issue_handler:\n label: needs-triage to the default branch; add needs-triage to an open issue as a write-access user. Expected: GET http://127.0.0.1:8787/api/forgejo/deliveries shows an issue_label delivery accepted, and the agent posts one analysis comment on the issue. Then add a different label to the same issue: delivery accepted, trigger dropped with configured label was not added by this change, no new comment.

Assumptions & contingencies

  • If a live issue_label delivery shows the action under a different name than label_updated, accept that name in classifyIssueLabel instead and update the doc comment.
  • If the timeline API omits label entries (e.g. type named differently on the instance), match on the observed type string; if label entries are absent entirely, stop and report on the issue rather than falling back to firing on every label change.
## 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.yaml` and the label event path that auto-review (step 19) and label-driven implementer intake (step 21, `implementation.intake_label`) will build on. - **Per-repo config:** a parser for `.omp/forgejo.yaml` in 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 only `issue_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. - **Label event path:** Forgejo delivers issue label changes as Event-Type `issue_label` (Event `issues`) with action `label_updated` (or `label_cleared` when all labels are removed). Verified in Forgejo `services/webhook/notifier.go` `IssueChangeLabels`: 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 are `type: "label"` with `body: "1"` for an add and `""` for a removal (Forgejo `models/issues/issue_label.go`); one change shares a `created_at` second. Rule: trigger iff the newest batch of label entries (max `created_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. - **Issue Handler session:** routes to the issue's session (find or create via `forgejo_sessions`) using the existing `forgejo_conversation` session 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.md` step 18; design `omp-deck/forgejo-integration.md` (Issue Handler / Analyzer, Issue labeled, Context Assembly, Per-Repo Configuration). ## Plan ### Context Add the `.omp/forgejo.yaml` parser, accept `issue_label` webhooks, and make the dispatcher run a `forgejo_conversation` turn with an issue-handler seed when `issue_handler.label` was just added by a user with write access. All code is in `apps/server/src`. Steps are ordered so the tree builds and `bun test` passes after each. ### Approach #### 1. Per-repo config parser (new file `forgejo/repo-config.ts`) No equivalent exists (`forgejo/config.ts` is the deck-wide Forgejo connection config). Create `apps/server/src/forgejo/repo-config.ts` with a module doc comment explaining: the file is optional, read from the default branch, unknown keys are ignored so later steps can add sections. ```ts export const REPO_CONFIG_PATH = ".omp/forgejo.yaml"; /** Automated behaviors a repository opts into; null = disabled. */ export interface RepoConfig { issueHandler: { label: string } | null; } export const DEFAULT_REPO_CONFIG: RepoConfig = { issueHandler: null }; export type RepoConfigResult = { ok: true; config: RepoConfig } | { ok: false; error: string }; /** `text` null = file absent → defaults. */ export function parseRepoConfig(text: string | null): RepoConfigResult; /** What a label configured in the repo triggers; step 21 adds "implementation_intake". */ export type LabelTrigger = "issue_handler"; /** The trigger configured for label `name` (exact, case-sensitive match), or null. */ export function labelTriggerFor(config: RepoConfig, name: string): LabelTrigger | null; ``` `parseRepoConfig` rules, in order: - `text === null` → `{ ok: true, config: DEFAULT_REPO_CONFIG }`. - Parse with `parse` from `yaml` (already a dependency, `apps/server/package.json`; same import style as `routes-routines.ts`: `import { parse as parseYaml } from "yaml"`) inside try/catch; a thrown error → `{ ok: false, error: \`invalid YAML: ${message}\` }`. - Parsed `null`/`undefined` (empty file) → defaults. - Not a plain object (array, scalar) → error `top level must be a mapping`. - `issue_handler` absent or `null` → `issueHandler: null`. Present but not a plain object → error `` `issue_handler` must be a mapping ``. - `issue_handler.label` not a string or blank after `trim()` → error `` `issue_handler.label` must be a non-empty string ``. Otherwise `issueHandler: { label: label.trim() }`. - All other keys (top-level and inside `issue_handler`) are ignored. `labelTriggerFor` returns `"issue_handler"` when `config.issueHandler?.label === name`, else null. #### 2. Dispatch API: raw file and label timeline In `apps/server/src/forgejo/dispatch-api.ts` extend `DispatchApi` and `createDispatchApi`: ```ts /** Text of `path` on the default branch; null when the file does not exist (404). */ readRepoFile(owner: string, repo: string, path: string, signal?: AbortSignal): Promise<ApiResult<string | null>>; /** Every label add/removal on issue `number`, oldest first. */ listLabelEvents(owner: string, repo: string, number: number, signal?: AbortSignal): Promise<ApiResult<LabelEvent[]>>; export interface LabelEvent { label: string; added: boolean; author: string; createdAt: string; } ``` - `readRepoFile`: `client.text(repoApiPath(owner, repo, "raw", encodePath(path)), { maxBytes: 64 * 1024, signal })` with no `ref` query (Forgejo serves the default branch), same call shape as `tools/prs.ts:261`. Import `encodePath` from `./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 as `listComments`), keep entries with `type === "label"` and a non-empty `label.name`, map to `{ label: label.name, added: body === "1", author: user?.login ?? "", createdAt: created_at ?? "" }`. Add `interface RawTimelineComment { type?: string; body?: string | null; created_at?: string; user?: { login?: string } | null; label?: { name?: string } | null }`. Add the two methods to `FakeApi` in `forgejo/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`: ```ts /** Labels added by the newest label change: the add entries sharing the max `createdAt`. Empty when none. */ export function labelsAddedByLatestChange(events: { label: string; added: boolean; createdAt: string }[]): string[]; ``` Compare `Date.parse(createdAt)`; ignore entries whose date does not parse; return the `label` of `added` entries whose time equals the max. ```ts /** The fields of an `issue_label` webhook payload the seed reads; anything may be missing. */ export interface IssueLabeledPayloadLike { issue?: { title?: string; body?: string | null; labels?: { name?: string }[] | null }; sender?: { login?: string } | null } /** The turn for an issue whose `issue_handler.label` was just added. */ export function buildIssueHandlerSeed(t: { repo: string; number: number; label: string; payload: IssueLabeledPayloadLike }): string; ``` Seed lines (follow `buildConversationSeed` style, description fenced with `fenceFor`): - `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.` - blank line, `Description:`, fence, body (`"(empty)"` when missing/blank), fence. #### 4. Webhook classification of `issue_label` In `apps/server/src/forgejo/webhook.ts`: - Add `classifyIssueLabel(payload: Record<string, unknown>, agentUsername: string): WebhookVerdict`: action must be `label_updated` (else ignore `` `unsupported action ${action}` ``, which covers `label_cleared`); `sender.login` missing → malformed `missing sender.login`; sender equal to agent (case-insensitive) → ignore `self-authored`; `repository.full_name` and `issue.number` validated exactly as in `classifyLifecycle`; accept with `dedupeKey: sha256(JSON.stringify(["label_updated", payload]))`, `event: "issue_label"`, `action: "label_updated"`, `isPull: false`, `commentAuthor: sender`. - In `classifyForgejoDelivery`: new branch `eventType === "issue_label"` → object check then `classifyIssueLabel`. In the existing `issues` branch also route `action === "label_updated"` to `classifyIssueLabel` (Forgejo versions without `X-Forgejo-Event-Type` send Event `issues`). `pull_request_label` keeps falling through to `unsupported event`. - Extend the module doc comment's Forgejo-facts list: label changes arrive as Event-Type `issue_label` (Event `issues`) / `pull_request_label`, action `label_updated` or `label_cleared`, carrying the full current `issue.labels` and no diff (Forgejo `services/webhook/notifier.go` `IssueChangeLabels`). #### 5. Dispatcher: label gate, config failure kind, issue-handler turn In `apps/server/src/forgejo/dispatcher.ts`: - `ReadyReason` gains `"issue-labeled"`. `Ready` gains `label: string | null` (the configured label that fired; null for every other reason — set it in the existing `return` at the end of `gate`). - `FailureKind` gains `"config"`; `failureLabel` returns ``the repository's `.omp/forgejo.yaml` is invalid`` for it. - Add `private async resolveLabelTrigger(job: Job, payload: Payload): Promise<string | null>` returning the label to handle, or null after finishing the row. Steps: 1. `api.readRepoFile(owner, repo, REPO_CONFIG_PATH)`; failure → `this.fail(job, \`could not read ${REPO_CONFIG_PATH}: ${status} ${message}\`, "forgejo", null)`, return null. 2. `parseRepoConfig(data)`; `!ok` → `this.fail(job, \`${REPO_CONFIG_PATH}: ${error}\`, "config", null)`, return null. 3. Configured labels = `config.issueHandler ? [config.issueHandler.label] : []`; empty → `finishForgejoTrigger(id, "dropped", "no label trigger configured")`, null. 4. Current labels = `payload.issue?.labels` names; keep configured labels that are present; none → drop `configured label not present`. 5. `api.listLabelEvents(...)`: 404 → drop `` `agent has no access to ${row.repo}` `` (same as `detect`); other failure → `fail(..., "forgejo", null)`; then `labelsAddedByLatestChange(events)`; first configured+present label in that list with `labelTriggerFor(config, l) === "issue_handler"` → return it; none → drop `configured label was not added by this change`. - In `gate`, before the existing reason selection: `const labeled = row.event === "issue_label";`. Add a branch `else if (labeled) { const label = await this.resolveLabelTrigger(job, payload); if (label === null) return null; reason = "issue-labeled"; }` placed before the `detect` fallback, and keep `label` for the returned `Ready`. Everything after (sender check, permission check, workspace matching) runs unchanged; `author` is read from `payload.issue?.user?.login` as for comments. - In `runReady`, add a seed branch `else if (reason === "issue-labeled") seed = buildIssueHandlerSeed({ repo: row.repo, number: row.number, label: ready.label!, payload });` before the conversation fallback. `resolveSession` needs no change: a non-pull row already maps to a `forgejo_conversation` session (created or reused via `forgejo_sessions`). - Extend the `Payload` type with `IssueLabeledPayloadLike` if needed for typing. #### 6. Conversation policy wording In `apps/server/src/bridge/session-policy.ts` `forgejoConversationPolicy.systemContext`, change ``You were started by a comment on`` to ``You were started by a comment or a label change on`` so the orientation is true for issue-handler sessions. No tool changes. ### Critical files & anchors - `apps/server/src/forgejo/webhook.ts` — `classifyForgejoDelivery` (≈L89–147); `issues` branch currently accepts only `closed`/`reopened`. - `apps/server/src/forgejo/dispatcher.ts` — `gate` (≈L279–390) reason selection; `runReady` seed selection (≈L403–413); `failureLabel` (≈L803). - `apps/server/src/forgejo/dispatch-api.ts` — `DispatchApi` interface and `createDispatchApi`. - `apps/server/src/forgejo/dispatcher.test.ts` — `FakeApi` and `harness()` (≈L193–321) to extend. ### Verification Run from `apps/server`: `bun test src/forgejo` then `bun 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]` → error `top level must be a mapping`; `issue_handler: x` → mapping error; `issue_handler: {label: ""}` → label error; `: : :` (bad YAML) → error starting `invalid YAML`. `labelTriggerFor` exact match vs `Needs-Triage` → null. - `src/forgejo/webhook.test.ts`: `issue_label`/`label_updated` → accept with `event: "issue_label"`, `isPull: false`, `commentAuthor` = sender; Event `issues` + `label_updated` → same accept; `label_cleared` → ignore; sender = agent → ignore `self-authored`; `pull_request_label` → ignore `unsupported event`. - `src/forgejo/conversation-detect.test.ts`: `labelsAddedByLatestChange` returns only adds in the newest second (older add of `needs-triage`, newer add of `bug` → `["bug"]`; same-second add of both → both; newest batch only removals → `[]`). - `src/forgejo/dispatcher.test.ts`, new `describe("ForgejoDispatcher issue labels")` with an `issue_label` row whose payload has `issue.labels` `[{name:"needs-triage"}]`: - config `issue_handler: {label: needs-triage}` and latest timeline batch adding `needs-triage` → one session created with trigger kind `forgejo_conversation`, prompt contains `added the label "needs-triage"`, row `done` with reason `issue-labeled`. - latest batch adds `bug` only (needs-triage added earlier) → row `dropped` `configured label was not added by this change`, no session. - `readRepoFile` → null → dropped `no label trigger configured`. - invalid YAML → row `failed`, a posted comment contains ``.omp/forgejo.yaml` is invalid``. - permission `read` → dropped `author lacks write access (read)`. - existing mapped conversation session for the issue → reused (no second session). 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.yaml` with `issue_handler:\n label: needs-triage` to the default branch; add `needs-triage` to an open issue as a write-access user. Expected: `GET http://127.0.0.1:8787/api/forgejo/deliveries` shows an `issue_label` delivery `accepted`, and the agent posts one analysis comment on the issue. Then add a different label to the same issue: delivery accepted, trigger dropped with `configured label was not added by this change`, no new comment. ### Assumptions & contingencies - If a live `issue_label` delivery shows the action under a different name than `label_updated`, accept that name in `classifyIssueLabel` instead and update the doc comment. - If the timeline API omits label entries (e.g. `type` named differently on the instance), match on the observed `type` string; if label entries are absent entirely, stop and report on the issue rather than falling back to firing on every label change.
lucas 2026-10-26 15:38:07 +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#78
No description provided.