Skip to content

Commit 9a033ce

Browse files
committed
Count a closed pull once, on the endpoint's own shape, and rebase on main 604ffa3 (#1173)
done today read a closed pull's merge time from a top-level `merged_at` the issues endpoint never sends, so an abandoned card was counted as merged work and a pull merged today could land twice; the card also invented a commit from a `head` that endpoint omits. The merge test now reads `pull_request.merged_at`, a pull closed without merging carries no `sha`, and the numbers already counted as merged are filtered out of that bucket so a row without a merge time still cannot duplicate a card. The branch is rebased onto main 604ffa3: the client bundle and the patch series are regenerated from the merged sources, and the two subsystem pages keep main's wording with our entries merged in. Tests: the fixture uses the shape /issues?state=closed really returns, a merged pull sits on that page too, and the case asserts a number appears exactly once with counts matching the column rather than a fixed length.
1 parent 604ffa3 commit 9a033ce

27 files changed

Lines changed: 8973 additions & 8255 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The task board dialog gains a **Delivery** view (#1173): one read-only snapshot of the repository's GitHub issues, pull requests and CI runs, in five columns — original issue, ready to start, in progress, in review, and done today — with a verdict/CI badge per pull request and a header that reports today against the last 7 days. It is repository-wide, so the per-Bot rail does not appear, and it never writes to GitHub: the host serves a cached snapshot built only from `GET` requests, and the token stays host-side. The view replaces the board with one honest line when no GitHub connection is set up.

‎core/client.js‎

Lines changed: 2071 additions & 1805 deletions
Large diffs are not rendered by default.

‎core/index.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import * as update from './src/host/update.js'
4141
import * as dshUpdate from './src/host/dsh-update.js'
4242
import * as hooks from './src/host/hooks.js'
4343
import * as remoteChat from './src/host/remote-chat.js'
44+
import * as deliveryBoard from './src/host/delivery-board.js'
4445
import * as channel from './src/host/channel.js'
4546

4647
export { COLORS } from './src/host/constants.js'
@@ -52,7 +53,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
5253
// the functions other modules call; they call them through `rt` at run time, never during
5354
// install. Listeners run and tools are listed in registration order, so this order holds:
5455
// usage meters model requests after every listener that rewrites them.
55-
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel]
56+
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, deliveryBoard, channel]
5657

5758
export function apply(ctx, config = {}) {
5859
const rt = createRuntime(ctx, config)

‎core/src/client/delivery-view.js‎

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Delivery board (#1173): the repository's GitHub issues and pull requests by delivery stage,
2+
// in the same dialog as the per-Bot Tasks board. This is a view only — nothing here writes to
3+
// GitHub, and the snapshot it renders is built host-side (src/host/delivery-board.js).
4+
import { createElement as h, useEffect, useState } from 'react'
5+
import { t } from './i18n.js'
6+
7+
const POLL_MS = 60_000
8+
9+
export const DELIVERY_COLUMNS = [
10+
{ id: 'raw', label: 'Original issue' },
11+
{ id: 'ready', label: 'Ready to start' },
12+
{ id: 'working', label: 'In progress' },
13+
{ id: 'review', label: 'In review' },
14+
{ id: 'done-today', label: 'Done today' },
15+
]
16+
17+
const BADGES = {
18+
inc: 'INC · rework',
19+
'pass-enqueue': 'PASS · waiting for enqueue',
20+
'pass-merge': 'PASS · waiting for merge',
21+
'ci-red': 'CI red',
22+
'ci-running': 'CI running',
23+
'no-verdict': 'No verdict',
24+
}
25+
26+
export const badgeLabel = (badge) => (badge ? t(BADGES[badge] ?? badge) : '')
27+
28+
export function duration(ms) {
29+
if (!Number.isFinite(ms) || ms < 0) return '—'
30+
const hours = ms / 3_600_000
31+
if (hours < 1) return `${Math.max(1, Math.round(ms / 60_000))}m`
32+
if (hours < 48) return `${Math.round(hours)}h`
33+
return `${Math.round(hours / 24)}d`
34+
}
35+
36+
// The header rows the dialog renders; kept pure so the numbers can be checked without React.
37+
export function metricRows(snapshot) {
38+
const today = snapshot?.today ?? {}
39+
const week = snapshot?.week ?? {}
40+
const ci = today.ci ?? {}
41+
const count = (value) => (value === null || value === undefined ? '—' : String(value))
42+
const rows = [
43+
{ label: t('New issues'), key: 'newIssues' },
44+
{ label: t('Closed issues'), key: 'closedIssues' },
45+
{ label: t('New pull requests'), key: 'newPullRequests' },
46+
{ label: t('Merged pull requests'), key: 'mergedPullRequests' },
47+
{ label: t('Median issue close'), key: 'medianIssueCloseMs', format: duration },
48+
{ label: t('Median PR merge'), key: 'medianPullMergeMs', format: duration },
49+
].map(row => {
50+
const format = row.format ?? count
51+
return { label: row.label, today: format(today[row.key]), week: format(week[row.key]) }
52+
})
53+
return {
54+
dayStart: snapshot?.dayStart ?? null,
55+
rows,
56+
ci: {
57+
failed: ci.failed ?? 0,
58+
total: ci.total ?? 0,
59+
counted: ci.counted ?? 0,
60+
caveat: Number.isFinite(ci.total) && Number.isFinite(ci.counted) && ci.counted < ci.total,
61+
superseded: ci.superseded ?? 0,
62+
},
63+
open: snapshot?.open ?? {},
64+
}
65+
}
66+
67+
export function useDeliveryBoard(actions) {
68+
const [snapshot, setSnapshot] = useState(null)
69+
const [message, setMessage] = useState('')
70+
useEffect(() => {
71+
let stopped = false
72+
const load = () => {
73+
const job = Promise.resolve(actions?.deliveryBoard?.())
74+
void job.then((next) => {
75+
if (stopped || !next || typeof next !== 'object') return
76+
if (next.ok === false) {
77+
setMessage(next.reason === 'no-github' ? t('No GitHub connection yet; the board fills in once one is added.') : t('The delivery board is not ready yet.'))
78+
return
79+
}
80+
setSnapshot(next)
81+
setMessage(next.error ? t('Showing the last snapshot from {time}: {error}', { time: timeOf(next.asOf), error: next.error }) : '')
82+
}).catch((error) => { if (!stopped) setMessage(String(error?.message ?? error)) })
83+
}
84+
load()
85+
const timer = setInterval(load, POLL_MS)
86+
return () => { stopped = true; clearInterval(timer) }
87+
}, [actions])
88+
return { snapshot, message }
89+
}
90+
91+
const timeOf = (iso) => {
92+
const at = Date.parse(iso ?? '')
93+
if (!Number.isFinite(at)) return '—'
94+
return new Date(at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
95+
}
96+
97+
function Label({ name }) {
98+
return h('span', { className: 'bt-del-label', 'data-label': name }, name)
99+
}
100+
101+
export function DeliveryCard({ card }) {
102+
const badge = badgeLabel(card?.badge)
103+
return h('article', { className: 'bt-del-card', 'data-kind': card?.kind, 'data-number': card?.number, 'data-badge': card?.badge ?? undefined },
104+
h('span', { className: 'bt-del-card-top' },
105+
h('span', { className: 'bt-del-id' }, `#${card?.number ?? '?'}`),
106+
badge ? h('span', { className: 'bt-del-badge', 'data-badge': card?.badge }, badge) : null),
107+
h('span', { className: 'bt-del-title' }, card?.title ?? ''),
108+
card?.labels?.length ? h('span', { className: 'bt-del-labels' }, card.labels.map(name => h(Label, { key: name, name }))) : null,
109+
card?.owner ? h('span', { className: 'bt-del-owner', 'data-kind': card.ownerKind ?? 'human' }, card.owner) : null,
110+
card?.linked ? h('span', { className: 'bt-del-linked' }, t('linked to #{number}', { number: card.linked })) : null,
111+
card?.sha ? h('span', { className: 'bt-del-sha' }, card.sha) : null,
112+
card?.url ? h('a', { className: 'bt-del-open', href: card.url, target: '_blank', rel: 'noreferrer' }, t('Open')) : null)
113+
}
114+
115+
export function DeliveryHeader({ snapshot }) {
116+
if (!snapshot) return null
117+
const { rows, ci, open } = metricRows(snapshot)
118+
return h('header', { className: 'bt-del-head' },
119+
h('div', { className: 'bt-del-metrics' },
120+
h('table', { className: 'bt-del-table' },
121+
h('thead', null, h('tr', null, h('th', null, ''), h('th', null, t('Today')), h('th', null, t('7 days')))),
122+
h('tbody', null, rows.map(row => h('tr', { key: row.label },
123+
h('th', { scope: 'row' }, row.label),
124+
h('td', null, row.today),
125+
h('td', null, row.week))))),
126+
h('p', { className: 'bt-del-ci' }, t('CI failed {failed} of {total}', { failed: ci.failed, total: ci.total }),
127+
ci.caveat ? ` · ${t('counted {counted} of {total}', { counted: ci.counted, total: ci.total })}` : '',
128+
ci.superseded ? ` · ${t('{count} superseded', { count: ci.superseded })}` : ''),
129+
h('p', { className: 'bt-del-open' }, t('Open: {issues} issues ({needs} need a human), {prs} pull requests', {
130+
issues: open.issues ?? 0, needs: open.needsHuman ?? 0, prs: open.pullRequests ?? 0,
131+
}), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== undefined
132+
? ` · ${t('median open age {age}', { age: duration(open.medianIssueAgeMs) })}` : '')),
133+
h('p', { className: 'bt-del-asof' }, t('as of {time}', { time: timeOf(snapshot.asOf) }),
134+
snapshot.refreshing ? ` · ${t('refreshing…')}` : ''))
135+
}
136+
137+
export function DeliveryBoard({ snapshot, message }) {
138+
if (!snapshot) {
139+
return h('div', { className: 'bt-del-body', 'data-view': 'delivery' },
140+
message
141+
? h('p', { className: 'bt-board-error', role: 'alert' }, message)
142+
: h('p', { className: 'bt-board-empty' }, t('No snapshot yet.')))
143+
}
144+
const columns = snapshot.columns ?? {}
145+
return h('div', { className: 'bt-del-body', 'data-view': 'delivery' },
146+
message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : null,
147+
h(DeliveryHeader, { snapshot: snapshot.metrics ? { ...snapshot.metrics, asOf: snapshot.asOf, refreshing: snapshot.refreshing } : null }),
148+
h('div', { className: 'bt-del-columns' },
149+
DELIVERY_COLUMNS.map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id },
150+
h('h3', { className: 'bt-board-column-head' },
151+
h('span', null, t(column.label)),
152+
h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))),
153+
(columns[column.id] ?? []).length === 0
154+
? h('p', { className: 'bt-board-empty' }, t('Nothing here.'))
155+
: (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${card.kind}-${card.number}`, card }))))))
156+
}
157+
158+
export function DeliveryView({ actions }) {
159+
const { snapshot, message } = useDeliveryBoard(actions)
160+
return h(DeliveryBoard, { snapshot, message })
161+
}
162+
163+
export const DELIVERY_CSS = `
164+
.bt-del-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px}
165+
.bt-del-head{display:flex;flex-direction:column;gap:6px;border-bottom:.5px solid var(--bt-line);padding-bottom:8px}
166+
.bt-del-metrics{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
167+
.bt-del-table{border-collapse:collapse;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums}
168+
.bt-del-table th,.bt-del-table td{padding:2px 8px;text-align:right;white-space:nowrap}
169+
.bt-del-table th[scope=row]{text-align:left;font-weight:500;color:var(--bt-ink-2)}
170+
.bt-del-table thead th{font-weight:600;color:var(--bt-ink-3)}
171+
.bt-del-ci,.bt-del-open{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)}
172+
.bt-del-asof{margin:0;font-size:11.5px;line-height:15px;color:var(--bt-ink-3)}
173+
.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto}
174+
.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto}
175+
.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))}
176+
.bt-del-card-top{display:flex;align-items:center;gap:6px}
177+
.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
178+
.bt-del-badge{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)}
179+
.bt-del-badge[data-badge=inc]{background:rgba(224,33,53,.12);color:#c21d2e}
180+
.bt-del-badge[data-badge=ci-red]{background:rgba(224,33,53,.12);color:#c21d2e}
181+
.bt-del-badge[data-badge=pass-enqueue],.bt-del-badge[data-badge=pass-merge]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))}
182+
.bt-del-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)}
183+
.bt-del-labels{display:flex;flex-wrap:wrap;gap:4px}
184+
.bt-del-label{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)}
185+
.bt-del-owner{font-size:12px;line-height:16px;color:var(--bt-ink-2)}
186+
.bt-del-owner[data-kind=human]{font-weight:600;color:var(--bt-ink)}
187+
.bt-del-linked,.bt-del-sha{font-size:11.5px;line-height:15px;color:var(--bt-ink-3)}
188+
.bt-del-open{align-self:flex-start;font-size:12px;line-height:16px;color:var(--bt-accent)}
189+
@media (max-width:800px){
190+
.bt-del-columns{overflow:auto}
191+
.bt-del-column{min-width:220px}
192+
}
193+
@media (max-width:640px){
194+
.bt-del-columns{flex-direction:column}
195+
.bt-del-column{min-width:0}
196+
}
197+
`

0 commit comments

Comments
 (0)