diff --git a/src/core/github-app-analytics.test.ts b/src/core/github-app-analytics.test.ts index cc8c522..ca5af24 100644 --- a/src/core/github-app-analytics.test.ts +++ b/src/core/github-app-analytics.test.ts @@ -3,73 +3,137 @@ * 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 { describe, expect, it } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, describe, expect, it } from 'vitest'; import { loadGitHubAppMetrics, - parseGitHubAppIssueSessionReferences, - parseGitHubAppMetricsRows, + parseGitHubAppWorkspacePrCohorts, } from './github-app-analytics'; +import type { GitHubAppMetrics } from './types'; -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, - }; +const APP_SCHEMA_SQL = ` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + session_type TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + session_id TEXT, + created_at TEXT NOT NULL, + source_issue_number INTEGER, + source_pr_number INTEGER, + source_pr_merged_at TEXT, + source_pr_state TEXT, + created_pr_number INTEGER, + created_pr_merged_at TEXT, + created_pr_state TEXT + ); + CREATE TABLE workspace_session_aliases ( + session_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE workspace_repo_contexts ( + workspace_id TEXT NOT NULL, + source_issue_number INTEGER, + source_pr_number INTEGER, + source_pr_merged_at TEXT, + source_pr_state TEXT, + created_pr_number INTEGER, + created_pr_merged_at TEXT, + created_pr_state TEXT + ); +`; + +const SESSION_STORE_SCHEMA_SQL = ` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + repository TEXT + ); + CREATE TABLE session_refs ( + session_id TEXT NOT NULL, + ref_type TEXT NOT NULL, + ref_value TEXT NOT NULL + ); + CREATE TABLE turns ( + session_id TEXT NOT NULL, + turn_index INTEGER NOT NULL, + user_message TEXT, + assistant_response TEXT + ); +`; + +const tempDirectories: string[] = []; + +function createDatabase(databasePath: string, schema: string, fixtures: string): void { + const database = new DatabaseSync(databasePath); + database.exec(schema); + database.exec(fixtures); + database.close(); +} + +function createFixtureDatabases( + appFixtures: string, + sessionStoreFixtures = '', +): { databasePath: string; sessionStorePath: string } { + const directory = fs.mkdtempSync(path.join(os.homedir(), '.ai-engineer-coach-test-')); + tempDirectories.push(directory); + const databasePath = path.join(directory, 'data.db'); + const sessionStorePath = path.join(directory, 'session-store.db'); + createDatabase(databasePath, APP_SCHEMA_SQL, appFixtures); + createDatabase(sessionStorePath, SESSION_STORE_SCHEMA_SQL, sessionStoreFixtures); + return { databasePath, sessionStorePath }; +} + +async function loadFixture( + appFixtures: string, + sessionStoreFixtures = '', +): Promise { + const snapshot = await loadGitHubAppMetrics( + createFixtureDatabases(appFixtures, sessionStoreFixtures), + ); + expect(snapshot.status).toBe('ready'); + if (snapshot.status !== 'ready') throw new Error('Expected ready metrics.'); + return snapshot.metrics; } -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'); +function cohortTotals(metrics: GitHubAppMetrics): { raised: number; merged: number } { + return metrics.workspacePrCohorts.reduce( + (totals, day) => ({ + raised: totals.raised + day.pullRequestsRaised, + merged: totals.merged + day.pullRequestsMerged, + }), + { raised: 0, merged: 0 }, + ); } +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + 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([ + it('parses workspace PR cohorts', () => { + expect(parseGitHubAppWorkspacePrCohorts(JSON.stringify([ + { date: '2026-08-23', cohortPullRequestsRaised: 4, cohortPullRequestsMerged: 3 }, + { date: '2026-08-24', cohortPullRequestsRaised: 0, cohortPullRequestsMerged: 0 }, + ]))).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'), + databasePath: path.join(os.homedir(), 'missing-copilot-data.db'), + sessionStorePath: path.join(os.homedir(), 'missing-session-store.db'), exists: () => false, query: () => Promise.reject(new Error('query should not run')), }); @@ -77,58 +141,160 @@ describe('GitHub App analytics availability', () => { expect(metrics).toEqual({ status: 'absent' }); }); - it('keeps the feature visible when an installed database cannot be queried', async () => { + it('reports unavailable when the session store is absent', async () => { + const databasePath = path.join(os.homedir(), 'copilot-data.db'); const metrics = await loadGitHubAppMetrics({ - databasePath: path.join(os.tmpdir(), 'copilot-data.db'), - sessionStorePath: path.join(os.tmpdir(), 'missing-session-store.db'), + databasePath, + sessionStorePath: path.join(os.homedir(), 'missing-session-store.db'), + exists: filePath => filePath === databasePath, + query: () => Promise.reject(new Error('query should not run')), + }); + + expect(metrics).toEqual({ status: 'unavailable' }); + }); + + it('does not return fallback metrics after a query failure', async () => { + const metrics = await loadGitHubAppMetrics({ + databasePath: path.join(os.homedir(), 'copilot-data.db'), + sessionStorePath: path.join(os.homedir(), '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)); - }, +describe('GitHub App delivery references', () => { + it('counts structured issues and early pasted links only', async () => { + const metrics = await loadFixture(` + INSERT INTO sessions VALUES + ('structured', 'project', '2026-08-20T10:00:00.000Z'), + ('pasted', 'project', '2026-08-21T10:00:00.000Z'), + ('late', 'project', '2026-08-22T10:00:00.000Z'), + ('prose', 'project', '2026-08-23T10:00:00.000Z'); + `, ` + INSERT INTO sessions VALUES + ('structured', 'org/repo'), + ('pasted', 'org/repo'), + ('late', 'org/repo'), + ('prose', 'org/repo'); + INSERT INTO session_refs VALUES ('structured', 'issue', '42'); + INSERT INTO turns VALUES + ('pasted', 0, 'Fix https://github.com/org/repo/issues/123', NULL), + ('late', 2, 'Later: https://github.com/org/repo/issues/456', NULL), + ('prose', 0, 'Issue 789 is not an explicit link', NULL); + `); + + expect(metrics.delivery).toMatchObject({ + totalProjectSessions: 4, + sessionsWithIssue: 2, + sessionsWithPullRequest: 0, + sessionsWithMergedPullRequest: 0, }); + }); + + it('counts a structured PR reference without assuming it merged', async () => { + const metrics = await loadFixture(` + INSERT INTO sessions VALUES ('referenced-pr', 'project', '2026-08-20T10:00:00.000Z'); + `, ` + INSERT INTO sessions VALUES ('referenced-pr', 'org/repo'); + INSERT INTO session_refs VALUES ('referenced-pr', 'pr', '99'); + `); - 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')"); + expect(metrics.delivery.sessionsWithPullRequest).toBe(1); + expect(metrics.delivery.sessionsWithMergedPullRequest).toBe(0); }); }); -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' }, - ])), +describe('GitHub App merge evidence', () => { + it('counts only explicit assistant merge evidence', async () => { + const metrics = await loadFixture(` + INSERT INTO sessions VALUES + ('merged', 'project', '2026-08-20T10:00:00.000Z'), + ('not-merged', 'project', '2026-08-21T10:00:00.000Z'), + ('merge-request', 'project', '2026-08-22T10:00:00.000Z'); + `, ` + INSERT INTO sessions VALUES + ('merged', 'org/repo'), + ('not-merged', 'org/repo'), + ('merge-request', 'org/repo'); + INSERT INTO session_refs VALUES + ('merged', 'pr', '1'), + ('not-merged', 'pr', '2'), + ('merge-request', 'pr', '3'); + INSERT INTO turns VALUES + ('merged', 2, NULL, 'The pull request has been merged.'), + ('not-merged', 2, NULL, 'The pull request is not merged.'), + ('merge-request', 2, NULL, 'I can merge the pull request after confirmation.'); + `); + + expect(metrics.delivery.sessionsWithPullRequest).toBe(3); + expect(metrics.delivery.sessionsWithMergedPullRequest).toBe(1); + }); +}); + +describe('GitHub App delivery ownership', () => { + it('attributes a workspace outcome only to its primary session', async () => { + const metrics = await loadFixture(` + INSERT INTO sessions VALUES + ('primary', 'project', '2026-08-20T10:00:00.000Z'), + ('stale-alias', 'project', '2026-08-21T10:00:00.000Z'); + INSERT INTO workspaces VALUES + ('workspace', 'primary', datetime('now', '-1 day'), 7, NULL, NULL, NULL, 101, datetime('now'), 'merged'); + INSERT INTO workspace_session_aliases VALUES + ('stale-alias', 'workspace', datetime('now', '-2 days')); + `); + + expect(metrics.delivery).toMatchObject({ + totalProjectSessions: 2, + sessionsWithIssue: 1, + sessionsWithPullRequest: 1, + sessionsWithMergedPullRequest: 1, }); + }); + + it('uses the latest alias when a workspace has no primary session', async () => { + const metrics = await loadFixture(` + INSERT INTO sessions VALUES + ('latest-alias', 'project', '2026-08-20T10:00:00.000Z'), + ('older-alias', 'general_chat', '2026-08-21T10:00:00.000Z'); + INSERT INTO workspaces VALUES + ('workspace', NULL, datetime('now', '-1 day'), NULL, NULL, NULL, NULL, 101, NULL, 'open'); + INSERT INTO workspace_session_aliases VALUES + ('older-alias', 'workspace', datetime('now', '-2 days')), + ('latest-alias', 'workspace', datetime('now', '-1 hour')); + `); + + expect(metrics.delivery.totalProjectSessions).toBe(1); + expect(metrics.delivery.sessionsWithPullRequest).toBe(1); + }); +}); + +describe('GitHub App PR outcome integrity', () => { + it('requires a created PR number before counting merge metadata', async () => { + const metrics = await loadFixture(` + INSERT INTO sessions VALUES ('merge-state-only', 'project', '2026-08-20T10:00:00.000Z'); + INSERT INTO workspaces VALUES + ('workspace', 'merge-state-only', datetime('now', '-1 day'), NULL, NULL, NULL, NULL, NULL, datetime('now'), 'merged'); + `); + + expect(metrics.delivery.sessionsWithPullRequest).toBe(0); + expect(metrics.delivery.sessionsWithMergedPullRequest).toBe(0); + expect(cohortTotals(metrics)).toEqual({ raised: 0, merged: 0 }); + }); + + it('uses context PRs without also counting legacy workspace fields', async () => { + const metrics = await loadFixture(` + INSERT INTO sessions VALUES ('owner', 'project', '2026-08-20T10:00:00.000Z'); + INSERT INTO workspaces VALUES + ('workspace', 'owner', datetime('now', '-1 day'), NULL, NULL, NULL, NULL, 100, NULL, 'open'); + INSERT INTO workspace_repo_contexts VALUES + ('workspace', NULL, NULL, NULL, NULL, 200, datetime('now'), 'merged'); + `); - expect(metrics.status).toBe('ready'); - if (metrics.status !== 'ready') throw new Error('Expected ready metrics.'); - expect(metrics.metrics.sessionsWithIssue).toBe(2); + expect(metrics.delivery.sessionsWithPullRequest).toBe(1); + expect(metrics.delivery.sessionsWithMergedPullRequest).toBe(1); + expect(cohortTotals(metrics)).toEqual({ raised: 1, merged: 1 }); }); }); diff --git a/src/core/github-app-analytics.ts b/src/core/github-app-analytics.ts index 4f2de9c..4658b6b 100644 --- a/src/core/github-app-analytics.ts +++ b/src/core/github-app-analytics.ts @@ -4,23 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import type { - GitHubAppMergeDay, GitHubAppMetrics, GitHubAppSnapshot, + GitHubAppWorkspacePrCohortDay, } from './types'; import { + parseSqliteJsonRows, resolveGitHubAppDatabaseAccess, type GitHubAppDatabaseDependencies, } from './github-app-database'; +import { loadGitHubAppDeliveryFunnel } from './github-app-delivery-funnel'; +import { WORKSPACE_CREATED_PULL_REQUESTS_CTE } from './github-app-pr-outcomes'; import { warnCore } from './log'; import { assertTrustedPath } from './parser-shared'; -const ISSUE_SESSION_REFERENCES_QUERY = ` -SELECT DISTINCT session_id AS sessionId -FROM session_refs -WHERE ref_type = 'issue'`; - -const METRICS_QUERY = ` +const WORKSPACE_PR_COHORT_QUERY = ` WITH RECURSIVE days(date) AS ( VALUES(date('now', '-7 days')) @@ -29,159 +27,37 @@ days(date) AS ( 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 -) +${WORKSPACE_CREATED_PULL_REQUESTS_CTE} 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 + COUNT(created_pull_requests.workspaceId) AS cohortPullRequestsRaised, + COALESCE(SUM(created_pull_requests.merged), 0) AS cohortPullRequestsMerged FROM days -CROSS JOIN summary -LEFT JOIN workspace_pull_requests AS pull_requests - ON date(pull_requests.workspaceCreatedAt) = days.date +LEFT JOIN workspace_created_pull_requests AS created_pull_requests + ON date(created_pull_requests.workspaceCreatedAt) = days.date GROUP BY - days.date, - summary.totalProjectSessions, - summary.sessionsWithIssue, - summary.sessionsWithPullRequest, - summary.sessionsWithMergedPullRequest, - summary.lastActivityAt + days.date 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 { +interface GitHubAppWorkspacePrCohortRow { 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 type GitHubAppAnalyticsDependencies = GitHubAppDatabaseDependencies; -function isMetricsRow(value: unknown): value is GitHubAppMetricsRow { +function isWorkspacePrCohortRow(value: unknown): value is GitHubAppWorkspacePrCohortRow { 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'); + && typeof row.cohortPullRequestsRaised === 'number' + && typeof row.cohortPullRequestsMerged === 'number'; } -function mergeDay(row: GitHubAppMetricsRow): GitHubAppMergeDay { +function workspacePrCohortDay( + row: GitHubAppWorkspacePrCohortRow, +): GitHubAppWorkspacePrCohortDay { return { date: row.date, pullRequestsRaised: row.cohortPullRequestsRaised, @@ -189,125 +65,42 @@ function mergeDay(row: GitHubAppMetricsRow): GitHubAppMergeDay { }; } -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; -} - -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 function parseGitHubAppWorkspacePrCohorts( + raw: string, +): GitHubAppWorkspacePrCohortDay[] { + const parsed = parseSqliteJsonRows(raw, 'GitHub App PR cohort'); + if (parsed.length === 0 || !parsed.every(isWorkspacePrCohortRow)) { + throw new Error('GitHub App PR cohort query returned an unexpected result.'); } + return parsed.map(workspacePrCohortDay); } export async function loadGitHubAppMetrics( dependencies: GitHubAppAnalyticsDependencies = {}, ): Promise { - const { databasePath, sessionStorePath, exists, query } = - resolveGitHubAppDatabaseAccess(dependencies); + const access = resolveGitHubAppDatabaseAccess(dependencies); + const { databasePath, sessionStorePath, exists, query } = access; try { assertTrustedPath(databasePath); + assertTrustedPath(sessionStorePath); } catch (error) { warnCore('github-app-analytics', 'Rejected GitHub App database path', error); return { status: 'absent' }; } if (!exists(databasePath)) return { status: 'absent' }; + if (!exists(sessionStorePath)) return { status: 'unavailable' }; try { - const [raw, issueLinkedWorkspaceCount] = await Promise.all([ - query(databasePath, METRICS_QUERY), - loadIssueLinkedWorkspaceCount(databasePath, sessionStorePath, exists, query), + const [workspacePrCohortsRaw, delivery] = await Promise.all([ + query(databasePath, WORKSPACE_PR_COHORT_QUERY), + loadGitHubAppDeliveryFunnel(access), ]); - const metrics = parseGitHubAppMetricsRows(raw); - if (issueLinkedWorkspaceCount !== null) { - metrics.sessionsWithIssue = Math.min( - metrics.totalProjectSessions, - issueLinkedWorkspaceCount, - ); - } + const metrics: GitHubAppMetrics = { + delivery, + workspacePrCohorts: parseGitHubAppWorkspacePrCohorts(workspacePrCohortsRaw), + }; return { status: 'ready', metrics }; } catch (error) { warnCore('github-app-analytics', 'Could not read GitHub App productivity metrics', error); diff --git a/src/core/github-app-database.ts b/src/core/github-app-database.ts index 3595db6..3d31b83 100644 --- a/src/core/github-app-database.ts +++ b/src/core/github-app-database.ts @@ -25,6 +25,15 @@ export interface GitHubAppDatabaseAccess { query: (databasePath: string, sql: string) => Promise; } +export function parseSqliteJsonRows(raw: string, description: string): unknown[] { + if (raw.trim().length === 0) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error(`${description} query returned an unexpected result.`); + } + return parsed; +} + function defaultDatabasePath(): string { const configuredHome = process.env.COPILOT_HOME?.trim(); const copilotHome = configuredHome ? path.resolve(configuredHome) : path.join(os.homedir(), '.copilot'); diff --git a/src/core/github-app-delivery-funnel.ts b/src/core/github-app-delivery-funnel.ts new file mode 100644 index 0000000..a57a526 --- /dev/null +++ b/src/core/github-app-delivery-funnel.ts @@ -0,0 +1,233 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + parseSqliteJsonRows, + type GitHubAppDatabaseAccess, +} from './github-app-database'; +import { + collectGitHubAppIssueSessionIds, + SESSION_ISSUES_QUERY, +} from './github-app-issue-references'; +import { + collectGitHubAppMergedPullRequestSessionIds, + collectGitHubAppPullRequestSessionIds, + SESSION_MERGE_EVIDENCE_QUERY, + SESSION_PULL_REQUESTS_QUERY, +} from './github-app-pr-references'; +import { WORKSPACE_CREATED_PULL_REQUESTS_CTE } from './github-app-pr-outcomes'; +import type { GitHubAppDeliveryFunnel } from './types'; + +const PROJECT_SESSIONS_QUERY = ` +SELECT + id AS sessionId, + updated_at AS updatedAt +FROM sessions +WHERE session_type = 'project'`; + +const WORKSPACE_DELIVERY_QUERY = ` +WITH +${WORKSPACE_CREATED_PULL_REQUESTS_CTE}, +workspace_pr_outcomes AS ( + SELECT + workspaceId, + MAX(merged) AS hasMergedPullRequest + FROM workspace_created_pull_requests + GROUP BY workspaceId +) +SELECT + COALESCE( + workspaces.session_id, + ( + SELECT aliases.session_id + FROM workspace_session_aliases AS aliases + WHERE aliases.workspace_id = workspaces.id + ORDER BY aliases.created_at DESC + LIMIT 1 + ) + ) AS sessionId, + 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 AS hasDirectIssue, + CASE + WHEN pr_outcomes.workspaceId IS NOT NULL + OR workspaces.source_pr_number IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM workspace_repo_contexts AS contexts + WHERE contexts.workspace_id = workspaces.id + AND contexts.source_pr_number IS NOT NULL + ) + THEN 1 ELSE 0 + END AS hasPullRequest, + CASE + WHEN COALESCE(pr_outcomes.hasMergedPullRequest, 0) = 1 + OR ( + workspaces.source_pr_number IS NOT NULL + AND ( + workspaces.source_pr_merged_at IS NOT NULL + OR lower(COALESCE(workspaces.source_pr_state, '')) = 'merged' + ) + ) + OR EXISTS ( + SELECT 1 + FROM workspace_repo_contexts AS contexts + WHERE contexts.workspace_id = workspaces.id + AND contexts.source_pr_number IS NOT NULL + AND ( + contexts.source_pr_merged_at IS NOT NULL + OR lower(COALESCE(contexts.source_pr_state, '')) = 'merged' + ) + ) + THEN 1 ELSE 0 + END AS hasMergedPullRequest +FROM workspaces +LEFT JOIN workspace_pr_outcomes AS pr_outcomes + ON pr_outcomes.workspaceId = workspaces.id`; + +interface ProjectSessionRow { + sessionId: string; + updatedAt: string; +} + +interface WorkspaceDeliveryRow { + sessionId: string | null; + hasDirectIssue: boolean; + hasPullRequest: boolean; + hasMergedPullRequest: boolean; +} + +interface SessionDeliveryEvidence { + issueSessionIds: ReadonlySet; + pullRequestSessionIds: ReadonlySet; + mergedPullRequestSessionIds: ReadonlySet; +} + +function isProjectSessionRow(value: unknown): value is ProjectSessionRow { + if (typeof value !== 'object' || value === null) return false; + const row = value as Record; + return typeof row.sessionId === 'string' && typeof row.updatedAt === 'string'; +} + +function parseProjectSessions(raw: string): ProjectSessionRow[] { + const rows = parseSqliteJsonRows(raw, 'Project session'); + if (!rows.every(isProjectSessionRow)) { + throw new Error('Project session query returned an unexpected row.'); + } + return rows; +} + +function isWorkspaceDeliveryRow(value: unknown): value is { + sessionId: string | null; + hasDirectIssue: number; + hasPullRequest: number; + hasMergedPullRequest: number; +} { + if (typeof value !== 'object' || value === null) return false; + const row = value as Record; + return (row.sessionId === null || typeof row.sessionId === 'string') + && (row.hasDirectIssue === 0 || row.hasDirectIssue === 1) + && (row.hasPullRequest === 0 || row.hasPullRequest === 1) + && (row.hasMergedPullRequest === 0 || row.hasMergedPullRequest === 1); +} + +function parseWorkspaceDelivery(raw: string): WorkspaceDeliveryRow[] { + const rows = parseSqliteJsonRows(raw, 'Workspace delivery'); + if (!rows.every(isWorkspaceDeliveryRow)) { + throw new Error('Workspace delivery query returned an unexpected row.'); + } + return rows.map(row => ({ + sessionId: row.sessionId, + hasDirectIssue: row.hasDirectIssue === 1, + hasPullRequest: row.hasPullRequest === 1, + hasMergedPullRequest: row.hasMergedPullRequest === 1, + })); +} + +function buildDeliveryFunnel( + projectSessions: readonly ProjectSessionRow[], + workspaceDelivery: readonly WorkspaceDeliveryRow[], + evidence: SessionDeliveryEvidence, +): GitHubAppDeliveryFunnel { + const projectSessionIds = new Set(projectSessions.map(session => session.sessionId)); + const issueSessions = new Set(); + const pullRequestSessions = new Set(); + const mergedPullRequestSessions = new Set(); + + for (const sessionId of evidence.issueSessionIds) { + if (projectSessionIds.has(sessionId)) issueSessions.add(sessionId); + } + + for (const sessionId of evidence.pullRequestSessionIds) { + if (projectSessionIds.has(sessionId)) pullRequestSessions.add(sessionId); + } + + for (const sessionId of evidence.mergedPullRequestSessionIds) { + if (!projectSessionIds.has(sessionId)) continue; + pullRequestSessions.add(sessionId); + mergedPullRequestSessions.add(sessionId); + } + + for (const outcome of workspaceDelivery) { + if (outcome.sessionId === null || !projectSessionIds.has(outcome.sessionId)) continue; + if (outcome.hasDirectIssue) issueSessions.add(outcome.sessionId); + if (outcome.hasPullRequest) pullRequestSessions.add(outcome.sessionId); + if (outcome.hasMergedPullRequest) mergedPullRequestSessions.add(outcome.sessionId); + } + + const lastActivityAt = projectSessions.reduce( + (latest, session) => (latest === null || session.updatedAt > latest) ? session.updatedAt : latest, + null, + ); + + return { + totalProjectSessions: projectSessionIds.size, + sessionsWithIssue: issueSessions.size, + sessionsWithPullRequest: pullRequestSessions.size, + sessionsWithMergedPullRequest: mergedPullRequestSessions.size, + lastActivityAt, + }; +} + +export async function loadGitHubAppDeliveryFunnel( + access: GitHubAppDatabaseAccess, +): Promise { + const [ + projectSessionsRaw, + workspaceDeliveryRaw, + sessionIssuesRaw, + sessionPullRequestsRaw, + sessionMergeEvidenceRaw, + ] = await Promise.all([ + access.query(access.databasePath, PROJECT_SESSIONS_QUERY), + access.query(access.databasePath, WORKSPACE_DELIVERY_QUERY), + access.query(access.sessionStorePath, SESSION_ISSUES_QUERY), + access.query(access.sessionStorePath, SESSION_PULL_REQUESTS_QUERY), + access.query(access.sessionStorePath, SESSION_MERGE_EVIDENCE_QUERY), + ]); + + return buildDeliveryFunnel( + parseProjectSessions(projectSessionsRaw), + parseWorkspaceDelivery(workspaceDeliveryRaw), + { + issueSessionIds: collectGitHubAppIssueSessionIds( + parseSqliteJsonRows(sessionIssuesRaw, 'Session issue'), + ), + pullRequestSessionIds: collectGitHubAppPullRequestSessionIds( + parseSqliteJsonRows(sessionPullRequestsRaw, 'Session pull request'), + ), + mergedPullRequestSessionIds: collectGitHubAppMergedPullRequestSessionIds( + parseSqliteJsonRows(sessionMergeEvidenceRaw, 'Session merge evidence'), + ), + }, + ); +} diff --git a/src/core/github-app-issue-credit-model.ts b/src/core/github-app-issue-credit-model.ts index 3a89d2c..58190fd 100644 --- a/src/core/github-app-issue-credit-model.ts +++ b/src/core/github-app-issue-credit-model.ts @@ -7,6 +7,13 @@ import type { GitHubAppIssueCreditEstimate, GitHubAppIssueCreditsMetrics, } from './types'; +import { + githubIssueKey, + normalizeGitHubRepository, + parseGitHubAppSessionIssues, + type GitHubIssueEvidence, + type GitHubIssueIdentity, +} from './github-app-issue-references'; export const NANO_AIU_PER_AI_CREDIT = 1_000_000_000; @@ -21,13 +28,6 @@ interface WorkspaceSessionRow { sessionId: string; } -interface SessionIssueRow { - sessionId: string; - repository: string; - issueNumber: number; - source: 'reference' | 'pasted-link'; -} - interface SessionUsageRow { sessionId: string; totalNanoAiu: number; @@ -40,16 +40,7 @@ export interface GitHubAppIssueCreditRows { sessionUsage: readonly unknown[]; } -interface IssueIdentity { - repository: string; - issueNumber: number; -} - -interface IssueEvidence extends IssueIdentity { - explicitLink: boolean; -} - -interface MutableIssueEstimate extends IssueIdentity { +interface MutableIssueEstimate extends GitHubIssueIdentity { sessionIds: Set; } @@ -71,26 +62,10 @@ function nonNegativeNumber(value: unknown): number | null { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; } -function normalizeRepository(value: unknown): string | null { - const raw = nonEmptyString(value); - if (!raw) return null; - const normalized = raw - .replace(/^https?:\/\/github\.com\//i, '') - .replace(/\.git$/i, '') - .replaceAll(/^\/+|\/+$/g, ''); - const segments = normalized.split('/'); - if (segments.length !== 2 || segments.some(segment => segment.length === 0)) return null; - return normalized; -} - -function issueKey(repository: string, issueNumber: number): string { - return `${repository.toLowerCase()}#${issueNumber}`; -} - function parseWorkspaceIssue(value: unknown): WorkspaceIssueRow | null { if (!isRecord(value)) return null; const workspaceId = nonEmptyString(value.workspaceId); - const repository = normalizeRepository(value.repository); + const repository = normalizeGitHubRepository(value.repository); const issueNumber = positiveInteger(value.issueNumber); return workspaceId && repository && issueNumber ? { workspaceId, repository, issueNumber } @@ -104,58 +79,6 @@ function parseWorkspaceSession(value: unknown): WorkspaceSessionRow | null { return workspaceId && sessionId ? { workspaceId, sessionId } : null; } -function parseIssueReference(refValue: unknown, fallbackRepository: unknown): IssueIdentity | null { - const ref = nonEmptyString(refValue); - if (!ref) return null; - - if (/^\d+$/.test(ref)) { - const repository = normalizeRepository(fallbackRepository); - const issueNumber = Number(ref); - return repository && Number.isSafeInteger(issueNumber) && issueNumber > 0 - ? { repository, issueNumber } - : null; - } - - const qualified = /^(?:https?:\/\/github\.com\/)?([^/\s]+\/[^/#\s]+?)(?:\/issues\/|#)(\d+)$/i.exec(ref); - if (!qualified) return null; - const repository = normalizeRepository(qualified[1]); - const issueNumber = Number(qualified[2]); - return repository && Number.isSafeInteger(issueNumber) && issueNumber > 0 - ? { repository, issueNumber } - : null; -} - -function parsePastedIssueLinks(refValue: unknown): IssueIdentity[] { - const message = nonEmptyString(refValue); - if (!message) return []; - const issues = new Map(); - for (const match of message.matchAll( - /https?:\/\/github\.com\/([^/\s]+\/[^/#?\s]+)\/issues\/(\d+)/gi, - )) { - const repository = normalizeRepository(match[1]); - const issueNumber = Number(match[2]); - if (!repository || !Number.isSafeInteger(issueNumber) || issueNumber <= 0) continue; - issues.set(issueKey(repository, issueNumber), { repository, issueNumber }); - } - return [...issues.values()]; -} - -function parseSessionIssues(value: unknown): SessionIssueRow[] { - if (!isRecord(value)) return []; - const sessionId = nonEmptyString(value.sessionId); - if (!sessionId) return []; - if (value.source === 'pasted-link') { - return parsePastedIssueLinks(value.refValue).map(issue => ({ - sessionId, - ...issue, - source: 'pasted-link', - })); - } - if (value.source !== 'reference') return []; - const issue = parseIssueReference(value.refValue, value.repository); - return issue ? [{ sessionId, ...issue, source: 'reference' }] : []; -} - function parseSessionUsage(value: unknown): SessionUsageRow | null { if (!isRecord(value)) return null; const sessionId = nonEmptyString(value.sessionId); @@ -187,21 +110,21 @@ function buildWorkspaceSessions(values: readonly unknown[]): Map> { - const directIssues = new Map>(); +function buildDirectIssues(values: readonly unknown[]): Map> { + const directIssues = new Map>(); for (const value of values) { const row = parseWorkspaceIssue(value); if (!row) continue; - setMapValue(directIssues, row.workspaceId, issueKey(row.repository, row.issueNumber), row); + setMapValue(directIssues, row.workspaceId, githubIssueKey(row.repository, row.issueNumber), row); } return directIssues; } -function buildSessionIssues(values: readonly unknown[]): Map> { - const sessionIssues = new Map>(); +function buildSessionIssues(values: readonly unknown[]): Map> { + const sessionIssues = new Map>(); for (const value of values) { - for (const row of parseSessionIssues(value)) { - const key = issueKey(row.repository, row.issueNumber); + for (const row of parseGitHubAppSessionIssues(value)) { + const key = githubIssueKey(row.repository, row.issueNumber); const current = sessionIssues.get(row.sessionId)?.get(key); setMapValue(sessionIssues, row.sessionId, key, { repository: row.repository, @@ -215,32 +138,32 @@ function buildSessionIssues(values: readonly unknown[]): Map, - directIssues: Map | undefined, - sessionIssues: Map>, -): Map { + directIssues: Map | undefined, + sessionIssues: Map>, +): Map { if (directIssues && directIssues.size > 0) return directIssues; - const referenced = new Map( + const referenced = new Map( [...sessionIds].flatMap(sessionId => [...(sessionIssues.get(sessionId)?.entries() ?? [])]), ); - const pasted = new Map( + const pasted = new Map( [...referenced].filter(([, evidence]) => evidence.explicitLink), ); if (pasted.size > 0) return pasted; - return referenced.size === 1 ? referenced : new Map(); + return referenced.size === 1 ? referenced : new Map(); } function linkIssuesToSessions( workspaceSessions: Map>, - directIssues: Map>, - sessionIssues: Map>, + directIssues: Map>, + sessionIssues: Map>, ): Map { const issues = new Map(); const workspaceIds = new Set([...workspaceSessions.keys(), ...directIssues.keys()]); for (const workspaceId of workspaceIds) { const sessionIds = workspaceSessions.get(workspaceId) ?? new Set(); for (const identity of issueCandidates(sessionIds, directIssues.get(workspaceId), sessionIssues).values()) { - const key = issueKey(identity.repository, identity.issueNumber); + const key = githubIssueKey(identity.repository, identity.issueNumber); let issue = issues.get(key); if (!issue) { issue = { ...identity, sessionIds: new Set() }; diff --git a/src/core/github-app-issue-credits.ts b/src/core/github-app-issue-credits.ts index c1c83bc..0e8e7ad 100644 --- a/src/core/github-app-issue-credits.ts +++ b/src/core/github-app-issue-credits.ts @@ -5,6 +5,7 @@ import type { GitHubAppIssueCreditsSnapshot } from './types'; import { + parseSqliteJsonRows, resolveGitHubAppDatabaseAccess, type GitHubAppDatabaseDependencies, } from './github-app-database'; @@ -12,6 +13,7 @@ import { aggregateGitHubAppIssueCredits, type GitHubAppIssueCreditRows, } from './github-app-issue-credit-model'; +import { SESSION_ISSUES_QUERY } from './github-app-issue-references'; import { warnCore } from './log'; import { assertTrustedPath } from './parser-shared'; @@ -60,28 +62,6 @@ UNION SELECT workspace_id AS workspaceId, session_id AS sessionId FROM workspace_session_aliases`; -const SESSION_ISSUES_QUERY = ` -SELECT - refs.session_id AS sessionId, - sessions.repository AS repository, - refs.ref_value AS refValue, - 'reference' AS source -FROM session_refs AS refs -LEFT JOIN sessions ON sessions.id = refs.session_id -WHERE refs.ref_type = 'issue' - -UNION ALL - -SELECT - turns.session_id AS sessionId, - sessions.repository AS repository, - turns.user_message AS refValue, - 'pasted-link' AS source -FROM turns -LEFT JOIN sessions ON sessions.id = turns.session_id -WHERE turns.turn_index IN (0, 1) - AND turns.user_message LIKE '%github.com/%/issues/%'`; - const SESSION_USAGE_QUERY = ` SELECT session_id AS sessionId, @@ -93,15 +73,6 @@ GROUP BY session_id`; export type GitHubAppIssueCreditsDependencies = GitHubAppDatabaseDependencies; -function parseRows(raw: string, description: string): unknown[] { - if (raw.trim().length === 0) return []; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) { - throw new Error(`${description} query returned an unexpected result.`); - } - return parsed; -} - export async function loadGitHubAppIssueCredits( dependencies: GitHubAppIssueCreditsDependencies = {}, ): Promise { @@ -128,10 +99,10 @@ export async function loadGitHubAppIssueCredits( query(sessionStorePath, SESSION_USAGE_QUERY), ]); const rows: GitHubAppIssueCreditRows = { - workspaceIssues: parseRows(workspaceIssues, 'Workspace issue'), - workspaceSessions: parseRows(workspaceSessions, 'Workspace session'), - sessionIssues: parseRows(sessionIssues, 'Session issue'), - sessionUsage: parseRows(sessionUsage, 'Session usage'), + workspaceIssues: parseSqliteJsonRows(workspaceIssues, 'Workspace issue'), + workspaceSessions: parseSqliteJsonRows(workspaceSessions, 'Workspace session'), + sessionIssues: parseSqliteJsonRows(sessionIssues, 'Session issue'), + sessionUsage: parseSqliteJsonRows(sessionUsage, 'Session usage'), }; return { status: 'ready', metrics: aggregateGitHubAppIssueCredits(rows) }; } catch (error) { diff --git a/src/core/github-app-issue-references.ts b/src/core/github-app-issue-references.ts new file mode 100644 index 0000000..21503a7 --- /dev/null +++ b/src/core/github-app-issue-references.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const SESSION_ISSUES_QUERY = ` +SELECT + refs.session_id AS sessionId, + sessions.repository AS repository, + refs.ref_value AS refValue, + 'reference' AS source +FROM session_refs AS refs +LEFT JOIN sessions ON sessions.id = refs.session_id +WHERE refs.ref_type = 'issue' + +UNION ALL + +SELECT + turns.session_id AS sessionId, + sessions.repository AS repository, + turns.user_message AS refValue, + 'pasted-link' AS source +FROM turns +LEFT JOIN sessions ON sessions.id = turns.session_id +WHERE turns.turn_index IN (0, 1) + AND turns.user_message LIKE '%github.com/%/issues/%'`; + +export interface GitHubIssueIdentity { + repository: string; + issueNumber: number; +} + +export interface GitHubIssueEvidence extends GitHubIssueIdentity { + explicitLink: boolean; +} + +export interface GitHubAppSessionIssueRow extends GitHubIssueIdentity { + sessionId: string; + source: 'reference' | 'pasted-link'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function nonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function normalizeGitHubRepository(value: unknown): string | null { + const raw = nonEmptyString(value); + if (!raw) return null; + const normalized = raw + .replace(/^https?:\/\/github\.com\//i, '') + .replace(/\.git$/i, '') + .replaceAll(/^\/+|\/+$/g, ''); + const segments = normalized.split('/'); + if (segments.length !== 2 || segments.some(segment => segment.length === 0)) return null; + return normalized; +} + +export function githubIssueKey(repository: string, issueNumber: number): string { + return `${repository.toLowerCase()}#${issueNumber}`; +} + +function parseIssueReference( + refValue: unknown, + fallbackRepository: unknown, +): GitHubIssueIdentity | null { + const ref = nonEmptyString(refValue); + if (!ref) return null; + + if (/^\d+$/.test(ref)) { + const repository = normalizeGitHubRepository(fallbackRepository); + const issueNumber = Number(ref); + return repository && Number.isSafeInteger(issueNumber) && issueNumber > 0 + ? { repository, issueNumber } + : null; + } + + const qualified = /^(?:https?:\/\/github\.com\/)?([^/\s]+\/[^/#\s]+?)(?:\/issues\/|#)(\d+)$/i.exec(ref); + if (!qualified) return null; + const repository = normalizeGitHubRepository(qualified[1]); + const issueNumber = Number(qualified[2]); + return repository && Number.isSafeInteger(issueNumber) && issueNumber > 0 + ? { repository, issueNumber } + : null; +} + +function parsePastedIssueLinks(refValue: unknown): GitHubIssueIdentity[] { + const message = nonEmptyString(refValue); + if (!message) return []; + const issues = new Map(); + for (const match of message.matchAll( + /https?:\/\/github\.com\/([^/\s]+\/[^/#?\s]+)\/issues\/(\d+)/gi, + )) { + const repository = normalizeGitHubRepository(match[1]); + const issueNumber = Number(match[2]); + if (!repository || !Number.isSafeInteger(issueNumber) || issueNumber <= 0) continue; + issues.set(githubIssueKey(repository, issueNumber), { repository, issueNumber }); + } + return [...issues.values()]; +} + +export function parseGitHubAppSessionIssues(value: unknown): GitHubAppSessionIssueRow[] { + if (!isRecord(value)) return []; + const sessionId = nonEmptyString(value.sessionId); + if (!sessionId) return []; + if (value.source === 'pasted-link') { + return parsePastedIssueLinks(value.refValue).map(issue => ({ + sessionId, + ...issue, + source: 'pasted-link', + })); + } + if (value.source !== 'reference') return []; + const issue = parseIssueReference(value.refValue, value.repository); + return issue ? [{ sessionId, ...issue, source: 'reference' }] : []; +} + +export function collectGitHubAppIssueSessionIds(values: readonly unknown[]): Set { + const sessionIds = new Set(); + for (const value of values) { + for (const row of parseGitHubAppSessionIssues(value)) sessionIds.add(row.sessionId); + } + return sessionIds; +} diff --git a/src/core/github-app-pr-outcomes.ts b/src/core/github-app-pr-outcomes.ts new file mode 100644 index 0000000..3cbd4e0 --- /dev/null +++ b/src/core/github-app-pr-outcomes.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const WORKSPACE_CREATED_PULL_REQUESTS_CTE = ` +workspace_created_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 + ) +)`; diff --git a/src/core/github-app-pr-references.ts b/src/core/github-app-pr-references.ts new file mode 100644 index 0000000..fa9b355 --- /dev/null +++ b/src/core/github-app-pr-references.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const SESSION_PULL_REQUESTS_QUERY = ` +SELECT DISTINCT + session_id AS sessionId, + ref_value AS refValue +FROM session_refs +WHERE ref_type = 'pr'`; + +export const SESSION_MERGE_EVIDENCE_QUERY = ` +SELECT + turns.session_id AS sessionId, + turns.assistant_response AS response +FROM turns +WHERE turns.assistant_response LIKE '%merged%' + AND EXISTS ( + SELECT 1 + FROM session_refs AS refs + WHERE refs.session_id = turns.session_id + AND refs.ref_type = 'pr' + )`; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function sessionId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isPullRequestReference(value: unknown): value is { + sessionId: string; + refValue: string; +} { + if (!isRecord(value)) return false; + return sessionId(value.sessionId) !== null + && typeof value.refValue === 'string' + && /^[1-9]\d*$/.test(value.refValue.trim()); +} + +function hasMergeEvidence(response: unknown): boolean { + if (typeof response !== 'string') return false; + return /\b(?:has been|was|is)\s+merged\b/i.test(response) + || /\bsuccessfully\s+merged\b/i.test(response) + || /\bmerged\s+successfully\b/i.test(response) + || /\b(?:state|status)\s*[:=]\s*["']?merged\b/i.test(response) + || /^\s*merged pull request\b/im.test(response); +} + +export function collectGitHubAppPullRequestSessionIds( + values: readonly unknown[], +): Set { + const sessionIds = new Set(); + for (const value of values) { + if (isPullRequestReference(value)) sessionIds.add(value.sessionId); + } + return sessionIds; +} + +export function collectGitHubAppMergedPullRequestSessionIds( + values: readonly unknown[], +): Set { + const sessionIds = new Set(); + for (const value of values) { + if (!isRecord(value) || !hasMergeEvidence(value.response)) continue; + const id = sessionId(value.sessionId); + if (id) sessionIds.add(id); + } + return sessionIds; +} diff --git a/src/core/types/github-app-types.ts b/src/core/types/github-app-types.ts index ad612bf..470fddb 100644 --- a/src/core/types/github-app-types.ts +++ b/src/core/types/github-app-types.ts @@ -3,19 +3,23 @@ * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export interface GitHubAppMergeDay { +export interface GitHubAppWorkspacePrCohortDay { date: string; pullRequestsRaised: number; pullRequestsMerged: number; } -export interface GitHubAppMetrics { +export interface GitHubAppDeliveryFunnel { totalProjectSessions: number; sessionsWithIssue: number; sessionsWithPullRequest: number; sessionsWithMergedPullRequest: number; lastActivityAt: string | null; - mergeHistory: GitHubAppMergeDay[]; +} + +export interface GitHubAppMetrics { + delivery: GitHubAppDeliveryFunnel; + workspacePrCohorts: GitHubAppWorkspacePrCohortDay[]; } export type GitHubAppDataSnapshot = diff --git a/src/webview/app.ts b/src/webview/app.ts index a202d1e..dcf9c04 100644 --- a/src/webview/app.ts +++ b/src/webview/app.ts @@ -113,7 +113,7 @@ 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); + if (snapshot.status === 'ready') setBadge('badge-github-app', snapshot.metrics.delivery.totalProjectSessions); else clearBadge('badge-github-app'); if ( !visible diff --git a/src/webview/page-github-app.ts b/src/webview/page-github-app.ts index 4807059..018781d 100644 --- a/src/webview/page-github-app.ts +++ b/src/webview/page-github-app.ts @@ -3,7 +3,12 @@ * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { GitHubAppMetrics, GitHubAppSnapshot } from '../core/types'; +import type { + GitHubAppDeliveryFunnel, + GitHubAppMetrics, + GitHubAppSnapshot, + GitHubAppWorkspacePrCohortDay, +} from '../core/types'; import { createChart, COLORS, formatNum } from './shared'; import { CanvasEl, html, render, type ComponentChildren } from './render'; @@ -29,26 +34,26 @@ function funnelStage(stage: FunnelStage): ComponentChildren { `; } -function projectSessionsStage(metrics: GitHubAppMetrics): ComponentChildren { - const sessionsWithoutIssue = metrics.totalProjectSessions - metrics.sessionsWithIssue; +function projectSessionsStage(delivery: GitHubAppDeliveryFunnel): ComponentChildren { + const sessionsWithoutIssue = delivery.totalProjectSessions - delivery.sessionsWithIssue; return html`
  • - ${formatNum(metrics.totalProjectSessions)} + ${formatNum(delivery.totalProjectSessions)} Project sessions100% started
    -
    With issue
    -
    ${formatNum(metrics.sessionsWithIssue)}
    +
    With issue reference
    +
    ${formatNum(delivery.sessionsWithIssue)}
    -
    Without issue
    +
    Without issue reference
    ${formatNum(sessionsWithoutIssue)}
    @@ -56,25 +61,25 @@ function projectSessionsStage(metrics: GitHubAppMetrics): ComponentChildren {
  • `; } -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); +function deliveryFunnel(delivery: GitHubAppDeliveryFunnel): ComponentChildren { + const pullRequestRate = percentage(delivery.sessionsWithPullRequest, delivery.totalProjectSessions); + const mergeRate = percentage(delivery.sessionsWithMergedPullRequest, delivery.totalProjectSessions); + const pullRequestMergeRate = percentage(delivery.sessionsWithMergedPullRequest, delivery.sessionsWithPullRequest); return html`
      - ${projectSessionsStage(metrics)} + ${projectSessionsStage(delivery)} ${funnelStage({ className: 'gha-funnel-stage-pr', width: Math.max(46, pullRequestRate), - count: metrics.sessionsWithPullRequest, - label: 'Sessions with a PR', + count: delivery.sessionsWithPullRequest, + label: 'Sessions linked to 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', + count: delivery.sessionsWithMergedPullRequest, + label: 'Sessions with a confirmed merge', detail: `${pullRequestMergeRate}% of PR sessions`, })}
    `; @@ -106,25 +111,25 @@ function renderUnavailable(container: HTMLElement): void { `, container); } -function funnelSection(metrics: GitHubAppMetrics): ComponentChildren { - const mergeRate = percentage(metrics.sessionsWithMergedPullRequest, metrics.totalProjectSessions); +function funnelSection(delivery: GitHubAppDeliveryFunnel): ComponentChildren { + const mergeRate = percentage(delivery.sessionsWithMergedPullRequest, delivery.totalProjectSessions); return html`

    Delivery funnel

    -

    Follow project sessions through raised and merged pull requests.

    +

    Follow project sessions through issue links, PR links, and locally confirmed merges.

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

    PR merge ratio ยท last 7 days

    -

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

    +

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

    @@ -150,13 +155,13 @@ function mergeRatioSection(metrics: GitHubAppMetrics): ComponentChildren {
    PRs merged
    ${formatNum(recentMerged)}
    From these daily cohorts
    -

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

    +

    Days are grouped by project-workspace 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 => { +function renderMergeRatioChart(cohorts: readonly GitHubAppWorkspacePrCohortDay[]): void { + if (!cohorts.some(day => day.pullRequestsRaised > 0)) return; + const labels = cohorts.map(item => { const [, month, day] = item.date.split('-'); return `${month}/${day}`; }); @@ -166,7 +171,7 @@ function renderMergeRatioChart(metrics: GitHubAppMetrics): void { { type: 'bar', label: 'PRs raised', - data: metrics.mergeHistory.map(day => day.pullRequestsRaised), + data: cohorts.map(day => day.pullRequestsRaised), yAxisID: 'count', backgroundColor: COLORS.blue + '3D', borderColor: COLORS.blue, @@ -176,7 +181,7 @@ function renderMergeRatioChart(metrics: GitHubAppMetrics): void { }, { label: 'Merge ratio', - data: metrics.mergeHistory.map(day => day.pullRequestsRaised > 0 + data: cohorts.map(day => day.pullRequestsRaised > 0 ? percentage(day.pullRequestsMerged, day.pullRequestsRaised) : null), yAxisID: 'ratio', @@ -212,14 +217,14 @@ function renderMetrics(container: HTMLElement, metrics: GitHubAppMetrics): void
    Last activity - ${formatLastActivity(metrics.lastActivityAt)} + ${formatLastActivity(metrics.delivery.lastActivityAt)}
    - ${funnelSection(metrics)} - ${mergeRatioSection(metrics)} + ${funnelSection(metrics.delivery)} + ${mergeRatioSection(metrics.workspacePrCohorts)} `, container); - renderMergeRatioChart(metrics); + renderMergeRatioChart(metrics.workspacePrCohorts); } export function renderGitHubApp(container: HTMLElement, snapshot: GitHubAppSnapshot): void {