|
| 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