From e6796b342a81a4b4c1176135eac26e4229209042 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:12:13 +0000 Subject: [PATCH 1/3] fix(workflow): use main's metadata/repos.yaml for privacy check Co-authored-by: marcusrbrown <831617+marcusrbrown@users.noreply.github.com> --- .github/workflows/merge-data.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/merge-data.yaml b/.github/workflows/merge-data.yaml index fd8731c67..1395e5850 100644 --- a/.github/workflows/merge-data.yaml +++ b/.github/workflows/merge-data.yaml @@ -36,11 +36,15 @@ jobs: ref: data path: data-branch-check + # Use main's metadata/repos.yaml as the authoritative private-repo list so + # stale orphan entries on the data branch cannot block the privacy gate. - name: 🔒 Block private wiki pages env: GITHUB_TOKEN: ${{ steps.get-workflow-app-token.outputs.token }} working-directory: data-branch-check - run: node ../scripts/check-wiki-private-presence.ts + run: | + cp ../metadata/repos.yaml metadata/repos.yaml + node ../scripts/check-wiki-private-presence.ts - name: 🔀 Open weekly data merge PR env: From 68ce4479c2adf1148103e8f2ea01fa3259b757dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:14:12 +0000 Subject: [PATCH 2/3] fix(metadata): drop orphan private repo entries blocking data merge R_kgDOSVJgdw and R_kgDOSZ9x-w resolve to GraphQL NOT_FOUND (deleted repos / lost App access). Already removed from main by PR #3394; this ensures the copilot branch is also clean before the PR is opened. Refs #3375 Co-authored-by: marcusrbrown <831617+marcusrbrown@users.noreply.github.com> --- metadata/repos.yaml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/metadata/repos.yaml b/metadata/repos.yaml index 826661439..2b6ac996c 100644 --- a/metadata/repos.yaml +++ b/metadata/repos.yaml @@ -214,18 +214,6 @@ repos: next_survey_eligible_at: 2026-05-27 private: false node_id: R_kgDOSKIp0Q - - owner: '[REDACTED]' - name: R_kgDOSVJgdw - added: 2026-05-05 - onboarding_status: pending - last_survey_at: 2026-05-06 - last_survey_status: success - has_fro_bot_workflow: true - has_renovate: true - discovery_channel: collab - next_survey_eligible_at: 2026-06-08 - private: true - node_id: R_kgDOSVJgdw - owner: fro-bot name: agent added: 2026-05-07 @@ -262,15 +250,3 @@ repos: next_survey_eligible_at: 2026-05-22 private: false node_id: R_kgDORLx6ew - - owner: '[REDACTED]' - name: R_kgDOSZ9x-w - added: 2026-05-13 - onboarding_status: pending - last_survey_at: null - last_survey_status: null - has_fro_bot_workflow: false - has_renovate: false - discovery_channel: collab - next_survey_eligible_at: null - private: true - node_id: R_kgDOSZ9x-w From e075b9fa3aa96f9e154d1bec2ae235b147c2b358 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:14:30 +0000 Subject: [PATCH 3/3] chore: sync files from main to match PR #3394 and #3389 Co-authored-by: marcusrbrown <831617+marcusrbrown@users.noreply.github.com> --- .github/workflows/fro-bot.yaml | 2 +- .github/workflows/survey-repo.yaml | 2 +- scripts/check-wiki-private-presence.test.ts | 62 ++++++++++++++++++++- scripts/check-wiki-private-presence.ts | 45 ++++++++++++++- 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/.github/workflows/fro-bot.yaml b/.github/workflows/fro-bot.yaml index c15ebef18..9033fc7de 100644 --- a/.github/workflows/fro-bot.yaml +++ b/.github/workflows/fro-bot.yaml @@ -209,7 +209,7 @@ jobs: - name: Run Fro Bot id: fro-bot-agent - uses: fro-bot/agent@9a2d4b08196d3d5ad70692b655311e18ed6b2726 # v0.46.1 + uses: fro-bot/agent@80f1fa11d8e25280d388947c0a28875ed18cdc25 # v0.48.1 env: OPENCODE_PROMPT_ARTIFACT: 'true' PERSONA: ${{ steps.persona.outputs.content }} diff --git a/.github/workflows/survey-repo.yaml b/.github/workflows/survey-repo.yaml index 6bc5e1965..bc64dd782 100644 --- a/.github/workflows/survey-repo.yaml +++ b/.github/workflows/survey-repo.yaml @@ -176,7 +176,7 @@ jobs: - name: Run Fro Bot survey ingest id: survey-agent - uses: fro-bot/agent@9a2d4b08196d3d5ad70692b655311e18ed6b2726 # v0.46.1 + uses: fro-bot/agent@80f1fa11d8e25280d388947c0a28875ed18cdc25 # v0.48.1 with: github-token: ${{ secrets.FRO_BOT_POLL_PAT }} auth-json: ${{ secrets.OPENCODE_AUTH_JSON }} diff --git a/scripts/check-wiki-private-presence.test.ts b/scripts/check-wiki-private-presence.test.ts index 1cb36ddb8..eb952a84f 100644 --- a/scripts/check-wiki-private-presence.test.ts +++ b/scripts/check-wiki-private-presence.test.ts @@ -1,6 +1,11 @@ import {describe, expect, it, vi} from 'vitest' -import {detectPrivateWikiLeaks, loadWikiFilenames, resolveCanonicalSlugs} from './check-wiki-private-presence.ts' +import { + detectPrivateWikiLeaks, + isNotFoundSignal, + loadWikiFilenames, + resolveCanonicalSlugs, +} from './check-wiki-private-presence.ts' // Hoisted mocks — vitest transforms these to the top of the module at compile time, // so they take effect before any imports regardless of source order. @@ -239,6 +244,35 @@ describe('resolveCanonicalSlugs', () => { expect(() => resolveCanonicalSlugs([{node_id: 'R_kgDOABCDEF'}])).toThrow(/node-null/) }) + it('classifies a non-zero gh exit carrying NOT_FOUND as node-null, not subprocess-threw', () => { + // #given gh api graphql exits non-zero but the captured stdout is a valid NOT_FOUND body + // (repo deleted or App lost access — data.node: null with a top-level errors array) + // #when resolveCanonicalSlugs is called + // #then the failure is classified node-null so operators investigate repo lifecycle, not transport + mockExecFileSync.mockImplementation(() => { + throw Object.assign(new Error('gh: Could not resolve to a node with the global id of "R_kgDOSVJgdw".'), { + status: 1, + stdout: JSON.stringify({ + data: {node: null}, + errors: [{type: 'NOT_FOUND', message: 'Could not resolve to a node with the global id'}], + }), + stderr: 'gh: Could not resolve to a node with the global id', + }) + }) + expect(() => resolveCanonicalSlugs([{node_id: 'R_kgDOSVJgdw'}])).toThrow(/node-null/) + expect(() => resolveCanonicalSlugs([{node_id: 'R_kgDOSVJgdw'}])).not.toThrow(/subprocess-threw/) + }) + + it('still classifies a genuine transport error (HTTP 401, no body) as subprocess-threw', () => { + // #given gh throws with no NOT_FOUND signal in any captured stream + // #when resolveCanonicalSlugs is called + // #then the failure remains subprocess-threw (network/rate-limit/auth) + mockExecFileSync.mockImplementation(() => { + throw Object.assign(new Error('gh: HTTP 401'), {status: 1, stdout: '', stderr: 'gh: HTTP 401'}) + }) + expect(() => resolveCanonicalSlugs([{node_id: 'R_kgDOABCDEF'}])).toThrow(/subprocess-threw/) + }) + it('distinguishes subprocess-threw from node-null in the error message (NBC #2)', () => { // #given one entry throws subprocess and another returns node: null // #when resolveCanonicalSlugs is called @@ -285,6 +319,32 @@ describe('resolveCanonicalSlugs', () => { }) }) +describe('isNotFoundSignal', () => { + it('returns false for empty input', () => { + expect(isNotFoundSignal('')).toBe(false) + }) + + it('detects a structured top-level NOT_FOUND error', () => { + expect(isNotFoundSignal(JSON.stringify({data: {node: null}, errors: [{type: 'NOT_FOUND'}]}))).toBe(true) + }) + + it('detects data.node: null even without an errors array', () => { + expect(isNotFoundSignal(JSON.stringify({data: {node: null}}))).toBe(true) + }) + + it('detects the plain-text gh NOT_FOUND line (non-JSON stderr)', () => { + expect(isNotFoundSignal('gh: Could not resolve to a node with the global id of "R_kgDOSVJgdw".')).toBe(true) + }) + + it('returns false for an unrelated transport error', () => { + expect(isNotFoundSignal('gh: HTTP 401 Unauthorized')).toBe(false) + }) + + it('returns false for a successful repository response', () => { + expect(isNotFoundSignal(JSON.stringify({data: {node: {nameWithOwner: 'acme/secret'}}}))).toBe(false) + }) +}) + describe('loadWikiFilenames', () => { it('returns empty array when knowledge/wiki/repos/ does not exist (ENOENT)', async () => { // #given the wiki repos directory does not exist diff --git a/scripts/check-wiki-private-presence.ts b/scripts/check-wiki-private-presence.ts index ce6286b68..b4e65bc82 100644 --- a/scripts/check-wiki-private-presence.ts +++ b/scripts/check-wiki-private-presence.ts @@ -70,6 +70,43 @@ function isGraphQLNodeNullResponse(value: unknown): value is {data: {node: null} return data.node === null } +/** + * Detect a GraphQL NOT_FOUND signal in captured subprocess output. + * + * `gh api graphql` exits non-zero whenever the response carries a top-level + * `errors` array — including the benign "Could not resolve to a node with the + * global id" NOT_FOUND that accompanies `data.node: null`. When that happens, + * execFileSync throws before we can parse `data.node`, so the body is only + * reachable via the thrown error's captured stdout/stderr. This classifies + * such a throw as a node-lifecycle failure rather than a transport failure. + */ +export function isNotFoundSignal(text: string): boolean { + if (text.length === 0) return false + // Try structured parse first: top-level errors[].type === 'NOT_FOUND' or data.node: null. + try { + const parsed: unknown = JSON.parse(text) + if (typeof parsed === 'object' && parsed !== null) { + const obj = parsed as Record + if (Array.isArray(obj.errors) && obj.errors.some(e => isNotFoundError(e))) return true + if (isGraphQLNodeNullResponse(parsed)) return true + } + } catch { + // Not JSON (e.g. plain `gh:` stderr line) — fall through to substring match. + } + return /Could not resolve to a node with the global id/i.test(text) || /"type":\s*"NOT_FOUND"/i.test(text) +} + +function isNotFoundError(value: unknown): boolean { + return typeof value === 'object' && value !== null && (value as Record).type === 'NOT_FOUND' +} + +function capturedOutput(error: unknown): string { + if (typeof error !== 'object' || error === null) return '' + const e = error as {stdout?: unknown; stderr?: unknown; message?: unknown} + const parts = [e.stdout, e.stderr, e.message].map(p => (typeof p === 'string' ? p : '')) + return parts.join('\n') +} + // --------------------------------------------------------------------------- // resolveCanonicalSlugs — exported for testing; fail-closed // --------------------------------------------------------------------------- @@ -130,8 +167,12 @@ export function resolveCanonicalSlugs(entries: readonly {node_id: string}[]): Sl // Unexpected response shape — treat as subprocess-level failure failures.push({node_id: entry.node_id, mode: 'subprocess-threw'}) } - } catch { - failures.push({node_id: entry.node_id, mode: 'subprocess-threw'}) + } catch (error) { + // `gh api graphql` exits non-zero on NOT_FOUND even though the body is a + // valid `data.node: null`. Inspect captured output so a deleted repo / lost + // App access is classified as node-null, not a transport failure. + const mode: FailureMode = isNotFoundSignal(capturedOutput(error)) ? 'node-null' : 'subprocess-threw' + failures.push({node_id: entry.node_id, mode}) } }