From e963e80a57276fb2ed6831ae293c43b105546ffa Mon Sep 17 00:00:00 2001 From: Aymen Furter Date: Tue, 25 Aug 2026 09:44:56 +0200 Subject: [PATCH] feat: add GitHub App productivity dashboard Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fb021b60-50a8-4b6d-92b6-cb4840d528c8 --- README.md | 8 + esbuild.mjs | 1 + src/core/github-app-analytics.test.ts | 134 ++++++++++ src/core/github-app-analytics.ts | 350 +++++++++++++++++++++++++ src/core/parser-vscode.test.ts | 52 +++- src/core/parser-vscode.ts | 60 +++-- src/core/types.ts | 1 + src/core/types/github-app-types.ts | 24 ++ src/core/types/rpc-types.ts | 6 +- src/webview/app.ts | 33 ++- src/webview/dashboard-shell.ts | 2 + src/webview/page-github-app.ts | 231 +++++++++++++++++ src/webview/panel-rpc.ts | 4 +- src/webview/styles-github-app.css | 359 ++++++++++++++++++++++++++ tests/e2e/harness.html | 27 ++ 15 files changed, 1262 insertions(+), 30 deletions(-) create mode 100644 src/core/github-app-analytics.test.ts create mode 100644 src/core/github-app-analytics.ts create mode 100644 src/core/types/github-app-types.ts create mode 100644 src/webview/page-github-app.ts create mode 100644 src/webview/styles-github-app.css diff --git a/README.md b/README.md index c8d18eba..438864f7 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,14 @@ A few features depend on the local VS Code language model and are hidden in canv | **Burndown** | Monthly AI token budget progress with projections _(temporarily disabled)_ | | **Patterns** | 7×24 activity heatmap and work-life balance signals | +### GitHub App + +This section appears only when the local GitHub Copilot app is installed. + +| Page | Description | +| ---------------- | ----------------------------------------------------------------------------------------------------------- | +| **Productivity** | Project sessions with and without issues, pull request and merge conversion, and a seven-day PR merge-ratio trend | + ### Improve | Page | Description | diff --git a/esbuild.mjs b/esbuild.mjs index 577d8aea..5cc8079a 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -113,6 +113,7 @@ if (fs.existsSync(metricsSrc)) { const cssSources = [ 'src/webview/styles.css', 'src/webview/styles-pages.css', + 'src/webview/styles-github-app.css', 'src/webview/styles-skills.css', 'src/webview/styles-learning.css', ]; diff --git a/src/core/github-app-analytics.test.ts b/src/core/github-app-analytics.test.ts new file mode 100644 index 00000000..cc8c5226 --- /dev/null +++ b/src/core/github-app-analytics.test.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as os from 'os'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +import { + loadGitHubAppMetrics, + parseGitHubAppIssueSessionReferences, + parseGitHubAppMetricsRows, +} from './github-app-analytics'; + +function row(date: string, overrides: Record = {}): Record { + return { + date, + cohortPullRequestsRaised: 4, + cohortPullRequestsMerged: 3, + totalProjectSessions: 12, + sessionsWithIssue: 7, + sessionsWithPullRequest: 8, + sessionsWithMergedPullRequest: 6, + lastActivityAt: '2026-08-24T10:00:00.000Z', + ...overrides, + }; +} + +function queryResult( + sql: string, + references: Record[] = [], + workspaceLinks: Record[] = [], +): string { + if (sql.includes('WITH RECURSIVE')) return JSON.stringify([row('2026-08-24')]); + if (sql.includes('FROM session_refs')) return JSON.stringify(references); + if (sql.includes('workspace_session_aliases')) return JSON.stringify(workspaceLinks); + throw new Error('Unexpected query'); +} + +describe('GitHub App analytics parsing', () => { + it('parses summary and daily PR merge cohorts', () => { + const metrics = parseGitHubAppMetricsRows(JSON.stringify([ + row('2026-08-23'), + row('2026-08-24', { cohortPullRequestsRaised: 0, cohortPullRequestsMerged: 0 }), + ])); + + expect(metrics).toMatchObject({ + totalProjectSessions: 12, + sessionsWithIssue: 7, + sessionsWithPullRequest: 8, + sessionsWithMergedPullRequest: 6, + }); + expect(metrics.mergeHistory).toEqual([ + { date: '2026-08-23', pullRequestsRaised: 4, pullRequestsMerged: 3 }, + { date: '2026-08-24', pullRequestsRaised: 0, pullRequestsMerged: 0 }, + ]); + }); + + it('parses issue-linked session identifiers', () => { + expect(parseGitHubAppIssueSessionReferences(JSON.stringify([ + { sessionId: 'session-1' }, + { sessionId: 'session-2' }, + ]))).toEqual(['session-1', 'session-2']); + }); + +}); + +describe('GitHub App analytics availability', () => { + it('hides the feature when the App database is absent', async () => { + const metrics = await loadGitHubAppMetrics({ + databasePath: path.join(os.tmpdir(), 'missing-copilot-data.db'), + sessionStorePath: path.join(os.tmpdir(), 'missing-session-store.db'), + exists: () => false, + query: () => Promise.reject(new Error('query should not run')), + }); + + expect(metrics).toEqual({ status: 'absent' }); + }); + + it('keeps the feature visible when an installed database cannot be queried', async () => { + const metrics = await loadGitHubAppMetrics({ + databasePath: path.join(os.tmpdir(), 'copilot-data.db'), + sessionStorePath: path.join(os.tmpdir(), 'missing-session-store.db'), + exists: () => true, + query: () => Promise.reject(new Error('sqlite unavailable')), + }); + + expect(metrics).toEqual({ status: 'unavailable' }); + }); + + it('loads seven completed days without querying the optional session store', async () => { + const databasePath = path.join(os.tmpdir(), 'copilot-data.db'); + const queries: string[] = []; + const metrics = await loadGitHubAppMetrics({ + databasePath, + sessionStorePath: path.join(os.tmpdir(), 'missing-session-store.db'), + exists: filePath => filePath === databasePath, + query: (_requestedPath, sql) => { + queries.push(sql); + return Promise.resolve(queryResult(sql)); + }, + }); + + expect(metrics.status).toBe('ready'); + expect(queries).toHaveLength(1); + expect(queries[0]).toContain("VALUES(date('now', '-7 days'))"); + expect(queries[0]).toContain("date('now', '-1 day')"); + }); +}); + +describe('GitHub App issue references', () => { + it('counts only issue references that map to project workspaces', async () => { + const databasePath = path.join(os.tmpdir(), 'copilot-data.db'); + const sessionStorePath = path.join(os.tmpdir(), 'session-store.db'); + const metrics = await loadGitHubAppMetrics({ + databasePath, + sessionStorePath, + exists: () => true, + query: (_requestedPath, sql) => Promise.resolve(queryResult(sql, [ + { sessionId: 'issue-session-1' }, + { sessionId: 'unrelated-issue-session' }, + ], [ + { workspaceId: 'workspace-direct', hasDirectIssue: 1, sessionId: null }, + { workspaceId: 'workspace-linked', hasDirectIssue: 0, sessionId: 'issue-session-1' }, + { workspaceId: 'workspace-linked', hasDirectIssue: 0, sessionId: 'alias-session' }, + { workspaceId: 'workspace-unlinked', hasDirectIssue: 0, sessionId: 'other-session' }, + ])), + }); + + expect(metrics.status).toBe('ready'); + if (metrics.status !== 'ready') throw new Error('Expected ready metrics.'); + expect(metrics.metrics.sessionsWithIssue).toBe(2); + }); +}); diff --git a/src/core/github-app-analytics.ts b/src/core/github-app-analytics.ts new file mode 100644 index 00000000..e378a7f8 --- /dev/null +++ b/src/core/github-app-analytics.ts @@ -0,0 +1,350 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFile } from 'child_process'; +import type { + GitHubAppMergeDay, + GitHubAppMetrics, + GitHubAppSnapshot, +} from './types'; +import { warnCore } from './log'; +import { assertTrustedPath } from './parser-shared'; + +const QUERY_TIMEOUT_MS = 10_000; +const QUERY_MAX_BUFFER = 2 * 1024 * 1024; +const ISSUE_SESSION_REFERENCES_QUERY = ` +SELECT DISTINCT session_id AS sessionId +FROM session_refs +WHERE ref_type = 'issue'`; + +const METRICS_QUERY = ` +WITH RECURSIVE +days(date) AS ( + VALUES(date('now', '-7 days')) + UNION ALL + SELECT date(date, '+1 day') + FROM days + WHERE date < date('now', '-1 day') +), +workspace_pull_requests AS ( + SELECT + contexts.workspace_id AS workspaceId, + workspaces.created_at AS workspaceCreatedAt, + CASE + WHEN contexts.created_pr_merged_at IS NOT NULL + OR lower(COALESCE(contexts.created_pr_state, '')) = 'merged' + THEN 1 ELSE 0 + END AS merged + FROM workspace_repo_contexts AS contexts + JOIN workspaces ON workspaces.id = contexts.workspace_id + WHERE contexts.created_pr_number IS NOT NULL + + UNION ALL + + SELECT + workspaces.id AS workspaceId, + workspaces.created_at AS workspaceCreatedAt, + CASE + WHEN workspaces.created_pr_merged_at IS NOT NULL + OR lower(COALESCE(workspaces.created_pr_state, '')) = 'merged' + THEN 1 ELSE 0 + END AS merged + FROM workspaces + WHERE workspaces.created_pr_number IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM workspace_repo_contexts AS contexts + WHERE contexts.workspace_id = workspaces.id + AND contexts.created_pr_number IS NOT NULL + ) +), +workspace_outcomes AS ( + SELECT + workspaceId, + MAX(merged) AS hasMergedPullRequest + FROM workspace_pull_requests + GROUP BY workspaceId +), +summary AS ( + SELECT + COUNT(*) AS totalProjectSessions, + COALESCE(SUM(CASE + WHEN workspaces.source_issue_number IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM workspace_repo_contexts AS contexts + WHERE contexts.workspace_id = workspaces.id + AND contexts.source_issue_number IS NOT NULL + ) + THEN 1 ELSE 0 + END), 0) AS sessionsWithIssue, + COALESCE(SUM(CASE WHEN outcomes.workspaceId IS NOT NULL THEN 1 ELSE 0 END), 0) + AS sessionsWithPullRequest, + COALESCE(SUM(outcomes.hasMergedPullRequest), 0) AS sessionsWithMergedPullRequest, + MAX(workspaces.updated_at) AS lastActivityAt + FROM workspaces + LEFT JOIN workspace_outcomes AS outcomes ON outcomes.workspaceId = workspaces.id +) +SELECT + days.date AS date, + COUNT(pull_requests.workspaceId) AS cohortPullRequestsRaised, + COALESCE(SUM(pull_requests.merged), 0) AS cohortPullRequestsMerged, + summary.totalProjectSessions, + summary.sessionsWithIssue, + summary.sessionsWithPullRequest, + summary.sessionsWithMergedPullRequest, + summary.lastActivityAt +FROM days +CROSS JOIN summary +LEFT JOIN workspace_pull_requests AS pull_requests + ON date(pull_requests.workspaceCreatedAt) = days.date +GROUP BY + days.date, + summary.totalProjectSessions, + summary.sessionsWithIssue, + summary.sessionsWithPullRequest, + summary.sessionsWithMergedPullRequest, + summary.lastActivityAt +ORDER BY days.date`; + +const WORKSPACE_ISSUE_LINKS_QUERY = ` +WITH +workspace_sessions(workspaceId, sessionId) AS ( + SELECT id, session_id FROM workspaces WHERE session_id IS NOT NULL + UNION + SELECT id, creator_session_id FROM workspaces WHERE creator_session_id IS NOT NULL + UNION + SELECT id, coordinating_creator_session_id + FROM workspaces + WHERE coordinating_creator_session_id IS NOT NULL + UNION + SELECT workspace_id, session_id FROM workspace_session_aliases +), +workspace_issue_flags(workspaceId, hasDirectIssue) AS ( + SELECT + workspaces.id, + CASE + WHEN workspaces.source_issue_number IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM workspace_repo_contexts AS contexts + WHERE contexts.workspace_id = workspaces.id + AND contexts.source_issue_number IS NOT NULL + ) + THEN 1 ELSE 0 + END + FROM workspaces +) +SELECT + issue_flags.workspaceId, + issue_flags.hasDirectIssue, + workspace_sessions.sessionId +FROM workspace_issue_flags AS issue_flags +LEFT JOIN workspace_sessions + ON workspace_sessions.workspaceId = issue_flags.workspaceId`; + +interface GitHubAppMetricsRow { + date: string; + cohortPullRequestsRaised: number; + cohortPullRequestsMerged: number; + totalProjectSessions: number; + sessionsWithIssue: number; + sessionsWithPullRequest: number; + sessionsWithMergedPullRequest: number; + lastActivityAt: string | null; +} + +interface GitHubAppWorkspaceIssueLink { + workspaceId: string; + hasDirectIssue: boolean; + sessionId: string | null; +} + +export interface GitHubAppAnalyticsDependencies { + databasePath?: string; + sessionStorePath?: string; + exists?: (filePath: string) => boolean; + query?: (databasePath: string, sql: string) => Promise; +} + +function isMetricsRow(value: unknown): value is GitHubAppMetricsRow { + if (typeof value !== 'object' || value === null) return false; + const row = value as Record; + const numericFields = [ + 'cohortPullRequestsRaised', + 'cohortPullRequestsMerged', + 'totalProjectSessions', + 'sessionsWithIssue', + 'sessionsWithPullRequest', + 'sessionsWithMergedPullRequest', + ]; + return typeof row.date === 'string' + && numericFields.every(field => typeof row[field] === 'number') + && (row.lastActivityAt === null || typeof row.lastActivityAt === 'string'); +} + +function mergeDay(row: GitHubAppMetricsRow): GitHubAppMergeDay { + return { + date: row.date, + pullRequestsRaised: row.cohortPullRequestsRaised, + pullRequestsMerged: row.cohortPullRequestsMerged, + }; +} + +export function parseGitHubAppMetricsRows(raw: string): GitHubAppMetrics { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isMetricsRow)) { + throw new Error('GitHub App metrics query returned an unexpected result.'); + } + + const first = parsed[0]; + return { + totalProjectSessions: first.totalProjectSessions, + sessionsWithIssue: first.sessionsWithIssue, + sessionsWithPullRequest: first.sessionsWithPullRequest, + sessionsWithMergedPullRequest: first.sessionsWithMergedPullRequest, + lastActivityAt: first.lastActivityAt, + mergeHistory: parsed.map(mergeDay), + }; +} + +function isIssueSessionReference(value: unknown): value is { sessionId: string } { + if (typeof value !== 'object' || value === null) return false; + const row = value as Record; + return typeof row.sessionId === 'string'; +} + +export function parseGitHubAppIssueSessionReferences(raw: string): string[] { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || !parsed.every(isIssueSessionReference)) { + throw new Error('GitHub App issue session query returned an unexpected result.'); + } + return parsed.map(row => row.sessionId); +} + +function isWorkspaceIssueLink(value: unknown): value is { + workspaceId: string; + hasDirectIssue: number; + sessionId: string | null; +} { + if (typeof value !== 'object' || value === null) return false; + const row = value as Record; + return typeof row.workspaceId === 'string' + && (row.hasDirectIssue === 0 || row.hasDirectIssue === 1) + && (row.sessionId === null || typeof row.sessionId === 'string'); +} + +function parseWorkspaceIssueLinks(raw: string): GitHubAppWorkspaceIssueLink[] { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || !parsed.every(isWorkspaceIssueLink)) { + throw new Error('GitHub App workspace issue-link query returned an unexpected result.'); + } + return parsed.map(row => ({ + workspaceId: row.workspaceId, + hasDirectIssue: row.hasDirectIssue === 1, + sessionId: row.sessionId, + })); +} + +function countIssueLinkedWorkspaces( + workspaceLinks: GitHubAppWorkspaceIssueLink[], + issueSessionIds: string[], +): number { + const issueSessions = new Set(issueSessionIds); + const issueWorkspaces = new Set(); + for (const link of workspaceLinks) { + if (link.hasDirectIssue || (link.sessionId !== null && issueSessions.has(link.sessionId))) { + issueWorkspaces.add(link.workspaceId); + } + } + return issueWorkspaces.size; +} + +function defaultDatabasePath(): string { + const configuredHome = process.env.COPILOT_HOME?.trim(); + const copilotHome = configuredHome ? path.resolve(configuredHome) : path.join(os.homedir(), '.copilot'); + return path.join(copilotHome, 'data.db'); +} + +function queryWithSqlite(databasePath: string, sql: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'sqlite3', + ['-readonly', '-json', databasePath, sql], + { + encoding: 'utf-8', + timeout: QUERY_TIMEOUT_MS, + maxBuffer: QUERY_MAX_BUFFER, + cwd: os.tmpdir(), + }, + (error, stdout) => { + if (error) reject(new Error(error.message, { cause: error })); + else resolve(stdout); + }, + ); + }); +} + +async function loadIssueLinkedWorkspaceCount( + databasePath: string, + sessionStorePath: string, + exists: (filePath: string) => boolean, + query: (databasePath: string, sql: string) => Promise, +): Promise { + if (!exists(sessionStorePath)) return null; + try { + assertTrustedPath(sessionStorePath); + const [issueReferencesRaw, workspaceLinksRaw] = await Promise.all([ + query(sessionStorePath, ISSUE_SESSION_REFERENCES_QUERY), + query(databasePath, WORKSPACE_ISSUE_LINKS_QUERY), + ]); + return countIssueLinkedWorkspaces( + parseWorkspaceIssueLinks(workspaceLinksRaw), + parseGitHubAppIssueSessionReferences(issueReferencesRaw), + ); + } catch (error) { + warnCore('github-app-analytics', 'Could not map issue references to GitHub App project sessions', error); + return null; + } +} + +export async function loadGitHubAppMetrics( + dependencies: GitHubAppAnalyticsDependencies = {}, +): Promise { + const databasePath = dependencies.databasePath ?? defaultDatabasePath(); + const sessionStorePath = dependencies.sessionStorePath ?? path.join(path.dirname(databasePath), 'session-store.db'); + const exists = dependencies.exists ?? fs.existsSync; + const query = dependencies.query ?? queryWithSqlite; + + try { + assertTrustedPath(databasePath); + } catch (error) { + warnCore('github-app-analytics', 'Rejected GitHub App database path', error); + return { status: 'absent' }; + } + + if (!exists(databasePath)) return { status: 'absent' }; + + try { + const [raw, issueLinkedWorkspaceCount] = await Promise.all([ + query(databasePath, METRICS_QUERY), + loadIssueLinkedWorkspaceCount(databasePath, sessionStorePath, exists, query), + ]); + const metrics = parseGitHubAppMetricsRows(raw); + if (issueLinkedWorkspaceCount !== null) { + metrics.sessionsWithIssue = Math.min( + metrics.totalProjectSessions, + issueLinkedWorkspaceCount, + ); + } + return { status: 'ready', metrics }; + } catch (error) { + warnCore('github-app-analytics', 'Could not read GitHub App productivity metrics', error); + return { status: 'unavailable' }; + } +} diff --git a/src/core/parser-vscode.test.ts b/src/core/parser-vscode.test.ts index c9f37ccd..8d63a194 100644 --- a/src/core/parser-vscode.test.ts +++ b/src/core/parser-vscode.test.ts @@ -9,7 +9,15 @@ import * as path from 'path'; import { describe, it, expect } from 'vitest'; import { reconstructFromJsonl } from './parser-vscode-files'; import { parseCLIEventsFile } from './parser-vscode-cli'; -import { parseSessionFile, harnessFromPath, findVsCodeDirs, scanVsCodeDirs } from './parser-vscode'; +import { + parseSessionFile, + harnessFromPath, + findVsCodeDirs, + scanVsCodeDirs, + processWorkspaceEntry, + processWorkspaceEntryAsync, +} from './parser-vscode'; +import { getParseWarningCounts, type ParseContext, resetParseWarnings } from './parser-shared'; function withTempFile(name: string, content: string, run: (filePath: string) => void): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-')); @@ -614,6 +622,48 @@ describe('scanVsCodeDirs', () => { }); }); +describe('scanVsCodeDirs — Copilot session state', () => { + it('only includes Copilot session-state directories that contain events', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-cli-scan-')); + const logsDir = path.join(root, '.copilot', 'session-state'); + try { + fs.mkdirSync(path.join(logsDir, 'with-events'), { recursive: true }); + fs.mkdirSync(path.join(logsDir, 'without-events')); + fs.writeFileSync(path.join(logsDir, 'with-events', 'events.jsonl'), ''); + + const { entries, totalDirs } = scanVsCodeDirs([logsDir]); + + expect(totalDirs).toBe(1); + expect(entries[0].dirEntries.map(entry => entry.name)).toEqual(['with-events']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not count a missing optional VS Code events file as skipped', async () => { + const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-vscode-scan-')); + const makeContext = (): ParseContext => ({ + workspaces: new Map(), + sessions: [], + editLocIndex: new Map(), + sessionSourceIndex: new Map(), + aiLoc: 0, + }); + try { + fs.mkdirSync(path.join(logsDir, 'workspace')); + resetParseWarnings(); + + processWorkspaceEntry(logsDir, 'workspace', 'Local Agent', makeContext()); + await processWorkspaceEntryAsync(logsDir, 'workspace', 'Local Agent', makeContext()); + + expect(getParseWarningCounts().skippedFiles).toBe(0); + } finally { + resetParseWarnings(); + fs.rmSync(logsDir, { recursive: true, force: true }); + } + }); +}); + describe('parseSessionFile — skill detection', () => { it('detects skills from promptFile variables pointing to SKILL.md', () => { const data = { diff --git a/src/core/parser-vscode.ts b/src/core/parser-vscode.ts index dfabe5d6..37a59757 100644 --- a/src/core/parser-vscode.ts +++ b/src/core/parser-vscode.ts @@ -71,7 +71,11 @@ export function scanVsCodeDirs(logsDirs: string[]): { for (const logsDir of logsDirs) { try { const all = fs.readdirSync(logsDir, { withFileTypes: true }); - const dirs = all.filter(e => e.isDirectory()); + const isCliSessionState = harnessFromPath(logsDir) === 'GitHub Copilot CLI'; + const dirs = all.filter(e => + e.isDirectory() && + (!isCliSessionState || fs.existsSync(path.join(logsDir, e.name, 'events.jsonl'))), + ); totalDirs += dirs.length; entries.push({ logsDir, dirEntries: dirs }); } catch (e) { @@ -153,6 +157,10 @@ function listEditStateFiles(esDir: string): string[] { } } +function sessionFileExists(filePath: string): boolean { + return prefetchCache.has(filePath) || fs.existsSync(filePath); +} + function countLinesAdded(edits: { text?: string }[] | undefined): number { let linesAdded = 0; for (const edit of (edits || [])) { @@ -278,16 +286,18 @@ export function processWorkspaceEntry( } const eventsFile = path.join(entryPath, 'events.jsonl'); - const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes); - if (cliSession) { - sessions.push(cliSession); - sessionSourceIndex.set(cliSession.sessionId, { - kind: 'cli-events', - filePath: eventsFile, - workspaceId: wsId, - workspaceName: wsName, - harness, - }); + if (sessionFileExists(eventsFile)) { + const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes); + if (cliSession) { + sessions.push(cliSession); + sessionSourceIndex.set(cliSession.sessionId, { + kind: 'cli-events', + filePath: eventsFile, + workspaceId: wsId, + workspaceName: wsName, + harness, + }); + } } const esDir = path.join(entryPath, 'chatEditingSessions'); @@ -387,19 +397,21 @@ export async function processWorkspaceEntryAsync( } const eventsFile = path.join(entryPath, 'events.jsonl'); - const tCli = Date.now(); - const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes); - addParseTiming('cli', Date.now() - tCli); - if (cliSession) { - stripSingleSession(cliSession); - sessions.push(cliSession); - sessionSourceIndex.set(cliSession.sessionId, { - kind: 'cli-events', - filePath: eventsFile, - workspaceId: wsId, - workspaceName: wsName, - harness, - }); + if (sessionFileExists(eventsFile)) { + const tCli = Date.now(); + const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes); + addParseTiming('cli', Date.now() - tCli); + if (cliSession) { + stripSingleSession(cliSession); + sessions.push(cliSession); + sessionSourceIndex.set(cliSession.sessionId, { + kind: 'cli-events', + filePath: eventsFile, + workspaceId: wsId, + workspaceName: wsName, + harness, + }); + } } for (let i = 0; i < editStateFiles.length; i++) { diff --git a/src/core/types.ts b/src/core/types.ts index 66e3f66c..a85ae647 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -9,5 +9,6 @@ export * from './types/catalog-types'; export * from './types/insights-types'; export * from './types/config-types'; export * from './types/context-types'; +export * from './types/github-app-types'; export * from './types/rule-types'; export * from './types/rpc-types'; diff --git a/src/core/types/github-app-types.ts b/src/core/types/github-app-types.ts new file mode 100644 index 00000000..2e61a901 --- /dev/null +++ b/src/core/types/github-app-types.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface GitHubAppMergeDay { + date: string; + pullRequestsRaised: number; + pullRequestsMerged: number; +} + +export interface GitHubAppMetrics { + totalProjectSessions: number; + sessionsWithIssue: number; + sessionsWithPullRequest: number; + sessionsWithMergedPullRequest: number; + lastActivityAt: string | null; + mergeHistory: GitHubAppMergeDay[]; +} + +export type GitHubAppSnapshot = + | { status: 'absent' } + | { status: 'unavailable' } + | { status: 'ready'; metrics: GitHubAppMetrics }; diff --git a/src/core/types/rpc-types.ts b/src/core/types/rpc-types.ts index 40648764..9d5ccb2c 100644 --- a/src/core/types/rpc-types.ts +++ b/src/core/types/rpc-types.ts @@ -23,7 +23,7 @@ export function isErrorResult(v: unknown): v is ErrorResult { return typeof v === 'object' && v !== null && typeof (v as { error?: unknown }).error === 'string'; } -import type { DateFilter, Session } from './session-types'; +import type { ImageGalleryData } from '../analyzer-images'; import type { AiCreditBurndownData, AiCreditData, @@ -51,7 +51,8 @@ import type { import type { ConfigHealthData } from './config-types'; import type { InsightsData } from './insights-types'; import type { ContextManagementData, FlowStateData, WorkspaceContextSessionsData } from './context-types'; -import type { ImageGalleryData } from '../analyzer-images'; +import type { GitHubAppSnapshot } from './github-app-types'; +import type { DateFilter, Session } from './session-types'; /* RPC method map: method name -> { params, result } */ export interface RpcMethodMap { @@ -78,6 +79,7 @@ export interface RpcMethodMap { getParserPreview: { params: { focusField?: string } | undefined; result: ParserPreviewData }; getWorkflowOptimization: { params: DateFilter | undefined; result: WorkflowOptimizationData }; getStats: { params: DateFilter | undefined; result: StatsResult }; + getGitHubAppMetrics: { params: undefined; result: GitHubAppSnapshot }; getConfigHealth: { params: DateFilter | undefined; result: ConfigHealthData }; getInsights: { params: DateFilter | undefined; result: InsightsData }; getFlowState: { params: DateFilter | undefined; result: FlowStateData }; diff --git a/src/webview/app.ts b/src/webview/app.ts index c8adbb73..0ff69289 100644 --- a/src/webview/app.ts +++ b/src/webview/app.ts @@ -5,7 +5,7 @@ /* Webview entry -- runs in the browser context inside the VS Code webview */ -import { AntiPatternData, DateFilter, StatsResult } from '../core/types'; +import { AntiPatternData, DateFilter, GitHubAppSnapshot, StatsResult } from '../core/types'; import { FF_TOKEN_REPORTING_ENABLED } from '../core/constants'; import { $, $$, rpc, destroyCharts, initMessageListener, withErrorBoundary, type WorkerTelemetry } from './shared'; import { updateTelemetry } from './telemetry-strip'; @@ -27,9 +27,12 @@ import { renderLevelUp } from './page-experiments'; import { renderDataExplorer } from './page-data-explorer'; import { renderRulePlayground } from './page-rule-playground'; import { renderImageGallery } from './page-image-gallery'; +import { renderGitHubApp } from './page-github-app'; +let githubAppSnapshot: GitHubAppSnapshot = { status: 'absent' }; function normalizePageForFeatureFlags(page: string): string { if (!FF_TOKEN_REPORTING_ENABLED && page === 'burndown') return 'dashboard'; + if (githubAppSnapshot.status === 'absent' && page === 'github-app') return 'dashboard'; return page; } @@ -63,6 +66,13 @@ function setBadge(id: string, value: string | number): void { el.classList.add('visible'); } +function clearBadge(id: string): void { + const el = document.getElementById(id); + if (!el) return; + el.textContent = ''; + el.classList.remove('visible'); +} + /** Exported so pages (e.g. Skill Finder) can update their badge after async work. */ export function updateNavBadge(id: string, value: string | number): void { setBadge(id, value); } @@ -86,6 +96,23 @@ function refreshNavBadges(filter: DateFilter): void { } +function applyGitHubAppSnapshot(snapshot: GitHubAppSnapshot): void { + githubAppSnapshot = snapshot; + const visible = snapshot.status !== 'absent'; + for (const item of $$('.github-app-nav-item')) item.hidden = !visible; + if (snapshot.status === 'ready') setBadge('badge-github-app', snapshot.metrics.totalProjectSessions); + else clearBadge('badge-github-app'); + if (!visible && currentPage === 'github-app') navigateTo('dashboard'); +} + +async function refreshGitHubAppSnapshot(): Promise { + try { + applyGitHubAppSnapshot(await rpc('getGitHubAppMetrics')); + } catch { + applyGitHubAppSnapshot({ status: 'unavailable' }); + } +} + /* ---- Progress + Data Ready ---- */ /** Phase labels matching LOAD_PHASES from parser.ts */ @@ -315,7 +342,7 @@ function onDataReady(currentWorkspace: string, skipped?: { skippedFiles: number; } }).catch(() => {}); - void loadCapabilities().finally(() => { + void Promise.allSettled([refreshGitHubAppSnapshot(), loadCapabilities()]).then(() => { navigateTo(currentPage); refreshNavBadges(currentFilter); maybeShowSkippedBanner(); @@ -358,6 +385,7 @@ export function navigateTo(page: string): void { page = normalizePageForFeatureFlags(page); if (!llmAvailable() && (page === 'skills' || page === 'level-up')) page = 'dashboard'; currentPage = page; + document.body.classList.toggle('github-app-view', page === 'github-app'); for (const a of $$('.nav-links a')) a.classList.toggle('active', a.dataset.page === page); void renderPage(page); } @@ -544,6 +572,7 @@ function renderPage(page: string): void { case 'data-explorer': withErrorBoundary('Data Explorer', content, () => renderDataExplorer(content, currentFilter)); break; case 'rule-playground': withErrorBoundary('Rule Playground', content, () => renderRulePlayground(content, currentFilter)); break; case 'image-gallery': withErrorBoundary('Image Gallery', content, () => renderImageGallery(content, currentFilter)); break; + case 'github-app': withErrorBoundary('GitHub App', content, () => renderGitHubApp(content, githubAppSnapshot)); break; default: render(html`

Unknown page

`, content); } } diff --git a/src/webview/dashboard-shell.ts b/src/webview/dashboard-shell.ts index 82ef4cff..fd254e42 100644 --- a/src/webview/dashboard-shell.ts +++ b/src/webview/dashboard-shell.ts @@ -23,6 +23,8 @@ export function getDashboardShellHtml(opts?: { includeSkillFinder?: boolean; inc
  • Output
  • ${FF_TOKEN_REPORTING_ENABLED ? '
  • Burndown
  • ' : ''}
  • Patterns
  • + +
  • Anti-Patterns
  • ${includeSkillFinder ? '
  • Skill Finder
  • ' : ''} diff --git a/src/webview/page-github-app.ts b/src/webview/page-github-app.ts new file mode 100644 index 00000000..48070590 --- /dev/null +++ b/src/webview/page-github-app.ts @@ -0,0 +1,231 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { GitHubAppMetrics, GitHubAppSnapshot } from '../core/types'; +import { createChart, COLORS, formatNum } from './shared'; +import { CanvasEl, html, render, type ComponentChildren } from './render'; + +interface FunnelStage { + className: string; + width: number; + count: number; + label: string; + detail: string; +} + +function percentage(value: number, total: number): number { + return total > 0 ? Math.round((value / total) * 100) : 0; +} + +function funnelStage(stage: FunnelStage): ComponentChildren { + return html` +
  • +
    + ${formatNum(stage.count)} + ${stage.label}${stage.detail} +
    +
  • `; +} + +function projectSessionsStage(metrics: GitHubAppMetrics): ComponentChildren { + const sessionsWithoutIssue = metrics.totalProjectSessions - metrics.sessionsWithIssue; + return html` +
  • +
    +
    + ${formatNum(metrics.totalProjectSessions)} + Project sessions100% started +
    +
    +
    +
    With issue
    +
    ${formatNum(metrics.sessionsWithIssue)}
    +
    +
    +
    Without issue
    +
    ${formatNum(sessionsWithoutIssue)}
    +
    +
    +
    +
  • `; +} + +function deliveryFunnel(metrics: GitHubAppMetrics): ComponentChildren { + const pullRequestRate = percentage(metrics.sessionsWithPullRequest, metrics.totalProjectSessions); + const mergeRate = percentage(metrics.sessionsWithMergedPullRequest, metrics.totalProjectSessions); + const pullRequestMergeRate = percentage(metrics.sessionsWithMergedPullRequest, metrics.sessionsWithPullRequest); + return html` +
      + ${projectSessionsStage(metrics)} + ${funnelStage({ + className: 'gha-funnel-stage-pr', + width: Math.max(46, pullRequestRate), + count: metrics.sessionsWithPullRequest, + label: 'Sessions with a PR', + detail: `${pullRequestRate}% of sessions`, + })} + ${funnelStage({ + className: 'gha-funnel-stage-merged', + width: Math.max(34, mergeRate), + count: metrics.sessionsWithMergedPullRequest, + label: 'Sessions with a merged PR', + detail: `${pullRequestMergeRate}% of PR sessions`, + })} +
    `; +} + +function formatLastActivity(value: string | null): string { + if (!value) return 'No activity yet'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Unknown'; + return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +function renderUnavailable(container: HTMLElement): void { + render(html` +
    +
    +
    +

    GitHub App

    +

    Project-session productivity from the local GitHub Copilot app.

    +
    +
    +
    +
    !
    +
    +

    Productivity data is not available

    +

    The GitHub Copilot app is installed, but its local database could not be read. Make sure the sqlite3 command is available, then reload the dashboard.

    +
    +
    +
    `, container); +} + +function funnelSection(metrics: GitHubAppMetrics): ComponentChildren { + const mergeRate = percentage(metrics.sessionsWithMergedPullRequest, metrics.totalProjectSessions); + return html` +
    +
    +
    +

    Delivery funnel

    +

    Follow project sessions through raised and merged pull requests.

    +
    + ${mergeRate}% reach merge +
    + ${metrics.totalProjectSessions === 0 + ? html`
    Create a project session in the GitHub Copilot app to start this delivery path.
    ` + : deliveryFunnel(metrics)} +
    `; +} + +function mergeRatioSection(metrics: GitHubAppMetrics): ComponentChildren { + const { recentRaised, recentMerged } = metrics.mergeHistory.reduce( + (totals, day) => ({ + recentRaised: totals.recentRaised + day.pullRequestsRaised, + recentMerged: totals.recentMerged + day.pullRequestsMerged, + }), + { recentRaised: 0, recentMerged: 0 }, + ); + const hasRecentPullRequests = recentRaised > 0; + return html` +
    +
    +
    +

    PR merge ratio · last 7 days

    +

    Current merged share of PRs from project sessions created on each completed day.

    +
    +
    +
    + ${hasRecentPullRequests + ? html`<${CanvasEl} id="githubAppMergeRatioChart" height=${210} />` + : html`
    No PRs were raised in the last seven completed days.
    `} +
    +
    7-day merge ratio
    ${hasRecentPullRequests ? `${percentage(recentMerged, recentRaised)}%` : '—'}
    ${formatNum(recentMerged)} of ${formatNum(recentRaised)} PRs
    +
    PRs raised
    ${formatNum(recentRaised)}
    Last 7 completed days
    +
    PRs merged
    ${formatNum(recentMerged)}
    From these daily cohorts
    +
    +
    +

    Days are grouped by project-session creation date. Ratios update when those PRs merge.

    +
    `; +} + +function renderMergeRatioChart(metrics: GitHubAppMetrics): void { + if (!metrics.mergeHistory.some(day => day.pullRequestsRaised > 0)) return; + const labels = metrics.mergeHistory.map(item => { + const [, month, day] = item.date.split('-'); + return `${month}/${day}`; + }); + createChart('githubAppMergeRatioChart', 'line', { + labels, + datasets: [ + { + type: 'bar', + label: 'PRs raised', + data: metrics.mergeHistory.map(day => day.pullRequestsRaised), + yAxisID: 'count', + backgroundColor: COLORS.blue + '3D', + borderColor: COLORS.blue, + borderWidth: 1, + borderRadius: 2, + order: 2, + }, + { + label: 'Merge ratio', + data: metrics.mergeHistory.map(day => day.pullRequestsRaised > 0 + ? percentage(day.pullRequestsMerged, day.pullRequestsRaised) + : null), + yAxisID: 'ratio', + backgroundColor: COLORS.green + '1F', + borderColor: COLORS.green, + borderWidth: 2, + pointBackgroundColor: COLORS.green, + pointRadius: 3, + tension: 0.28, + spanGaps: false, + fill: true, + order: 1, + }, + ], + }, { + interaction: { mode: 'index', intersect: false }, + plugins: { legend: { position: 'bottom' } }, + scales: { + x: { grid: { display: false } }, + ratio: { position: 'left', min: 0, max: 100, ticks: { callback: (value: number | string) => `${value}%` } }, + count: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, ticks: { precision: 0 } }, + }, + }); +} + +function renderMetrics(container: HTMLElement, metrics: GitHubAppMetrics): void { + render(html` +
    +
    +
    +

    GitHub App

    +

    Follow project sessions from their starting context through pull request delivery.

    +
    +
    + Last activity + ${formatLastActivity(metrics.lastActivityAt)} +
    +
    + ${funnelSection(metrics)} + ${mergeRatioSection(metrics)} +
    `, container); + + renderMergeRatioChart(metrics); +} + +export function renderGitHubApp(container: HTMLElement, snapshot: GitHubAppSnapshot): void { + if (snapshot.status !== 'ready') { + renderUnavailable(container); + return; + } + renderMetrics(container, snapshot.metrics); +} diff --git a/src/webview/panel-rpc.ts b/src/webview/panel-rpc.ts index f6437cd7..bb02f493 100644 --- a/src/webview/panel-rpc.ts +++ b/src/webview/panel-rpc.ts @@ -8,6 +8,8 @@ import { Analyzer } from '../core/analyzer'; import { ParseResult } from '../core/parser'; import { loadSessionFromDisk } from '../core/cache'; +import { FF_TOKEN_REPORTING_ENABLED } from '../core/constants'; +import { loadGitHubAppMetrics } from '../core/github-app-analytics'; import { extractSessionImages } from '../core/parser-vscode-files'; import { DateFilter, RpcMethodName, BurndownConfig } from '../core/types'; import type { RpcMethodMap, RpcResult } from '../core/types/rpc-types'; @@ -39,7 +41,6 @@ import { compileNaturalLanguageRule } from '../core/rule-compiler'; import type { SessionRequest, Session } from '../core/types'; import { errorResult, isString, isNumber, isOptionalString, isRecord } from './panel-shared'; import { DSL_CHEATSHEET } from './dsl-cheatsheet'; -import { FF_TOKEN_REPORTING_ENABLED } from '../core/constants'; /** * Pick `reqs` or `sessions` based on scope and return them typed as @@ -723,6 +724,7 @@ const rpcHandlers: TypedRpcHandlers = { getParserPreview: (a, _p, params) => a.getParserPreview(typeof params?.focusField === 'string' ? params.focusField : undefined), getWorkflowOptimization: (a, _p, params) => a.getWorkflowOptimization(validateDateFilter(params)), getStats: (a, _p, params) => a.getStats(validateDateFilter(params)), + getGitHubAppMetrics: () => loadGitHubAppMetrics(), getConfigHealth: (a, _p, params) => a.getConfigHealth(validateDateFilter(params)), getInsights: (a, _p, params) => a.getInsights(validateDateFilter(params)), getFlowState: (a, _p, params) => a.getFlowState(validateDateFilter(params)), diff --git a/src/webview/styles-github-app.css b/src/webview/styles-github-app.css new file mode 100644 index 00000000..96474c49 --- /dev/null +++ b/src/webview/styles-github-app.css @@ -0,0 +1,359 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* GitHub App productivity */ + +.github-app-view .sidebar-filters { display: none; } +.github-app-nav-item[hidden] { display: none; } + +.gha-page { + max-width: 1120px; + margin: 0 auto; +} + +.gha-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.gha-header h1 { + margin: 0; + font-size: 24px; + line-height: 1.2; +} + +.gha-header p, +.gha-section-heading p { + max-width: 68ch; + margin: 4px 0 0; + color: var(--text-muted); + font-size: 12px; +} + +.gha-last-activity { + display: flex; + flex-direction: column; + align-items: flex-end; + flex-shrink: 0; +} + +.gha-last-activity span { + color: var(--text-muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.gha-last-activity strong { + margin-top: 2px; + font-size: 12px; + font-weight: 600; +} + +.gha-funnel-card, +.gha-merge-ratio { + border: 1px solid var(--border); + background: var(--card-bg); +} + +.gha-funnel-card { + padding: 20px; + border-radius: 10px 10px 0 0; +} + +.gha-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; +} + +.gha-section-heading h2, +.gha-empty h2 { + margin: 0; + font-size: 15px; +} + +.gha-section-heading > span { + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--accent-green) 45%, var(--border)); + border-radius: 999px; + color: var(--accent-green); + font-size: 11px; + font-weight: 600; +} + +.gha-dot { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 5px; + border-radius: 50%; +} + +.gha-dot-issue { background: var(--accent-blue); } +.gha-dot-direct { background: var(--accent-purple); } + +.gha-funnel { + display: flex; + align-items: center; + flex-direction: column; + gap: 5px; + margin: 0; + padding: 0; + list-style: none; +} + +.gha-funnel-stage { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: var(--funnel-width); + min-height: 68px; + box-sizing: border-box; + padding: 10px max(34px, 8%); + isolation: isolate; +} + +.gha-funnel-stage::before { + position: absolute; + z-index: -1; + inset: 0; + border: 1px solid color-mix(in srgb, var(--accent-blue) 36%, var(--border)); + clip-path: polygon(3% 0, 97% 0, 91% 100%, 9% 100%); + background: color-mix(in srgb, var(--accent-blue) 14%, var(--surface-2)); + content: ''; +} + +.gha-funnel-stage-pr::before { + border-color: color-mix(in srgb, var(--accent-purple) 42%, var(--border)); + background: color-mix(in srgb, var(--accent-purple) 18%, var(--surface-2)); +} + +.gha-funnel-stage-merged::before { + border-color: color-mix(in srgb, var(--accent-green) 50%, var(--border)); + background: color-mix(in srgb, var(--accent-green) 19%, var(--surface-2)); +} + +.gha-funnel-stage-body, +.gha-funnel-stage-total { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.gha-funnel-stage-body-start { + justify-content: space-between; + width: min(650px, 100%); + gap: 24px; +} + +.gha-funnel-origin-breakdown { + display: flex; + align-items: stretch; + margin: 0; +} + +.gha-funnel-origin-breakdown > div { + display: flex; + min-width: 112px; + padding: 2px 14px; + flex-direction: column; +} + +.gha-funnel-origin-breakdown > div + div { + border-left: 1px solid color-mix(in srgb, var(--text) 20%, transparent); +} + +.gha-funnel-origin-breakdown dt { + display: flex; + align-items: center; + color: var(--text-muted); + font-size: 10px; + white-space: nowrap; +} + +.gha-funnel-origin-breakdown dd { + margin: 1px 0 0 12px; + font-size: 16px; + font-weight: 700; + line-height: 1.2; + font-variant-numeric: tabular-nums; +} + +.gha-funnel-stage-copy { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.gha-stage-number { + font-size: 28px; + font-weight: 750; + line-height: 1; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; +} + +.gha-funnel-stage-copy strong { + font-size: 12px; + white-space: nowrap; +} + +.gha-funnel-stage-copy small { + color: var(--text-muted); + font-size: 10px; + white-space: nowrap; +} + +.gha-funnel-stage-merged .gha-stage-number { color: var(--accent-green); } + +.gha-merge-ratio { + padding: 22px 20px; + border-top: 0; + border-radius: 0 0 10px 10px; +} + +.gha-merge-ratio-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 210px; + gap: 24px; + align-items: stretch; +} + +.gha-merge-ratio .chart-wrap { + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.gha-merge-ratio-stats { + display: grid; + align-content: center; + border-left: 1px solid var(--border); +} + +.gha-merge-ratio-stats div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 0 12px; + padding: 9px 0 9px 16px; +} + +.gha-merge-ratio-stats dt, +.gha-merge-ratio-stats small { + color: var(--text-muted); + font-size: 10px; +} + +.gha-merge-ratio-stats dd { + margin: 0; + color: var(--accent-green); + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.gha-merge-ratio-stats small { + grid-column: 1 / -1; +} + +.gha-cohort-note { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 10px; +} + +.gha-inline-empty { + padding: 18px 0 4px; + color: var(--text-muted); + font-size: 12px; +} + +.gha-empty { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--card-bg); +} + +.gha-empty-icon { + display: grid; + width: 28px; + height: 28px; + place-items: center; + flex-shrink: 0; + border-radius: 50%; + background: color-mix(in srgb, var(--accent-orange) 18%, transparent); + color: var(--accent-orange); + font-weight: 700; +} + +.gha-empty p { + max-width: 70ch; + margin: 4px 0 0; + color: var(--text-muted); + font-size: 12px; +} + +.gha-empty code { color: var(--text); } + +@media (max-width: 800px) { + .gha-header { + align-items: flex-start; + flex-direction: column; + } + + .gha-last-activity { align-items: flex-start; } + + .gha-funnel-stage { + min-height: 64px; + padding-inline: 32px; + } + + .gha-funnel-stage-start { width: 100%; } + .gha-funnel-stage-pr { width: 88%; } + .gha-funnel-stage-merged { width: 76%; } + + .gha-funnel-stage::before { + clip-path: polygon(2% 0, 98% 0, 95% 100%, 5% 100%); + } + + .gha-funnel-stage-body-start { + align-items: center; + flex-direction: column; + gap: 8px; + } + + .gha-funnel-stage-start { min-height: 96px; } + + .gha-funnel-origin-breakdown > div { + min-width: 106px; + padding-inline: 10px; + } + + .gha-merge-ratio-layout { grid-template-columns: 1fr; } + + .gha-merge-ratio-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + border-top: 1px solid var(--border); + border-left: 0; + } + + .gha-merge-ratio-stats div { padding: 10px 12px 4px 0; } +} diff --git a/tests/e2e/harness.html b/tests/e2e/harness.html index 84c6a69e..a79a0c41 100644 --- a/tests/e2e/harness.html +++ b/tests/e2e/harness.html @@ -51,6 +51,8 @@
  • Data Explorer
  • Rule Playground
  • + +