Skip to content
Open
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
63 changes: 58 additions & 5 deletions hypaware-core/plugins-workspace/github/src/capture.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,34 @@ export async function resolveRepos(config, client, log, observedRepos) {
* @returns {Promise<{ repos: number, events: number, requests: number, pending: boolean, errors: Array<{ repo: string, error: string }> }>}
*/
export async function captureRepos({ client, config, cursors, append, log, mode, only, observedRepos, requestLimit = CAPTURE_REQUEST_LIMIT }) {
let repos = await resolveRepos(config, client, log, observedRepos)
/** @type {Array<{ repo: string, error: string }>} */
const errors = []
/** @type {string[]} */
let repos
try {
repos = await resolveRepos(config, client, log, observedRepos)
} catch (err) {
// `all_visible` enumeration is a network call, so it fails the way a
// repository pass does (a refused continuation, an API error). Escaping
// here would abort the whole tick, which is what the per-repo isolation
// below exists to prevent, so report it as one more captured failure. The
// inventory has no durable cursor: the next tick enumerates from scratch.
const message = errMessage(err)
errors.push({ repo: '(inventory)', error: message })
log.error('github.inventory_resolve_failed', {
mode: config.inventory ?? 'session_repos',
error: message,
...errKind(err),
})
// The failure itself is not backlog (LLP 0360#cadence), but it also did
// not retire any: a tick that never resolved its inventory captured
// nothing, so whatever bounded work the cursors held before it still
// waits. Reporting `false` unconditionally would CLEAR the source's
// backlog flag, sending saved continuations from the 15-minute backlog
// cadence back to a full poll interval (LLP 0361#budget). Read the answer
// off the durable cursors instead of inventing one.
return { repos: 0, events: 0, requests: 0, pending: hasSavedWork(cursors), errors }
}
// A positional `hyp github backfill owner/repo` narrows this one invocation.
// The round-robin continuation is a property of the WHOLE inventory, so a
// narrowed run must not publish a `next_repo` drawn from its subset: doing so
Expand All @@ -100,8 +127,6 @@ export async function captureRepos({ client, config, cursors, append, log, mode,

let events = 0
const budget = requestBudget(requestLimit)
/** @type {Array<{ repo: string, error: string }>} */
const errors = []
let pending = false
let visited = 0

Expand Down Expand Up @@ -146,8 +171,7 @@ export async function captureRepos({ client, config, cursors, append, log, mode,
// continuation lands (the tampered-sidecar vector), and the whole-tick
// handler in `source.js` never sees it, so without this the kind is not
// filterable exactly where it matters.
const kind = /** @type {{ hypErrorKind?: string }} */ (err)?.hypErrorKind
log.error('github.repo_capture_failed', { repo, error: message, ...(kind ? { error_kind: kind } : {}) })
log.error('github.repo_capture_failed', { repo, error: message, ...errKind(err) })
}
events += repoEvents
// Persist the advanced cursor onto the shared state after each repo.
Expand Down Expand Up @@ -692,3 +716,32 @@ function updatedAt(pr) {
function errMessage(err) {
return err instanceof Error ? err.message : String(err)
}

/**
* The `error_kind` attribute for a thrown error. Spread into a log payload, so
* an error carrying no kind contributes no key at all.
*
* @param {unknown} err
* @returns {{ error_kind?: string }}
*/
function errKind(err) {
const kind = /** @type {{ hypErrorKind?: string }} */ (err)?.hypErrorKind
return kind ? { error_kind: kind } : {}
}

/**
* True when any repository cursor still holds a continuation, i.e. bounded
* work survives on disk independently of this tick. The durable cursors are
* the only evidence a tick that never resolved an inventory has, and they are
* approximate on both sides: they also carry work a *failed* repository left
* behind (which the per-repo path deliberately does not call backlog), and
* they say nothing about repositories a budget-exhausted rotation never
* reached. It is still the better answer than a flat `false`, which would
* discard a real continuation rather than merely mis-time a retry.
*
* @param {CursorState} cursors
* @returns {boolean}
*/
function hasSavedWork(cursors) {
return Object.values(cursors.repos).some((cursor) => Boolean(cursor?.work))
}
11 changes: 10 additions & 1 deletion hypaware-core/plugins-workspace/github/src/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,19 @@ 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) {
// An empty selection means "these repositories are not in the inventory"
// only when the inventory actually resolved. When resolution itself failed
// the selection is unknown rather than empty, and `reportErrors` has just
// named the real cause: claiming the repositories are absent sends the
// reader to their config instead of the failure they were shown.
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
}
// The same ambiguity on the success tail: an unresolved inventory captured
// nothing at all, so the next step is retrying capture, not projecting a
// table this run never touched. `reportErrors` has already said why.
if (result.repos === 0 && result.errors.length > 0) return 1
ctx.stdout.write("run 'hyp graph project' to project github_events into the graph\n")
return result.errors.length > 0 ? 1 : 0
} catch (err) {
Expand Down
56 changes: 56 additions & 0 deletions test/plugins/github-capture.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -621,3 +621,59 @@ test('staged phase watermarks survive the cursor sidecar round trip', (t) => {
assert.equal(work?.commits_high, '2026-06-02T00:00:00Z')
assert.equal(work?.comments_high, '2026-06-03T00:00:00Z')
})

test('a failed all_visible enumeration is recorded, not thrown out of the tick', async () => {
const client = fakeClient({ viewerRepos: ['owner/a'] })
const err = Object.assign(
new Error('GitHub continuation URL refused: it does not address the configured API base (origin https://evil.example)'),
{ hypErrorKind: 'github_foreign_origin' },
)
client.listViewerRepos = async () => { throw err }
const cursors = freshCursors()
/** @type {Array<{ name: string, attrs: any }>} */
const logged = []
const result = await captureRepos({
client,
config: cfg({ inventory: 'all_visible' }),
cursors,
append: async () => {},
log: { ...silentLog, error(name, attrs) { logged.push({ name, attrs }) } },
mode: 'poll',
})

assert.equal(result.repos, 0)
assert.equal(result.events, 0)
assert.equal(result.errors.length, 1, 'the enumeration failure is reported as a tick error')
assert.match(result.errors[0].error, /continuation URL refused/)
assert.equal(logged[0].name, 'github.inventory_resolve_failed')
assert.equal(logged[0].attrs.error_kind, 'github_foreign_origin')
assert.equal(
result.pending,
false,
'an error is not bounded backlog: pending drives the poll cadence (LLP 0360#cadence)'
)
})

test('a failed enumeration does not retire backlog the cursors still hold', async () => {
const client = fakeClient({ viewerRepos: ['owner/a'] })
client.listViewerRepos = async () => { throw new Error('rate limited') }
const cursors = freshCursors()
// A prior tick ran out of budget mid-repository and saved its continuation.
cursors.repos['owner/a'] = { work: { mode: 'poll', phase: 'issues' } }

const result = await captureRepos({
client,
config: cfg({ inventory: 'all_visible' }),
cursors,
append: async () => {},
log: { ...silentLog, error() {} },
mode: 'poll',
})

assert.equal(result.errors.length, 1)
assert.equal(
result.pending,
true,
'clearing pending here would push a saved continuation from the backlog cadence back to a full poll interval'
)
})
90 changes: 90 additions & 0 deletions test/plugins/github.qkg1.topmands.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @ts-check

import assert from 'node:assert/strict'
import fs from 'node:fs'
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 { setGithubRuntime } from '../../hypaware-core/plugins-workspace/github/src/runtime.js'
import { fakeClient } from './github-fake-client.js'

/** @import { GithubClient } from '../../hypaware-core/plugins-workspace/github/src/types.js' */

/** Collect what a command writes, with the `CommandRunContext` surface it uses. */
function recordingCtx() {
let out = ''
let err = ''
return {
ctx: /** @type {any} */ ({
stdout: { write: (/** @type {string} */ s) => { out += s } },
stderr: { write: (/** @type {string} */ s) => { err += s } },
}),
stdout: () => out,
stderr: () => err,
}
}

/**
* @param {string} stateDir
* @param {GithubClient} client
*/
function activate(stateDir, client) {
setGithubRuntime(/** @type {any} */ ({
stateDir,
config: {
ignore: [],
token_env: 'GITHUB_TOKEN',
poll_interval: '24h',
inventory: 'all_visible',
},
observedRepos: { async list() { return [] } },
clientFactory: () => client,
storage: {
cacheTablePath() { return path.join(stateDir, 'github_events') },
async appendRows() { throw new Error('a failed inventory must not append') },
},
env: {},
log: { info() {}, error() {} },
}))
}

test('backfill reports a failed inventory resolve instead of diagnosing an absent repository', async (t) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypaware-github.qkg1.topmands-'))
t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }))
const client = fakeClient({})
client.listViewerRepos = async () => {
throw Object.assign(
new Error('GitHub continuation URL refused: it does not address the configured API base'),
{ hypErrorKind: 'github_foreign_origin' },
)
}
activate(stateDir, client)
const rec = recordingCtx()

const code = await runGithubBackfill(['owner/a'], rec.ctx)

assert.equal(code, 1)
assert.match(rec.stderr(), /continuation URL refused/)
assert.ok(
!rec.stderr().includes('active repository inventory'),
'an unresolved inventory is unknown, not empty: the repository-absent diagnosis would be false',
)
assert.ok(
!rec.stdout().includes('hyp graph project'),
'a tick that captured nothing must not point at projection as the next step',
)
})

test('backfill still diagnoses a genuinely absent repository', async (t) => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypaware-github.qkg1.topmands-'))
t.after(() => fs.rmSync(stateDir, { recursive: true, force: true }))
activate(stateDir, fakeClient({ viewerRepos: ['owner/b'] }))
const rec = recordingCtx()

const code = await runGithubBackfill(['owner/a'], rec.ctx)

assert.equal(code, 1)
assert.match(rec.stderr(), /none of \[owner\/a\] are in the active repository inventory/)
})
Loading