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
11 changes: 9 additions & 2 deletions hypaware-core/plugins-workspace/github/src/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,18 @@ export async function runGithubBackfill(argv, ctx) {
ctx.stdout.write(`github backfill: ${result.events} event(s) across ${result.repos} repo(s)\n`)
if (result.pending) ctx.stdout.write('github backfill: bounded work remains and will resume on the next GitHub capture tick\n')
reportErrors(ctx, result.errors)
if (only && result.repos === 0) {
// Zero repos with an error reported is an inventory that never resolved,
// not a selection that missed: `reportErrors` already printed the real
// cause, so do not contradict it with a claim about the user's config.
if (only && result.repos === 0 && result.errors.length === 0) {
ctx.stderr.write(`hyp github backfill: none of [${only.join(', ')}] are in the active repository inventory\n`)
return 1
}
ctx.stdout.write("run 'hyp graph project' to project github_events into the graph\n")
// Nothing captured because the run errored leaves nothing new to project,
// so the next-step advice would only dress up a failure as progress.
if (result.repos > 0 || result.errors.length === 0) {
ctx.stdout.write("run 'hyp graph project' to project github_events into the graph\n")
}
return result.errors.length > 0 ? 1 : 0
} catch (err) {
ctx.stderr.write(`hyp github backfill: ${errMessage(err)}\n`)
Expand Down
36 changes: 31 additions & 5 deletions hypaware-core/plugins-workspace/github/src/tick.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,37 @@ import { getClient } from './runtime.js'
export async function runCaptureTick(runtime, opts) {
const cursors = readCursors(runtime.stateDir)
const client = getClient(runtime)
const observedRepos = opts.observedRepos ?? (
runtime.config.inventory === 'session_repos'
? await runtime.observedRepos.list()
: undefined
)
/** @type {string[] | undefined} */
let observedRepos = opts.observedRepos
if (observedRepos === undefined && runtime.config.inventory === 'session_repos') {
try {
observedRepos = await runtime.observedRepos.list()
} catch (err) {
// Escaping here aborts the whole tick, which is what the per-repo
// isolation inside `captureRepos` exists to prevent, so report the
// unresolved inventory as one more captured failure instead.
const message = err instanceof Error ? err.message : String(err)
const kind = /** @type {{ hypErrorKind?: string }} */ (err)?.hypErrorKind
runtime.log.error('github.inventory_resolve_failed', {
mode: 'session_repos',
error: message,
...(kind ? { error_kind: kind } : {}),
})
// The failure itself is not backlog (LLP 0360#cadence: failures retry on
// the ordinary cadence), but a tick that never resolved its inventory
// retired none either. A flat `false` would clear the source's backlog
// flag, sending saved continuations back to a full poll interval
// (LLP 0361#budget), so read the answer off the state the failed read
// left behind: a revalidation an earlier tick already persisted, and the
// durable per-repo cursors. Only a persisted pass counts: `update()`
// swaps its state on success alone, so a pass this failing call started
// is discarded and reports nothing.
const pending =
runtime.observedRepos.revalidationPending?.() === true ||
Object.values(cursors.repos).some((cursor) => Boolean(cursor?.work))
return { repos: 0, events: 0, requests: 0, pending, errors: [{ repo: '(inventory)', error: message }] }
}
}
// Incomplete inventory revalidation is bounded local work remaining, in
// exactly the LLP 0361#budget sense capture's own `pending` carries, so it
// rides the same backlog cadence instead of waiting a full poll interval to
Expand Down
123 changes: 123 additions & 0 deletions test/plugins/github-observed-repos.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import os from 'node:os'
import path from 'node:path'
import test from 'node:test'

import { runGithubBackfill } from '../../hypaware-core/plugins-workspace/github/src/commands.js'
import { writeCursors } from '../../hypaware-core/plugins-workspace/github/src/cursors.js'
import { createLocalObservedReposIndex } from '../../hypaware-core/plugins-workspace/github/src/observed-repos.js'
import { setGithubRuntime } from '../../hypaware-core/plugins-workspace/github/src/runtime.js'
import { runCaptureTick } from '../../hypaware-core/plugins-workspace/github/src/tick.js'

/** @import { QueryStorageService } from '../../hypaware-core/plugins-workspace/github/src/types.d.ts' */
Expand Down Expand Up @@ -356,3 +359,123 @@ test('an incomplete revalidation surfaces as bounded pending work on the capture
assert.equal(report.pending, true, 'revalidation work remaining rides the backlog cadence')
assert.equal(report.repos, 0)
})

/**
* A `session_repos` runtime whose local inventory read fails. Nothing past
* that read may run: an unresolved inventory must neither enumerate GitHub nor
* append rows.
*
* @param {string} stateDir
* @param {unknown} err
* @param {(name: string, attrs: any) => void} [onError]
*/
function failingInventoryRuntime(stateDir, err, onError = () => {}) {
return /** @type {any} */ ({
stateDir,
config: { ignore: [], token_env: 'GITHUB_TOKEN', poll_interval: '24h', inventory: 'session_repos' },
observedRepos: {
async list() { throw err },
revalidationPending() { return false },
},
clientFactory: () => ({
async listViewerRepos() { throw new Error('conservative inventory must not enumerate') },
}),
storage: {
cacheTablePath() { return '/cache/github_events' },
async appendRows() { throw new Error('an unresolved inventory must not append') },
},
log: { error: onError, info() {} },
})
}

test('a failed session_repos inventory read is recorded, not thrown out of the tick', async (t) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypaware-github-failed-tick-'))
t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }))
const err = Object.assign(new Error('cache partition unreadable'), { hypErrorKind: 'github_observed_repos_failed' })
/** @type {Array<{ name: string, attrs: any }>} */
const logged = []

const report = await runCaptureTick(
failingInventoryRuntime(stateDir, err, (name, attrs) => logged.push({ name, attrs })),
{ mode: 'poll' },
)

assert.equal(report.repos, 0)
assert.equal(report.events, 0)
assert.equal(report.errors.length, 1, 'the inventory failure is reported as a tick error')
assert.match(report.errors[0].error, /cache partition unreadable/)
assert.equal(logged[0].name, 'github.inventory_resolve_failed')
assert.equal(logged[0].attrs.error_kind, 'github_observed_repos_failed')
assert.equal(
report.pending,
false,
'an error is not bounded backlog: pending drives the poll cadence (LLP 0360#cadence)',
)
})

test('a failed session_repos inventory read does not retire backlog the cursors still hold', async (t) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypaware-github-failed-tick-backlog-'))
t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }))
// A prior tick ran out of budget mid-repository and saved its continuation.
writeCursors(stateDir, { schema_version: 1, repos: { 'acme/widgets': { work: { mode: 'poll', phase: 'issues' } } } })

const report = await runCaptureTick(
failingInventoryRuntime(stateDir, new Error('cache partition unreadable')),
{ mode: 'poll' },
)

assert.equal(report.errors.length, 1)
assert.equal(
report.pending,
true,
'clearing pending here would push a saved continuation from the backlog cadence back to a full poll interval',
)
})

test('a failed session_repos inventory read keeps an unfinished revalidation on the backlog cadence', async (t) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypaware-github-failed-tick-reval-'))
t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }))
// A revalidation an earlier tick persisted is still reported after this
// tick's `list()` throws: `update()` swaps its state on success alone, so the
// failed call leaves that earlier pass exactly as it found it. (A pass this
// call started is discarded instead, and reports nothing.) No cursor holds
// work, so `revalidationPending()` is the only thing keeping this pending.
const runtime = failingInventoryRuntime(stateDir, new Error('cache partition unreadable'))
runtime.observedRepos.revalidationPending = () => true

const report = await runCaptureTick(runtime, { mode: 'poll' })

assert.equal(report.errors.length, 1)
assert.equal(
report.pending,
true,
'an unfinished revalidation is bounded work the failed tick did not retire (LLP 0361#budget)',
)
})

test('hyp github backfill reports the inventory failure without contradicting it', async (t) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypaware-github-backfill-cli-'))
t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }))
setGithubRuntime(failingInventoryRuntime(stateDir, new Error('cache partition unreadable')))
let out = ''
let err = ''
const ctx = /** @type {any} */ ({
stdout: { write(/** @type {string} */ s) { out += s } },
stderr: { write(/** @type {string} */ s) { err += s } },
})

const code = await runGithubBackfill(['acme/widgets'], ctx)

assert.equal(code, 1, 'an unresolved inventory is a failed backfill')
assert.match(err, /! \(inventory\): cache partition unreadable/, 'the real cause is reported')
assert.doesNotMatch(
err,
/active repository inventory/,
'the inventory never resolved, so blaming the configured selection would be a false claim',
)
assert.doesNotMatch(
out,
/hyp graph project/,
'nothing was captured, so the next-step advice would dress a failure up as progress',
)
})
Loading