Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1250-delivery-repo.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The delivery board now reads the repository this instance actually works in, and the header says which one it read and where the name came from: `delivery.repo` in the instance settings first, then the workspace's git `origin`, then the package's own repository as a labelled default (#1250). An instance with none of the three refuses with `no repository — set delivery.repo` instead of showing another repository's numbers.
27 changes: 26 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,13 @@ var HOST_ZH = {
Copy: "复制",
Close: "关闭",
"this repository": "这个仓",
// #1250: whose repository the board read, and where that name came from — a package default
// shown as a fact is what put one instance's numbers on another.
"no repository — set delivery.repo": "没有仓库 —— 请设置 delivery.repo",
"from config": "来自 config",
"from remote": "来自 remote",
"from the package (default)": "来自安装包(默认值)",
"Set delivery.repo in this instance's settings to the repository this instance works in.": "在这个实例的设置里把 delivery.repo 指向本实例实际工作的仓库。",
"Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.": "在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。",
"GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).": "GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。",
"GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.": "GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。",
Expand Down Expand Up @@ -8289,6 +8296,7 @@ function hintFor(error, { repo = null } = {}) {
const scope = error.scope ?? null;
const kind = error.kind ?? null;
const target = repo ?? t("this repository");
if (kind === "no-repo") return t("Set delivery.repo in this instance's settings to the repository this instance works in.");
if (kind === "no-token") return t("Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.", { repo: target });
if (kind === "rate-limit") {
return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) });
Expand Down Expand Up @@ -8412,6 +8420,12 @@ function createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms
}
};
}
function repoSourceText(source) {
if (source === "config") return t("from config");
if (source === "remote") return t("from remote");
if (source === "package") return t("from the package (default)");
return null;
}
function useDeliveryBoard(actions) {
const [view, setView] = (0, import_react15.useState)({ state: "idle" });
const [retry, setRetry] = (0, import_react15.useState)(() => () => {
Expand All @@ -8433,7 +8447,7 @@ function useDeliveryBoard(actions) {
}
function probeText(report) {
if (!report) return "";
const lines = [`${report.repo ?? ""} · ${timeOf(report.at)}`];
const lines = [`${report.repo ?? ""}${report.repoSource ? ` · ${report.repoSource}` : ""} · ${timeOf(report.at)}`];
for (const row of report.rows ?? []) {
const status = row.status === null || row.status === void 0 ? "—" : String(row.status);
lines.push(`${row.ok ? "ok" : "FAIL"} ${status.padEnd(4)} ${row.endpoint}${row.ok ? "" : ` ${row.kind ?? ""}`}`);
Expand Down Expand Up @@ -8480,9 +8494,20 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe =
const { rows, ci, open } = metricRows(metricsPayload);
const error = payload.error ?? null;
const state = payload.state ?? "fresh";
const repoName = payload.repo ?? repo ?? null;
const repoSource = payload.repoSource ?? null;
const sourceText = repoSourceText(repoSource);
return (0, import_react15.createElement)(
"header",
{ className: "bt-del-head" },
// Whose numbers these are, and where the name came from (#1250): a package default that looks
// like a fact is what showed one instance another repository's board.
(0, import_react15.createElement)(
"p",
{ className: "bt-del-repo", "data-repo": repoName ?? void 0, "data-repo-source": repoSource ?? void 0 },
repoName === null ? t("no repository — set delivery.repo") : repoName,
sourceText === null ? "" : ` · ${sourceText}`
),
(0, import_react15.createElement)(
"div",
{ className: "bt-del-metrics" },
Expand Down
21 changes: 16 additions & 5 deletions core/scripts/delivery-probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
// so an instance that "does not come up" can be explained by pasting five lines instead of guessing
// which permission is missing. There is no second implementation here on purpose.
//
// node core/scripts/delivery-probe.mjs # repo from the git remote, token from GITHUB_TOKEN
// Which repository it reads is the host's own question (#1250), answered by the same
// `resolveRepo()` the board installs: `--repo`, else the `origin` of `--workspace` (default the
// current folder), else the package's own `repository.url`.
//
// node core/scripts/delivery-probe.mjs # repo from cwd's git remote, token from GITHUB_TOKEN
// node core/scripts/delivery-probe.mjs --repo o/r --token-env MY_TOKEN
// node core/scripts/delivery-probe.mjs --workspace ../my-checkout
import { probeGitHub } from '../src/host/delivery-board.js'
import { NO_REPO_MESSAGE, resolveRepo } from '../src/host/delivery-repo.js'
import { repoSlug } from '../src/host/update.js'

const argv = process.argv.slice(2)
Expand All @@ -15,16 +21,21 @@ const argOf = (name, fallback = null) => {
return index === -1 ? fallback : argv[index + 1] ?? fallback
}

const repo = argOf('--repo') ?? (() => { try { return repoSlug() } catch { return null } })()
if (repo === null || repo === '') {
console.error('delivery-probe: no repository — pass --repo owner/name')
const found = resolveRepo({
setting: argOf('--repo'),
workspace: argOf('--workspace', process.cwd()),
packageSlug: repoSlug(),
})
if (found.repo === null) {
console.error(`delivery-probe: ${NO_REPO_MESSAGE} (pass --repo owner/name, or --workspace <folder> with a GitHub origin)`)
process.exit(2)
}
const repo = found.repo
const tokenEnv = argOf('--token-env', 'GITHUB_TOKEN')
const token = process.env[tokenEnv] ?? null
const report = await probeGitHub({ repo, token })

console.log(`delivery-probe ${repo} · ${report.at}${token === null ? ` · no ${tokenEnv}` : ''}`)
console.log(`delivery-probe ${repo} · ${found.source} · ${report.at}${token === null ? ` · no ${tokenEnv}` : ''}`)
for (const row of report.rows) {
const status = row.status === null ? '—' : String(row.status)
console.log(`${row.ok ? 'ok ' : 'FAIL'} ${status.padEnd(4)} ${row.endpoint}${row.ok ? '' : ` ${row.kind}${row.message === null ? '' : `: ${row.message}`}`}`)
Expand Down
20 changes: 19 additions & 1 deletion core/src/client/delivery-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function hintFor(error, { repo = null } = {}) {
const scope = error.scope ?? null
const kind = error.kind ?? null
const target = repo ?? t('this repository')
if (kind === 'no-repo') return t('Set delivery.repo in this instance\'s settings to the repository this instance works in.')
if (kind === 'no-token') return t('Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.', { repo: target })
if (kind === 'rate-limit') {
return error.retryAfterMs === null || error.retryAfterMs === undefined
Expand Down Expand Up @@ -218,6 +219,15 @@ export function createPoller({ load, onView, now = () => Date.now(), setTimer =
}
}

// Where the repository name came from (#1250). A bare source word is deliberate: the page says what
// the host read, not a sentence that hides the difference between a setting and a default.
export function repoSourceText(source) {
if (source === 'config') return t('from config')
if (source === 'remote') return t('from remote')
if (source === 'package') return t('from the package (default)')
return null
}

export function useDeliveryBoard(actions) {
const [view, setView] = useState({ state: 'idle' })
const [retry, setRetry] = useState(() => () => {})
Expand All @@ -240,7 +250,7 @@ export function useDeliveryBoard(actions) {
// `--probe` CLI prints (#1211).
export function probeText(report) {
if (!report) return ''
const lines = [`${report.repo ?? ''} · ${timeOf(report.at)}`]
const lines = [`${report.repo ?? ''}${report.repoSource ? ` · ${report.repoSource}` : ''} · ${timeOf(report.at)}`]
for (const row of report.rows ?? []) {
const status = row.status === null || row.status === undefined ? '—' : String(row.status)
lines.push(`${row.ok ? 'ok' : 'FAIL'} ${status.padEnd(4)} ${row.endpoint}${row.ok ? '' : ` ${row.kind ?? ''}`}`)
Expand Down Expand Up @@ -280,7 +290,15 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on
const { rows, ci, open } = metricRows(metricsPayload)
const error = payload.error ?? null
const state = payload.state ?? 'fresh'
const repoName = payload.repo ?? repo ?? null
const repoSource = payload.repoSource ?? null
const sourceText = repoSourceText(repoSource)
return h('header', { className: 'bt-del-head' },
// Whose numbers these are, and where the name came from (#1250): a package default that looks
// like a fact is what showed one instance another repository's board.
h('p', { className: 'bt-del-repo', 'data-repo': repoName ?? undefined, 'data-repo-source': repoSource ?? undefined },
repoName === null ? t('no repository — set delivery.repo') : repoName,
sourceText === null ? '' : ` · ${sourceText}`),
h('div', { className: 'bt-del-metrics' },
h(DeliveryMetrics, { rows, daily: metricsPayload.daily }),
// An unreadable CI number is "unknown", never "0 of 0" (#1211).
Expand Down
7 changes: 7 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,13 @@ const HOST_ZH = {
Copy: '复制',
Close: '关闭',
'this repository': '这个仓',
// #1250: whose repository the board read, and where that name came from — a package default
// shown as a fact is what put one instance's numbers on another.
'no repository — set delivery.repo': '没有仓库 —— 请设置 delivery.repo',
'from config': '来自 config',
'from remote': '来自 remote',
'from the package (default)': '来自安装包(默认值)',
'Set delivery.repo in this instance\'s settings to the repository this instance works in.': '在这个实例的设置里把 delivery.repo 指向本实例实际工作的仓库。',
'Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.': '在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。',
'GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).': 'GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。',
'GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.': 'GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。',
Expand Down
79 changes: 62 additions & 17 deletions core/src/host/delivery-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// `{ message, endpoint, status, kind, scope, at }` so the view can say what failed, where, and what
// to do about it. "No GitHub connection yet" is reserved for a host that has never tried.
import { repoSlug } from './update.js'
import { NO_REPO_MESSAGE, resolveRepo } from './delivery-repo.js'

export const DELIVERY_COLUMNS = ['raw', 'ready', 'working', 'review', 'done-today']

Expand Down Expand Up @@ -251,7 +252,7 @@ export async function probeGitHub({ repo, token, request = fetch, now = Date.now
return { ok: rows.every(row => row.ok), repo, at: new Date(now()).toISOString(), rows }
}

export function createDeliveryBoard({ repo, token, request = fetch, now = Date.now, ttl = CACHE_TTL_MS, warn = () => {}, budgetMs = BUILD_BUDGET_MS } = {}) {
export function createDeliveryBoard({ repo, repoSource = null, token, request = fetch, now = Date.now, ttl = CACHE_TTL_MS, warn = () => {}, budgetMs = BUILD_BUDGET_MS } = {}) {
// Secrets are read asynchronously after install, so the token is resolved per request: a value
// captured at install time is empty until the next restart (#1173).
const tokenNow = () => (typeof token === 'function' ? token() : token)
Expand Down Expand Up @@ -652,15 +653,16 @@ export function createDeliveryBoard({ repo, token, request = fetch, now = Date.n
function view(extra = {}) {
const elapsedMs = since === null ? 0 : Math.max(0, now() - Date.parse(since))
if (snapshot === null) {
if (inflight !== null) return { ok: true, state: 'loading', repo, since, attempt, elapsedMs, error: lastError, retryAt: null, ...extra }
if (lastError !== null) return { ok: true, state: 'failed', repo, since, attempt, elapsedMs, error: lastError, retryAt: nextAttemptAt || null, ...extra }
if (inflight !== null) return { ok: true, state: 'loading', repo, repoSource, since, attempt, elapsedMs, error: lastError, retryAt: null, ...extra }
if (lastError !== null) return { ok: true, state: 'failed', repo, repoSource, since, attempt, elapsedMs, error: lastError, retryAt: nextAttemptAt || null, ...extra }
// Never tried: the one case where the view may say there is no snapshot yet (#1211).
return { ok: false, state: 'idle', repo, since: null, attempt: 0, elapsedMs: 0, error: null, retryAt: null, ...extra }
return { ok: false, state: 'idle', repo, repoSource, since: null, attempt: 0, elapsedMs: 0, error: null, retryAt: null, ...extra }
}
const state = lastError !== null || inflight !== null ? 'stale' : (snapshot.missing?.length ? 'partial' : 'fresh')
return {
ok: true,
...snapshot,
repoSource,
state,
stale: lastError !== null || inflight !== null,
refreshing: inflight !== null,
Expand Down Expand Up @@ -743,30 +745,73 @@ export function createDeliveryBoard({ repo, token, request = fetch, now = Date.n
}

export function install(rt) {
const repo = (() => {
try { return rt.repoSlug?.() ?? repoSlug() } catch { return repoSlug() }
})()
// One board per repository, not one global board with a repo baked in (#1250): a changed
// `delivery.repo` resolves to a new name, and the new name's first read is its own `loading`
// instead of the previous repository's snapshot.
const boards = new Map()
const notedDefaults = new Set()
const warn = (...args) => rt.warn?.(...args)
const tokenOf = () => {
try { return rt.secretValue?.('GITHUB_TOKEN') } catch { return undefined }
}
const request = (url, init) => (rt.fetch ?? fetch)(url, init)
const board = createDeliveryBoard({
repo,
token: tokenOf,
request,
warn: (...args) => rt.warn?.(...args),
// The workspace is the team folder, not one Bot's folder: the board is host-level, and a Bot's
// folder would make the answer depend on which Bot happened to be named (#1250).
const workspace = () => {
try { return rt.ctx?.workspaceRegistry?.get?.(rt.state?.workspaceId)?.path ?? null } catch { return null }
}
const setting = () => {
try { return rt.config?.deliveryRepo ?? null } catch { return null }
}
// Level 3 is the package's own repository, not the instance's choice. The shipped package carries
// it; a host whose package.json has none (or a test) hands in '' and the board refuses rather than
// asking for `/repos/null` (#1250).
const packageSlug = () => {
try { return rt.packageRepository === undefined ? repoSlug() : rt.packageRepository } catch { return null }
}
// Resolved on every call, so the setting is read live and the cache below is keyed by its answer.
const resolveNow = () => {
const found = resolveRepo({ setting: setting(), workspace: workspace(), packageSlug: packageSlug() })
// A default is acceptable only while it is visible as a default: the page labels it, and this
// line records it too, so nothing falls back silently (#1250).
if (found.source === 'package' && !notedDefaults.has(found.repo)) {
notedDefaults.add(found.repo)
warn('ds-bot: delivery board: no delivery.repo and no git remote in the workspace; reading the package repository %s (default)', found.repo)
}
return found
}
const boardFor = ({ repo }) => {
let board = boards.get(repo)
if (board === undefined) {
board = createDeliveryBoard({ repo, token: tokenOf, request, warn })
boards.set(repo, board)
}
return board
}
// Nothing resolves: the answer is a failure, and it is answered before any board exists so no
// request can be made for `/repos/null` (#1250).
const noRepo = () => ({
ok: false, state: 'failed', repo: null, repoSource: null, since: null, attempt: 0, elapsedMs: 0,
error: { message: NO_REPO_MESSAGE, endpoint: null, status: null, kind: 'no-repo', scope: 'repo', retryAfterMs: null, at: new Date().toISOString() },
retryAt: null,
})
rt.deliveryBoard = async (payload = {}) => {
const found = resolveNow()
if (found.repo === null) return noRepo()
const board = boardFor(found)
// The channel answers before a host restart would; load once so the first read is signed.
// The answer is `loading` either way — the round runs behind `inflight` (#1211).
await rt.loadSecrets?.().catch(error => rt.warn?.('ds-bot: delivery board: secrets did not load: %s', error?.message ?? error))
return board.read({ force: payload?.force === true, wait: payload?.wait === true })
await rt.loadSecrets?.().catch(error => warn('ds-bot: delivery board: secrets did not load: %s', error?.message ?? error))
return { ...(await board.read({ force: payload?.force === true, wait: payload?.wait === true })), repoSource: found.source }
}
// The 诊断 button and `--probe` run this one function (#1211): same reads, same statuses.
rt.deliveryProbe = async () => {
await rt.loadSecrets?.().catch(error => rt.warn?.('ds-bot: delivery probe: secrets did not load: %s', error?.message ?? error))
try { return await board.probe() } catch (error) {
return { ok: false, repo, at: new Date().toISOString(), rows: [], error: String(error?.message ?? error) }
const found = resolveNow()
if (found.repo === null) return { ok: false, repo: null, repoSource: null, at: new Date().toISOString(), rows: [], error: NO_REPO_MESSAGE }
const board = boardFor(found)
await rt.loadSecrets?.().catch(error => warn('ds-bot: delivery probe: secrets did not load: %s', error?.message ?? error))
try { return { ...(await board.probe()), repoSource: found.source } } catch (error) {
return { ok: false, repo: found.repo, repoSource: found.source, at: new Date().toISOString(), rows: [], error: String(error?.message ?? error) }
}
}
}
Loading
Loading