Skip to content
Merged
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/373-change-story.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The job page now opens with a "Why this change" block: what the agent said it changed, the risks it named, what it ran, and the size of the diff, read from the pull request body and its changed-file totals. A section the body does not carry says "not stated" instead of guessing, a repeated heading shows the last one and says so, and a pull request that cannot be read leaves the page at 200 with the unavailable fact named.
7 changes: 7 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,13 @@ refused rather than judged, which is deliberate — an unread file is not a
permitted one. Rows stored before these fields existed keep their old sentence
and are upgraded on the next check, so there is no migration.

`PullRequestInfo` also carries the pull request `body`, which the reader never
interprets: the job page shows the three sections the agent wrote into it.
`pullRequestDiffTotals` reduces the same listing to `{ files, additions,
deletions }` for that page, and reports no totals when the walk was capped — the
same rule as the path guard, stated once: a partial listing is unavailable, never
rounded down and presented as the change.

## Job endpoints

These are called by the FlyBot bridge/agent, not by a browser.
Expand Down
25 changes: 25 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,31 @@ makes the sweep idempotent: a rerun offers nothing and mails nobody, and an
account restored by hand stays skipped. Operator output carries the login and the
registration date, never an address.

### The job page's change story

The job page answers "did it pass", and a maintainer's first question is "what
is the change". `GET /repos/:id/jobs/:jobId` therefore makes one read of the
job's pull request and hands the render a `ChangeStory`: the body split into
what changed, risks and ran, plus the diff totals. It is a read model over what
the agent already wrote — no model call and no summary of a summary.

The read is cached per job for 60 seconds, the same shape as `inboxCache` above,
so refreshing the page spends no GitHub request; a job with no pull request
skips the read entirely and the block is absent. The body comes from
`PullRequestInfo.body`, and the totals from `pullRequestDiffTotals`, which sums
the existing changed-file walk. When that walk hits its own cap the totals are
reported as unavailable rather than summed: a number taken from part of a change
would understate it.

`src/lib/changeStory.ts` is pure. It recognises `## What changed`, `## Risks`
and `## Ran` at any heading level, and the bare `Ran:` line this repository's PR
template uses; a repeated heading keeps the last occurrence and the block says
so, so an edited body cannot silently show the abandoned draft. A heading the
body does not carry renders `not stated` and is never filled from another
section. A read that fails — 404, no token, a capped list — leaves the page at
200 and names the unavailable fact instead, because a page that 500s on a read
it did not need is worse than no block.

### Email notifications

Fledge sends mail from the configured `MAIL_FROM` (production:
Expand Down
42 changes: 41 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ import { parseAttributionJson } from './lib/attribution.js'
import { failureBreakdown } from './lib/failure.js'
import { fetchFailureLog, logFailureReason } from './actions/workflowLog.js'
import { parseVerification } from './lib/verification.js'
import { readChangeStory, type ChangeStory } from './lib/changeStory.js'
import { DEFAULT_TRIAGE_GOAL, Jobs } from './jobs/service.js'
import { parseIssueUrl } from './jobs/prUrl.js'
import { parseIssueUrl, parsePrUrl } from './jobs/prUrl.js'
import { createRunner, type Runner } from './jobs/runner.js'
import { log } from './lib/log.js'
import type { PriorityLevel } from './lib/priority.js'
Expand Down Expand Up @@ -835,6 +836,41 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
error: job.error === null ? null : localizeMessage(locale, job.error) ?? job.error,
}))

// #373 slice 1: the job page's "Why this change" block reads the pull request
// body and the changed-file totals. One read per job with a 60 s bound, the
// same shape as `inboxCache` above: a page refresh must not spend a GitHub
// request, and a job without a pull request spends none at all.
const changeStoryTtlMs = 60_000
const changeStoryCache = new Map<string, { at: number; story: ChangeStory }>()
const changeStoryFor = async (repo: RepoRow, job: JobRow): Promise<ChangeStory | undefined> => {
if (job.pr_url === null || job.pr_url === '') return undefined
const cached = changeStoryCache.get(job.id)
if (cached !== undefined && Date.now() - cached.at < changeStoryTtlMs) return cached.story
const parsed = parsePrUrl(repo, job.pr_url, config.github.apiBase)
let story: ChangeStory
if (!parsed.ok) {
story = readChangeStory({ body: null, totals: null, diffState: 'unreadable' })
} else {
try {
const token = await repoGithubToken(repo, config, githubApp, store)
const client = new GithubClient(token, config.github.apiBase)
const pr = await client.getPullRequest(parsed.ref.owner, parsed.ref.name, parsed.ref.number)
const diff = await client.pullRequestDiffTotals(parsed.ref.owner, parsed.ref.name, parsed.ref.number, pr.changedFiles)
story = readChangeStory({ body: pr.body, totals: diff.totals, diffState: diff.complete ? 'ok' : 'capped' })
} catch (error) {
// A read that failed is a fact the block reports, not a reason to fail
// the page: the evidence below it is what the maintainer came for.
log.warn('change story read failed', { repo: repo.id, job: job.id, error: error instanceof Error ? error.message : String(error) })
story = readChangeStory({ body: null, totals: null, diffState: 'unreadable' })
}
}
if (changeStoryCache.size >= 200) {
for (const [key, entry] of changeStoryCache) if (Date.now() - entry.at >= changeStoryTtlMs) changeStoryCache.delete(key)
}
changeStoryCache.set(job.id, { at: Date.now(), story })
return story
}

const requireAdvanced = (viewer: { user: UserRow; preview?: boolean }): void => {
if (viewer.user.role !== 'superadmin' || viewer.preview === true) throw httpError(403, '仅超级管理员可访问实时团队页面。', 'SUPERADMIN_ONLY')
}
Expand Down Expand Up @@ -2971,12 +3007,16 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
// label map keeps the viewer's own repositories readable without naming
// anybody else's.
const repoLabels = new Map(store.listRepos(viewer.user.id).map((entry) => [entry.id, `${entry.upstream_owner || entry.owner}/${entry.upstream_name || entry.name}`]))
// #373: one read of the pull request, cached per job, so the page can say
// what the change is and not only that its checks passed.
const changeStory = await changeStoryFor(repo, job)
return reply.type('text/html').send(jobPage({
config,
locale: request.locale ?? 'zh',
viewer: viewOf(viewer),
repo,
job,
changeStory,
queued,
viewerActive: store.listActiveJobsForUser(viewer.user.id),
repoLabels,
Expand Down
175 changes: 175 additions & 0 deletions src/lib/changeStory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// #373 slice 1: a job page is where a maintainer decides, and until now it
// showed what the checks proved but not what the agent said it changed, what it
// thought could go wrong, and what it actually ran — the three sections every
// PR body in this repository already carries. This module is the read model:
// it parses the body the agent wrote and adds the diff totals. Pure, no model
// call, no network.

export const CHANGE_STORY_KEYS = ['whatChanged', 'risks', 'ran'] as const

export type ChangeStoryKey = (typeof CHANGE_STORY_KEYS)[number]

export interface DiffTotals {
files: number
additions: number
deletions: number
}

export interface ChangeStorySection {
key: ChangeStoryKey
// `null` means the body does not carry that heading. It is never filled from
// another section: the block says "not stated" instead of guessing.
text: string | null
// The heading appeared more than once; the last occurrence is the text above.
duplicated: boolean
}

export type ChangeStoryDiffState = 'ok' | 'capped' | 'unreadable'

export interface ChangeStory {
// `unreadable` means GitHub could not be read at all (404, no token, a failed
// request): every fact is then unavailable rather than "not stated".
bodyState: 'ok' | 'unreadable'
sections: ChangeStorySection[]
totals: DiffTotals | null
diffState: ChangeStoryDiffState
}

// Labels are the caller's: the parser is language-free and the page passes the
// reader's language in.
export interface ChangeStoryLabels {
notStated?: string
unavailable?: string
capped?: string
duplicate?: string
files?: (count: number) => string
}

export interface ChangeStoryFact {
key: ChangeStoryKey | 'diff'
value: string
// A second sentence the page shows under the value; empty when there is none.
note: string
}

const HEADINGS: Record<string, ChangeStoryKey> = {
'what changed': 'whatChanged',
risks: 'risks',
risk: 'risks',
ran: 'ran',
}

// `## Risks`, `### Ran`, `# What changed`. The level does not matter: any heading
// the body carries is a boundary, and the ones that name a fact start one.
function headingKey(line: string): ChangeStoryKey | null {
const heading = /^#{1,6}\s+(.*)$/.exec(line.trim())
if (heading === null) return null
const text = (heading[1] ?? '').replace(/[:\s]+$/, '').toLowerCase()
return HEADINGS[text] ?? null
}

// A `Not run:` line is a fact of its own ("what the agent did not run"), so it
// ends the section above it instead of being read as part of `Ran`.
function isNotRun(line: string): boolean {
return /^not\s+run\s*:?\s*$/i.test(line.trim())
}

// The house PR template uses a bare `Ran:` line rather than a heading, so the
// block reads this repository's own pull requests and not only PRs that adopted
// heading style.
function isBareRan(line: string): boolean {
return /^ran\s*:?\s*$/i.test(line.trim())
}

function isFence(line: string): boolean {
return /^\s*(```|~~~)/.test(line)
}

function endsSection(line: string): boolean {
return /^#{1,6}\s+/.test(line.trim()) || isNotRun(line)
}

// A fenced block may hold `#` comments; they are text, not headings.
export function parseChangeStoryBody(body: string): ChangeStorySection[] {
const lines = body.replace(/\r\n/g, '\n').split('\n')
const found = new Map<ChangeStoryKey, { text: string | null; seen: number }>()
let current: ChangeStoryKey | null = null
let buffer: string[] = []
let fenced = false
const flush = (): void => {
if (current === null) return
const text = buffer.join('\n').trim()
const prior = found.get(current)
found.set(current, { text: text === '' ? null : text, seen: (prior?.seen ?? 0) + 1 })
buffer = []
}
for (const line of lines) {
if (isFence(line)) {
fenced = !fenced
if (current !== null) buffer.push(line)
continue
}
if (!fenced) {
const key = headingKey(line)
if (key !== null) {
flush()
current = key
continue
}
if (isBareRan(line)) {
flush()
current = 'ran'
continue
}
if (endsSection(line)) {
flush()
current = null
continue
}
}
if (current !== null) buffer.push(line)
}
flush()
return CHANGE_STORY_KEYS.map((key) => {
const entry = found.get(key)
return { key, text: entry?.text ?? null, duplicated: (entry?.seen ?? 0) > 1 }
})
}

// `totals` is the caller's sum (the reader owns the file walk and its cap);
// `diffState` says whether that sum is the whole change.
export function readChangeStory(input: {
body: string | null
totals: DiffTotals | null
diffState: ChangeStoryDiffState
}): ChangeStory {
const bodyState: ChangeStory['bodyState'] = input.body === null ? 'unreadable' : 'ok'
const sections = bodyState === 'ok'
? parseChangeStoryBody(input.body ?? '')
: CHANGE_STORY_KEYS.map((key) => ({ key, text: null, duplicated: false }))
// A sum that was not produced cannot be rendered as a state of its own.
const diffState: ChangeStoryDiffState = input.diffState === 'ok' && input.totals === null ? 'unreadable' : input.diffState
return { bodyState, sections, totals: diffState === 'ok' ? input.totals : null, diffState }
}

const DEFAULT_LABELS: Required<ChangeStoryLabels> = {
notStated: 'not stated',
unavailable: 'not available',
capped: 'the file list exceeded the cap',
duplicate: 'a repeated heading; the last one is shown',
files: (count: number) => `${count} files`,
}

export function changeStoryFacts(story: ChangeStory, labels: ChangeStoryLabels = {}): ChangeStoryFact[] {
const l = { ...DEFAULT_LABELS, ...labels }
const facts: ChangeStoryFact[] = story.sections.map((section) => ({
key: section.key,
value: section.text ?? (story.bodyState === 'ok' ? l.notStated : l.unavailable),
note: section.duplicated ? l.duplicate : '',
}))
const diffValue = story.diffState === 'ok' && story.totals !== null
? `${l.files(story.totals.files)} +${story.totals.additions} −${story.totals.deletions}`
: story.diffState === 'capped' ? l.capped : l.unavailable
facts.push({ key: 'diff', value: diffValue, note: '' })
return facts
}
37 changes: 36 additions & 1 deletion src/repos/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ export interface PullRequestInfo {
draft: boolean
merged: boolean
changedFiles: number
// #373: the body is what the agent already wrote about the change. The job
// page shows it; nothing here interprets it.
body: string
}

// #373: the job page shows how large the change is. The per-file walk below
// already carries `additions` and `deletions`, so the sum is defined once, next
// to the reader that owns the walk and its cap.
export function summarizePullRequestFiles(
files: ReadonlyArray<{ additions: number; deletions: number }>,
): { files: number; additions: number; deletions: number } {
let additions = 0
let deletions = 0
for (const file of files) {
additions += Number.isFinite(file.additions) ? file.additions : 0
deletions += Number.isFinite(file.deletions) ? file.deletions : 0
}
return { files: files.length, additions, deletions }
}

export interface CheckRunInfo {
Expand Down Expand Up @@ -506,7 +524,7 @@ export class GithubClient {
name: string,
input: { title: string; head: string; base: string; body: string },
): Promise<PullRequestInfo> {
const pr = await this.request<{ number: number; html_url: string; title: string; state: string; head: { ref: string; sha: string }; base: { ref: string }; mergeable?: boolean | null; mergeable_state?: string; draft?: boolean; merged?: boolean; changed_files?: number }>(
const pr = await this.request<{ number: number; html_url: string; title: string; state: string; head: { ref: string; sha: string }; base: { ref: string }; mergeable?: boolean | null; mergeable_state?: string; draft?: boolean; merged?: boolean; changed_files?: number; body?: string | null }>(
'POST',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`,
input,
Expand All @@ -524,6 +542,7 @@ export class GithubClient {
draft: pr.draft === true,
merged: pr.merged === true,
changedFiles: Number(pr.changed_files ?? 0),
body: typeof pr.body === 'string' ? pr.body : '',
}
}

Expand All @@ -540,6 +559,7 @@ export class GithubClient {
draft: boolean
merged: boolean
changed_files?: number
body?: string | null
}>(
'GET',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`,
Expand All @@ -557,6 +577,7 @@ export class GithubClient {
draft: pr.draft,
merged: pr.merged,
changedFiles: Number(pr.changed_files ?? 0),
body: typeof pr.body === 'string' ? pr.body : '',
}
}

Expand Down Expand Up @@ -836,6 +857,20 @@ export class GithubClient {
return { files, complete }
}

// #373: the job page needs the change size, not the file list. A capped walk is
// reported as such rather than summed: a total taken from part of the change
// would understate it, and the page says the fact is unavailable instead.
async pullRequestDiffTotals(
owner: string,
name: string,
number: number,
reportedFiles = 0,
): Promise<{ totals: { files: number; additions: number; deletions: number } | null; complete: boolean }> {
const listed = await this.listPullRequestFiles(owner, name, number, reportedFiles)
if (!listed.complete) return { totals: null, complete: false }
return { totals: summarizePullRequestFiles(listed.files), complete: true }
}

async getBranchProtection(owner: string, name: string, branch: string): Promise<{ protected: boolean; requiredChecks: string[]; requiredApprovals: number } | null> {
try {
const result = await this.request<{
Expand Down
Loading
Loading