Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/637-mention-trigger.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A comment that mentions the bot (`@fledge` by default; `FLEDGE_MENTION_NAMES` changes the names) now starts work: a mention on a pull request starts a review of that pull request at its head, and a mention on an issue starts a follow-up for the issue that may open one branch and one pull request. A second mention at the same head does not create a second job, a newer mention replaces a queued job that has not started, and a running job is never interrupted — it finishes first. The trigger uses the same trusted-author filter and per-comment dedupe as the `/fledge` commands, and records the comment, the head SHA, the actor and the outcome.
42 changes: 39 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1448,9 +1448,15 @@ maintainer can see what the agent was told. The bridge prompt tells the agent to
push to the same branch and update the existing PR, never open a second PR. The
follow-up is deduped by delivery id and by repo + PR + head SHA + a fingerprint
of the reason with that instruction, and is skipped while the repository has an
active job; requests are parked per fingerprint, so two different reviews on one
commit are two follow-ups rather than one overwritten entry. A follow-up does not
receive a second automation comment; the job list labels it "Follow-up".
active job. When the running job is a review of the same pull request and head,
updates merge into ONE parked request — one per pull request and head — and each
contributes its own trigger (kind, comment id, actor); the release pass then
queues one job whose `Reviewer asked:` line names every request, so two updates
that arrive before the agent's first model call become one job, not two. A
running review is never cancelled: an update that arrives after that point is not
injected into its current turn, it waits in the same merged request and continues
the session in the next task. A follow-up does not receive a second automation
comment; the job list labels it "Follow-up".

### Default-branch failure intake

Expand Down Expand Up @@ -1539,6 +1545,36 @@ manufacture work. Each action reuses the existing audit strings (`job.retried`,
`job.paused`, `job.resumed`, `job.cancelled`) and their timeline kinds rather
than adding a second naming scheme.

### Mention triggers

`@fledge` in a comment is a second trigger on the same surface, not a second
router. `parseMentionTrigger` sits beside `parseIssueCommand`; it takes the names
from `FLEDGE_MENTION_NAMES` (default `fledge`), matches case-insensitively at a
word boundary, and reuses the command path's trusted set
(`OWNER`/`MEMBER`/`COLLABORATOR`), its created-only rule and its claim keyed by
the comment id, so a replayed delivery starts nothing. The bot's own comment is
never a trigger, and neither is an edited, deleted or closed one.

The kind of job follows the target's kind. A mention on a pull request starts a
`review` of that pull request at its head: the job reads the diff and comments
and never writes to the branch. A mention on an issue starts a follow-up for
that issue, which may cut one branch and open one pull request under the
one-issue-one-branch-one-PR rule. `Jobs.mention` decides, and the rule itself is
`src/lib/mentionGovernance.ts` so every case is a pure function.

A mention is identified by `(target kind, target number, head sha)`. The same
target at the same head never opens a second job: while one is queued or running
the mention is recorded as a `job.mention_recorded` event on that job. A newer
mention supersedes a **queued** job — the queued row ends `superseded`, and one
`trigger.mention_superseded` row names both — because that job has not run. A
**running** job is never interrupted: the mention is recorded on it and parks in
`pending_mention:<repo>`, and the newest parked mention starts when the slot
frees. A draft pull request or a closed target writes `trigger.mention_ignored`;
a target the installation cannot read writes one `trigger.mention_refused` row
and starts nothing. Every accepted trigger writes `trigger.mention` with the
comment id, the head SHA, the actor and the outcome (`started`, `recorded`,
`parked`), and answers in the thread.

The inbox is also available as `GET /api/inbox/events`, a per-user SSE stream.
The server reuses the cached inbox snapshot, sends the server-rendered inbox HTML
when the question signature changes, and the browser replaces the section and
Expand Down
12 changes: 7 additions & 5 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,13 @@ and the bridge success rate. The GitHub 429 rate and the open-alert count read a
real `0` on the same window and keep printing it.

The job success rate counts only `succeeded` and `failed` jobs. The other terminal
states — `cancelled`, `paused` and `quota_exhausted` — are excluded from the
numerator and from the denominator alike, so the percentage is a rate over jobs
that ran to success or failure, not over every job Fledge accepted: a surge of
quota refusals or cancellations shrinks that population without moving the rate,
and the report does not show how many jobs it left out. The set itself is decided
states — `cancelled`, `paused`, `quota_exhausted` and `superseded` — are excluded
from the numerator and from the denominator alike, so the percentage is a rate
over jobs that ran to success or failure, not over every job Fledge accepted: a
surge of quota refusals, cancellations or superseded queued jobs shrinks that
population without moving the rate, and the report does not show how many jobs it
left out. A `superseded` job is one a newer trigger replaced before it started, so
it neither failed nor ran. The set itself is decided
in [decisions/0008](decisions/0008-slo-denominator.md).

## Traces
Expand Down
76 changes: 69 additions & 7 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { allowedRepoRoles, parseRepoRole, roleAllowed } from './lib/repoRole.js'
import { isWorkItemState, MAINTAINER_ONLY_STATES, WORK_ITEM_TRANSITIONS, type WorkItemState } from './lib/workItems.js'
import { parseStageMode } from './lib/stages.js'
import { issuePriority, issueState, shouldTriage } from './lib/issueIntake.js'
import { parseIssueCommand } from './lib/issueCommands.js'
import { parseIssueCommand, parseMentionTrigger, type IssueCommandInput, type MentionTriggerInput } from './lib/issueCommands.js'
import { VERSION } from './version.js'
import { clearGauge, incCounter, observeHistogram, renderMetrics, setGauge } from './lib/metrics.js'
import { readReleaseStatus } from './lib/releaseStatus.js'
Expand Down Expand Up @@ -118,7 +118,7 @@ import { isRulePath, MAX_RULE_FILE_BYTES } from './rules.js'
import { audienceVisible, memoryDigest, parseMemoryInput, searchMemories, visibleMemories } from './lib/memory.js'
import { DENSITY_COOKIE, isDensity, resolveDensity } from './views/density.js'
import { THEME_COOKIE, isTheme, resolveTheme, withTheme } from './views/theme.js'
import { GithubClient, parseRepoInput, prepareRepo, prepareRepoWithInstallation, repoPermissionWithToken } from './repos/github.js'
import { GithubClient, GithubError, parseRepoInput, prepareRepo, prepareRepoWithInstallation, repoPermissionWithToken } from './repos/github.js'
import { checkWebhookSignature, GithubApp, webhookSecretSlot } from './repos/app.js'
import { repoGithubToken } from './repos/token.js'
import { InstanceManager } from './flybot/instances.js'
Expand Down Expand Up @@ -5440,9 +5440,17 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
const repo = store.findRepoByInstallationAndFullName(installationId, fullName)
if (repo === undefined) return
const parsed = parseIssueCommand(body)
if (parsed === null) return
const { command, issueUrl, number, title, detail } = parsed
const key = `issue_command:${parsed.commentId}`
// #637: a mention is the second matcher on the same comment surface. It is
// read only when the comment is not a `/fledge` command, so one comment can
// never be both, and it reuses the claim below: the comment id is the key.
const mention = parsed === null
? parseMentionTrigger(body, { names: config.triggers.mentions, botLogin: `${config.githubApp.slug}[bot]` })
: null
if (parsed === null && mention === null) return
const source = (parsed ?? mention) as IssueCommandInput | MentionTriggerInput
const command = parsed?.command
const { issueUrl, number, title, detail } = source
const key = `issue_command:${source.commentId}`
const marker = readIssueCommandMarker(store.getSetting(key))
// Answered already: a redelivery says nothing twice.
if (marker?.state === 'done') return
Expand All @@ -5462,6 +5470,60 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
store.setSetting(key, `done:${nowIso()}`)
}

if (mention !== null) {
// #637: the kind of job follows the target's kind. A pull request is read,
// not written: the review job reads the diff at its head and comments. An
// issue is where a follow-up may open one branch and one pull request.
if (!mention.onPullRequest) {
const result = await jobs.mention(repo, {
target: { kind: 'issue', number: mention.number, url: mention.issueUrl, headSha: '', state: 'open', draft: false },
commentId: mention.commentId,
actor: mention.actor,
})
if (result.ok && (result.action === 'start' || result.action === 'supersede')) await reply(`Mention accepted: starting a follow-up for this issue (${result.jobId}).`)
else if (result.ok && result.action === 'record') await reply('Mention recorded on the job that already holds this issue; nothing new was started.')
else if (result.ok) await reply('Mention recorded; the follow-up starts when the job that is running now finishes.')
else await reply(`Mention received, but nothing started: ${result.error}`)
return
}
try {
const pr = await client.getPullRequest(owner, name, mention.number)
const result = await jobs.mention(repo, {
target: {
kind: 'pull_request',
number: mention.number,
url: pr.url,
headSha: pr.headSha,
state: pr.state === 'open' && !pr.merged ? 'open' : 'closed',
draft: pr.draft,
branch: pr.head,
baseBranch: pr.base,
},
commentId: mention.commentId,
actor: mention.actor,
})
if (result.ok && (result.action === 'start' || result.action === 'supersede')) await reply(`Mention accepted: review started for ${pr.headSha.slice(0, 7)} on ${pr.url}.`)
else if (result.ok && result.action === 'record') await reply('This head is already being reviewed; the mention is recorded on that job.')
else if (result.ok) await reply('Mention recorded; the review starts when the job that is running now finishes.')
else if (result.code === 'MENTION_DRAFT') await reply('This pull request is a draft; nothing was started.')
else if (result.code === 'MENTION_CLOSED') await reply('This pull request is not open; nothing was started.')
else await reply(`Mention received, but nothing started: ${result.error}`)
return
} catch (error) {
// A target we cannot read starts nothing and says so in one audit line;
// the claim settles, because a redelivery would fail the same read.
if (error instanceof GithubError && (error.status === 404 || error.status === 403)) {
store.audit(repo.user_id, 'trigger.mention_refused', `${mention.commentId} unreadable ${mention.number}`)
store.setSetting(key, `done:${nowIso()}`)
return
}
// Anything else is not the trigger's fault: the claim is released so the
// redelivery can retry it.
store.deleteSetting(key)
throw error
}
}

// #540 slice 6: the chat half. An issue comment is a third surface on the same
// command contract as the console forms, so the reply is built from the receipt
// and the audit row, the revision fence and the refusal codes are the same ones.
Expand All @@ -5470,13 +5532,13 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
// the claim absorbs a duplicate delivery, and if a delivery is retaken the
// receipt answers the replay instead of queueing a second job.
const runChatCreate = async (kind: JobKind, goal: string, itemId: string): Promise<CommandOutcome> => {
const actor = managementActor({ userId: repo.user_id, repo, channel: 'chat', requestId: `issue_command:${parsed.commentId}` })
const actor = managementActor({ userId: repo.user_id, repo, channel: 'chat', requestId: `issue_command:${source.commentId}` })
return management.run(actor, {
operation: 'task.create',
target: { projectId: repo.id },
arguments: { kind, goal, workItemId: itemId },
expectedRevision: management.revisionOf('project', repo.id),
idempotencyKey: `issue_command:${parsed.commentId}`,
idempotencyKey: `issue_command:${source.commentId}`,
})
}

Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export interface Config {
// #708: the per-class windows mutating routes take. A route names its class;
// the numbers live here so a deployment tightens a limit without a rebuild.
rateLimit: RateLimitBudgets
// #637: the comment names that count as a trigger. `@<name>` in an issue or
// pull request comment starts a review; a deployment renames or adds names
// here rather than in code, and an empty list disables the trigger.
triggers: {
mentions: string[]
}
// #540: how far the console adapters go for commands added after slice 1.
// `off` (the default) is the route that existed before the contract.
managementService: ManagementServiceMode
Expand Down Expand Up @@ -300,6 +306,12 @@ export function loadConfig(overrides: Partial<Config> = {}): Config {
windowMs: Math.max(1_000, number('RATE_LIMIT_EXPENSIVE_WINDOW_MS', DEFAULT_RATE_LIMIT_BUDGETS.expensive.windowMs)),
},
},
triggers: {
mentions: text('FLEDGE_MENTION_NAMES', 'fledge')
.split(',')
.map((name) => name.trim())
.filter((name) => name !== ''),
},
managementService: parseManagementService(text('MANAGEMENT_SERVICE', 'off')),
tokenEncryptionKey: encryptionKey,
github: {
Expand Down
2 changes: 1 addition & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const { DatabaseSync } = require('node:sqlite') as { DatabaseSync: new (path: st

export type UserStatus = 'onboarding' | 'pending' | 'approved' | 'rejected'
export type UserRole = 'user' | 'admin' | 'superadmin'
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'paused' | 'quota_exhausted'
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'paused' | 'quota_exhausted' | 'superseded'

// #361: a repository's review history, read from rows that already exist.
// A window is one `since`; the page asks for two. The outcome of a review is
Expand Down
Loading
Loading