use labels in triage #232

Closed
opened 2026-11-08 02:12:10 +01:00 by lucas · 0 comments
Owner

Problem

Implement tools to query and set labels. Give the issue handler access so that triage can set labels. Explicitly deny all labels set in omp config, so the agent cannot trigger itself.

Most of this landed after the issue was filed, in 7c59d3c feat(forgejo): let conversation sessions relabel their own issue (2026-11-09):

  • forgejo_update_issue_labels exists (apps/server/src/forgejo/tools/issues.ts:208-262): add/remove by name, resolved against repo + org labels, applied as one replace-all PUT.
  • Triage already has it. Triage is a forgejo_conversation trigger with issueHandler: true (dispatcher.ts:1862-1892), and the tool is in CONVERSATION_TOOL_NAMES (session-policy.ts:188), restricted to the session's own issue number (:337-342).
  • The self-trigger denial exists: protectedLabelDenial (session-policy.ts:266-315) re-reads .omp/forgejo.yaml live and denies issue_handler.label, implementation.{intake,progress,planning,planned}_label, review.label, ci_watchdog.others_prs.label, both exclude_labels lists and Status/Blocked, matching names through labelKey (trimmed, lowercased, -_ folded to a space). When the config cannot be read it falls back to Status/Blocked plus the agent/ namespace.

What is still missing or wrong:

  1. No query tool. There is no way for an agent to see the repository's labels. The only listing is the internal helper fetchAvailableLabels (tools/labels.ts:124-141), which drops every field except id and name. An agent learns valid names only by making a failing forgejo_update_issue_labels call and reading the error, and has no way at all to learn which labels are off-limits before being denied.
  2. The default triage turn forbids labeling. buildIssueHandlerSeed (conversation-detect.ts:764-767) ends its built-in branch with "Do not change labels or anything else; do not implement.", while the same session's system context (session-policy.ts:359) says it can label. Only a repo that sets a custom issue_handler.prompt gets labeling behaviour; the deck's own .omp/forgejo.yaml:1-8 leaves prompt commented out.
  3. forgejo_create_issue escapes the rule. It takes labels by name (issues.ts:132) and both the conversation policy (session-policy.ts:333) and the pull-request policy (:457) allow it unconditionally, with no protected-label check.
  4. The pull-request policy offers a tool it always refuses. forgejoPullRequestPolicy.toolNames spreads CONVERSATION_TOOL_NAMES (:443), so PR sessions see forgejo_update_issue_labels, but decide has no rule for it: it falls through to "default", and trigger sessions turn every ask into an unattended deny (permission-filter.ts:14). Their system context meanwhile states "You cannot change labels" (:496).

Note on the threat model: classifyLabel ignores any label event whose sender.login is the agent's own account (webhook.ts:449), and resolveLabelTrigger requires the configured label to be both present and just added (dispatcher.ts:950-962). The agent therefore cannot re-trigger itself through labels today. The denial is defence in depth and protection of the dispatcher's own bookkeeping labels (agent/in progress, agent/planned, Status/Blocked); that is why removals stay denied too even though a removal can never fire a trigger.

Plan

Context

Add a read-only forgejo_list_labels tool that shows the repository's label vocabulary and marks the labels that steer automation; make labeling part of the default triage turn; close the forgejo_create_issue hole in the protected-label rule; and remove the label tool from the pull-request policy's tool list. No new config keys. No web client work: ForgejoTool.tsx already renders unknown Forgejo tools with the generic result view and summarizeForgejoArgs falls through for a parameterless tool, and read tools like forgejo_search_issues carry no protocol details payload, so packages/protocol/src/forgejo-tools.ts is untouched.

Step 1 — one shared definition of "automation label"

The protected-name derivation is inline in protectedLabelDenial today; the new tool must mark exactly the same names, so it moves into the module that owns the config.

In apps/server/src/forgejo/repo-config.ts, next to labelKey / sameLabel (around line 426-434), add:

/** Namespace of the deck's own workflow labels, protected when the repository config cannot be read. */
export const AGENT_LABEL_PREFIX = "agent/";

/** Every label name this repository's automation keys on, including `Status/Blocked`. */
export function automationLabelNames(config: RepoConfig): string[] {
	const { issueHandler, implementation, review, ciWatchdogOthersPrs } = config;
	return [
		BLOCKED_LABEL,
		issueHandler?.label,
		implementation.intakeLabel,
		implementation.progressLabel,
		implementation.plannedLabel,
		implementation.planningLabel,
		review?.label,
		ciWatchdogOthersPrs?.label,
		...(review?.excludeLabels ?? []),
		...(ciWatchdogOthersPrs?.excludeLabels ?? []),
	].filter((n): n is string => typeof n === "string");
}

/**
 * Whether `name` steers the repository's automation. A null `config` means the
 * config could not be read or parsed: then `Status/Blocked` and the `agent/`
 * namespace stay protected and everything else is treated as free.
 */
export function isAutomationLabel(name: string, config: RepoConfig | null): boolean {
	if (config === null) return sameLabel(name, BLOCKED_LABEL) || labelKey(name).startsWith(AGENT_LABEL_PREFIX);
	return automationLabelNames(config).some((protectedName) => sameLabel(name, protectedName));
}

Then rewrite protectedLabelDenial (apps/server/src/bridge/session-policy.ts:266-315) to use it, and delete the local AGENT_LABEL_PREFIX constant at :255-257 and the inline protectedNames array. Two further changes in the same function, both load-bearing for later steps:

  • The touched-names extraction must also cover forgejo_create_issue, whose parameter is labels, not add/remove. Change the destructure to const p = (params as { add?: unknown; remove?: unknown; labels?: unknown } | null) ?? {}; and flatten [p.add, p.remove, p.labels].
  • Add a fifth parameter advice: string = "propose the change in your comment instead" and build the denial as `the label "${name}" steers this repository's automation; ${advice}`, so the existing message stays byte-identical for the update tool.

The parsed handling stays as it is: file?.ok ? parseRepoConfig(file.data) : null, a 404 yielding parseRepoConfig(null) and therefore the defaults, and the call site now passes parsed?.ok ? parsed.config : null to isAutomationLabel.

Independent of steps 2-6; do it first because both the tool and the policy change depend on it.

Step 2 — carry descriptions out of fetchAvailableLabels

apps/server/src/forgejo/tools/labels.ts currently narrows every label to { id, name } (:137), discarding the description the API returns (ApiLabel.description?: string | null, tools/api-types.ts:11-14), and loses whether the label came from the repo or the owning org.

Add, next to LabelRef (:12-15):

/** A label the repository can apply, with the fields `forgejo_list_labels` shows. */
export interface AvailableLabel extends LabelRef {
	/** Empty string when the label has no description. */
	description: string;
	/** Defined on the owning organisation rather than on the repository. */
	org: boolean;
}

Change fetchAvailableLabels to Promise<ApiResult<AvailableLabel[]>>: iterate the repo page and the org page separately instead of concatenating them, pushing { id: l.id, name: l.name, description: l.description ?? "", org }. Keep the existing seen de-duplication by id and the existing error handling verbatim (repo failure surfaced; org failure surfaced only for status === 0 or >= 500).

resolveLabelNames and planLabelDelta keep taking readonly LabelRef[]; AvailableLabel[] is assignable, so forgejo_create_issue (issues.ts:138-142) and forgejo_update_issue_labels (issues.ts:222-229) need no change.

Step 3 — read the repo config from a tool, and format the listing

Still in apps/server/src/forgejo/tools/labels.ts. Tools hold a ForgejoClient (shared.ts:30-36) but no DispatchApi; the raw-file read is three lines, exactly as dispatch-api.ts:505-508 does it, so no plumbing is added.

/**
 * The repository's `.omp/forgejo.yaml`, or null when it cannot be read or
 * parsed. A missing file is not a failure: it yields the defaults.
 */
export async function readRepoConfigForTools(
	ctx: Pick<ForgejoToolContext, "client" | "owner" | "repo">,
	signal?: AbortSignal,
): Promise<RepoConfig | null> {
	const res = await ctx.client.text(repoApiPath(ctx.owner, ctx.repo, "raw", encodePath(REPO_CONFIG_PATH)), {
		maxBytes: 64 * 1024,
		signal,
	});
	if (!res.ok) return res.status === 404 ? DEFAULT_REPO_CONFIG : null;
	// `client.text` yields `{ text, truncated }`, not `{ data }`; a truncated
	// config is not a config.
	if (res.truncated) return null;
	const parsed = parseRepoConfig(res.text);
	return parsed.ok ? parsed.config : null;
}

encodePath and repoApiPath are exported from ../client.ts (client.ts:176-182); REPO_CONFIG_PATH, DEFAULT_REPO_CONFIG, parseRepoConfig, isAutomationLabel and RepoConfig from ../repo-config.ts.

Add the pure formatter beside it, so the output is unit-testable without HTTP (the convention in tools/prs.test.ts, which tests buildReviewPayload rather than the tool):

/**
 * The text of `forgejo_list_labels`. `config` null means `.omp/forgejo.yaml`
 * could not be read, so the automation marks are the conservative fallback and
 * the output says so.
 */
export function formatLabelList(
	labels: readonly AvailableLabel[],
	opts: { owner: string; repo: string; config: RepoConfig | null },
): string;

Output, in the order fetchAvailableLabels returns (repo labels first, then org labels):

  • Header: `${labels.length} labels in ${owner}/${repo}:`, or no labels in ${owner}/${repo} when the list is empty (return that line alone).
  • One line per label: the name, then — <description> when the description is non-empty, then [org] when org, then [automation] when isAutomationLabel(name, config).
  • When at least one label is marked, a blank line and: [automation] labels steer this repository's automation; adding or removing them is refused in unattended sessions.
  • When config is null, a further line: .omp/forgejo.yaml could not be read, so the [automation] marks are a fallback: Status/Blocked and the agent/ namespace.

Step 4 — the forgejo_list_labels tool

Define it in apps/server/src/forgejo/tools/issues.ts among the read tools (above the // ─── Mutating tools ─── divider at :122), where forgejo_update_issue_labels already lives, and append it to ISSUE_TOOLS (:332-342). This keeps the registry at the three existing groups.

const listLabels = defineForgejoTool({
	name: "forgejo_list_labels",
	label: "Forgejo List Labels",
	description:
		`Forgejo: list the labels that can be applied in ${REPO_SCOPE}: the repository's own labels plus the owning organisation's, with their descriptions. Labels that steer the repository's automation are marked \`[automation]\`; setting those is refused in unattended sessions. Use before \`forgejo_update_issue_labels\` or \`forgejo_create_issue\` to pick valid names.`,
	mutating: false,
	parameters: z.object({}),
	async run(ctx, _p, signal) {
		const [available, config] = await Promise.all([fetchAvailableLabels(ctx, signal), readRepoConfigForTools(ctx, signal)]);
		if (!available.ok) return apiError(available);
		return textResult(
			withCap(formatLabelList(available.data, { owner: ctx.owner, repo: ctx.repo, config }), "Too many labels to show in full."),
		);
	},
});

No details payload, matching the other read tools. Update the registry header comment in tools/index.ts:1-8, which says "the 23 tools" while index.test.ts:80-82 already asserts 24; it becomes 25.

Step 5 — session tool lists

All in apps/server/src/bridge/session-policy.ts:

  • Add "forgejo_list_labels" to CONVERSATION_TOOL_NAMES (:160-189), in the Forgejo read block after "forgejo_get_run_logs" (:183). CONVERSATION_NO_CHECKOUT_TOOL_NAMES (:191) and WATCHDOG_TOOL_NAMES (:367-372) are derived by filter and pick it up automatically.
  • Add "forgejo_list_labels" to IMPLEMENTER_TOOL_NAMES (:196-237), after "forgejo_get_run_logs" (:229).
  • Change forgejoPullRequestPolicy.toolNames (:443) to drop the label tool it never allows:
    toolNames: [
    	...CONVERSATION_TOOL_NAMES.filter((n) => n !== "forgejo_update_issue_labels"),
    	"forgejo_post_review",
    	...OTHERS_PR_WRITE_TOOLS,
    ],
    

Idea mode needs no change: decideIdeaModeForgejo gates Forgejo write tools, and the new tool is mutating: false, so FORGEJO_MUTATING_TOOLS (tools/index.ts:34-36) and defaultAsk (permission-filter.ts:58-61) leave it alone — it never prompts in attended sessions either.

Step 6 — protected labels on forgejo_create_issue, and the prompts

In forgejoConversationPolicy.decide (session-policy.ts:333), replace the unconditional allow:

if (toolName === "forgejo_create_issue") {
	return (await protectedLabelDenial(scope, params, api, signal, "create the issue without it")) ?? "allow";
}

Do the same at forgejoPullRequestPolicy.decide:457. That policy's decide is currently synchronous and does not receive api / signal; widen its signature to async decide(toolName, params, scope, sessionId, cwd, api, signal), which the SessionPolicy interface already permits (:116-124, returning PolicyDecision | Promise<PolicyDecision>). Every other branch of that function stays as it is.

Prompt text, same file:

  • Conversation bullet (:359) becomes:
    `- You can add and remove labels on #${number} with \`forgejo_update_issue_labels\`, but not on any other issue or pull request. \`forgejo_list_labels\` shows the repository's labels and marks the ones that steer its automation (its issue-handler, implementation, review and CI-watchdog labels, and \`Status/Blocked\`); those are refused, whether you set them here or on a new issue with \`forgejo_create_issue\`.`
    
  • Pull-request bullet (:496) keeps "You cannot change labels, state or descriptions." and gains, after the forgejo_create_issue sentence: `\`forgejo_list_labels\` shows the labels you can put on a new issue; the ones it marks \`[automation]\` are refused.`

Step 7 — labeling becomes part of the default triage turn

In apps/server/src/forgejo/conversation-detect.ts, the prompt === null branch of buildIssueHandlerSeed (:764-767) becomes exactly:

`Task: analyze this issue. Search for similar or duplicate issues with forgejo_search_issues, list the repository's labels with forgejo_list_labels and apply the ones that fit with forgejo_update_issue_labels, then post one comment on #${t.number} with your analysis: related issues (linked as #N), clarifying questions, and initial feedback. Do not implement.`

The custom-prompt branch (:768-771) is unchanged: a repository that sets issue_handler.prompt owns its own steering.

Critical files & anchors

  • apps/server/src/bridge/session-policy.ts — protectedLabelDenial (~266), CONVERSATION_TOOL_NAMES (~160), forgejoConversationPolicy.decide (~320), forgejoPullRequestPolicy (~442). Every policy change lands here.
  • apps/server/src/forgejo/tools/labels.ts — fetchAvailableLabels (~124) and the new readRepoConfigForTools / formatLabelList.
  • apps/server/src/forgejo/repo-config.ts — labelKey / sameLabel (~426), BLOCKED_LABEL (43), REPO_CONFIG_PATH (40); the new shared helpers go here so the tool and the policy cannot drift.
  • apps/server/src/forgejo/conversation-detect.ts — buildIssueHandlerSeed (742-778); its two branches are pinned by conversation-detect.test.ts:527-546.
  • apps/server/src/forgejo/tools/issues.ts — read tools end at the divider (~122), ISSUE_TOOLS (332-342).

Verification

From the repository root.

  1. bun run typecheck — the fetchAvailableLabels return-type change and the PR policy's now-async decide must both compile.
  2. New behaviour, formatLabelList, in apps/server/src/forgejo/tools/labels.test.ts:
    • Input [{id:1,name:"kind/bug",description:"Something is broken",org:false},{id:2,name:"agent/ready",description:"",org:false},{id:3,name:"area/ui",description:"",org:true}] with a config whose implementation.intake_label is Agent/Ready. Expected: header 3 labels in o/r:, then kind/bug — Something is broken, agent/ready [automation], area/ui [org], then the [automation] explanation line and no fallback line.
    • Same input with config: null. Expected: agent/ready still marked (prefix fallback), kind/bug and area/ui unmarked, and the .omp/forgejo.yaml could not be read line present.
    • Empty input returns the single no labels in o/r line.
  3. New behaviour, policy, in apps/server/src/bridge/session-policy.test.ts (extend the existing label describe at :61-99, reusing its configApi helper from :43-49):
    • decidePolicy(TRIGGER, "forgejo_create_issue", { title: "x", labels: ["Agent/Ready"] }, …, configApi("issue_handler:\n label: needs-triage\nimplementation:\n intake_label: Agent/Ready\n")) → { deny: 'the label "Agent/Ready" steers this repository\'s automation; create the issue without it' }.
    • The same call with labels: ["kind/bug"], and one with no labels at all → "allow".
    • policyToolNames({ kind: "forgejo_pull_request", … }) contains forgejo_list_labels and does not contain forgejo_update_issue_labels; policyToolNames(TRIGGER) and the implementer trigger both contain forgejo_list_labels.
    • The existing expectations at :69-97 must still pass unchanged — that is the regression check on the refactor into isAutomationLabel.
  4. bun test apps/server/src/forgejo/tools/index.test.ts — update the inventory test at :80-85 to 25 tools and add list_labels to the read list and to the self-identifying bare list at :90-95.
  5. bun test apps/server/src/forgejo/conversation-detect.test.ts — update the default-branch assertion at :527-546 to the new seed text; the custom-prompt assertion must be untouched.
  6. bun test apps/server/src/bridge apps/server/src/forgejo for the surrounding suites (permission-filter.test.ts, repo-config.test.ts, dispatcher.test.ts, plan-issue.test.ts).

Assumptions & contingencies

  • Forgejo's GET /repos/{owner}/{repo}/labels returns description for repo labels; ApiLabel already declares it optional and the formatter treats a missing description as empty, so nothing breaks if an instance omits it.
  • The [automation] marks are advisory in attended deck sessions, where no policy denies anything; the wording ("refused in unattended sessions") is written to stay true in both cases.
  • issue_handler.prompt repositories keep their current behaviour by design; if a repo wants the new labeling steering it drops its custom prompt.
## Problem Implement tools to query and set labels. Give the issue handler access so that triage can set labels. Explicitly deny all labels set in omp config, so the agent cannot trigger itself. Most of this landed after the issue was filed, in `7c59d3c feat(forgejo): let conversation sessions relabel their own issue` (2026-11-09): - `forgejo_update_issue_labels` exists (`apps/server/src/forgejo/tools/issues.ts:208-262`): add/remove by name, resolved against repo + org labels, applied as one replace-all `PUT`. - Triage already has it. Triage is a `forgejo_conversation` trigger with `issueHandler: true` (`dispatcher.ts:1862-1892`), and the tool is in `CONVERSATION_TOOL_NAMES` (`session-policy.ts:188`), restricted to the session's own issue number (`:337-342`). - The self-trigger denial exists: `protectedLabelDenial` (`session-policy.ts:266-315`) re-reads `.omp/forgejo.yaml` live and denies `issue_handler.label`, `implementation.{intake,progress,planning,planned}_label`, `review.label`, `ci_watchdog.others_prs.label`, both `exclude_labels` lists and `Status/Blocked`, matching names through `labelKey` (trimmed, lowercased, `-_ ` folded to a space). When the config cannot be read it falls back to `Status/Blocked` plus the `agent/` namespace. What is still missing or wrong: 1. **No query tool.** There is no way for an agent to see the repository's labels. The only listing is the internal helper `fetchAvailableLabels` (`tools/labels.ts:124-141`), which drops every field except `id` and `name`. An agent learns valid names only by making a failing `forgejo_update_issue_labels` call and reading the error, and has no way at all to learn which labels are off-limits before being denied. 2. **The default triage turn forbids labeling.** `buildIssueHandlerSeed` (`conversation-detect.ts:764-767`) ends its built-in branch with "Do not change labels or anything else; do not implement.", while the same session's system context (`session-policy.ts:359`) says it can label. Only a repo that sets a custom `issue_handler.prompt` gets labeling behaviour; the deck's own `.omp/forgejo.yaml:1-8` leaves `prompt` commented out. 3. **`forgejo_create_issue` escapes the rule.** It takes `labels` by name (`issues.ts:132`) and both the conversation policy (`session-policy.ts:333`) and the pull-request policy (`:457`) allow it unconditionally, with no protected-label check. 4. **The pull-request policy offers a tool it always refuses.** `forgejoPullRequestPolicy.toolNames` spreads `CONVERSATION_TOOL_NAMES` (`:443`), so PR sessions see `forgejo_update_issue_labels`, but `decide` has no rule for it: it falls through to `"default"`, and trigger sessions turn every ask into an unattended deny (`permission-filter.ts:14`). Their system context meanwhile states "You cannot change labels" (`:496`). Note on the threat model: `classifyLabel` ignores any label event whose `sender.login` is the agent's own account (`webhook.ts:449`), and `resolveLabelTrigger` requires the configured label to be both present and just added (`dispatcher.ts:950-962`). The agent therefore cannot re-trigger itself through labels today. The denial is defence in depth and protection of the dispatcher's own bookkeeping labels (`agent/in progress`, `agent/planned`, `Status/Blocked`); that is why removals stay denied too even though a removal can never fire a trigger. ## Plan ### Context Add a read-only `forgejo_list_labels` tool that shows the repository's label vocabulary and marks the labels that steer automation; make labeling part of the default triage turn; close the `forgejo_create_issue` hole in the protected-label rule; and remove the label tool from the pull-request policy's tool list. No new config keys. No web client work: `ForgejoTool.tsx` already renders unknown Forgejo tools with the generic result view and `summarizeForgejoArgs` falls through for a parameterless tool, and read tools like `forgejo_search_issues` carry no protocol `details` payload, so `packages/protocol/src/forgejo-tools.ts` is untouched. ### Step 1 — one shared definition of "automation label" The protected-name derivation is inline in `protectedLabelDenial` today; the new tool must mark exactly the same names, so it moves into the module that owns the config. In `apps/server/src/forgejo/repo-config.ts`, next to `labelKey` / `sameLabel` (around line 426-434), add: ```ts /** Namespace of the deck's own workflow labels, protected when the repository config cannot be read. */ export const AGENT_LABEL_PREFIX = "agent/"; /** Every label name this repository's automation keys on, including `Status/Blocked`. */ export function automationLabelNames(config: RepoConfig): string[] { const { issueHandler, implementation, review, ciWatchdogOthersPrs } = config; return [ BLOCKED_LABEL, issueHandler?.label, implementation.intakeLabel, implementation.progressLabel, implementation.plannedLabel, implementation.planningLabel, review?.label, ciWatchdogOthersPrs?.label, ...(review?.excludeLabels ?? []), ...(ciWatchdogOthersPrs?.excludeLabels ?? []), ].filter((n): n is string => typeof n === "string"); } /** * Whether `name` steers the repository's automation. A null `config` means the * config could not be read or parsed: then `Status/Blocked` and the `agent/` * namespace stay protected and everything else is treated as free. */ export function isAutomationLabel(name: string, config: RepoConfig | null): boolean { if (config === null) return sameLabel(name, BLOCKED_LABEL) || labelKey(name).startsWith(AGENT_LABEL_PREFIX); return automationLabelNames(config).some((protectedName) => sameLabel(name, protectedName)); } ``` Then rewrite `protectedLabelDenial` (`apps/server/src/bridge/session-policy.ts:266-315`) to use it, and delete the local `AGENT_LABEL_PREFIX` constant at `:255-257` and the inline `protectedNames` array. Two further changes in the same function, both load-bearing for later steps: - The touched-names extraction must also cover `forgejo_create_issue`, whose parameter is `labels`, not `add`/`remove`. Change the destructure to `const p = (params as { add?: unknown; remove?: unknown; labels?: unknown } | null) ?? {};` and flatten `[p.add, p.remove, p.labels]`. - Add a fifth parameter `advice: string = "propose the change in your comment instead"` and build the denial as `` `the label "${name}" steers this repository's automation; ${advice}` ``, so the existing message stays byte-identical for the update tool. The `parsed` handling stays as it is: `file?.ok ? parseRepoConfig(file.data) : null`, a 404 yielding `parseRepoConfig(null)` and therefore the defaults, and the call site now passes `parsed?.ok ? parsed.config : null` to `isAutomationLabel`. Independent of steps 2-6; do it first because both the tool and the policy change depend on it. ### Step 2 — carry descriptions out of `fetchAvailableLabels` `apps/server/src/forgejo/tools/labels.ts` currently narrows every label to `{ id, name }` (`:137`), discarding the `description` the API returns (`ApiLabel.description?: string | null`, `tools/api-types.ts:11-14`), and loses whether the label came from the repo or the owning org. Add, next to `LabelRef` (`:12-15`): ```ts /** A label the repository can apply, with the fields `forgejo_list_labels` shows. */ export interface AvailableLabel extends LabelRef { /** Empty string when the label has no description. */ description: string; /** Defined on the owning organisation rather than on the repository. */ org: boolean; } ``` Change `fetchAvailableLabels` to `Promise<ApiResult<AvailableLabel[]>>`: iterate the repo page and the org page separately instead of concatenating them, pushing `{ id: l.id, name: l.name, description: l.description ?? "", org }`. Keep the existing `seen` de-duplication by id and the existing error handling verbatim (repo failure surfaced; org failure surfaced only for `status === 0` or `>= 500`). `resolveLabelNames` and `planLabelDelta` keep taking `readonly LabelRef[]`; `AvailableLabel[]` is assignable, so `forgejo_create_issue` (`issues.ts:138-142`) and `forgejo_update_issue_labels` (`issues.ts:222-229`) need no change. ### Step 3 — read the repo config from a tool, and format the listing Still in `apps/server/src/forgejo/tools/labels.ts`. Tools hold a `ForgejoClient` (`shared.ts:30-36`) but no `DispatchApi`; the raw-file read is three lines, exactly as `dispatch-api.ts:505-508` does it, so no plumbing is added. ```ts /** * The repository's `.omp/forgejo.yaml`, or null when it cannot be read or * parsed. A missing file is not a failure: it yields the defaults. */ export async function readRepoConfigForTools( ctx: Pick<ForgejoToolContext, "client" | "owner" | "repo">, signal?: AbortSignal, ): Promise<RepoConfig | null> { const res = await ctx.client.text(repoApiPath(ctx.owner, ctx.repo, "raw", encodePath(REPO_CONFIG_PATH)), { maxBytes: 64 * 1024, signal, }); if (!res.ok) return res.status === 404 ? DEFAULT_REPO_CONFIG : null; // `client.text` yields `{ text, truncated }`, not `{ data }`; a truncated // config is not a config. if (res.truncated) return null; const parsed = parseRepoConfig(res.text); return parsed.ok ? parsed.config : null; } ``` `encodePath` and `repoApiPath` are exported from `../client.ts` (`client.ts:176-182`); `REPO_CONFIG_PATH`, `DEFAULT_REPO_CONFIG`, `parseRepoConfig`, `isAutomationLabel` and `RepoConfig` from `../repo-config.ts`. Add the pure formatter beside it, so the output is unit-testable without HTTP (the convention in `tools/prs.test.ts`, which tests `buildReviewPayload` rather than the tool): ```ts /** * The text of `forgejo_list_labels`. `config` null means `.omp/forgejo.yaml` * could not be read, so the automation marks are the conservative fallback and * the output says so. */ export function formatLabelList( labels: readonly AvailableLabel[], opts: { owner: string; repo: string; config: RepoConfig | null }, ): string; ``` Output, in the order `fetchAvailableLabels` returns (repo labels first, then org labels): - Header: `` `${labels.length} labels in ${owner}/${repo}:` ``, or `no labels in ${owner}/${repo}` when the list is empty (return that line alone). - One line per label: the name, then ` — <description>` when the description is non-empty, then ` [org]` when `org`, then ` [automation]` when `isAutomationLabel(name, config)`. - When at least one label is marked, a blank line and: `[automation] labels steer this repository's automation; adding or removing them is refused in unattended sessions.` - When `config` is null, a further line: `.omp/forgejo.yaml could not be read, so the [automation] marks are a fallback: Status/Blocked and the agent/ namespace.` ### Step 4 — the `forgejo_list_labels` tool Define it in `apps/server/src/forgejo/tools/issues.ts` among the read tools (above the `// ─── Mutating tools ───` divider at `:122`), where `forgejo_update_issue_labels` already lives, and append it to `ISSUE_TOOLS` (`:332-342`). This keeps the registry at the three existing groups. ```ts const listLabels = defineForgejoTool({ name: "forgejo_list_labels", label: "Forgejo List Labels", description: `Forgejo: list the labels that can be applied in ${REPO_SCOPE}: the repository's own labels plus the owning organisation's, with their descriptions. Labels that steer the repository's automation are marked \`[automation]\`; setting those is refused in unattended sessions. Use before \`forgejo_update_issue_labels\` or \`forgejo_create_issue\` to pick valid names.`, mutating: false, parameters: z.object({}), async run(ctx, _p, signal) { const [available, config] = await Promise.all([fetchAvailableLabels(ctx, signal), readRepoConfigForTools(ctx, signal)]); if (!available.ok) return apiError(available); return textResult( withCap(formatLabelList(available.data, { owner: ctx.owner, repo: ctx.repo, config }), "Too many labels to show in full."), ); }, }); ``` No `details` payload, matching the other read tools. Update the registry header comment in `tools/index.ts:1-8`, which says "the 23 tools" while `index.test.ts:80-82` already asserts 24; it becomes 25. ### Step 5 — session tool lists All in `apps/server/src/bridge/session-policy.ts`: - Add `"forgejo_list_labels"` to `CONVERSATION_TOOL_NAMES` (`:160-189`), in the Forgejo read block after `"forgejo_get_run_logs"` (`:183`). `CONVERSATION_NO_CHECKOUT_TOOL_NAMES` (`:191`) and `WATCHDOG_TOOL_NAMES` (`:367-372`) are derived by filter and pick it up automatically. - Add `"forgejo_list_labels"` to `IMPLEMENTER_TOOL_NAMES` (`:196-237`), after `"forgejo_get_run_logs"` (`:229`). - Change `forgejoPullRequestPolicy.toolNames` (`:443`) to drop the label tool it never allows: ```ts toolNames: [ ...CONVERSATION_TOOL_NAMES.filter((n) => n !== "forgejo_update_issue_labels"), "forgejo_post_review", ...OTHERS_PR_WRITE_TOOLS, ], ``` Idea mode needs no change: `decideIdeaModeForgejo` gates Forgejo *write* tools, and the new tool is `mutating: false`, so `FORGEJO_MUTATING_TOOLS` (`tools/index.ts:34-36`) and `defaultAsk` (`permission-filter.ts:58-61`) leave it alone — it never prompts in attended sessions either. ### Step 6 — protected labels on `forgejo_create_issue`, and the prompts In `forgejoConversationPolicy.decide` (`session-policy.ts:333`), replace the unconditional allow: ```ts if (toolName === "forgejo_create_issue") { return (await protectedLabelDenial(scope, params, api, signal, "create the issue without it")) ?? "allow"; } ``` Do the same at `forgejoPullRequestPolicy.decide:457`. That policy's `decide` is currently synchronous and does not receive `api` / `signal`; widen its signature to `async decide(toolName, params, scope, sessionId, cwd, api, signal)`, which the `SessionPolicy` interface already permits (`:116-124`, returning `PolicyDecision | Promise<PolicyDecision>`). Every other branch of that function stays as it is. Prompt text, same file: - Conversation bullet (`:359`) becomes: ``` `- You can add and remove labels on #${number} with \`forgejo_update_issue_labels\`, but not on any other issue or pull request. \`forgejo_list_labels\` shows the repository's labels and marks the ones that steer its automation (its issue-handler, implementation, review and CI-watchdog labels, and \`Status/Blocked\`); those are refused, whether you set them here or on a new issue with \`forgejo_create_issue\`.` ``` - Pull-request bullet (`:496`) keeps "You cannot change labels, state or descriptions." and gains, after the `forgejo_create_issue` sentence: `` `\`forgejo_list_labels\` shows the labels you can put on a new issue; the ones it marks \`[automation]\` are refused.` `` ### Step 7 — labeling becomes part of the default triage turn In `apps/server/src/forgejo/conversation-detect.ts`, the `prompt === null` branch of `buildIssueHandlerSeed` (`:764-767`) becomes exactly: ```ts `Task: analyze this issue. Search for similar or duplicate issues with forgejo_search_issues, list the repository's labels with forgejo_list_labels and apply the ones that fit with forgejo_update_issue_labels, then post one comment on #${t.number} with your analysis: related issues (linked as #N), clarifying questions, and initial feedback. Do not implement.` ``` The custom-prompt branch (`:768-771`) is unchanged: a repository that sets `issue_handler.prompt` owns its own steering. ### Critical files & anchors - `apps/server/src/bridge/session-policy.ts` — `protectedLabelDenial` (~266), `CONVERSATION_TOOL_NAMES` (~160), `forgejoConversationPolicy.decide` (~320), `forgejoPullRequestPolicy` (~442). Every policy change lands here. - `apps/server/src/forgejo/tools/labels.ts` — `fetchAvailableLabels` (~124) and the new `readRepoConfigForTools` / `formatLabelList`. - `apps/server/src/forgejo/repo-config.ts` — `labelKey` / `sameLabel` (~426), `BLOCKED_LABEL` (43), `REPO_CONFIG_PATH` (40); the new shared helpers go here so the tool and the policy cannot drift. - `apps/server/src/forgejo/conversation-detect.ts` — `buildIssueHandlerSeed` (742-778); its two branches are pinned by `conversation-detect.test.ts:527-546`. - `apps/server/src/forgejo/tools/issues.ts` — read tools end at the divider (~122), `ISSUE_TOOLS` (332-342). ### Verification From the repository root. 1. `bun run typecheck` — the `fetchAvailableLabels` return-type change and the PR policy's now-async `decide` must both compile. 2. New behaviour, `formatLabelList`, in `apps/server/src/forgejo/tools/labels.test.ts`: - Input `[{id:1,name:"kind/bug",description:"Something is broken",org:false},{id:2,name:"agent/ready",description:"",org:false},{id:3,name:"area/ui",description:"",org:true}]` with a config whose `implementation.intake_label` is `Agent/Ready`. Expected: header `3 labels in o/r:`, then `kind/bug — Something is broken`, `agent/ready [automation]`, `area/ui [org]`, then the `[automation]` explanation line and no fallback line. - Same input with `config: null`. Expected: `agent/ready` still marked (prefix fallback), `kind/bug` and `area/ui` unmarked, and the `.omp/forgejo.yaml could not be read` line present. - Empty input returns the single `no labels in o/r` line. 3. New behaviour, policy, in `apps/server/src/bridge/session-policy.test.ts` (extend the existing label describe at `:61-99`, reusing its `configApi` helper from `:43-49`): - `decidePolicy(TRIGGER, "forgejo_create_issue", { title: "x", labels: ["Agent/Ready"] }, …, configApi("issue_handler:\n label: needs-triage\nimplementation:\n intake_label: Agent/Ready\n"))` → `{ deny: 'the label "Agent/Ready" steers this repository\'s automation; create the issue without it' }`. - The same call with `labels: ["kind/bug"]`, and one with no `labels` at all → `"allow"`. - `policyToolNames({ kind: "forgejo_pull_request", … })` contains `forgejo_list_labels` and does **not** contain `forgejo_update_issue_labels`; `policyToolNames(TRIGGER)` and the implementer trigger both contain `forgejo_list_labels`. - The existing expectations at `:69-97` must still pass unchanged — that is the regression check on the refactor into `isAutomationLabel`. 4. `bun test apps/server/src/forgejo/tools/index.test.ts` — update the inventory test at `:80-85` to 25 tools and add `list_labels` to the read list and to the self-identifying `bare` list at `:90-95`. 5. `bun test apps/server/src/forgejo/conversation-detect.test.ts` — update the default-branch assertion at `:527-546` to the new seed text; the custom-prompt assertion must be untouched. 6. `bun test apps/server/src/bridge apps/server/src/forgejo` for the surrounding suites (`permission-filter.test.ts`, `repo-config.test.ts`, `dispatcher.test.ts`, `plan-issue.test.ts`). ### Assumptions & contingencies - Forgejo's `GET /repos/{owner}/{repo}/labels` returns `description` for repo labels; `ApiLabel` already declares it optional and the formatter treats a missing description as empty, so nothing breaks if an instance omits it. - The `[automation]` marks are advisory in attended deck sessions, where no policy denies anything; the wording ("refused in unattended sessions") is written to stay true in both cases. - `issue_handler.prompt` repositories keep their current behaviour by design; if a repo wants the new labeling steering it drops its custom prompt.
lucas closed this issue 2026-11-11 11:39:38 +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#232
No description provided.