diff --git a/changelog.d/366-temporal-delta.added.md b/changelog.d/366-temporal-delta.added.md new file mode 100644 index 00000000..dc992fb5 --- /dev/null +++ b/changelog.d/366-temporal-delta.added.md @@ -0,0 +1 @@ +An iterate job's goal now carries one line of temporal context — `Since 4f2c1a9: 3 commits ahead, 2 merged PRs (#812, #815)` — built from the previous finished run's base SHA, and the job page shows the same line. A first run says `Since: no previous run`, and a compare that failed or a page walk that hit its cap is marked `partial` instead of reporting a smaller number. diff --git a/docs/architecture.md b/docs/architecture.md index 0047c91d..80519351 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -639,8 +639,8 @@ recorded". `scripts/restore-drill.mjs --status-file` is the writer. terminal state (`merged` or `closed`) is left exactly as it is: the expiry is still recorded in the audit row and in the `plan_expired:` settings key, but no "已过期关闭" notice is sent for an item the pass did not end (#408). - Iterate jobs preflight the target branch, record the base SHA - and warn when the fork is behind. An open work item changes what the next + Iterate jobs preflight the target branch, record the base SHA, + warn when the fork is behind, and add the temporal delta line described below. An open work item changes what the next scheduled run continues; it never blocks a due repository (#219). Work-item states do not gate the scheduler; see "Scheduled work, continuity and work items" below. @@ -1292,7 +1292,22 @@ request — so a closed issue or a merged pull request is dropped from the block and counted instead of being carried into the next run. The block states what it did in its header, `carry-over: 1 used, 1 dropped (closed/merged)`, with an `, N unverified` segment when a read failed; a failed read keeps its line rather -than dropping it silently, and the job page shows the same header line. Each finished run is classified as `pr`, `issue`, `review`, `zero`, +than dropping it silently, and the job page shows the same header line. + +The goal also carries one **temporal delta** line (#366), built by +`summariseDelta()` in `src/lib/temporal.ts` from two bounded reads against the +previous finished run's base: `compareCommits()` and, through the previous +base's `committedAt`, `listMergedPullRequestsSince()`. The line names the short +previous base, the commit count and the merged pull requests, for example +`Since 4f2c1a9: 3 commits ahead, 2 merged PRs (#812, #815)`; a first run says +`Since: no previous run` and makes neither read. A failed compare prints +`delta unavailable (partial)` and a capped page walk prints `merged PRs partial` +— never the smaller number that happened to fit — because a delta that +understates the change is worse than one that admits it is incomplete. The +result is stored in `jobs.temporal_json` (`sinceSha`, `status`, `aheadBy`, +`mergedPrs`, `complete`), the additive column that lets a later slice compare two +deltas, and the job page shows the same line. Nothing reads the delta to make a +decision yet; slice 1 only records and displays it. Each finished run is classified as `pr`, `issue`, `review`, `zero`, `meta` or `failed`. A meta issue (`存量/盘点/清账/reconcile/inventory/backlog audit`) is closed on the tracking board and notifies the owner instead of counting as a productive run. Two diff --git a/src/db.ts b/src/db.ts index d3126027..96047277 100644 --- a/src/db.ts +++ b/src/db.ts @@ -427,6 +427,9 @@ export interface JobRow { plan_allow_paths: string | null plan_deny_paths: string | null base_sha: string | null + // #366: one run's delta against the previous run's base, as JSON. Additive; + // NULL for every run that predates it and for a first run on a repository. + temporal_json: string | null callback_fingerprint: string | null path_guard_json: string | null verification_json: string | null @@ -1267,6 +1270,7 @@ CREATE TABLE IF NOT EXISTS jobs ( plan_allow_paths TEXT, plan_deny_paths TEXT, base_sha TEXT, + temporal_json TEXT, callback_fingerprint TEXT, path_guard_json TEXT, verification_json TEXT, @@ -2473,6 +2477,7 @@ export class Store { if (!jobColumns.has('plan_allow_paths')) this.run('ALTER TABLE jobs ADD COLUMN plan_allow_paths TEXT') if (!jobColumns.has('plan_deny_paths')) this.run('ALTER TABLE jobs ADD COLUMN plan_deny_paths TEXT') if (!jobColumns.has('base_sha')) this.run('ALTER TABLE jobs ADD COLUMN base_sha TEXT') + if (!jobColumns.has('temporal_json')) this.run('ALTER TABLE jobs ADD COLUMN temporal_json TEXT') if (!jobColumns.has('callback_fingerprint')) this.run('ALTER TABLE jobs ADD COLUMN callback_fingerprint TEXT') if (!jobColumns.has('path_guard_json')) this.run('ALTER TABLE jobs ADD COLUMN path_guard_json TEXT') if (!jobColumns.has('verification_json')) this.run('ALTER TABLE jobs ADD COLUMN verification_json TEXT') @@ -5675,7 +5680,7 @@ export class Store { } - updateJob(id: string, patch: Partial>): void { + updateJob(id: string, patch: Partial>): void { const entries = Object.entries(redactJobText(patch as Record)).filter(([, value]) => value !== undefined) if (entries.length === 0) return const sets: string[] = [] diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 8a7eaebe..1ae653ad 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -50,6 +50,7 @@ import { GithubClient, GithubError, prepareRepo, prepareRepoWithInstallation, re import { GithubApp } from '../repos/app.js' import { parseIssueUrl, parsePrUrl } from './prUrl.js' import { buildCarryOver, buildRunDigest, decideNextJob, failureStreak, lastDurationMs, MIN_DISCOVERY_INTERVAL_MINUTES, parseCarryOverLink, type CarryOverReader } from './continuity.js' +import { isTemporalStatus, summariseDelta, type TemporalDelta, type TemporalStatus } from '../lib/temporal.js' import { planTick, scheduleFromRow, type TickPlan } from '../schedules/tick.js' import { utcDayEnd, utcDayStart } from '../schedules/budget.js' import type { ScheduleRow } from '../db.js' @@ -896,7 +897,7 @@ export class Jobs { } } - private async preflight(repo: RepoRow, baseBranch: string, trigger: string): Promise<{ ok: boolean; baseSha?: string; warning?: string; error?: string; code?: EnqueueRefusalCode; detail?: string }> { + private async preflight(repo: RepoRow, baseBranch: string, trigger: string): Promise<{ ok: boolean; baseSha?: string; warning?: string; temporal?: TemporalDelta; error?: string; code?: EnqueueRefusalCode; detail?: string }> { if (this.runner.name !== 'flybot') return { ok: true } try { const token = await repoGithubToken(repo, this.config, this.githubApp, this.store) @@ -918,7 +919,8 @@ export class Jobs { else if (forkBranch.sha !== branch.sha) warning = 'fork is behind upstream' } } - return { ok: true, baseSha: branch.sha, warning } + const temporal = await this.temporalDelta(client, repo.id, upstreamOwner, upstreamName, branch.sha) + return { ok: true, baseSha: branch.sha, warning, temporal } } catch (error) { if (error instanceof GithubError && (error.status === 401 || error.status === 403) && !/rate limit/i.test(error.message)) { this.markCredentialInvalid(repo, error) @@ -928,6 +930,37 @@ export class Jobs { } } + // #366 slice 1: what changed between the previous finished run's base and this + // one. Two bounded reads, and every way they can end short is `partial` rather + // than a number the goal would present as complete. + private async temporalDelta(client: GithubClient, repoId: string, owner: string, name: string, baseSha: string): Promise { + // The newest finished job that recorded a base. A rerun at the same base is + // still a previous run and says `no new commits`, which is a fact worth + // having; only a repository with no such job at all has no previous run. + const since = this.store.listJobsForRepo(repoId, 20).find((job) => job.finished_at !== null && typeof job.base_sha === 'string' && job.base_sha !== '') + const sinceSha = since?.base_sha ?? null + if (sinceSha === null) return { sinceSha: null, status: 'ahead', aheadBy: 0, mergedPrs: [], complete: true } + let status: TemporalStatus = 'unknown' + let aheadBy = 0 + try { + const compare = await client.compareCommits(owner, name, sinceSha, baseSha) + status = isTemporalStatus(compare.status) ? compare.status : 'unknown' + aheadBy = status === 'unknown' ? 0 : Math.max(0, compare.aheadBy) + } catch (error) { + log.warn('temporal compare failed', { repo: repoId, error: String(error) }) + return { sinceSha, status: 'unknown', aheadBy: 0, mergedPrs: [], complete: false } + } + try { + const previous = await client.getCommitMeta(owner, name, sinceSha) + if (previous === null) return { sinceSha, status, aheadBy, mergedPrs: [], complete: false } + const merged = await client.listMergedPullRequestsSince(owner, name, previous.committedAt) + return { sinceSha, status, aheadBy, mergedPrs: merged.pulls.map((pull) => pull.number), complete: merged.complete } + } catch (error) { + log.warn('temporal merged-PR read failed', { repo: repoId, error: String(error) }) + return { sinceSha, status, aheadBy, mergedPrs: [], complete: false } + } + } + private async checkCredential(repo: RepoRow): Promise<{ ok: boolean; code?: EnqueueRefusalCode; error?: string }> { const cached = this.credentialCache.get(repo.id) if (cached !== undefined && Date.now() - cached.at < 10 * 60_000) return { ok: true } @@ -1074,11 +1107,13 @@ export class Jobs { } let preflightSha: string | null = null let preflightWarning = '' + let temporal: TemporalDelta | undefined if (kind === 'iterate') { const preflight = await this.preflight(repo, baseBranch, trigger) if (!preflight.ok) return { ok: false, error: preflight.error, code: preflight.code, detail: preflight.detail } preflightSha = preflight.baseSha ?? null preflightWarning = preflight.warning ?? '' + temporal = preflight.temporal } const baseGoal = kind === 'review' ? DEFAULT_REVIEW_GOAL : kind === 'discover' ? DEFAULT_DISCOVER_GOAL : kind === 'triage' ? DEFAULT_TRIAGE_GOAL : kind === 'scope' ? DEFAULT_SCOPE_GOAL : kind === 'verify' ? DEFAULT_VERIFY_GOAL : (repo.goal !== '' ? repo.goal : DEFAULT_GOAL) const rawGoal = goalOverride ?? baseGoal @@ -1092,7 +1127,10 @@ export class Jobs { read: (this.carryOverReaderOverride ?? ((target: RepoRow) => this.carryOverReader(target)))(repo), })).text : '' + // #366 slice 1: exactly one data line, and only when a previous run exists. + const temporalLine = temporal === undefined ? '' : summariseDelta(temporal) const goal = ((preflightWarning === '' ? rawGoal : `${rawGoal}\n\nPreflight: ${preflightWarning}. Sync the fork before creating the working branch.`) + + (temporalLine === '' ? '' : `\n\n${temporalLine}`) + (carryOver === '' ? '' : `\n\n${carryOver}`)).slice(0, 4000) const continuityUsed = carryOver !== '' const jobId = `job_${randomToken(10)}` @@ -1194,6 +1232,10 @@ export class Jobs { throw error } if (continuityUsed) this.store.updateJob(job.id, { continuity_used: 1 }) + if (temporal !== undefined) { + job.temporal_json = JSON.stringify(temporal) + this.store.updateJob(job.id, { temporal_json: job.temporal_json }) + } if (rules !== undefined) { this.store.saveJobContext({ jobId: job.id, diff --git a/src/lib/temporal.ts b/src/lib/temporal.ts new file mode 100644 index 00000000..3949bc28 --- /dev/null +++ b/src/lib/temporal.ts @@ -0,0 +1,68 @@ +// #366 slice 1: the temporal contract. A job's goal carries one line saying what +// happened between the previous run's base and this run's base. The wording is +// decided here, in a pure function, so the same inputs always produce the same +// line and the reads cannot quietly change it. +// +// The rule the line exists for: a read that failed or hit its page cap is +// reported as `partial`, never as the smaller number it managed to see. A job +// that is told "2 merged PRs" when the walk stopped at two pages would rather +// not be told anything than be told a number it can trust. + +export type TemporalStatus = 'ahead' | 'identical' | 'behind' | 'diverged' | 'unknown' + +export interface TemporalDelta { + // `base_sha` of the newest finished job for the same repository, `null` for a + // first run. Never the current job's own base. + sinceSha: string | null + // `compareCommits(sinceSha, baseSha).status`, or `unknown` when that read failed. + status: TemporalStatus + aheadBy: number + // Merged pull request numbers inside the window, deduplicated by the caller or + // here; `complete` is false when the page cap was reached or a read failed. + mergedPrs: number[] + complete: boolean +} + +const STATUSES: readonly TemporalStatus[] = ['ahead', 'identical', 'behind', 'diverged', 'unknown'] + +// GitHub returns `status: 'unknown'` for a compare it could not resolve, so the +// name alone does not prove the read worked; anything not in the list is treated +// the same way rather than guessed at. +export function isTemporalStatus(value: string): value is TemporalStatus { + return (STATUSES as readonly string[]).includes(value) +} + +const SHORT_SHA = 7 +const LISTED_PRS = 5 + +export function summariseSince(sinceSha: string | null): string { + return sinceSha === null ? 'Since' : `Since ${sinceSha.slice(0, SHORT_SHA)}` +} + +function plural(count: number, one: string, many: string): string { + return count === 1 ? one : many +} + +function describeCommits(delta: TemporalDelta): string { + if (delta.status === 'identical') return 'no new commits' + if (delta.status === 'behind') return 'base is behind the previous run' + if (delta.status === 'diverged') return 'diverged from the previous run' + return `${String(delta.aheadBy)} ${plural(delta.aheadBy, 'commit', 'commits')} ahead` +} + +function describePullRequests(delta: TemporalDelta): string { + if (!delta.complete) return 'merged PRs partial' + const numbers = [...new Set(delta.mergedPrs)].sort((a, b) => a - b) + const count = numbers.length + const head = `${String(count)} ${plural(count, 'merged PR', 'merged PRs')}` + if (count === 0) return head + const shown = numbers.slice(0, LISTED_PRS).map((number) => `#${String(number)}`) + const tail = count > LISTED_PRS ? `${shown.join(', ')}, …` : shown.join(', ') + return `${head} (${tail})` +} + +export function summariseDelta(delta: TemporalDelta): string { + if (delta.sinceSha === null) return 'Since: no previous run' + if (delta.status === 'unknown') return `${summariseSince(delta.sinceSha)}: delta unavailable (partial)` + return `${summariseSince(delta.sinceSha)}: ${describeCommits(delta)}, ${describePullRequests(delta)}` +} diff --git a/src/views/pages.ts b/src/views/pages.ts index 71a289fa..20acc39e 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -17,6 +17,7 @@ import { escapeHtml, layout, money, quotaMoney, type PageNotification, type View import { renderState, stateCell, type StateAction } from './states.js' import { redact } from '../lib/secrets.js' import { REVIEW_INSTRUCTION_LABEL, reviewInstructionFromGoal } from '../lib/followUpInstruction.js' +import { summariseDelta, type TemporalDelta } from '../lib/temporal.js' import { firstRunOutcome, setupChecklist, type SetupState, type SetupStepKey } from './checklist.js' import { oauthConfigured, tokenLoginEnabled } from '../auth/login.js' import { allowedRepoRoles, REPO_ROLE_LABELS, type RepoRole } from '../lib/repoRole.js' @@ -2988,6 +2989,19 @@ function changeStoryLabel(t: Translate, key: ChangeStoryFact['key']): string { return t('改动规模', 'Diff') } +// #366 slice 1: the stored delta is data, so a row written by an older build or a +// hand-edited value renders nothing instead of failing the page. +function temporalLine(job: JobRow): string { + if (typeof job.temporal_json !== 'string' || job.temporal_json === '') return '' + try { + const delta = JSON.parse(job.temporal_json) as TemporalDelta + if (typeof delta !== 'object' || delta === null || typeof delta.status !== 'string') return '' + return summariseDelta(delta) + } catch { + return '' + } +} + export function jobPage(input: { config: Config locale: Locale @@ -3336,7 +3350,7 @@ export function jobPage(input: {
${escapeHtml(t('任务', 'JOB'))}

${escapeHtml(job.id)}

-

${escapeHtml(repo.upstream_owner || repo.owner)}/${escapeHtml(repo.upstream_name || repo.name)} · ${badge(t, job.status)} · ${escapeHtml(jobKindLabel(t, job))} · ${escapeHtml(triggerLabel(t, job.trigger))} · ${escapeHtml(t('目标分支', 'Base branch'))} ${escapeHtml(job.base_branch ?? (repo.target_branch !== '' ? repo.target_branch : (repo.upstream_default_branch || repo.default_branch)))}${job.base_sha === null ? '' : ` · ${escapeHtml(t('基于', 'Based on'))} ${escapeHtml(job.base_sha.slice(0, 12))}`}${job.last_callback_at === null ? '' : ` · ${escapeHtml(t('最后心跳', 'Last heartbeat'))} ${escapeHtml(humanTimeShort(job.last_callback_at))}`}

+

${escapeHtml(repo.upstream_owner || repo.owner)}/${escapeHtml(repo.upstream_name || repo.name)} · ${badge(t, job.status)} · ${escapeHtml(jobKindLabel(t, job))} · ${escapeHtml(triggerLabel(t, job.trigger))} · ${escapeHtml(t('目标分支', 'Base branch'))} ${escapeHtml(job.base_branch ?? (repo.target_branch !== '' ? repo.target_branch : (repo.upstream_default_branch || repo.default_branch)))}${job.base_sha === null ? '' : ` · ${escapeHtml(t('基于', 'Based on'))} ${escapeHtml(job.base_sha.slice(0, 12))}`}${job.last_callback_at === null ? '' : ` · ${escapeHtml(t('最后心跳', 'Last heartbeat'))} ${escapeHtml(humanTimeShort(job.last_callback_at))}`}${temporalLine(job) === '' ? '' : ` · ${escapeHtml(temporalLine(job))}`}

${escapeHtml(t('返回仓库', 'Back to repository'))} diff --git a/test/temporal.test.ts b/test/temporal.test.ts new file mode 100644 index 00000000..585fafe8 --- /dev/null +++ b/test/temporal.test.ts @@ -0,0 +1,126 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import type { JobRow } from '../src/db.js' +import { Store } from '../src/db.js' +import { summariseDelta, summariseSince, type TemporalDelta } from '../src/lib/temporal.js' +import { jobPage } from '../src/views/pages.js' +import { makeUser, testConfig } from './helpers.js' + +// #366 slice 1: one line of temporal context in the goal, built by a pure +// function so the wording is decided here and not by whatever the reads +// happened to return. The two rules the issue pins down are tested below: +// a first job says so and calls nothing, and a capped or failed read is marked +// `partial` instead of reporting the smaller number it did get. + +function delta(overrides: Partial = {}): TemporalDelta { + return { sinceSha: '4f2c1a9e8d3b2a1c0f9e8d7c6b5a4f3e2d1c0b9a', status: 'ahead', aheadBy: 3, mergedPrs: [], complete: true, ...overrides } +} + +test('a first job says there is no previous run', () => { + const line = summariseDelta(delta({ sinceSha: null, aheadBy: 0, mergedPrs: [] })) + assert.equal(line, 'Since: no previous run') +}) + +test('a delta names the short previous sha, the commit count and the merged PRs', () => { + const line = summariseDelta(delta({ mergedPrs: [812, 815] })) + assert.equal(line, 'Since 4f2c1a9: 3 commits ahead, 2 merged PRs (#812, #815)') +}) + +test('single commit and single pull request are written in the singular', () => { + assert.equal(summariseDelta(delta({ aheadBy: 1, mergedPrs: [7] })), 'Since 4f2c1a9: 1 commit ahead, 1 merged PR (#7)') +}) + +test('no merged pull requests says zero rather than dropping the clause', () => { + assert.equal(summariseDelta(delta({ aheadBy: 2, mergedPrs: [] })), 'Since 4f2c1a9: 2 commits ahead, 0 merged PRs') +}) + +// The central rule: a capped page walk or a failed read is reported as partial, +// never as the count that happened to fit in the pages we did read. +test('a capped merged-PR walk is marked partial and reports no count at all', () => { + const line = summariseDelta(delta({ mergedPrs: [801, 803], complete: false })) + assert.equal(line, 'Since 4f2c1a9: 3 commits ahead, merged PRs partial') + assert.doesNotMatch(line, /\d+ merged PRs/) + assert.doesNotMatch(line, /#/) +}) + +test('a failed compare is marked partial and does not claim the base is unchanged', () => { + const line = summariseDelta(delta({ status: 'unknown', aheadBy: 0, mergedPrs: [], complete: true })) + assert.equal(line, 'Since 4f2c1a9: delta unavailable (partial)') +}) + +test('an identical base says no new commits and still reports the merged PRs it saw', () => { + assert.equal(summariseDelta(delta({ status: 'identical', aheadBy: 0 })), 'Since 4f2c1a9: no new commits, 0 merged PRs') + assert.equal( + summariseDelta(delta({ status: 'identical', aheadBy: 0, mergedPrs: [900] })), + 'Since 4f2c1a9: no new commits, 1 merged PR (#900)', + ) +}) + +test('a base that moved backwards or diverged is named, not called ahead', () => { + assert.equal(summariseDelta(delta({ status: 'behind', aheadBy: 0 })), 'Since 4f2c1a9: base is behind the previous run, 0 merged PRs') + assert.equal( + summariseDelta(delta({ status: 'diverged', aheadBy: 0, mergedPrs: [1, 2] })), + 'Since 4f2c1a9: diverged from the previous run, 2 merged PRs (#1, #2)', + ) +}) + +test('pull request numbers are sorted, deduplicated and capped with an ellipsis', () => { + const line = summariseDelta(delta({ aheadBy: 9, mergedPrs: [815, 803, 801, 812, 803, 820, 826] })) + assert.equal(line, 'Since 4f2c1a9: 9 commits ahead, 6 merged PRs (#801, #803, #812, #815, #820, …)') +}) + +test('the line is one line and carries no newline', () => { + const line = summariseSince(delta({ mergedPrs: [1] }).sinceSha) + assert.equal(line, 'Since 4f2c1a9') + assert.doesNotMatch(summariseDelta(delta({ mergedPrs: [1, 2, 3] })), /\n/) +}) + +test('the delta survives a store round trip and a job without one reads as null', () => { + const store = new Store(':memory:') + try { + const user = makeUser(store) + const repo = store.createRepo({ + id: 'repo_temporal', userId: user.id, owner: 'octo', name: 'demo', defaultBranch: 'main', + tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: 'fix bugs', + autoIterate: false, intervalMinutes: 1440, + }) + const job = store.createJob({ id: 'job_temporal', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: 'h', platformTokenHash: '' }) + assert.equal(job.temporal_json, null) + const delta = { sinceSha: '4f2c1a9e8d3b2a1c0f9e8d7c6b5a4f3e2d1c0b9a', status: 'ahead' as const, aheadBy: 3, mergedPrs: [812, 815], complete: true } + store.updateJob(job.id, { temporal_json: JSON.stringify(delta) }) + assert.deepEqual(JSON.parse(store.getJob(job.id)!.temporal_json!), delta) + } finally { + store.close() + } +}) + +test('the job page shows the line for a stored delta and nothing for a missing or broken one', () => { + const config = testConfig() + const store = new Store(':memory:') + try { + const user = makeUser(store) + const repo = store.createRepo({ + id: 'repo_temporal_page', userId: user.id, owner: 'octo', name: 'demo', defaultBranch: 'main', + tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: 'fix bugs', + autoIterate: false, intervalMinutes: 1440, + }) + // One active job per repository is a partial unique index, so the previous + // fixture has to finish before the next one exists. + const make = (id: string, temporal: string | null): JobRow => { + for (const older of store.listJobsForRepo(repo.id, 10)) { + if (older.finished_at === null) store.updateJob(older.id, { status: 'succeeded', finished_at: new Date().toISOString() }) + } + const created = store.createJob({ id, repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'fix bugs', callbackHash: 'h', platformTokenHash: '' }) + if (temporal !== null) store.updateJob(id, { temporal_json: temporal }) + return store.getJob(created.id)! + } + const viewer = { user: store.userById(user.id)!, csrfToken: 'csrf' } + const page = (job: JobRow): string => jobPage({ config, locale: 'en', viewer, repo: store.getRepo(repo.id)!, job, events: [] }) + const withDelta = page(make('job_t_page', JSON.stringify({ sinceSha: '4f2c1a9e8d3b2a1c0f9e8d7c6b5a4f3e2d1c0b9a', status: 'ahead', aheadBy: 3, mergedPrs: [812, 815], complete: true }))) + assert.match(withDelta, /data-temporal>Since 4f2c1a9: 3 commits ahead, 2 merged PRs \(#812, #815\)