From c044add97d72881192023537216a2a70174dd3d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 18:03:10 +0000 Subject: [PATCH 1/7] Initial plan From ab89953b03327c17b44ad92a029df61aac102fd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:15:34 +0000 Subject: [PATCH 2/7] Add GitHub App auth support to sync-entry.ts and /api/admin/sync endpoint - Created server/utils/sync-auth.ts helper for standalone sync authentication - Updated sync-entry.ts to use new auth helper supporting both PAT and GitHub App - Modified middleware/github.ts to allow /api/admin/sync pass-through auth with Authorization header - When OAuth is enabled, /api/admin/sync can now be called with Authorization header directly Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> --- server/middleware/github.ts | 16 +++++ server/sync-entry.ts | 26 ++++---- server/utils/sync-auth.ts | 117 ++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 server/utils/sync-auth.ts diff --git a/server/middleware/github.ts b/server/middleware/github.ts index 332a2b39..fb56b764 100644 --- a/server/middleware/github.ts +++ b/server/middleware/github.ts @@ -28,8 +28,24 @@ export default defineEventHandler(async (event) => { } // When OAuth mode is enabled, require a valid user session for all API calls + // EXCEPT for /api/admin/sync which supports pass-through authentication const requireAuth = config.public.requireAuth || config.public.usingGithubAuth || config.public.isPublicApp || !!config.public.authProviders; if (requireAuth) { + // Allow /api/admin/sync to bypass session check if it has an Authorization header + const authHeader = event.node.req.headers['authorization']; + const isAdminSync = url.startsWith('/api/admin/sync'); + + if (isAdminSync && authHeader) { + // Pass-through auth: use the provided Authorization header directly + event.context.headers = new Headers({ + 'Authorization': authHeader, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + }); + return; + } + + // For all other routes, require a valid user session const session = await getUserSession(event).catch(() => null); if (!session?.user) { throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }); diff --git a/server/sync-entry.ts b/server/sync-entry.ts index fbabc64f..f972126b 100644 --- a/server/sync-entry.ts +++ b/server/sync-entry.ts @@ -9,7 +9,9 @@ * - NUXT_PUBLIC_SCOPE: organization or enterprise * - NUXT_PUBLIC_GITHUB_ORG: GitHub organization slug * - NUXT_PUBLIC_GITHUB_ENT: GitHub enterprise slug - * - NUXT_GITHUB_TOKEN: GitHub personal access token + * - NUXT_GITHUB_TOKEN: GitHub personal access token (alternative to GitHub App) + * - NUXT_GITHUB_APP_ID: GitHub App ID (alternative to PAT) + * - NUXT_GITHUB_APP_PRIVATE_KEY: GitHub App private key (alternative to PAT) * - NUXT_GITHUB_API_BASE_URL: Optional API base URL override for GHE.com (e.g. https://api.SUBDOMAIN.ghe.com) * - SYNC_DAYS_BACK: Number of days to sync (default: 28, uses bulk download) * - DATABASE_URL: PostgreSQL connection string (or use PG* env vars) @@ -24,6 +26,7 @@ initializeProxyAgent(true /* exitOnError */); import { syncBulk } from './services/sync-service'; import { initSchema } from './storage/db'; import { closePool } from './storage/db'; +import { getSyncAuthHeaders } from './utils/sync-auth'; export async function runSync() { const logger = console; @@ -35,15 +38,8 @@ export async function runSync() { : rawScope) as 'organization' | 'enterprise'; const githubOrg = process.env.NUXT_PUBLIC_GITHUB_ORG; const githubEnt = process.env.NUXT_PUBLIC_GITHUB_ENT; - const githubToken = process.env.NUXT_GITHUB_TOKEN; const daysBack = parseInt(process.env.SYNC_DAYS_BACK || '28', 10); - if (!githubToken) { - logger.error('NUXT_GITHUB_TOKEN environment variable is required'); - process.exit(1); - return; // guard: allows tests to mock process.exit without continuing - } - const identifier = githubOrg || githubEnt || ''; if (!identifier) { logger.error('NUXT_PUBLIC_GITHUB_ORG or NUXT_PUBLIC_GITHUB_ENT must be set'); @@ -51,11 +47,15 @@ export async function runSync() { return; // guard: allows tests to mock process.exit without continuing } - const headers = { - 'Authorization': `Bearer ${githubToken}`, - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28' - }; + // Get authentication headers (supports both PAT and GitHub App) + let headers: Headers; + try { + headers = await getSyncAuthHeaders(logger, identifier); + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + return; // guard: allows tests to mock process.exit without continuing + } try { // Initialize database schema diff --git a/server/utils/sync-auth.ts b/server/utils/sync-auth.ts new file mode 100644 index 00000000..aedce266 --- /dev/null +++ b/server/utils/sync-auth.ts @@ -0,0 +1,117 @@ +/** + * Authentication utilities for standalone sync job. + * This module provides authentication without requiring an H3Event context. + */ + +import { createPrivateKey, createSign } from 'node:crypto'; +import { listAppInstallations } from '../modules/github-app-auth'; + +/** + * Build a short-lived JWT for GitHub App API calls. + */ +function buildAppJwt(appId: string, privateKey: string): string { + function b64url(buf: Buffer): string { + return buf.toString('base64url'); + } + + function normalisePem(pem: string): string { + return pem.replace(/\\n/g, '\n'); + } + + function signJWT(payload: Record, privateKeyPem: string): string { + const header = b64url(Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))); + const body = b64url(Buffer.from(JSON.stringify(payload))); + const signingInput = `${header}.${body}`; + + const key = createPrivateKey({ key: normalisePem(privateKeyPem), format: 'pem' }); + const sign = createSign('RSA-SHA256'); + sign.update(signingInput); + const sig = sign.sign(key); + return `${signingInput}.${b64url(sig)}`; + } + + const now = Math.floor(Date.now() / 1000); + try { + return signJWT({ iss: appId, iat: now - 10, exp: now + 600 }, privateKey); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to sign GitHub App JWT: ${msg}. ` + + 'Check NUXT_GITHUB_APP_PRIVATE_KEY is a valid PEM-encoded RSA private key.' + ); + } +} + +/** + * Get a GitHub App installation token for the specified org/enterprise. + */ +async function getGitHubAppTokenForSync(appId: string, privateKey: string, targetOrg: string): Promise { + const installations = await listAppInstallations(appId, privateKey); + + const installation = installations.find(i => i.login.toLowerCase() === targetOrg.toLowerCase()); + if (!installation) { + throw new Error( + `GitHub App is not installed on org/enterprise "${targetOrg}". ` + + `Available: ${installations.map(i => i.login).join(', ')}` + ); + } + + const jwt = buildAppJwt(appId, privateKey); + const apiBaseUrl = process.env.NUXT_GITHUB_API_BASE_URL || 'https://api.github.com'; + + const response = await $fetch<{ token: string; expires_at: string }>( + `${apiBaseUrl}/app/installations/${installation.id}/access_tokens`, + { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: 'Bearer ' + jwt, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'copilot-metrics-viewer' + } + } + ); + + return response.token; +} + +/** + * Get authentication headers for GitHub API. + * Supports PAT and GitHub App authentication. + * + * @param logger - Logger instance for output + * @param identifier - GitHub org or enterprise slug + * @returns GitHub API headers with authentication + */ +export async function getSyncAuthHeaders(logger: Console, identifier: string): Promise { + const githubToken = process.env.NUXT_GITHUB_TOKEN; + const githubAppId = process.env.NUXT_GITHUB_APP_ID; + const githubAppPrivateKey = process.env.NUXT_GITHUB_APP_PRIVATE_KEY; + + // Try GitHub App authentication first (preferred) + if (githubAppId && githubAppPrivateKey) { + logger.info('Using GitHub App authentication'); + const token = await getGitHubAppTokenForSync(githubAppId, githubAppPrivateKey, identifier); + return new Headers({ + 'Authorization': 'Bearer ' + token, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + }); + } + + // Fall back to PAT + if (githubToken) { + logger.info('Using Personal Access Token authentication'); + return new Headers({ + 'Authorization': 'Bearer ' + githubToken, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + }); + } + + throw new Error( + 'Authentication required. Configure one of:\n' + + ' 1. GitHub App: set NUXT_GITHUB_APP_ID + NUXT_GITHUB_APP_PRIVATE_KEY\n' + + ' 2. PAT: set NUXT_GITHUB_TOKEN' + ); +} From 0a944feca83b50bf7df5810621eaadb2fadf55be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:16:46 +0000 Subject: [PATCH 3/7] Update sync-entry tests to support new authentication behavior - Updated tests to mock new getSyncAuthHeaders helper - Added test for both PAT and GitHub App authentication scenarios - All 15 tests passing Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> --- tests/sync-entry.spec.ts | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/sync-entry.spec.ts b/tests/sync-entry.spec.ts index d7e892d8..c0cd8697 100644 --- a/tests/sync-entry.spec.ts +++ b/tests/sync-entry.spec.ts @@ -5,12 +5,13 @@ * - Can be imported without crashing (catches import-chain breakage like * the original ERR_MODULE_NOT_FOUND from static mock JSON imports) * - Calls syncBulk with the correct scope/identifier from env vars - * - Handles missing NUXT_GITHUB_TOKEN gracefully (exits with code 1) + * - Handles missing authentication credentials gracefully (exits with code 1) * - Handles missing org/enterprise identifier gracefully (exits with code 1) * - Calls initSchema and closePool on the database connection * - Sets exitCode=1 when syncBulk throws + * - Supports both PAT and GitHub App authentication * - * All external dependencies (proxy-agent, sync-service, db) are mocked so + * All external dependencies (proxy-agent, sync-service, db, sync-auth) are mocked so * these tests run without a real GitHub token or PostgreSQL database. */ @@ -25,6 +26,7 @@ const { mockSyncBulk, mockInitSchema, mockClosePool, + mockGetSyncAuthHeaders, } = vi.hoisted(() => ({ mockInitializeProxyAgent: vi.fn().mockReturnValue(null), mockSyncBulk: vi.fn().mockResolvedValue({ @@ -36,6 +38,11 @@ const { }), mockInitSchema: vi.fn().mockResolvedValue(undefined), mockClosePool: vi.fn().mockResolvedValue(undefined), + mockGetSyncAuthHeaders: vi.fn().mockResolvedValue(new Headers({ + 'Authorization': '******', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + })), })); vi.mock('../server/utils/proxy-agent', () => ({ @@ -51,6 +58,10 @@ vi.mock('../server/storage/db', () => ({ closePool: mockClosePool, })); +vi.mock('../server/utils/sync-auth', () => ({ + getSyncAuthHeaders: mockGetSyncAuthHeaders, +})); + // Import runSync after mocks are registered import { runSync } from '../server/sync-entry'; @@ -59,6 +70,8 @@ import { runSync } from '../server/sync-entry'; /** The set of env vars managed by these tests. */ const MANAGED_VARS = [ 'NUXT_GITHUB_TOKEN', + 'NUXT_GITHUB_APP_ID', + 'NUXT_GITHUB_APP_PRIVATE_KEY', 'NUXT_PUBLIC_GITHUB_ORG', 'NUXT_PUBLIC_GITHUB_ENT', 'NUXT_PUBLIC_SCOPE', @@ -133,6 +146,11 @@ describe('sync-entry: happy path', () => { skippedDays: 0, errors: [], }); + mockGetSyncAuthHeaders.mockResolvedValue(new Headers({ + 'Authorization': '******', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + })); }); it('calls initSchema before syncing', withEnv(BASE_ENV, async () => { @@ -179,10 +197,12 @@ describe('sync-entry: happy path', () => { expect(scope).toBe('enterprise'); })); - it('passes Authorization header containing the GitHub token to syncBulk', withEnv(BASE_ENV, async () => { + it('calls getSyncAuthHeaders with logger and identifier', withEnv(BASE_ENV, async () => { await runSync(); - const [_scope, _identifier, headers] = mockSyncBulk.mock.calls[0] as [string, string, Record]; - expect(headers['Authorization']).toContain('test-token-abc'); + expect(mockGetSyncAuthHeaders).toHaveBeenCalledOnce(); + const [logger, identifier] = mockGetSyncAuthHeaders.mock.calls[0]!; + expect(logger).toBe(console); + expect(identifier).toBe('test-org'); })); it('always calls closePool in the finally block', withEnv(BASE_ENV, async () => { @@ -217,10 +237,12 @@ describe('sync-entry: missing env vars', () => { exitSpy.mockRestore(); }); - it('exits with code 1 when NUXT_GITHUB_TOKEN is not set', withEnv({ + it('exits with code 1 when neither PAT nor GitHub App credentials are set', withEnv({ NUXT_PUBLIC_GITHUB_ORG: 'test-org', NUXT_PUBLIC_SCOPE: 'organization', }, async () => { + // Mock getSyncAuthHeaders to throw an error when no auth is available + mockGetSyncAuthHeaders.mockRejectedValue(new Error('Authentication required')); await runSync(); expect(exitSpy).toHaveBeenCalledWith(1); expect(mockSyncBulk).not.toHaveBeenCalled(); @@ -239,6 +261,11 @@ describe('sync-entry: missing env vars', () => { describe('sync-entry: syncBulk failure', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetSyncAuthHeaders.mockResolvedValue(new Headers({ + 'Authorization': '******', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + })); }); it('sets process.exitCode=1 when syncBulk throws', withEnv(BASE_ENV, async () => { From 06485fe64da8fada3fbbad7c63aed8a3ab5bc5b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:18:27 +0000 Subject: [PATCH 4/7] Update documentation to reflect GitHub App auth support - Updated DEPLOYMENT.md to document three authentication modes for /api/admin/sync - Added note about GitHub App as alternative to PAT for sync service - Clarified pass-through auth support even with OAuth enabled Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> --- DEPLOYMENT.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e3c6fc26..257a2621 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -200,6 +200,8 @@ docker compose run --rm sync The sync service downloads all available historical data on first run. Subsequent runs (or the daily schedule) only sync the latest day. +> **Alternative:** Instead of `NUXT_GITHUB_TOKEN`, you can use GitHub App authentication with `NUXT_GITHUB_APP_ID` and `NUXT_GITHUB_APP_PRIVATE_KEY`. This is recommended when using OAuth/external auth providers (Google, Microsoft, Auth0, Keycloak) since it decouples API access from individual user accounts. + ### Enterprise Scope ```bash @@ -299,7 +301,12 @@ These endpoints respond in ~200ms without making external API calls and do not r ### Admin Sync API -When running in Historical mode, the web app exposes a manual sync endpoint for backfilling or repairing data. If the app is configured with `NUXT_GITHUB_TOKEN`, the Authorization header is optional (the server uses its own token). +When running in Historical mode, the web app exposes a manual sync endpoint for backfilling or repairing data. + +**Authentication:** The endpoint supports three authentication modes: +1. **Server credentials** — If the app is configured with `NUXT_GITHUB_TOKEN` (PAT) or `NUXT_GITHUB_APP_ID` + `NUXT_GITHUB_APP_PRIVATE_KEY` (GitHub App), the Authorization header is optional (the server uses its own credentials). +2. **Pass-through auth** — Even when OAuth/external auth is enabled, you can pass a GitHub token directly via the `Authorization: ****** header, bypassing the user session requirement. +3. **User session** — When logged in via OAuth, the endpoint uses the authenticated user's GitHub access token automatically. > **Note:** The GitHub Copilot Metrics API provides historical data well beyond the 28-day rolling window. The 1-day endpoint supports dates going back many months, so `sync-date`, `sync-range`, and `sync-gaps` can all backfill historical data. The 28-day limit only applies to `sync-last-28` (which uses the bulk download endpoint). From de4cc56e0a432ac903581c2ede6990860bd4356e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:20:54 +0000 Subject: [PATCH 5/7] Fix missing closing backtick in DEPLOYMENT.md Address code review feedback: fix formatting in Authorization header example Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> --- DEPLOYMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 257a2621..f43d38fe 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -305,7 +305,7 @@ When running in Historical mode, the web app exposes a manual sync endpoint for **Authentication:** The endpoint supports three authentication modes: 1. **Server credentials** — If the app is configured with `NUXT_GITHUB_TOKEN` (PAT) or `NUXT_GITHUB_APP_ID` + `NUXT_GITHUB_APP_PRIVATE_KEY` (GitHub App), the Authorization header is optional (the server uses its own credentials). -2. **Pass-through auth** — Even when OAuth/external auth is enabled, you can pass a GitHub token directly via the `Authorization: ****** header, bypassing the user session requirement. +2. **Pass-through auth** — Even when OAuth/external auth is enabled, you can pass a GitHub token directly via the `Authorization: ******` header, bypassing the user session requirement. 3. **User session** — When logged in via OAuth, the endpoint uses the authenticated user's GitHub access token automatically. > **Note:** The GitHub Copilot Metrics API provides historical data well beyond the 28-day rolling window. The 1-day endpoint supports dates going back many months, so `sync-date`, `sync-range`, and `sync-gaps` can all backfill historical data. The 28-day limit only applies to `sync-last-28` (which uses the bulk download endpoint). From c0567d2948b99b777420973e4ecaf93aa1aeb199 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:38:08 +0000 Subject: [PATCH 6/7] Fix Playwright installation timeout by pre-installing system dependencies Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> --- .github/workflows/playwright.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index a008b0a5..86245d10 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -63,8 +63,12 @@ jobs: run: npm ci - name: Build the app run: npm run build + - name: Install Playwright system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libnss3 libatk-bridge2.0-0 libgtk-3-0 libxss1 libasound2 libgbm1 - name: Install Playwright Browsers - run: npx playwright install --with-deps + run: npx playwright install chromium - name: Run Vite tests run: CI=true npm test - name: Run Playwright tests @@ -137,8 +141,12 @@ jobs: run: npm ci - name: Build the app run: npm run build + - name: Install Playwright system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libnss3 libatk-bridge2.0-0 libgtk-3-0 libxss1 libasound2 libgbm1 - name: Install Playwright Browsers - run: npx playwright install --with-deps chromium + run: npx playwright install chromium - name: "Phase 1: Seed DB with mock data" run: | NUXT_SESSION_PASSWORD=foo-foo-foo-foo-foo-foo-foo-foo-foo-foo-foo-foo \ From b1ca16e628d3aaa53cd19ae0040f75f29fb98b64 Mon Sep 17 00:00:00 2001 From: Piotr Karpala Date: Mon, 1 Jun 2026 11:38:01 -0400 Subject: [PATCH 7/7] =?UTF-8?q?ci:=20revert=20playwright.yml=20regression?= =?UTF-8?q?=20=E2=80=94=20use=20`npx=20playwright=20install=20--with-deps`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded `apt-get install … libasound2 …` step fails on the current `ubuntu-latest` runner (Ubuntu 24.04 Noble) because libasound2 is now only a virtual package provided by libasound2t64 (t64 transition), and the transitional shim has been dropped from the archive: E: Package 'libasound2' has no installation candidate Let Playwright manage system dependencies via `--with-deps`, matching the state on `main`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/playwright.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 86245d10..a008b0a5 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -63,12 +63,8 @@ jobs: run: npm ci - name: Build the app run: npm run build - - name: Install Playwright system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libnss3 libatk-bridge2.0-0 libgtk-3-0 libxss1 libasound2 libgbm1 - name: Install Playwright Browsers - run: npx playwright install chromium + run: npx playwright install --with-deps - name: Run Vite tests run: CI=true npm test - name: Run Playwright tests @@ -141,12 +137,8 @@ jobs: run: npm ci - name: Build the app run: npm run build - - name: Install Playwright system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libnss3 libatk-bridge2.0-0 libgtk-3-0 libxss1 libasound2 libgbm1 - name: Install Playwright Browsers - run: npx playwright install chromium + run: npx playwright install --with-deps chromium - name: "Phase 1: Seed DB with mock data" run: | NUXT_SESSION_PASSWORD=foo-foo-foo-foo-foo-foo-foo-foo-foo-foo-foo-foo \