From 750e00eb3305cf9bfaecd72c095a08a324d9dab6 Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 15:47:47 +0200 Subject: [PATCH 01/10] chore: ignore .worktrees directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 70f5101..65eaba3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ node_modules/ *.db-wal *.tsbuildinfo coverage/ +.worktrees From b6aacc7c8607b0435443c034a4172921f252d5ea Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 15:49:47 +0200 Subject: [PATCH 02/10] refactor: resolveJiraUser accepts emails directly instead of querying DB --- src/lib/__tests__/unit/jira-mapper.test.ts | 33 +++++++++++++--------- src/lib/jira/mapper.ts | 12 ++------ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/lib/__tests__/unit/jira-mapper.test.ts b/src/lib/__tests__/unit/jira-mapper.test.ts index d0e853d..e23ab6b 100644 --- a/src/lib/__tests__/unit/jira-mapper.test.ts +++ b/src/lib/__tests__/unit/jira-mapper.test.ts @@ -24,36 +24,43 @@ describe('resolveJiraUser', () => { jira_email: 'dev@co.com', }], null]); - const result = await resolveJiraUser('myorg', 'devuser', 'report-1'); + const result = await resolveJiraUser('myorg', 'devuser', ['dev@co.com']); expect(result).toEqual({ accountId: 'jira-123', email: 'dev@co.com' }); + // Only one DB call — the mapping lookup; no email query + expect(mockDb.execute).toHaveBeenCalledTimes(1); }); it('returns null when no mapping and no Jira client', async () => { mockDb.execute.mockResolvedValueOnce([[], null]); mockGetJiraClient.mockReturnValue(null); - const result = await resolveJiraUser('myorg', 'devuser', 'report-1'); + const result = await resolveJiraUser('myorg', 'devuser', ['dev@co.com']); expect(result).toBeNull(); }); - it('auto-discovers via commit emails and persists mapping', async () => { + it('returns null when no mapping, client present, but no emails provided', async () => { + mockDb.execute.mockResolvedValueOnce([[], null]); + mockGetJiraClient.mockReturnValue({ findUserByEmail: jest.fn() }); + + const result = await resolveJiraUser('myorg', 'devuser', []); + expect(result).toBeNull(); + }); + + it('auto-discovers via provided emails and persists mapping', async () => { + // DB calls: 1 mapping lookup + 1 persist (no longer a 3rd call for email query) mockDb.execute - .mockResolvedValueOnce([[], null]) - .mockResolvedValueOnce([[ - { author_email: 'dev@co.com' }, - { author_email: 'dev@personal.com' }, - ], null]) - .mockResolvedValueOnce([[], null]); + .mockResolvedValueOnce([[], null]) // mapping lookup → not found + .mockResolvedValueOnce([[], null]); // persist INSERT const mockClient = { findUserByEmail: jest.fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ accountId: 'jira-456', displayName: 'Dev', emailAddress: 'dev@personal.com' }), + .mockResolvedValueOnce(null) // dev@co.com → not found + .mockResolvedValueOnce({ accountId: 'jira-456', displayName: 'Dev' }), }; mockGetJiraClient.mockReturnValue(mockClient); - const result = await resolveJiraUser('myorg', 'devuser', 'report-1'); + const result = await resolveJiraUser('myorg', 'devuser', ['dev@co.com', 'dev@personal.com']); expect(result).toEqual({ accountId: 'jira-456', email: 'dev@personal.com' }); - expect(mockDb.execute).toHaveBeenCalledTimes(3); + expect(mockDb.execute).toHaveBeenCalledTimes(2); }); }); diff --git a/src/lib/jira/mapper.ts b/src/lib/jira/mapper.ts index 32b0697..5dcef7d 100644 --- a/src/lib/jira/mapper.ts +++ b/src/lib/jira/mapper.ts @@ -9,7 +9,7 @@ interface JiraMapping { export async function resolveJiraUser( org: string, githubLogin: string, - reportId: string, + emails: string[], log?: (msg: string) => void, ): Promise { // 1. Check existing mapping @@ -22,18 +22,12 @@ export async function resolveJiraUser( return { accountId: rows[0].jira_account_id, email: rows[0].jira_email }; } - // 2. Auto-discover via commit emails + // 2. Auto-discover via provided emails const client = getJiraClient(); if (!client) return null; - const [emailRows] = await db.execute( - `SELECT DISTINCT author_email FROM commit_analyses WHERE report_id = ? AND github_login = ? AND author_email IS NOT NULL AND author_email != ''`, - [reportId, githubLogin], - ) as [any[], any]; - - const emails: string[] = emailRows.map((r: any) => r.author_email); if (emails.length === 0) { - log?.(`[jira] No commit emails found for @${githubLogin}, cannot auto-discover Jira mapping`); + log?.(`[jira] No commit emails provided for @${githubLogin}, cannot auto-discover Jira mapping`); return null; } From c0221299a42bbd7f0cb668a9c6e4925dc21b03ac Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 15:52:18 +0200 Subject: [PATCH 03/10] feat: merge Jira discovery into main per-member loop, run concurrently with LLM analysis --- .../integration/report-runner.test.ts | 60 +++++++ src/lib/report-runner.ts | 147 ++++++++---------- 2 files changed, 129 insertions(+), 78 deletions(-) diff --git a/src/lib/__tests__/integration/report-runner.test.ts b/src/lib/__tests__/integration/report-runner.test.ts index f7d8115..2b8c35b 100644 --- a/src/lib/__tests__/integration/report-runner.test.ts +++ b/src/lib/__tests__/integration/report-runner.test.ts @@ -15,17 +15,29 @@ jest.mock('p-limit', () => ({ __esModule: true, default: () => (fn: () => T) => fn(), })); +jest.mock('@/lib/jira', () => ({ + getJiraClient: jest.fn(), + resolveJiraUser: jest.fn(), +})); +jest.mock('@/lib/app-config/service', () => ({ + getAppConfig: jest.fn(), +})); import { runReport, requestStop } from '@/lib/report-runner'; import { listOrgMembers, fetchUserActivity } from '@/lib/github'; import { analyzeCommit } from '@/lib/analyzer'; import db from '@/lib/db/index'; import { updateProgress, addLog } from '@/lib/progress-store'; +import { getJiraClient, resolveJiraUser } from '@/lib/jira'; +import { getAppConfig } from '@/lib/app-config/service'; const mockListOrgMembers = listOrgMembers as jest.Mock; const mockFetchUserActivity = fetchUserActivity as jest.Mock; const mockAnalyzeCommit = analyzeCommit as jest.Mock; const mockDbExecute = db.execute as jest.Mock; +const mockGetJiraClient = getJiraClient as jest.Mock; +const mockResolveJiraUser = resolveJiraUser as jest.Mock; +const mockGetAppConfig = getAppConfig as jest.Mock; describe('runReport', () => { beforeEach(() => { @@ -47,6 +59,9 @@ describe('runReport', () => { ); mockDbExecute.mockResolvedValue([[], null]); + // Required: getAppConfig is fully mocked, so ALL tests need a default return value + // or they throw TypeError: Cannot read properties of undefined (reading 'jira') + mockGetAppConfig.mockReturnValue({ jira: { enabled: false, projects: [] } }); }); it('happy path: calls analyzeCommit for each unique commit and writes to DB', async () => { @@ -181,4 +196,49 @@ describe('runReport', () => { ); expect(completedCall).toBeTruthy(); }); + + it('fires Jira discovery per active member when Jira is enabled', async () => { + mockGetAppConfig.mockReturnValue({ jira: { enabled: true, projects: [] } }); + + const mockJiraClient = { + searchDoneIssues: jest.fn().mockResolvedValue([ + { + projectKey: 'PROJ', issueKey: 'PROJ-1', issueType: 'Story', + summary: 'A ticket', description: '', status: 'Done', + labels: [], storyPoints: null, originalEstimateSeconds: null, + issueUrl: 'https://jira.example.com/browse/PROJ-1', + createdAt: '2025-01-01', resolvedAt: '2025-01-10', + }, + ]), + }; + mockGetJiraClient.mockReturnValue(mockJiraClient); + mockResolveJiraUser.mockResolvedValue({ accountId: 'jira-abc', email: 'alice@co.com' }); + + mockDbExecute.mockImplementation(async (sql: string) => { + if (typeof sql === 'string' && sql.includes('jira_issues') && sql.includes('COUNT')) { + return [[{ cnt: 0 }], null]; + } + return [[], null]; + }); + + await runReport('r-jira', 'my-org', 14); + + // Both alice and bob have commits → Jira should be queried for both + expect(mockResolveJiraUser).toHaveBeenCalledTimes(2); + expect(mockJiraClient.searchDoneIssues).toHaveBeenCalledTimes(2); + + // Verify jira_issues INSERT was called for both members (1 issue each) + const jiraInserts = mockDbExecute.mock.calls.filter( + (call: any[]) => typeof call[0] === 'string' && call[0].includes('INSERT IGNORE INTO jira_issues'), + ); + expect(jiraInserts).toHaveLength(2); + + // Verify final developer_stats includes total_jira_issues = 1 + const statsInserts = mockDbExecute.mock.calls.filter( + (call: any[]) => typeof call[0] === 'string' && call[0].includes('INSERT INTO developer_stats'), + ); + const finalInsert = statsInserts[statsInserts.length - 1]; + // total_jira_issues is the 13th param (index 12) in the VALUES list + expect(finalInsert[1][12]).toBe(1); + }); }); diff --git a/src/lib/report-runner.ts b/src/lib/report-runner.ts index 82a4793..c1a21e2 100644 --- a/src/lib/report-runner.ts +++ b/src/lib/report-runner.ts @@ -84,6 +84,7 @@ export async function runReport( const analyses = new Map(existingAnalyses); const seen = new Set(); // global dedup const pendingLLM: Promise[] = []; + const pendingJira: Promise[] = []; const limit = pLimit(CONCURRENCY); let llmErrors = 0; let processedMembers = 0; @@ -147,6 +148,10 @@ export async function runReport( updateProgress(reportId, { completedDevelopers: completedMembers.size }); } + const jiraConfig = getAppConfig().jira; + const jiraClient = jiraConfig.enabled ? getJiraClient() : null; + const jiraIssueCountByLogin = new Map(); + // 2. Pipelined fetch+LLM loop for (const member of members) { if (shouldStop(reportId)) throw new Error('Stopped by user'); @@ -236,6 +241,65 @@ export async function runReport( pendingLLM.push(p); } + // Jira discovery: fire in parallel with LLM work — only needs emails from commits + if (jiraClient && thisMemCommits.length > 0) { + const login = member.login; + const emails = [...new Set(thisMemCommits.map(c => c.authorEmail).filter((e): e is string => Boolean(e)))]; + const jp = (async () => { + if (shouldStop(reportId)) return; + + // Resume: skip if already have jira_issues for this user/report + const [existingJira] = await db.execute( + `SELECT COUNT(*) as cnt FROM jira_issues WHERE report_id = ? AND github_login = ?`, + [reportId, login], + ) as [any[], any]; + + if (existingJira[0]?.cnt > 0) { + jiraIssueCountByLogin.set(login, existingJira[0].cnt); + log(`[jira] @${login}: ${existingJira[0].cnt} issues already in DB (resume)`); + return; + } + + try { + const mapping = await resolveJiraUser(org, login, emails, log); + if (!mapping) { + jiraIssueCountByLogin.set(login, 0); + return; + } + + const issues = await jiraClient.searchDoneIssues( + mapping.accountId, days, jiraConfig.projects.length > 0 ? jiraConfig.projects : undefined, + ); + + for (const issue of issues) { + await db.execute( + `INSERT IGNORE INTO jira_issues + (report_id, github_login, jira_account_id, jira_email, + project_key, issue_key, issue_type, summary, description, + status, labels, story_points, original_estimate_seconds, + issue_url, created_at, resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + reportId, login, mapping.accountId, mapping.email, + issue.projectKey, issue.issueKey, issue.issueType, + issue.summary, issue.description, issue.status, + JSON.stringify(issue.labels), issue.storyPoints, + issue.originalEstimateSeconds, issue.issueUrl, + issue.createdAt, issue.resolvedAt, + ], + ); + } + + jiraIssueCountByLogin.set(login, issues.length); + if (issues.length > 0) log(`[jira] @${login}: ${issues.length} resolved issues`); + } catch (err) { + log(`[jira] ERROR @${login}: ${err instanceof Error ? err.message : String(err)}`); + jiraIssueCountByLogin.set(login, 0); + } + })(); + pendingJira.push(jp); + } + // If no new commits needed LLM, member is immediately complete if (pendingCount === 0 && thisMemCommits.length > 0) { checkMemberComplete(member.login); @@ -253,88 +317,15 @@ export async function runReport( }); log(`Total: ${seen.size} unique commits from ${membersWithCommits} active developers`); - // Wait for remaining LLM work - await Promise.all(pendingLLM); + // Wait for remaining LLM work and concurrent Jira discovery + await Promise.all([...pendingLLM, ...pendingJira]); if (shouldStop(reportId)) throw new Error('Stopped by user'); log(`LLM analysis complete: ${analyses.size} total, ${llmErrors} failed`); - - // Jira integration: resolve users and fetch done issues - const jiraConfig = getAppConfig().jira; - const jiraIssueCountByLogin = new Map(); - - if (jiraConfig.enabled) { - const jiraClient = getJiraClient(); - if (jiraClient) { - log('Starting Jira issue collection...'); - let jiraProcessed = 0; - const jiraTotal = [...memberCommits.entries()].filter(([, c]) => c.length > 0).length; - - for (const [login, commits] of memberCommits.entries()) { - if (commits.length === 0) continue; - if (shouldStop(reportId)) throw new Error('Stopped by user'); - - jiraProcessed++; - updateProgress(reportId, { - step: `[${jiraProcessed}/${jiraTotal}] Fetching Jira issues: @${login}`, - }); - - // Resume: skip if already have jira_issues for this user/report - const [existingJira] = await db.execute( - `SELECT COUNT(*) as cnt FROM jira_issues WHERE report_id = ? AND github_login = ?`, - [reportId, login], - ) as [any[], any]; - - if (existingJira[0]?.cnt > 0) { - jiraIssueCountByLogin.set(login, existingJira[0].cnt); - log(`[jira] @${login}: ${existingJira[0].cnt} issues already in DB (resume)`); - continue; - } - - try { - const mapping = await resolveJiraUser(org, login, reportId, log); - if (!mapping) { - jiraIssueCountByLogin.set(login, 0); - continue; - } - - const issues = await jiraClient.searchDoneIssues( - mapping.accountId, - days, - jiraConfig.projects.length > 0 ? jiraConfig.projects : undefined, - jiraConfig.storyPointsFields, - ); - - for (const issue of issues) { - await db.execute( - `INSERT IGNORE INTO jira_issues - (report_id, github_login, jira_account_id, jira_email, - project_key, issue_key, issue_type, summary, description, - status, labels, story_points, original_estimate_seconds, - issue_url, created_at, resolved_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - reportId, login, mapping.accountId, mapping.email, - issue.projectKey, issue.issueKey, issue.issueType, - issue.summary, issue.description, issue.status, - JSON.stringify(issue.labels), issue.storyPoints, - issue.originalEstimateSeconds, issue.issueUrl, - issue.createdAt, issue.resolvedAt, - ], - ); - } - - jiraIssueCountByLogin.set(login, issues.length); - if (issues.length > 0) log(`[jira] @${login}: ${issues.length} resolved issues`); - } catch (err) { - log(`[jira] ERROR @${login}: ${err instanceof Error ? err.message : String(err)}`); - jiraIssueCountByLogin.set(login, 0); - } - } - - log(`Jira collection complete: ${[...jiraIssueCountByLogin.values()].reduce((a, b) => a + b, 0)} total issues`); - } + if (jiraClient) { + const jiraTotal = [...jiraIssueCountByLogin.values()].reduce((a, b) => a + b, 0); + log(`Jira collection complete: ${jiraTotal} total issues`); } // 3. Final aggregation with full cross-member view (overwrites per-member stats) From f5e93f282d5a3f660657a8aa4ceffd60d6ebdbcb Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 16:13:03 +0200 Subject: [PATCH 04/10] fix: show Jira column whenever Jira is enabled, not only when data is present --- src/app/page.tsx | 7 ++++++- src/app/report/[id]/org/page.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index c2fb783..8c64951 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -70,6 +70,7 @@ export default function Home() { const logsEndRef = useRef(null); const commitCache = useRef>(new Map()); const jiraCache = useRef>(new Map()); + const [jiraEnabled, setJiraEnabled] = useState(false); const [filterLogins, setFilterLogins] = useState>(new Set()); const [filterQuery, setFilterQuery] = useState(''); const [filterOpen, setFilterOpen] = useState(false); @@ -93,6 +94,10 @@ export default function Home() { .then((r) => r.json()) .then(setPastReports) .catch((err) => console.error('[glooker]', err)); + fetch('/api/llm-config') + .then((r) => r.json()) + .then((cfg) => setJiraEnabled(cfg?.jira?.enabled ?? false)) + .catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -791,7 +796,7 @@ export default function Home() { {/* Developer table */} {(() => { const filteredDevs = filterLogins.size > 0 ? developers.filter(d => filterLogins.has(d.github_login)) : developers; - const hasJira = developers.some(d => (d.total_jira_issues ?? 0) > 0); + const hasJira = jiraEnabled || developers.some(d => (d.total_jira_issues ?? 0) > 0); return filteredDevs.length > 0 && (
diff --git a/src/app/report/[id]/org/page.tsx b/src/app/report/[id]/org/page.tsx index ecb10e8..63246b6 100644 --- a/src/app/report/[id]/org/page.tsx +++ b/src/app/report/[id]/org/page.tsx @@ -39,6 +39,7 @@ export default function OrgDetailPage() { const [developers, setDevelopers] = useState([]); const [timeline, setTimeline] = useState([]); const [error, setError] = useState(null); + const [jiraEnabled, setJiraEnabled] = useState(false); useEffect(() => { fetch(`/api/report/${params.id}/org`) @@ -50,6 +51,10 @@ export default function OrgDetailPage() { }) .catch(e => setError(e.message)) .finally(() => setLoading(false)); + fetch('/api/llm-config') + .then(r => r.json()) + .then(cfg => setJiraEnabled(cfg?.jira?.enabled ?? false)) + .catch(() => {}); }, [params.id]); if (loading) return
Loading...
; @@ -79,7 +84,7 @@ export default function OrgDetailPage() { const typeEntries = Object.entries(orgTypes).sort((a, b) => b[1] - a[1]); const totalTyped = typeEntries.reduce((s, [, c]) => s + c, 0); - const hasJira = developers.some(d => (d.total_jira_issues ?? 0) > 0); + const hasJira = jiraEnabled || developers.some(d => (d.total_jira_issues ?? 0) > 0); // Repo breakdown across all developers const repoMap = new Map(); From f87515ff2b95eae9635e702ac9b9615c463e6bf6 Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 16:17:25 +0200 Subject: [PATCH 05/10] fix: update progressive developer stats with Jira count as soon as it's available --- src/lib/report-runner.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/report-runner.ts b/src/lib/report-runner.ts index c1a21e2..a8136ba 100644 --- a/src/lib/report-runner.ts +++ b/src/lib/report-runner.ts @@ -138,7 +138,7 @@ export async function runReport( s.impactScore, s.prPercentage, s.aiPercentage, - s.totalJiraIssues, + jiraIssueCountByLogin.get(s.githubLogin) ?? s.totalJiraIssues, JSON.stringify(s.typeBreakdown), JSON.stringify(s.activeRepos), ], @@ -291,7 +291,16 @@ export async function runReport( } jiraIssueCountByLogin.set(login, issues.length); - if (issues.length > 0) log(`[jira] @${login}: ${issues.length} resolved issues`); + if (issues.length > 0) { + log(`[jira] @${login}: ${issues.length} resolved issues`); + // If LLM finished first and already wrote stats with 0, patch the count now + if (completedMembers.has(login)) { + db.execute( + `UPDATE developer_stats SET total_jira_issues = ? WHERE report_id = ? AND github_login = ?`, + [issues.length, reportId, login], + ).catch((err) => log(`DB WARN updating jira count for @${login}: ${err}`)); + } + } } catch (err) { log(`[jira] ERROR @${login}: ${err instanceof Error ? err.message : String(err)}`); jiraIssueCountByLogin.set(login, 0); From 9059c4b9f5b24c2dedc5a40762b582117f78b7ee Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 20:45:17 +0200 Subject: [PATCH 06/10] fix: replace LEFT() with SUBSTR() for SQLite compatibility in project-insights query --- src/app/api/project-insights/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/project-insights/route.ts b/src/app/api/project-insights/route.ts index 82d5f8c..690be3e 100644 --- a/src/app/api/project-insights/route.ts +++ b/src/app/api/project-insights/route.ts @@ -45,7 +45,7 @@ export async function GET() { // Gather data for LLM // 1. All Jira issues (compact) const [jiraRows] = await db.execute( - `SELECT issue_key, project_key, issue_type, github_login, LEFT(summary, 80) as summary + `SELECT issue_key, project_key, issue_type, github_login, SUBSTR(summary, 1, 80) as summary FROM jira_issues WHERE report_id = ? ORDER BY project_key, issue_key`, [report.id], ) as [any[], any]; From 7328a5dc224c9d34b4f5823d2950b3efe80d6a23 Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 20:45:50 +0200 Subject: [PATCH 07/10] docs: note SUBSTR vs LEFT compatibility in CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 30488bb..e57f130 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Glooker is a Next.js 15 web app that generates developer impact reports for a Gi - Some LLM providers wrap JSON in markdown fences despite `response_format: json_object` — the parser strips ` ```json ``` ` fences - Smartling auth token expires in ~24h — `smartling-auth.ts` caches and auto-refreshes 5 min before expiry - `next build` artifacts conflict with `next dev` — always `rm -rf .next` when switching -- SQLite SQL translator handles `INSERT IGNORE`, `ON DUPLICATE KEY UPDATE`, and `NOW()` — if adding new MySQL-specific SQL, update `translateSQL()` in `db/sqlite.ts` +- SQLite SQL translator handles `INSERT IGNORE`, `ON DUPLICATE KEY UPDATE`, and `NOW()` — if adding new MySQL-specific SQL, update `translateSQL()` in `db/sqlite.ts`. Avoid `LEFT(str, n)` — use `SUBSTR(str, 1, n)` instead (standard SQL, works in both engines) - Progress store and stop-signal store use `globalThis` to survive Next.js HMR module reloads - `@octokit/rest` is ESM-only — any test file that imports from `github.ts` (directly or transitively) must `jest.mock('@octokit/rest')` before the import - Tests use Jest + ts-jest with `@/` path alias — config in `jest.config.ts` From fedf9886408e3fdf5bfefd846d6739f13e9483e0 Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 20:48:06 +0200 Subject: [PATCH 08/10] refactor: rename /api/llm-config route to /api/app-config --- .../plans/2026-03-25-jira-discovery-merge.md | 407 ++++++++++++++++++ .../api/{llm-config => app-config}/route.ts | 0 src/app/page.tsx | 2 +- src/app/report/[id]/org/page.tsx | 2 +- src/app/settings/page.tsx | 4 +- 5 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-25-jira-discovery-merge.md rename src/app/api/{llm-config => app-config}/route.ts (100%) diff --git a/docs/superpowers/plans/2026-03-25-jira-discovery-merge.md b/docs/superpowers/plans/2026-03-25-jira-discovery-merge.md new file mode 100644 index 0000000..cd03877 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-jira-discovery-merge.md @@ -0,0 +1,407 @@ +# Jira Discovery Loop Merge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Merge the sequential post-LLM Jira discovery loop into the main per-member GitHub fetch loop so both run concurrently per member. + +**Architecture:** `resolveJiraUser` currently reads emails from the `commit_analyses` DB table, which is only populated after LLM analysis runs — forcing Jira to be a second pass. We change the signature to accept `emails: string[]` directly (already available in-memory from `fetchUserActivity`). With that dependency removed, each member's Jira fetch can be fired off as a non-awaited promise alongside LLM work, collected in a `pendingJira` array, and awaited before final aggregation. Note: per-member Jira progress step messages (`[N/M] Fetching Jira issues`) are intentionally dropped — concurrent execution makes them meaningless; a single summary log remains. + +**Tech Stack:** TypeScript, Jest + ts-jest, SQLite/MySQL via `db.execute` + +--- + +## File Map + +| File | Change | +|------|--------| +| `src/lib/jira/mapper.ts` | Replace `reportId: string` param with `emails: string[]`; remove DB email query | +| `src/lib/__tests__/unit/jira-mapper.test.ts` | Update tests to new signature; remove DB mock for email query | +| `src/lib/report-runner.ts` | Add `pendingJira[]`; fire Jira per member after commit dedup; remove old Jira loop; await both arrays | +| `src/lib/__tests__/integration/report-runner.test.ts` | Add Jira mocks; add test verifying Jira fires per active member | + +--- + +### Task 1: Update `resolveJiraUser` to accept emails directly + +**Files:** +- Modify: `src/lib/jira/mapper.ts` +- Test: `src/lib/__tests__/unit/jira-mapper.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Replace the entire test file content — the new signature drops `reportId` and accepts `emails: string[]` as the third argument. The auto-discovery test no longer mocks a DB call for emails (old test used 3 DB calls; new test uses 2). + +```typescript +// src/lib/__tests__/unit/jira-mapper.test.ts +import { resolveJiraUser } from '@/lib/jira/mapper'; + +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { execute: jest.fn() }, +})); + +jest.mock('@/lib/jira/client', () => ({ + getJiraClient: jest.fn(), +})); + +import db from '@/lib/db'; +import { getJiraClient } from '@/lib/jira/client'; + +const mockDb = db as any; +const mockGetJiraClient = getJiraClient as jest.Mock; + +describe('resolveJiraUser', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns existing mapping from DB', async () => { + mockDb.execute.mockResolvedValueOnce([[{ + jira_account_id: 'jira-123', + jira_email: 'dev@co.com', + }], null]); + + const result = await resolveJiraUser('myorg', 'devuser', ['dev@co.com']); + expect(result).toEqual({ accountId: 'jira-123', email: 'dev@co.com' }); + // Only one DB call — the mapping lookup; no email query + expect(mockDb.execute).toHaveBeenCalledTimes(1); + }); + + it('returns null when no mapping and no Jira client', async () => { + mockDb.execute.mockResolvedValueOnce([[], null]); + mockGetJiraClient.mockReturnValue(null); + + const result = await resolveJiraUser('myorg', 'devuser', ['dev@co.com']); + expect(result).toBeNull(); + }); + + it('returns null when no mapping, client present, but no emails provided', async () => { + mockDb.execute.mockResolvedValueOnce([[], null]); + mockGetJiraClient.mockReturnValue({ findUserByEmail: jest.fn() }); + + const result = await resolveJiraUser('myorg', 'devuser', []); + expect(result).toBeNull(); + }); + + it('auto-discovers via provided emails and persists mapping', async () => { + // DB calls: 1 mapping lookup + 1 persist (no longer a 3rd call for email query) + mockDb.execute + .mockResolvedValueOnce([[], null]) // mapping lookup → not found + .mockResolvedValueOnce([[], null]); // persist INSERT + + const mockClient = { + findUserByEmail: jest.fn() + .mockResolvedValueOnce(null) // dev@co.com → not found + .mockResolvedValueOnce({ accountId: 'jira-456', displayName: 'Dev' }), + }; + mockGetJiraClient.mockReturnValue(mockClient); + + const result = await resolveJiraUser('myorg', 'devuser', ['dev@co.com', 'dev@personal.com']); + expect(result).toEqual({ accountId: 'jira-456', email: 'dev@personal.com' }); + expect(mockDb.execute).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd /Users/fluffy/Work/Projects/services/glooker +npm test -- --testPathPattern="jira-mapper" 2>&1 | tail -30 +``` + +Expected: type errors or test failures because `resolveJiraUser` still has old signature. + +- [ ] **Step 3: Update `resolveJiraUser` implementation** + +New signature: replace `reportId: string` with `emails: string[]`. Remove the `commit_analyses` DB query block (lines 29–38 of current file). Use the passed-in `emails` array directly. + +```typescript +// src/lib/jira/mapper.ts +import db from '@/lib/db'; +import { getJiraClient } from './client'; + +interface JiraMapping { + accountId: string; + email: string | null; +} + +export async function resolveJiraUser( + org: string, + githubLogin: string, + emails: string[], + log?: (msg: string) => void, +): Promise { + // 1. Check existing mapping + const [rows] = await db.execute( + `SELECT jira_account_id, jira_email FROM user_mappings WHERE org = ? AND github_login = ?`, + [org, githubLogin], + ) as [any[], any]; + + if (rows.length > 0 && rows[0].jira_account_id) { + return { accountId: rows[0].jira_account_id, email: rows[0].jira_email }; + } + + // 2. Auto-discover via provided emails + const client = getJiraClient(); + if (!client) return null; + + if (emails.length === 0) { + log?.(`[jira] No commit emails provided for @${githubLogin}, cannot auto-discover Jira mapping`); + return null; + } + + for (const email of emails) { + try { + await new Promise(r => setTimeout(r, 1000)); + const user = await client.findUserByEmail(email); + if (user) { + log?.(`[jira] Auto-discovered: @${githubLogin} → ${user.displayName} (${email})`); + // 3. Persist mapping + await db.execute( + `INSERT INTO user_mappings (org, github_login, jira_account_id, jira_email, created_at) + VALUES (?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE jira_account_id = VALUES(jira_account_id), jira_email = VALUES(jira_email)`, + [org, githubLogin, user.accountId, email], + ); + return { accountId: user.accountId, email }; + } + } catch (err) { + log?.(`[jira] Error looking up ${email} for @${githubLogin}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + log?.(`[jira] No Jira user found for @${githubLogin} (tried ${emails.length} email(s))`); + return null; +} +``` + +- [ ] **Step 4: Run tests and verify they pass** + +```bash +npm test -- --testPathPattern="jira-mapper" 2>&1 | tail -20 +``` + +Expected: all 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/jira/mapper.ts src/lib/__tests__/unit/jira-mapper.test.ts +git commit -m "refactor: resolveJiraUser accepts emails directly instead of querying DB" +``` + +--- + +### Task 2: Merge Jira discovery into main loop in `report-runner.ts` + +**Files:** +- Modify: `src/lib/report-runner.ts` +- Test: `src/lib/__tests__/integration/report-runner.test.ts` + +- [ ] **Step 1: Add Jira mocks and a new test to the integration test file** + +**Critical:** `jest.mock('@/lib/app-config/service')` replaces `getAppConfig` with an auto-mock returning `undefined`. This breaks ALL existing tests unless `beforeEach` provides a default return value — they will throw `TypeError: Cannot read properties of undefined (reading 'jira')` at runtime. The `beforeEach` addition below is required for correctness, not just for the new test. + +Add these mocks at the top of `report-runner.test.ts` (after the existing `jest.mock` calls): + +```typescript +jest.mock('@/lib/jira', () => ({ + getJiraClient: jest.fn(), + resolveJiraUser: jest.fn(), +})); +jest.mock('@/lib/app-config/service', () => ({ + getAppConfig: jest.fn(), +})); +``` + +Add to imports (after existing imports): + +```typescript +import { getJiraClient, resolveJiraUser } from '@/lib/jira'; +import { getAppConfig } from '@/lib/app-config/service'; + +const mockGetJiraClient = getJiraClient as jest.Mock; +const mockResolveJiraUser = resolveJiraUser as jest.Mock; +const mockGetAppConfig = getAppConfig as jest.Mock; +``` + +Add to the existing `beforeEach` block (required — disables Jira for all existing tests): + +```typescript +mockGetAppConfig.mockReturnValue({ jira: { enabled: false, projects: [] } }); +``` + +Add the new test at the end of the `describe('runReport')` block: + +```typescript +it('fires Jira discovery per active member when Jira is enabled', async () => { + mockGetAppConfig.mockReturnValue({ jira: { enabled: true, projects: [] } }); + + const mockJiraClient = { + searchDoneIssues: jest.fn().mockResolvedValue([ + { + projectKey: 'PROJ', issueKey: 'PROJ-1', issueType: 'Story', + summary: 'A ticket', description: '', status: 'Done', + labels: [], storyPoints: null, originalEstimateSeconds: null, + issueUrl: 'https://jira.example.com/browse/PROJ-1', + createdAt: '2025-01-01', resolvedAt: '2025-01-10', + }, + ]), + }; + mockGetJiraClient.mockReturnValue(mockJiraClient); + mockResolveJiraUser.mockResolvedValue({ accountId: 'jira-abc', email: 'alice@co.com' }); + + mockDbExecute.mockImplementation(async (sql: string) => { + if (typeof sql === 'string' && sql.includes('jira_issues') && sql.includes('COUNT')) { + return [[{ cnt: 0 }], null]; + } + return [[], null]; + }); + + await runReport('r-jira', 'my-org', 14); + + // Both alice and bob have commits → Jira should be queried for both + expect(mockResolveJiraUser).toHaveBeenCalledTimes(2); + expect(mockJiraClient.searchDoneIssues).toHaveBeenCalledTimes(2); + + // Verify jira_issues INSERT was called for both members (1 issue each) + const jiraInserts = mockDbExecute.mock.calls.filter( + (call: any[]) => typeof call[0] === 'string' && call[0].includes('INSERT IGNORE INTO jira_issues'), + ); + expect(jiraInserts).toHaveLength(2); + + // Verify final developer_stats includes total_jira_issues = 1 for both members + const statsInserts = mockDbExecute.mock.calls.filter( + (call: any[]) => typeof call[0] === 'string' && call[0].includes('INSERT INTO developer_stats'), + ); + const finalInsert = statsInserts[statsInserts.length - 1]; + // total_jira_issues is the 13th param (index 12) in the VALUES list + expect(finalInsert[1][12]).toBe(1); +}); +``` + +- [ ] **Step 2: Run tests to verify the new test fails** + +```bash +npm test -- --testPathPattern="report-runner" 2>&1 | tail -30 +``` + +Expected: the new test fails; all existing tests still pass (because `beforeEach` provides the default `getAppConfig` mock). + +- [ ] **Step 3: Update `report-runner.ts`** + +Apply all edits below **atomically** — steps 3b and 3e must both be applied before running TypeScript, since 3b adds early declarations and 3e removes the old ones. Having both `const jiraClient` declarations simultaneously will cause a compile error. + +**Note on progressive stats:** `checkMemberComplete` fires inside LLM promises and writes `developer_stats` progressively. Since LLM and Jira run concurrently, those progressive inserts will always write `totalJiraIssues = 0`. The final aggregation block (step 3d, after both arrays are awaited) overwrites them with the correct values. This is intentional — if you observe `total_jira_issues = 0` during a run, that's expected until completion. + +**3a.** In the variable declarations block (around line 88, alongside `pendingLLM`), add: + +```typescript +const pendingJira: Promise[] = []; +``` + +**3b.** Before the member loop (before `// 2. Pipelined fetch+LLM loop` comment, around line 150), add: + +```typescript +const jiraConfig = getAppConfig().jira; +const jiraClient = jiraConfig.enabled ? getJiraClient() : null; +const jiraIssueCountByLogin = new Map(); +``` + +**3c.** Inside the member loop, after the LLM queuing block (just before the `// If no new commits needed LLM` comment at line 238), add: + +```typescript +// Jira discovery: fire in parallel with LLM work — only needs emails from commits +if (jiraClient && thisMemCommits.length > 0) { + const login = member.login; + const emails = [...new Set(thisMemCommits.map(c => c.authorEmail).filter((e): e is string => Boolean(e)))]; + const jp = (async () => { + if (shouldStop(reportId)) return; + + // Resume: skip if already have jira_issues for this user/report + const [existingJira] = await db.execute( + `SELECT COUNT(*) as cnt FROM jira_issues WHERE report_id = ? AND github_login = ?`, + [reportId, login], + ) as [any[], any]; + + if (existingJira[0]?.cnt > 0) { + jiraIssueCountByLogin.set(login, existingJira[0].cnt); + log(`[jira] @${login}: ${existingJira[0].cnt} issues already in DB (resume)`); + return; + } + + try { + const mapping = await resolveJiraUser(org, login, emails, log); + if (!mapping) { + jiraIssueCountByLogin.set(login, 0); + return; + } + + const issues = await jiraClient.searchDoneIssues( + mapping.accountId, days, jiraConfig.projects.length > 0 ? jiraConfig.projects : undefined, + ); + + for (const issue of issues) { + await db.execute( + `INSERT IGNORE INTO jira_issues + (report_id, github_login, jira_account_id, jira_email, + project_key, issue_key, issue_type, summary, description, + status, labels, story_points, original_estimate_seconds, + issue_url, created_at, resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + reportId, login, mapping.accountId, mapping.email, + issue.projectKey, issue.issueKey, issue.issueType, + issue.summary, issue.description, issue.status, + JSON.stringify(issue.labels), issue.storyPoints, + issue.originalEstimateSeconds, issue.issueUrl, + issue.createdAt, issue.resolvedAt, + ], + ); + } + + jiraIssueCountByLogin.set(login, issues.length); + if (issues.length > 0) log(`[jira] @${login}: ${issues.length} resolved issues`); + } catch (err) { + log(`[jira] ERROR @${login}: ${err instanceof Error ? err.message : String(err)}`); + jiraIssueCountByLogin.set(login, 0); + } + })(); + pendingJira.push(jp); +} +``` + +**3d.** Change `await Promise.all(pendingLLM)` to: + +```typescript +await Promise.all([...pendingLLM, ...pendingJira]); + +if (jiraClient) { + const jiraTotal = [...jiraIssueCountByLogin.values()].reduce((a, b) => a + b, 0); + log(`Jira collection complete: ${jiraTotal} total issues`); +} +``` + +**3e.** Delete the old Jira block in its entirety. This block starts at the comment `// Jira integration: resolve users and fetch done issues` (line 263) and ends at the closing `}` of the outer `if (jiraClient)` block (line 335), plus the `log(...)` summary line immediately after. Also delete the three variable declarations that preceded it: `const jiraConfig`, `const jiraIssueCountByLogin`, and `const jiraClient` — these are now declared above the loop from step 3b. + +- [ ] **Step 4: Run the targeted tests** + +```bash +npm test -- --testPathPattern="report-runner|jira-mapper" 2>&1 | tail -40 +``` + +Expected: all tests pass, including the new Jira test. + +- [ ] **Step 5: Run the full test suite** + +```bash +npm test 2>&1 | tail -20 +``` + +Expected: all tests pass, no regressions. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/report-runner.ts src/lib/__tests__/integration/report-runner.test.ts +git commit -m "feat: merge Jira discovery into main per-member loop, run concurrently with LLM analysis" +``` diff --git a/src/app/api/llm-config/route.ts b/src/app/api/app-config/route.ts similarity index 100% rename from src/app/api/llm-config/route.ts rename to src/app/api/app-config/route.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 8c64951..025be73 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -94,7 +94,7 @@ export default function Home() { .then((r) => r.json()) .then(setPastReports) .catch((err) => console.error('[glooker]', err)); - fetch('/api/llm-config') + fetch('/api/app-config') .then((r) => r.json()) .then((cfg) => setJiraEnabled(cfg?.jira?.enabled ?? false)) .catch(() => {}); diff --git a/src/app/report/[id]/org/page.tsx b/src/app/report/[id]/org/page.tsx index 63246b6..e944862 100644 --- a/src/app/report/[id]/org/page.tsx +++ b/src/app/report/[id]/org/page.tsx @@ -51,7 +51,7 @@ export default function OrgDetailPage() { }) .catch(e => setError(e.message)) .finally(() => setLoading(false)); - fetch('/api/llm-config') + fetch('/api/app-config') .then(r => r.json()) .then(cfg => setJiraEnabled(cfg?.jira?.enabled ?? false)) .catch(() => {}); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 1510a8c..8d2a71d 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -805,7 +805,7 @@ function AppSettingsTab({ org }: { org: string }) { const [rowErrors, setRowErrors] = useState>({}); useEffect(() => { - fetch('/api/llm-config').then(r => r.json()).then(setConfig).catch(() => {}).finally(() => setLoading(false)); + fetch('/api/app-config').then(r => r.json()).then(setConfig).catch(() => {}).finally(() => setLoading(false)); }, []); useEffect(() => { @@ -830,7 +830,7 @@ function AppSettingsTab({ org }: { org: string }) { setTesting(true); setTestResult(null); try { - const res = await fetch('/api/llm-config', { method: 'POST' }); + const res = await fetch('/api/app-config', { method: 'POST' }); const data = await res.json(); setTestResult(data); } catch { From 36a4e95feb31cd3a59a3dc16a7960caf1b50f602 Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 21:28:18 +0200 Subject: [PATCH 09/10] fix: pass storyPointsFields to searchDoneIssues in report runner --- src/lib/report-runner.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/report-runner.ts b/src/lib/report-runner.ts index a8136ba..68230bb 100644 --- a/src/lib/report-runner.ts +++ b/src/lib/report-runner.ts @@ -268,7 +268,9 @@ export async function runReport( } const issues = await jiraClient.searchDoneIssues( - mapping.accountId, days, jiraConfig.projects.length > 0 ? jiraConfig.projects : undefined, + mapping.accountId, days, + jiraConfig.projects.length > 0 ? jiraConfig.projects : undefined, + jiraConfig.storyPointsFields, ); for (const issue of issues) { From 0b36ecbca05cb82346f0b14cb212ce8003fc828f Mon Sep 17 00:00:00 2001 From: Loparev Date: Wed, 25 Mar 2026 21:33:29 +0200 Subject: [PATCH 10/10] fix: throttle concurrent Jira requests with pLimit (JIRA_CONCURRENCY, default 3) --- src/lib/report-runner.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/report-runner.ts b/src/lib/report-runner.ts index 68230bb..afba640 100644 --- a/src/lib/report-runner.ts +++ b/src/lib/report-runner.ts @@ -9,6 +9,7 @@ import { resolveJiraUser } from './jira'; import { getAppConfig } from './app-config/service'; const CONCURRENCY = Number(process.env.LLM_CONCURRENCY || 5); +const JIRA_CONCURRENCY = Number(process.env.JIRA_CONCURRENCY || 3); // Stop signal store (globalThis to survive Next.js HMR) const g = globalThis as typeof globalThis & { __glooker_stops?: Set }; @@ -86,6 +87,7 @@ export async function runReport( const pendingLLM: Promise[] = []; const pendingJira: Promise[] = []; const limit = pLimit(CONCURRENCY); + const limitJira = pLimit(JIRA_CONCURRENCY); let llmErrors = 0; let processedMembers = 0; let activeMemberCount = 0; @@ -245,7 +247,7 @@ export async function runReport( if (jiraClient && thisMemCommits.length > 0) { const login = member.login; const emails = [...new Set(thisMemCommits.map(c => c.authorEmail).filter((e): e is string => Boolean(e)))]; - const jp = (async () => { + const jp = limitJira(async () => { if (shouldStop(reportId)) return; // Resume: skip if already have jira_issues for this user/report @@ -307,7 +309,7 @@ export async function runReport( log(`[jira] ERROR @${login}: ${err instanceof Error ? err.message : String(err)}`); jiraIssueCountByLogin.set(login, 0); } - })(); + }); pendingJira.push(jp); }