diff --git a/console/README.md b/console/README.md index dd4ee9baa..8283ed5db 100644 --- a/console/README.md +++ b/console/README.md @@ -207,7 +207,14 @@ folding still works through live updates. Keyboard row navigation skips folded rows, and the same controls work in every Theme and on phones. Without JavaScript, groups start open and their native disclosures still work. The folders on the left can filter to one section, including Done and Resolved. -Rows show on one line each: state, subject, title, gist and age. Ready for review +Rows show state, subject, title, gist and age. A Spec with recorded Tickets also +shows `2 / 5 Tickets complete`, including on phones and on the visible row of a +Pass that dispatched it. This counts recorded merged Tickets, including Tickets +closed before the run, and updates with the Command record. Fleet uses the owning +machine's records. The last count remains after the run ends; absent or empty +Ticket accounting shows no counter. Completing all Tickets still leaves Spec +review and Delivery to finish; the row's state and stage show where it stands. +Ready for review subjects link to the recorded pull request and show its title from the batched GitHub refresh, including Review runs without an issue. Until a title is known, the PR link stays usable with “(title unavailable)”. Without a PR, rows keep diff --git a/console/tests/fleet.test.cjs b/console/tests/fleet.test.cjs index 039a2e8e3..c530095bd 100644 --- a/console/tests/fleet.test.cjs +++ b/console/tests/fleet.test.cjs @@ -50,6 +50,10 @@ test('Fleet Inbox polls with page subscriptions, retains rows, and fits a phone' issue: null, process_id: 0, process_start: 'gone', harness: 'codex', started_at: now, ended_at: now, stages: [], session_log: null, outcome: 'failed', cause: index ? 'Remote fixture needs your attention' : 'Local fixture needs your attention', + tickets: index ? ['merged', 'waiting'].map((state, i) => ({ + issue: { number: 10 + i, url: `https://github.com/acme/widgets/issues/${10 + i}`, title: `Ticket ${i}` }, + state, blockers: [], + })) : [], })); } const cli = (home, args, input) => execFileSync(binary, args, { env: { ...process.env, HOME: home }, input, encoding: 'utf8' }); @@ -97,6 +101,8 @@ test('Fleet Inbox polls with page subscriptions, retains rows, and fits a phone' assert.equal(await page.locator('.command').count(), 2); await remoteRows.getByRole('button', { name: 'Dismiss', exact: true }).waitFor(); assert.match(await remoteRows.locator('.machine-tag').textContent(), /remote-workstation/); + await remoteRows.locator(':scope > .row-head > .ticket-progress').getByText('1 / 2 Tickets complete', { exact: true }).waitFor(); + assert.equal(await page.locator('.command[data-console=""] .ticket-progress').count(), 0, 'local records supply no progress'); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); if (process.env.AFTER_DESKTOP) { for (const [width, height, path] of [[1440, 900, process.env.AFTER_DESKTOP], [390, 844, process.env.AFTER_MOBILE]]) { @@ -124,9 +130,11 @@ test('Fleet Inbox polls with page subscriptions, retains rows, and fits a phone' await page.waitForFunction(() => localStorage.getItem('thirdshift:console:notification-count')); const remotePath = join(homes[1], '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); const added = JSON.parse(await readFile(remotePath, 'utf8')); + added.tickets[1].state = 'merged'; added.kind = 'Process'; added.process_name = 'custom-build'; await writeFile(remotePath, JSON.stringify(added)); await remoteRows.locator('.subject[title="Process custom-build on acme/widgets"]').waitFor(); + await remoteRows.locator(':scope > .row-head > .ticket-progress').getByText('2 / 2 Tickets complete', { exact: true }).waitFor(); added.name = 'another-remote-command'; added.cause = 'Another remote problem'; await writeFile(remotePath.replace('1-fixture', '2-fixture'), JSON.stringify(added)); await page.waitForFunction(() => notices.length === 1); diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 6ca2108fc..562b1c4a6 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -66,6 +66,73 @@ async function pageFor(t, options) { return page; } +test('Ticket progress stays visible with stage and health, updates, and follows a dispatched Spec', { skip: process.platform !== 'linux' }, async t => { + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const original = await readFile(path, 'utf8'); + const fixture = JSON.parse(original); + const stat = await readFile(`/proc/${server.pid}/stat`, 'utf8'); + const start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`; + const began = new Date(Date.now() - 15 * 60 * 1000).toISOString(); + const log = path.replace('.command.json', '.log'); + Object.assign(fixture, { + name: 'spec-progress', process_id: server.pid, process_start: start, + started_at: began, ended_at: null, outcome: null, cause: null, + stages: [{ name: 'Tickets', began_at: began, session_log: null }], + tickets: ['merged', 'merged', 'running', 'failed', 'waiting', 'not started'].map((state, index) => ({ + issue: { number: 310 + index, url: `https://github.com/acme/widgets/issues/${310 + index}`, title: `Ticket ${index}` }, + state, blockers: [], + })), + }); + const page = await pageFor(t); + const row = page.locator('#inbox > .group > .commands > .command[data-command*="1-fixture.command.json"]'); + const head = row.locator(':scope > .row-head'); + const progress = head.locator('.ticket-progress'); + try { + await utimes(log, new Date(began), new Date(began)); + await replaceRecord(path, JSON.stringify(fixture)); + await page.goto(`${origin}/?section=overview`); + for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { + await page.setViewportSize(viewport); + for (const family of ['focus', 'plain', 'press', 'signal', 'garden', 'factory']) { + await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, `${family}-light`); + await progress.getByText('2 / 6 Tickets complete', { exact: true }).waitFor(); + await head.locator('.gist').getByText(/Tickets.*silent 15 min/).waitFor(); + assert.equal(await head.locator('.state').textContent(), 'Silent'); + assert.equal(await progress.evaluate(el => el.scrollWidth <= el.clientWidth), true, `${family}: full counter`); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${family}: page fits`); + } + } + fixture.tickets[2].state = 'merged'; + await replaceRecord(path, JSON.stringify(fixture)); + await progress.getByText('3 / 6 Tickets complete', { exact: true }).waitFor(); + + const now = new Date().toISOString(); + const spec = { ...fixture, started_at: now, tickets: fixture.tickets.map(ticket => ({ ...ticket, state: 'merged' })), + stages: [{ name: 'Spec review', began_at: now, session_log: null }] }; + Object.assign(fixture, { kind: 'Pickup run', name: 'pickup-progress', issue: null, tickets: [], + stages: [{ name: 'dispatched Spec run', began_at: now, session_log: null }], dispatched: [spec] }); + await replaceRecord(path, JSON.stringify(fixture)); + await progress.getByText('6 / 6 Tickets complete', { exact: true }).waitFor(); + assert.equal(await row.getAttribute('data-live'), 'true', 'Tickets complete still leaves Spec review running'); + assert.equal(await row.evaluate(el => el.closest('.group').dataset.group), 'working'); + assert.equal(await row.locator(':scope > .children').isVisible(), false, 'progress is on the visible Pass row'); + for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { + await page.setViewportSize(viewport); + await progress.waitFor({ state: 'visible' }); + await head.locator('.gist').waitFor({ state: 'visible' }); + assert.equal(await progress.evaluate(el => el.scrollWidth <= el.clientWidth), true); + } + fixture.outcome = 'failed'; fixture.ended_at = now; + fixture.dispatched[0].outcome = 'failed'; fixture.dispatched[0].ended_at = now; + await replaceRecord(path, JSON.stringify(fixture)); + await head.locator('.state').getByText('Failed', { exact: true }).waitFor(); + assert.equal(await progress.textContent(), '6 / 6 Tickets complete', 'ended accounting is retained'); + } finally { + await replaceRecord(path, original); + await utimes(log, new Date(), new Date()); + } +}); + test('the header machine name returns to the full Overview from filtered and secondary pages', async t => { for (const javaScriptEnabled of [true, false]) { for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { diff --git a/docs/guide.md b/docs/guide.md index 6e18b4341..f8e158757 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. +Specs with recorded Tickets show a count such as **2 / 5 Tickets complete** on their Inbox row, including on phones and when a Pass dispatched the Spec. It counts merged Tickets, including those closed before the run, from the owning machine's last recorded accounting. Record updates refresh the count, and ended runs retain it. Missing or empty accounting shows no counter. All Tickets complete can still leave Spec review or Delivery running; the row keeps its state, stage and health information. + 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..bd2456c7c 100644 --- a/site/architecture/index.html +++ b/site/architecture/index.html @@ -199,6 +199,7 @@

Architecture

Rust's data-carrying enums represent the unattended Ticket run, Claim and CI states. Exhaustive match means adding a state fails compilation until every match handles it. Pickup's reservation protocol across competing hosts is model-checked with stateright inside cargo test (ADR-0017). These checks matter when nobody is watching the control flow.

Console and host resources +

Inbox Ticket progress comes from typed Ticket records: merged Tickets over all recorded Tickets, including those closed before the Spec run. It updates with existing record notifications, uses the owning machine's records for Fleet, and stays visible on a Pass that dispatched a Spec. Empty accounting has no counter; completing Tickets does not finish Spec review or Delivery.

Fleet starts and administration call the owning machine's API. Start choices and cron Passes come from its /api/v1/start-options, gated by run; User config and Update require admin. Controls use live whoami scopes for the signed-in person. Page forwarding names that person; the owner intersects their scopes with the token's, starts the Command and records the person and calling Console. CLI start, config and update --machine use the token holder. API endpoints stay local and cannot borrow another Console's Fleet credentials.

Cron jobs' explicit Fleet schedules page and cronjobs --fleet [--json] share request-owned aggregation of local and owning-machine inventories. Repository metadata and nullable Command kinds come from each owner; the client groups by repository and machine without resolving remote paths or converting cron time zones. Independent bounded reads preserve healthy results alongside unavailable, refused, incompatible and link-only states; link-only entries receive no requests. Page reads forward the signed-in person and require effective read access. /api/v1/cronjobs stays local and refuses Fleet selection, and Run now keeps its separate authorization.

The Inbox polls live Fleet entries every 15 seconds only while a page subscription is open. Each request runs independently off the async executor with its own three-second timeout, so a bad machine never holds up a good one or a page request. Last good rows and the last error stay in memory only. Rows carry machine tags; each machine shows its answer age and active-run count. After 60 seconds without a good answer it is unreachable, with grey rows. Refused tokens and older Consoles have distinct diagnostics and links. ?machine=<name> filters the Inbox; its absence includes the Fleet in every group, the tab's Needs you count and browser notifications. Remote details use a request-owned client to fetch the owning machine's /api/v1 and render with the local Peek, Waterfall and log templates. Nothing is cached beyond the request or written to disk. The owning machine enforces security for Security run details and findings; the caller renders its refusal. Detail links retain the Fleet entry, and each row and view also links to the owning Console. The CLI's show and logs use the same remote reader with --machine <name>, returning the owner's JSON.

diff --git a/site/docs/index.html b/site/docs/index.html index 8fb1a1460..443e1c951 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.

+

Specs with recorded Tickets show a count such as 2 / 5 Tickets complete on their Inbox row, including on phones and when a Pass dispatched the Spec. It counts merged Tickets, including those closed before the run, from the owning machine's last recorded accounting. Record updates refresh the count, and ended runs retain it. Missing or empty accounting shows no counter. All Tickets complete can still leave Spec review or Delivery running; the row keeps its state, stage and health information.

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..6791be655 100644 --- a/site/how-it-works/index.html +++ b/site/how-it-works/index.html @@ -186,6 +186,7 @@

How thirdshift works

A later Command on the same pull request, as on the same issue, takes up an earlier row waiting in Ready for review or Needs you. This includes a Fix run on a pull request with no linked issue: the earlier row says “taken up by a later Command started at <time>”. Ready for review rows with CI red or Merge conflict offer Start Fix run when the owning machine currently permits run and knows the repository's Launch directory. Confirm the pull request, repository and machine to start there using its User config and CLI defaults; Cancel or Escape starts nothing. Other-account Commands stay read-only. Copy fix command remains on the row and in Command details: thirdshift fix <PR URL>. Copy it and run it from a clone of the repository.

Every Console tab title names its host; the Inbox also keeps its Needs you count through live updates.

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.

+

A Spec's Inbox row shows its last recorded Ticket progress, such as 2 / 5 Tickets complete, on desktop and phone. A Pass that dispatched the Spec carries that count on its visible row. All Tickets complete can still leave Spec review or Delivery running.

The Console and status show the serving OS account's Commands by default. thirdshift console other-accounts readable, or a comma-separated list of local account names, adds readable Other-account Commands with their owner shown. They appear in Needs you and its tab count, but have no Stop, Requeue, Dismiss or Undo. Those actions apply only to the serving account's Commands. Grant read access with filesystem permissions or ACLs; thirdshift never grants access. Account visibility.

Ready for review subjects link directly to the recorded pull request and show its title, including Review runs without an issue. Titles come from the batched GitHub refresh and update on open pages. Until a title is known, the PR link stays usable with “(title unavailable)”. Without a PR, rows use their issue subject and title, or the Command subject when neither exists. Activating the link opens GitHub without opening Details.

Command details opens with the Command's issue subject linked to the issue and its full title, above Stop and Dismiss. Long titles wrap; an unknown title says “(title unavailable)”. A Command without an issue shows its Command subject alone. The recorded issue title is also in thirdshift show <command> --json.

diff --git a/site/index.html b/site/index.html index 509f7ff3d..a9a635fca 100644 --- a/site/index.html +++ b/site/index.html @@ -471,6 +471,7 @@

Seven workflows

The Console serves its read views as JSON at /api/v1, using scoped bearer tokens or the page's sign-in and the same JSON as each CLI twin's --json. A token without the security scope does not receive Security findings or Security session traces. Issue and revoke tokens from the CLI; console whoami identifies the machine and caller. Discover its routes with thirdshift console openapi or /api/v1/openapi.json. Failed tokens and passwords are slowed per client address, then refused for a cool-down with a retry time. Console API.

Pair another machine with thirdshift console fleet add <name> <address>, supplying its token on standard input. console fleet list --json shows saved scopes, version and last contact; console fleet remove <name> removes the entry and token. The header links every Fleet entry, including link-only entries without a token. Fleet pairing.

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.

+

Specs with recorded Tickets show 2 / 5 Tickets complete on their Inbox row, including on phones and when dispatched by a Pass. The count follows recorded Ticket completion; Spec review and Delivery keep their own state.

thirdshift status reads paired machines into the same Inbox groups, tagging each Command with its machine and showing the Fleet's active runs with a per-machine breakdown. status --machine <name> selects another machine. Unreachable, refused or older Consoles get their own diagnostic; the rest still prints.

The Console's Inbox combines those same Fleet groups, with machine tags, answer ages and active-run counts. It fetches every 15 seconds while a page subscription is open, keeping remote answers and errors only in memory. After 60 seconds without a good answer, the machine is unreachable and its last rows turn grey; refused tokens and older Consoles have their own status and Console link. Needs you counts and browser notifications include Fleet rows. Choose a Machine to keep its filter in the URL, or show the whole Fleet. Remote Command details, Waterfall, Command logs and Session traces open in the same Console, fetched live without storing remote content. Security run details need the owning token's security scope. Each remote row and details view also links to its owning Console; show and logs accept --machine <name> and return that machine's JSON.

Remote rows offer Stop with confirmation, Dismiss and Undo when the owning machine permits the signed-in person and token to act. The owning machine carries out the action and keeps its Activity log, naming the person and holder. Read-only people, read-only tokens and Other-account Commands have no controls. From the shell, use thirdshift stop <command> --machine <name> or thirdshift dismiss <command> --machine <name>, adding --undo to take a Dismissal back. CLI actions are attributed to the token's holder. Acting across the Fleet.

diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 9dffb1341..dd0d4d006 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -262,6 +262,7 @@ body:is(.mailbox, .archive-page).peek-open { max-width: 1600px; grid-template-co .one-line .command > .fix-command { display: flex; margin: 4px 10px 8px; } .one-line .command:hover { background: var(--sel-bg); } .one-line .row-head { display: grid; grid-template-columns: 6.5em minmax(8em, 16em) fit-content(60%) minmax(0, 1fr) auto fit-content(4em) fit-content(10em) auto; gap: 12px; padding: 7px 10px; } + .one-line .row-head:has(> .ticket-progress) { row-gap: 0; padding-block: 2px; line-height: 1.2; } .one-line .row-head > * { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .one-line .row-head > .heartbeat { position: absolute; left: 2px; top: 50%; margin-top: -3px; } .one-line .row-head > .state { grid-column: 1; justify-self: start; max-width: 100%; } @@ -269,7 +270,9 @@ body:is(.mailbox, .archive-page).peek-open { max-width: 1600px; grid-template-co .one-line .row-head > .issue-title { grid-column: 3; color: var(--text); } .one-line .row-head > .issue-title:empty { display: none; } .one-line .row-head > .gist { display: block; grid-column: 4; } - .one-line .row-head > .issue-title:empty + .gist { grid-column: 3 / 5; } + .one-line .row-head:not(:has(> .ticket-progress)) > .issue-title:empty + .gist { grid-column: 3 / 5; } + .one-line .row-head > .ticket-progress { grid-column: 2; grid-row: 2; color: var(--text); white-space: normal; } + .one-line .row-head:has(> .ticket-progress) > .gist { grid-column: 3 / -1; grid-row: 2; white-space: normal; } /* Keep the pull request indicators together as the row narrows. */ .one-line .row-head > .pr-checks { grid-column: 5; grid-row: 1; align-self: center; overflow: visible; } .one-line .row-head > .new { grid-column: 6; font-size: 0; } @@ -300,6 +303,7 @@ body:is(.mailbox, .archive-page).peek-open { max-width: 1600px; grid-template-co } @media (scripting: enabled) and (min-width: 761px) and (max-width: 1100px) { .one-line .row-head > .pr-checks { grid-column: 1 / -1; grid-row: 2; justify-self: start; } + .one-line .row-head:has(> .ticket-progress) > .pr-checks { grid-row: 3; } } @media (max-width: 760px) { #inbox { display: block; } @@ -307,12 +311,16 @@ body:is(.mailbox, .archive-page).peek-open { max-width: 1600px; grid-template-co .folders a { flex: none; } @media (scripting: enabled) { .one-line .row-head { grid-template-columns: auto minmax(0, 1fr) auto; row-gap: 2px; } + .one-line .row-head:has(> .ticket-progress) { row-gap: 2px; padding-block: 7px; line-height: 1.4; } .one-line .row-head > .new { display: none; } .one-line .row-head > .pr-checks { grid-column: 1 / 3; grid-row: 3; justify-self: start; } .one-line .row-head > .age { grid-column: 3; grid-row: 1; } .one-line .row-head > .row-controls { grid-column: 3; grid-row: 2; justify-self: end; } .one-line .row-head > .issue-title { grid-column: 1 / 3; grid-row: 2; } .one-line .row-head > .gist { display: none; } - .one-line .row-head > .issue-title:empty + .gist { display: block; grid-column: 1 / 3; grid-row: 2; } + .one-line .row-head:not(:has(> .ticket-progress)) > .issue-title:empty + .gist { display: block; grid-column: 1 / 3; grid-row: 2; } + .one-line .row-head > .ticket-progress { grid-column: 1 / -1; grid-row: 3; } + .one-line .row-head:has(> .ticket-progress) > .gist { display: block; grid-column: 1 / -1; grid-row: 4; white-space: normal; } + .one-line .row-head:has(> .ticket-progress) > .pr-checks { grid-row: 5; } } } diff --git a/src/console/row.html b/src/console/row.html index b68799597..6214a76f4 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,6 +1,7 @@
  • {% 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 }} + {% match row.ticket_progress %}{% when Some with (progress) %}{{ progress.complete }} / {{ progress.total }} Tickets complete{% when None %}{% endmatch %} {% 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/logs/status/inbox.rs b/src/logs/status/inbox.rs index 4d0a90a9e..522367ac3 100644 --- a/src/logs/status/inbox.rs +++ b/src/logs/status/inbox.rs @@ -62,12 +62,19 @@ pub(crate) struct Row { pub pull_request: String, pub ci: Option, pub merge_conflict: bool, + pub ticket_progress: Option, pub tickets: Vec, pub children: Vec, pub findings: Vec, pub sessions: Vec, } +#[derive(Clone, Copy)] +pub(crate) struct TicketProgress { + pub complete: usize, + pub total: usize, +} + pub(crate) struct Timing { pub finish: Option, pub age: String, @@ -466,6 +473,19 @@ fn present(value: &serde_json::Value, now: DateTime) -> Result { pull_request: facts.pull_request.unwrap_or_default(), ci: None, merge_conflict: false, + ticket_progress: (!facts.tickets.is_empty()).then(|| TicketProgress { + complete: facts + .tickets + .iter() + .filter(|ticket| { + matches!( + ticket.state, + crate::logs::command_record::TicketState::Merged + ) + }) + .count(), + total: facts.tickets.len(), + }), tickets: facts .tickets .iter() @@ -480,6 +500,14 @@ fn present(value: &serde_json::Value, now: DateTime) -> Result { findings: facts.security_findings, sessions: Vec::new(), }; + // Compact Inbox rows hide nested runs. Carry the dispatched Spec's + // recorded accounting onto its Pass without counting child attempts. + if row.ticket_progress.is_none() { + row.ticket_progress = row.children[..facts.dispatched.len()] + .iter() + .rev() + .find_map(|child| child.ticket_progress); + } if ready { row.present_ready(ready_title, facts.github_ci, facts.github_mergeable); } @@ -636,6 +664,7 @@ fn entry_row(entry: Observation<'_>, now: DateTime, root: &std::path::Pat pull_request: String::new(), ci: None, merge_conflict: false, + ticket_progress: None, tickets: Vec::new(), children: Vec::new(), findings: Vec::new(), diff --git a/tests/console/inbox.rs b/tests/console/inbox.rs index 334ea71c7..74732f7d7 100644 --- a/tests/console/inbox.rs +++ b/tests/console/inbox.rs @@ -3,6 +3,66 @@ use serde_json::Value; use std::path::Path; use std::{fs, process::Command}; +#[test] +fn overview_ticket_progress_counts_recorded_states_and_omits_missing_accounting() { + use serde_json::json; + + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let mut fixture = record(home.path(), 1, Some("failed"), 0); + fixture["tickets"] = ["merged", "merged", "running", "failed", "waiting", "not started"] + .into_iter() + .enumerate() + .map(|(index, state)| json!({ + "issue": {"number": index + 10, "url": format!("https://github.com/acme/widgets/issues/{}", index + 10), "title": "Ticket"}, + "state": state, "blockers": [] + })) + .collect(); + // Merged accounting includes Tickets closed before any child Run existed. + super::write_record(home.path(), 1, &fixture); + for attempt in [20, 21] { + let mut child = record(home.path(), attempt, Some("merged"), 0); + child["kind"] = json!("Run"); + child["issue"]["number"] = json!(12); + child["issue"]["url"] = json!("https://github.com/acme/widgets/issues/12"); + child["tickets"] = json!([]); + child["parent_record"] = json!( + home.path() + .join(".thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json") + ); + super::write_record(home.path(), attempt, &child); + } + let mut empty = record(home.path(), 2, Some("failed"), 0); + empty["tickets"] = json!([]); + super::write_record(home.path(), 2, &empty); + let mut legacy = record(home.path(), 3, Some("failed"), 0); + legacy.as_object_mut().unwrap().remove("tickets"); + super::write_record(home.path(), 3, &legacy); + let mut ticket = record(home.path(), 4, Some("failed"), 0); + ticket["kind"] = json!("Run"); + ticket["tickets"] = json!([]); + super::write_record(home.path(), 4, &ticket); + + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let html = console.get("/?section=overview").text().unwrap(); + let heads: Vec<_> = section(&html, "needs you") + .split("
    ") + .skip(1) + .map(|head| head.split("
    ").next().unwrap()) + .collect(); + let spec = heads + .iter() + .find(|head| head.contains("issues/1\"")) + .unwrap(); + assert!(spec.contains("2 / 6 Tickets complete"), "{spec}"); + assert_eq!( + html.matches("class=\"ticket-progress\"").count(), + 1, + "{html}" + ); + assert!(!html.contains("0 / 0"), "{html}"); +} + #[test] fn ready_rows_prefer_pull_request_subjects_and_keep_issue_and_command_fallbacks() { use serde_json::json;