From 63163f8c763104768446f16ee606c0629296bcbb Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 10 Nov 2026 21:26:13 -0500 Subject: [PATCH 1/2] Show live Fix and Review targets in compact Working rows --- console/README.md | 6 ++ console/tests/working-targets.test.cjs | 114 +++++++++++++++++++++++++ docs/guide.md | 2 + site/architecture/index.html | 1 + site/docs/index.html | 1 + site/how-it-works/index.html | 2 +- site/index.html | 2 +- src/console/assets/console.css | 2 + src/console/row.html | 5 +- src/fix/base_ci.rs | 1 + src/logs.rs | 5 ++ src/logs/command_record.rs | 4 + src/logs/recording.rs | 7 ++ src/logs/status/inbox.rs | 17 +++- tests/console/inbox.rs | 3 +- tests/fix_base_ci.rs | 26 +++++- 16 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 console/tests/working-targets.test.cjs diff --git a/console/README.md b/console/README.md index dd4ee9baa..edf962ba5 100644 --- a/console/README.md +++ b/console/README.md @@ -40,6 +40,12 @@ Command name, including Security runs, Triage runs and Custom processes. Long repository names wrap on desktop and phone; the full Command subject remains available on hover and in Command details. +Live Fix runs and Review runs also show a clickable **PR #number** in the compact +Working row, including Overview, for explicit and Pass-selected targets, locally +and across the Fleet. Base branch CI repairs show their recorded **Base branch** +before PR publication and retain it alongside the PR link afterwards. Older +records without a target keep their repository and Command label. + For Console CSS and JavaScript development, serve the assets from this checkout: ```sh diff --git a/console/tests/working-targets.test.cjs b/console/tests/working-targets.test.cjs new file mode 100644 index 000000000..066753b8b --- /dev/null +++ b/console/tests/working-targets.test.cjs @@ -0,0 +1,114 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn, execFileSync } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, writeFile, readFile, rm } = require('node:fs/promises'); +const { tmpdir, hostname } = require('node:os'); +const { join, resolve } = require('node:path'); +const { createInterface } = require('node:readline'); +const { chromium } = require('./playwright.cjs'); +const { replaceRecord } = require('./records.cjs'); + +test('Working identifies live Review and Fix targets locally and across the Fleet', { skip: process.platform !== 'linux' }, async t => { + const root = await mkdtemp(join(tmpdir(), 'thirdshift-working-targets-')); + const binary = process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'); + const children = []; + let browser; + t.after(async () => { + await browser?.close(); + for (const { child, exited } of children) { + if (child.exitCode === null) child.kill(); + await exited; + } + await rm(root, { recursive: true, force: true }); + }); + const homes = ['local', 'remote'].map(name => join(root, name)); + const cli = (home, args, input) => execFileSync(binary, args, { env: { ...process.env, HOME: home }, input, encoding: 'utf8' }); + const records = []; + for (const home of homes) { + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/fix'); + await mkdir(dir, { recursive: true }); + await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); + const child = spawn(binary, ['console', '--no-sign-in'], { + env: { ...process.env, HOME: home, THIRDSHIFT_FLEET_INTERVAL_MS: '100' }, stdio: ['ignore', 'pipe', 'inherit'], + }); + const running = { child, exited: once(child, 'exit') }; + children.push(running); + const lines = createInterface({ input: child.stdout }); + try { [running.origin] = await once(lines, 'line', { signal: AbortSignal.timeout(15000) }); } + finally { lines.close(); } + const stat = await readFile(`/proc/${child.pid}/stat`, 'utf8'); + const start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`; + for (const [name, kind, number] of [ + ['review-explicit', 'Review run', 41], ['review-pass', 'Review run', 42], + ['fix-explicit', 'Fix run', 43], ['fix-pass', 'Fix run', 45], + ['fix-base', 'Fix run', null], ['fix-legacy', 'Fix run', null], + ]) { + const now = new Date().toISOString(); + const value = { version: 1, name, kind, repository: 'acme/widgets', hostname: 'fixture', + process_id: child.pid, process_start: start, harness: 'codex', + started_at: now, ended_at: null, outcome: null, issue: null, + stages: [{ name: 'ci-fix', began_at: now, session_log: null }], + pull_request: number && `https://github.com/acme/widgets/pull/${number}`, + ...(name === 'fix-base' ? { base_branch: 'release/2026.11', issue_branch: 'unrelated-repair-name' } : {}), + }; + const path = join(dir, `${name}.command.json`); + await replaceRecord(path, JSON.stringify(value)); + if (name === 'fix-base') records.push({ path, value }); + } + } + const token = cli(homes[1], ['console', 'token', 'create', 'caller', '--scopes', 'read']).trim(); + cli(homes[0], ['console', 'fleet', 'add', 'remote', children[1].origin], `${token}\n`); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + for (const [width, height, capture] of [[1440, 900, 'DESKTOP'], [390, 844, 'MOBILE']]) { + await page.setViewportSize({ width, height }); + for (const section of ['overview', 'working']) { + await page.goto(`${children[0].origin}/?section=${section}`); + await page.locator('.command[data-machine="remote"]').first().waitFor(); + if (section === 'overview' && process.env[`BEFORE_${capture}`]) { + await page.screenshot({ path: process.env[`BEFORE_${capture}`], fullPage: true }); + } + if (process.env.CAPTURE_BEFORE) continue; + const rows = page.locator('#inbox > [data-group="working"] .command'); + assert.equal(await rows.count(), 12); + for (const machine of [hostname(), 'remote']) { + for (const [name, kind, number] of [ + ['review-explicit', 'Review run', 41], ['review-pass', 'Review run', 42], + ['fix-explicit', 'Fix run', 43], ['fix-pass', 'Fix run', 45], + ]) { + const row = page.locator(`.command[data-machine="${machine}"][data-command*="${name}.command.json"]`); + const subject = row.locator('.row-head .subject'); + const link = subject.getByRole('link', { name: `PR #${number}`, exact: true }); + assert.equal(await link.isVisible(), true, `${width}px ${machine} ${name}: visible PR target`); + assert.equal(await link.getAttribute('href'), `https://github.com/acme/widgets/pull/${number}`); + assert.equal(await subject.getByText('acme/widgets', { exact: true }).isVisible(), true); + assert.match(await subject.textContent(), new RegExp(kind)); + } + const base = page.locator(`.command[data-machine="${machine}"][data-command*="fix-base.command.json"]`); + assert.equal(await base.locator('.row-head').getByText('Base branch release/2026.11', { exact: true }).isVisible(), true); + assert.equal(await base.locator('.row-head').getByRole('link', { name: 'PR #44', exact: true }).count(), 0); + const legacy = page.locator(`.command[data-machine="${machine}"][data-command*="fix-legacy.command.json"]`); + assert.match(await legacy.locator('.subject').textContent(), /acme\/widgets/); + assert.equal(await legacy.locator('.row-head').getByText(/^Base branch /).count(), 0); + } + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + if (section === 'overview' && process.env[`AFTER_${capture}`]) { + await page.screenshot({ path: process.env[`AFTER_${capture}`], fullPage: true }); + } + } + } + if (process.env.CAPTURE_BEFORE) return; + for (const { path, value } of records) { + await replaceRecord(path, JSON.stringify({ ...value, pull_request: 'https://github.com/acme/widgets/pull/44' })); + } + // Both local notifications and Fleet polling update the open compact rows. + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + for (const machine of [hostname(), 'remote']) { + const base = page.locator(`.command[data-machine="${machine}"][data-command*="fix-base.command.json"] .row-head`); + await base.getByRole('link', { name: 'PR #44', exact: true }).waitFor(); + assert.equal(await base.getByText('Base branch release/2026.11', { exact: true }).isVisible(), true); + } + } +}); diff --git a/docs/guide.md b/docs/guide.md index 6e18b4341..37a1b37ce 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -405,6 +405,8 @@ Open the localhost address it prints. The Console shows the same Inbox as `third Click the serving machine's name in the Console header to return to Overview (`/?section=overview`) from another page or section, clearing any machine filter. +Live Fix runs and Review runs show a clickable **PR #number** beside their repository and run type in compact Working rows, including Overview, on desktop and phone. Explicit targets and Pass-selected targets use the same presentation for local and Fleet Commands. A Fix Pass repairing Base branch CI shows the exact selected **Base branch** before a PR exists, and keeps that branch visible alongside the PR link once published. Older records without a recorded target keep their repository and Command label. + Every Inbox group starts open. Click its heading, or focus it and press Enter or Space, to fold its rows while keeping its heading and count visible. Each browser remembers its group choices through live updates, folder changes and reloads when storage is available. Folding still works through live updates without storage, and native disclosures work without JavaScript. Counts and active-run totals include folded rows; keyboard row navigation skips them. This works in every Theme and on phones. Command details shows the last five lines of recent Command output above **Full Command log**, updating as output arrives. It reads at most 4 KiB, so a long log stays a small preview. This helps assess a Slow run without leaving its details. Empty or unavailable logs say so; the full-log link stays available. diff --git a/site/architecture/index.html b/site/architecture/index.html index e3da2d25c..8c272c8da 100644 --- a/site/architecture/index.html +++ b/site/architecture/index.html @@ -215,6 +215,7 @@

Architecture

The Inbox's batched pull request read also captures titles and the latest head commit's combined checks and status. Ready for review subjects link to the recorded PR and show its cached title, including Review runs without an issue. Missing titles keep the PR link with “(title unavailable)”; rows without a PR use their issue or Command subject. Title changes wake open pages without a network read during rendering. Ready for review rows show CI green, red, pending, no checks or unknown, using the same saved answer as status. CI changes wake open pages without changing the row's Ready state; failed reads keep the last answer with the existing freshness warning.

The same query captures pull request mergeability independently of head CI. A conflicting pull request stays Ready for review with a separate Merge conflict pill beside CI, even when CI is green. Mergeability changes also wake open pages; unknown answers show no conflict pill. status --json includes github_mergeable, and show --json reads the locally kept GitHub facts and observation times without refreshing them over the network.

The Console reads ready rows' pull requests and Needs you rows' issues and pull requests in a batched GraphQL query in the background once a minute, with blocking gh calls off its async thread. It keeps one answer per machine beside the logs. A changed answer or warning wakes open Inbox pages through the file watcher; unchanged reads update freshness without rendering or waking pages. thirdshift status uses that answer and reads GitHub itself only when it is missing or five minutes old. Failed reads retain the last facts and failure; after five minutes without a good read, both readers warn with when GitHub last answered and why the latest read failed. Both share a quota gate: fewer than 500 GraphQL points or REST requests holds off all their reads until reset, with a warning, preserving the rest for the factory. These limits and timings are not User config settings. An ended Needs you row moves to Done, Resolved or History when its issue closes or its pull request merges; a Triage row also moves when needs-triage is removed. Architect and Security runs use their dispatched Spec run's or Ticket run's issue and pull request. Live Silent and Slow rows stay until their Commands end. Ready rows leave on a merged or closed pull request. Resolved counts from the settlement time, and only rows GitHub cannot speak for age out while still waiting. Reopening brings back rows still in Done or Resolved. Rows in History retain the last facts without further reads, including when another row shares their issue or pull request. Dismissals hold across GitHub changes, and Undo uses the latest kept facts. These reads never write to GitHub (ADR-0023).

+

Local and Fleet Commands share the compact Working-row presentation, including Overview: live Fix and Review runs show a clickable PR #number beside the repository and run type on desktop and phone, for explicit and Pass-selected targets. Base branch CI repairs retain an optional base_branch in the Command record before the session starts. The row shows that exact selection before a PR exists and keeps it alongside the PR link once recorded, without deriving it from the repair branch or requesting GitHub. Older records keep their existing fallback.

Security fix dispatch stores advisory metadata in the dispatched Command record’s security_fix_findings list before launch checks. Command details links each advisory with its title and severity (or “ungraded”), and the parent Security run marks the advisories it dispatched a fix for using that same list. Failed and interrupted fixes retain their links. The list is also exposed by thirdshift show <command> --json. Older records need no migration and no advisory links are inferred from issue bodies.

Any signed-in account can use Dismiss on the row in Needs you or Ready for review, always visible beside Copy where available. With JavaScript, d dismisses the selected row and selects the next; text fields and open dialogs keep their keys. An inline dismissal shows a temporary notice with Undo for that Command, outside the live Inbox so row replacement cannot remove it. The notice stays while hovered, focused, saving or showing an error. Without JavaScript, rows keep their full Dismiss form. Command details also offers Dismiss and Undo for a dismissed row. Command details shows each CLI form: thirdshift dismiss <command> or thirdshift dismiss <command> --undo. Both use the Console's session and CSRF checks. The browser keeps pending feedback and disables duplicate submissions through live updates; failed requests show their error and allow a retry. The dismissal lives beside the Command record and changes only the row's group; filesystem notifications update open pages, and the Activity log names the account and the Console. A live Command keeps running, and nothing is written to GitHub.

A dismissal accumulates covered kinds for the whole row, across child Ticket runs, and status --json exposes them as dismissal.kinds. An uncovered kind brings the row back, including a Finding behind a dismissed Security failure only while GitHub still shows its findings waiting. Each read adds one paginated REST list per repository with a Security run whose findings were waiting, respecting the quota reserve and using the Security run’s private-storage selection. The kept answer holds only finding URLs and waiting flags, with the first observed triage time; it retains no finding title, severity or write-up. A Finding leaves saying “findings triaged” once its records no longer wait: a draft advisory without severity or an open security-finding issue labelled needs-triage waits, and a record closed by a merged fix does not.

diff --git a/site/docs/index.html b/site/docs/index.html index 8fb1a1460..bcce12216 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -380,6 +380,7 @@

Open the localhost address it prints. The Console shows the same Inbox as thirdshift status, with live stages, logs and Session traces. It opens on Overview, showing Needs you, Ready for review, Landing and Working together under labeled headings with counts. The folders beside the one-line rows filter to an individual section; /?section=done opens Done and /?section=overview returns to Overview. Hidden sections stay hidden in Overview, and these views work without JavaScript. A clicked row's Command details open alongside on a wide screen. Ended rows show their relative age, with the recorded finish in this machine's time zone on hover, or in full without JavaScript, for example Ended 2026-10-23 19:03 EDT · 52 min ago. Every date and time the Console shows reads this way, to the minute. This is time since finish, not run duration. Nested Ticket runs and History use each Command's own finish time; missing or invalid finish times say End time unknown. The finish information also works without JavaScript. With run, use Start work on Inbox or Run now on Cron jobs to request a Command with confirmation. The Console starts work only when asked; the CLI and operating system's scheduler remain available too.

Click the serving machine's name in the Console header to return to Overview (/?section=overview) from another page or section, clearing any machine filter.

+

Live Fix runs and Review runs show a clickable PR #number beside their repository and run type in compact Working rows, including Overview, on desktop and phone. Explicit targets and Pass-selected targets use the same presentation for local and Fleet Commands. A Fix Pass repairing Base branch CI shows the exact selected Base branch before a PR exists, and keeps that branch visible alongside the PR link once published. Older records without a recorded target keep their repository and Command label.

Every Inbox group starts open. Click its heading, or focus it and press Enter or Space, to fold its rows while keeping its heading and count visible. Each browser remembers its group choices through live updates, folder changes and reloads when storage is available. Folding still works through live updates without storage, and native disclosures work without JavaScript. Counts and active-run totals include folded rows; keyboard row navigation skips them. This works in every Theme and on phones.

Command details shows the last five lines of recent Command output above Full Command log, updating as output arrives. It reads at most 4 KiB, so a long log stays a small preview. This helps assess a Slow run without leaving its details. Empty or unavailable logs say so; the full-log link stays available.

Fleet

diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html index 95c4f40e9..ce1ee75b1 100644 --- a/site/how-it-works/index.html +++ b/site/how-it-works/index.html @@ -246,7 +246,7 @@

How thirdshift works

Review Passes recognize reviewed and pushed heads only in comments by the GitHub account thirdshift acts as. When an upper pull request lacks Review run fixes delivered to its base, its comment says it needs updating before merge; the author updates the stack.

A Review retry recovers its clean retained checkout only after verifying ownership, the same captured PR head and descendant commits. Other local conflicts stay preserved: author Passes notify once per state and scan later waiting pull requests; explicit PR targets fail with recovery advice.

-

Review runs and pull-request Custom processes show their target PR URL in thirdshift status and the Console's Working row while running. The Command log names the URL before the first session starts.

+

Review runs and pull-request Custom processes show their target PR URL in thirdshift status while running. The Command log names the URL before the first session starts. Live Fix and Review targets appear as clickable PR #number links beside their repository and run type in compact Working rows, including Overview, on desktop and phone. Local and Fleet rows show explicit and Pass-selected targets alike. A Base branch CI repair shows its exact selected Base branch before publication, then keeps it beside the PR link; older records without a target keep their Command label.

Addressing Review runs and pull-request Custom processes watch checks and commit statuses after publishing, on the exact delivered head even when no fixes were pushed. They wait for pending CI before reporting ready; absent CI passes after the grace period. The branch's own failures allow up to five CI-fix Repairs, or a lower Custom-process cap, each validating and publishing its final commit before another CI watch. An unchanged Repair gets one eligible Check re-run, then stops if still red. Inherited failures, failed observations and unresolved red CI end failed with check evidence in the comment and Run notification. Concurrent PR changes stop Delivery; a final conflict requires an author update. Review never merges or rebases the author's branch or stack, replaces the PR or Self-merges. --recommend-only starts no CI-fix Repairs or branch changes.

After addressing findings, every Review run's session runs final validation from the repository's agent instructions and extra commands configured per repository in review.commands_by_repo in the User config. Its comment says what ran, passed, failed or could not run, with reasons. A suite invalid from a worktree is not run against the branch, never passing. With no list, repository final validation still runs. --recommend-only validates after reviewing too. If a later Step commits more changes, Delivery repeats validation on the final head before pushing and reports those results. When that report includes private Security review material, the comment says the results were withheld instead of quoting the session.

thirdshift review --author <login> is a Pass for cron: it takes that author's lowest-numbered eligible open pull request, one per invocation. Reviewed or pushed heads and in-progress pull requests are skipped, as are bases that are open pull requests not yet reviewed at their current head. At the Command limit, or with none waiting, it skips without a session or notification. Review runs reserve their pull request with in-progress, remove it when they end, and leave closed pull requests for the Sweep to clear after a crash.

diff --git a/site/index.html b/site/index.html index 509f7ff3d..121d40d2b 100644 --- a/site/index.html +++ b/site/index.html @@ -118,7 +118,7 @@

Your issues become pull requests overnight.

Review Passes recognize reviewed and pushed heads only in comments by the GitHub account thirdshift acts as. When an upper pull request lacks Review run fixes delivered to its base, its comment says it needs updating before merge; the author updates the stack.

Review retries recover clean retained worktrees with verified ownership and committed fixes above the same captured PR head. Author Passes skip other local conflicts, notify once per state and continue to later waiting pull requests. An explicit PR URL still fails with preservation advice.

-

Review runs and pull-request Custom processes record their target PR URL before the first session starts. It appears in thirdshift status, the Console's Working row and the Command log while the run is live.

+

Review runs and pull-request Custom processes record their target PR URL before the first session starts. It appears in thirdshift status and the Command log while the run is live. Compact Working rows, including Overview, show live Fix and Review targets as clickable PR #number links beside the repository and run type, on desktop and phone, locally and across the Fleet. Explicit and Pass-selected targets appear alike. A Base branch CI repair shows its exact Base branch before publication and keeps it beside the PR link afterwards; older records without a target keep their Command label.

thirdshift fix <PR URL or number> takes an open pull request by a Trusted account, ready or draft, straight through Delivery and the Repair loop against its own Base branch. With no PR target, thirdshift fix [base <branch>] [--author <login>] is a Pass taking the lowest-numbered eligible ready PR with a Merge conflict or CI red, skipping at the Command limit or when none can be reserved. It skips implementation and initial code review, reserves the PR until it ends, and supplies Repairs with trusted PR and linked closing-issue context. Add merge for Self-merge or base-fix for inherited failures; the usual User config defaults apply. Self-merge needs recorded agent review of the adopted head; without it the repaired PR stays ready for review. Failure preserves work, drafts the PR and offers a Fix run retry. Fix runs

Without --author, a Fix Pass first checks Base branch CI on origin. A failed first push attempt gets one failed-jobs re-run and a skip while it runs. Pending Base checks skip before PR selection too. Persistent or non-rerunnable failures get a CI-fix session on fix-<base>-<short-sha> and a repair PR targeting the Base branch, with every red check its own to fix. Failed opening sessions publish preserved work as draft repair PRs with a Fix Recovery command. An existing repair branch or PR prevents duplicates, including failed drafts. Completed Ticket, Spec and Base CI Deliveries record their exact agent-authored or reviewed heads for later Fix Self-merge. Merge and email flags and User config apply; Self-merge also closes the Base branch's CI-red reports and Base fix issues.

After publishing, addressing Review runs and pull-request Custom processes wait for checks and commit statuses on the exact delivered head, including unchanged branches. Up to five CI-fix Repairs validate and publish fixes for the branch's own failures; a Custom process may lower the cap. An unchanged Repair gets one eligible Check re-run. Inherited failures, unreadable CI and unresolved red CI end failed with check evidence in the comment and notification. No ready notification is sent while CI is pending. Concurrent PR changes stop Delivery, and a final merge conflict asks the author to update the branch. Review never merges or rebases the author's stack. --recommend-only stays report-only.

diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 9dffb1341..6e843d639 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -285,6 +285,8 @@ body:is(.mailbox, .archive-page).peek-open { max-width: 1600px; grid-template-co /* A Command name must never push its repository out of view. */ .row-head > .subject:has(> .subject-command) { min-width: 0; max-width: 100%; white-space: normal; overflow: visible; overflow-wrap: anywhere; } .subject-command { display: block; color: var(--dim); } +.working-target { display: block; overflow-wrap: anywhere; } +.row-head > .subject:has(.working-target) { white-space: normal; overflow: visible; } .machine-filter select { max-width: 100%; } .machine-filter, .fleet-machines { grid-column: 1 / -1; min-width: 0; overflow-wrap: anywhere; } .one-line .command > .machine-tag { display: block; padding: 4px 10px 0; font-size: .8em; color: var(--dim); overflow-wrap: anywhere; } diff --git a/src/console/row.html b/src/console/row.html index b68799597..0f96768c3 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,6 +1,9 @@
  • {% if !row.machine.is_empty() %}{% if row.console.is_empty() %}{{ row.machine }}{% else %}{{ row.machine }}{% endif %}{% endif %} -
    {% if row.live %}{% endif %}{{ row.state }} {% match row.account %}{% when Some with (account) %} {% when None %}{% endmatch %}{% match row.subject_url %}{% when Some with (url) %}{% match row.subject.rsplit_once('#') %}{% when Some with ((repository, number)) %}{{ repository }}#{{ number }}{% when None %}{{ row.subject }}{% endmatch %}{% when None %}{% match row.subject.rsplit_once(" on ") %}{% when Some with ((command, repository)) %}{{ repository }} {{ command }}{% when None %}{{ row.subject }}{% endmatch %}{% endmatch %} {{ row.title }}{{ self.gist()|readable }}{% match row.timing.finish %}{% when Some with (finish) %} · {% when None %}{% endmatch %}{{ row.timing.age }} +
    {% if row.live %}{% endif %}{{ row.state }} {% match row.account %}{% when Some with (account) %} {% when None %}{% endmatch %}{% match row.subject_url %}{% when Some with (url) %}{% match row.subject.rsplit_once('#') %}{% when Some with ((repository, number)) %}{{ repository }}#{{ number }}{% when None %}{{ row.subject }}{% endmatch %}{% when None %}{% match row.subject.rsplit_once(" on ") %}{% when Some with ((command, repository)) %}{{ repository }} {{ command }}{% when None %}{{ row.subject }}{% endmatch %}{% endmatch %} + {% match row.base_branch %}{% when Some with (base) %}Base branch {{ base }}{% when None %}{% endmatch %} + {% match row.target_pr %}{% when Some with (pr) %}PR #{{ pr.issue.number }}{% when None %}{% endmatch %} + {{ row.title }}{{ self.gist()|readable }}{% match row.timing.finish %}{% when Some with (finish) %} · {% when None %}{% endmatch %}{{ row.timing.age }} {% match row.ci %}{% when Some with (ci) %}{{ ci.label() }}{% if row.merge_conflict %}Merge conflict{% endif %}{% when None %}{% endmatch %} {% if can_copy || (can_act && (row.can_dismiss || row.remote_stop || row.remote_undo)) %}
    {% if can_copy %} {% endif %} {% if can_act && !row.console.is_empty() && row.remote_stop %}
    {% endif %} diff --git a/src/fix/base_ci.rs b/src/fix/base_ci.rs index eac9e93c2..a87f44f1e 100644 --- a/src/fix/base_ci.rs +++ b/src/fix/base_ci.rs @@ -56,6 +56,7 @@ pub(super) fn run( } harness.check()?; logs::started(Work::FixRun(repo), harness); + logs::base_branch(base); logs::issue_branch(Some(&branch)); let title = format!("Fix CI on {base}: {}", ci::title_check_names(&failed)); logs::issue_title(Some(title.clone())); diff --git a/src/logs.rs b/src/logs.rs index 934dbb532..444b86c6b 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -407,6 +407,11 @@ pub(crate) fn pull_request(url: &str) { record().pull_request(url); } +/// The selected Base branch of a Fix Pass's CI repair, before a PR exists. +pub(crate) fn base_branch(branch: &str) { + record().base_branch(branch); +} + /// Retain Delivery's final counts for the active Run's completion metrics. pub(crate) fn unaddressed_findings(counts: Option) { record().unaddressed_findings(counts); diff --git a/src/logs/command_record.rs b/src/logs/command_record.rs index 2dc49a226..8cf33a117 100644 --- a/src/logs/command_record.rs +++ b/src/logs/command_record.rs @@ -114,6 +114,9 @@ pub(super) struct CommandRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub resume_command: Option, pub issue_branch: Option, + /// The selected Base branch for a Fix Pass repairing its CI, not its repair branch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_branch: Option, pub pull_request: Option, #[serde(default)] pub parent_record: Option, @@ -448,6 +451,7 @@ impl CommandRecord { limit: None, resume_command: None, issue_branch: None, + base_branch: None, pull_request: None, parent_record: None, parent_dispatch: None, diff --git a/src/logs/recording.rs b/src/logs/recording.rs index 4a257f07d..1afadee37 100644 --- a/src/logs/recording.rs +++ b/src/logs/recording.rs @@ -453,6 +453,13 @@ impl Record { } } + pub(super) fn base_branch(&mut self, branch: &str) { + if let Some((_, tree)) = &mut self.command_record { + tree.active().base_branch = Some(branch.into()); + self.write_record(); + } + } + pub(super) fn pull_request(&mut self, url: &str) { if let Some((_, tree)) = &mut self.command_record { tree.active().pull_request = Some(url.into()); diff --git a/src/logs/status/inbox.rs b/src/logs/status/inbox.rs index 4d0a90a9e..a72e75736 100644 --- a/src/logs/status/inbox.rs +++ b/src/logs/status/inbox.rs @@ -47,6 +47,8 @@ pub(crate) struct Row { pub subject: String, /// The pull request or issue's `https://` address, which the subject links to. pub subject_url: Option, + pub target_pr: Option, + pub base_branch: Option, pub title: String, pub state: String, pub now_line: String, @@ -321,6 +323,7 @@ fn present(value: &serde_json::Value, now: DateTime) -> Result { command_log: Option, cause: Option, pull_request: Option, + base_branch: Option, harness: Option, model: Option, effort: Option, @@ -372,7 +375,10 @@ fn present(value: &serde_json::Value, now: DateTime) -> Result { .unwrap_or_default(); let process = (facts.kind == "Process").then(|| facts.process_name.as_deref().unwrap_or(&facts.name)); - let name = if value["source"] == "command_record" { + let live_target = live && matches!(facts.kind.as_str(), "Fix run" | "Review run"); + let name = if live_target { + &facts.kind + } else if value["source"] == "command_record" { &facts.name } else { &facts.kind @@ -434,6 +440,13 @@ fn present(value: &serde_json::Value, now: DateTime) -> Result { logs_url: String::new(), subject, subject_url, + target_pr: facts + .pull_request + .as_deref() + .filter(|_| live_target) + .and_then(|url| crate::review::PullRequestUrl::parse(url).ok()) + .filter(|pr| pr.issue.repo_slug().eq_ignore_ascii_case(&facts.repository)), + base_branch: facts.base_branch.filter(|_| live_target), title, state, now_line: super::stage_details( @@ -620,6 +633,8 @@ fn entry_row(entry: Observation<'_>, now: DateTime, root: &std::path::Pat .unwrap_or_else(|| "/?page=logs".into()), subject, subject_url: issue_url(command.issue.as_ref()), + target_pr: None, + base_branch: None, title, state: command.state.into(), now_line: command diff --git a/tests/console/inbox.rs b/tests/console/inbox.rs index 334ea71c7..ed70cb12e 100644 --- a/tests/console/inbox.rs +++ b/tests/console/inbox.rs @@ -98,7 +98,8 @@ fn a_live_review_run_links_its_pull_request_in_working() { let working = section(&html, "working"); assert!(working.contains("data-live=\"true\""), "{working}"); - assert!(working.contains("

    "), "{working}"); + assert!(working.contains("class=\"working-target\""), "{working}"); + assert!(working.contains("PR #1"), "{working}"); assert!(working.contains(&format!("href=\"{url}\"")), "{working}"); } diff --git a/tests/fix_base_ci.rs b/tests/fix_base_ci.rs index 935a0db0d..f6fb9a797 100644 --- a/tests/fix_base_ci.rs +++ b/tests/fix_base_ci.rs @@ -251,8 +251,20 @@ fn named_base_with_no_rerunnable_jobs_repairs_its_origin_tip() { state["runs"][0]["head_sha"] = tip.trim().into(); state["runs"][0]["jobs"] = json!([]); scenario.write_gh_state(&state); - scenario.agent_does(FIX); - let result = scenario.run(&["fix", "base", "release", "no-email"]); + scenario.agent_waits_for_release(FIX); + let held = scenario.run_until(&["fix", "base", "release", "no-email"], &[], "started"); + let status = scenario.run(&["status", "--local", "--json"]); + let status: serde_json::Value = serde_json::from_str(&status.stdout).unwrap(); + let working = status["commands"] + .as_array() + .unwrap() + .iter() + .find(|command| command["kind"] == "Fix run") + .unwrap(); + assert_eq!(working["base_branch"], "release"); + assert!(working["pull_request"].is_null()); + std::fs::write(scenario.path("release"), "go").unwrap(); + let result = held.finish(); assert_eq!(result.code, Some(0), "{}", result.stderr); let state = scenario.gh_state(); assert_eq!(state["prs"][0]["base"], "release"); @@ -262,6 +274,16 @@ fn named_base_with_no_rerunnable_jobs_repairs_its_origin_tip() { format!("fix-release-{}", &tip[..7]) ); assert!(scenario.gh_calls_of("run", "rerun").is_empty()); + let status = scenario.run(&["status", "--local", "--json"]); + let status: serde_json::Value = serde_json::from_str(&status.stdout).unwrap(); + let repaired = status["commands"] + .as_array() + .unwrap() + .iter() + .find(|command| command["kind"] == "Fix run") + .unwrap(); + assert_eq!(repaired["base_branch"], "release"); + assert_eq!(repaired["pull_request"], state["prs"][0]["url"]); } /// A commit status or external check can be posted by a principal who cannot From a89261c75f400e0168be011bccbb111b93212883 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 10 Nov 2026 21:32:11 -0500 Subject: [PATCH 2/2] Preserve targetless subject text in the row template --- src/console/row.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/console/row.html b/src/console/row.html index 0f96768c3..6d3bc4881 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,8 +1,8 @@

  • {% if !row.machine.is_empty() %}{% if row.console.is_empty() %}{{ row.machine }}{% else %}{{ row.machine }}{% endif %}{% endif %}
    {% if row.live %}{% endif %}{{ row.state }} {% match row.account %}{% when Some with (account) %} {% when None %}{% endmatch %}{% match row.subject_url %}{% when Some with (url) %}{% match row.subject.rsplit_once('#') %}{% when Some with ((repository, number)) %}{{ repository }}#{{ number }}{% when None %}{{ row.subject }}{% endmatch %}{% when None %}{% match row.subject.rsplit_once(" on ") %}{% when Some with ((command, repository)) %}{{ repository }} {{ command }}{% when None %}{{ row.subject }}{% endmatch %}{% endmatch %} - {% match row.base_branch %}{% when Some with (base) %}Base branch {{ base }}{% when None %}{% endmatch %} - {% match row.target_pr %}{% when Some with (pr) %}PR #{{ pr.issue.number }}{% when None %}{% endmatch %} + {%- match row.base_branch %}{% when Some with (base) %}Base branch {{ base }}{% when None %}{% endmatch %} + {%- match row.target_pr %}{% when Some with (pr) %}PR #{{ pr.issue.number }}{% when None %}{% endmatch -%} {{ row.title }}{{ self.gist()|readable }}{% match row.timing.finish %}{% when Some with (finish) %} · {% when None %}{% endmatch %}{{ row.timing.age }} {% match row.ci %}{% when Some with (ci) %}{{ ci.label() }}{% if row.merge_conflict %}Merge conflict{% endif %}{% when None %}{% endmatch %} {% if can_copy || (can_act && (row.can_dismiss || row.remote_stop || row.remote_undo)) %}
    {% if can_copy %} {% endif %}