From be78be0c0539ecc1cdd268fd105d63eb5c5fe85a Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 13:31:17 -0400 Subject: [PATCH 01/12] docs(spec): GLOOK-25 project drill-down design --- ...06-23-glook-25-project-drilldown-design.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-glook-25-project-drilldown-design.md diff --git a/docs/superpowers/specs/2026-06-23-glook-25-project-drilldown-design.md b/docs/superpowers/specs/2026-06-23-glook-25-project-drilldown-design.md new file mode 100644 index 0000000..d80c86e --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-glook-25-project-drilldown-design.md @@ -0,0 +1,143 @@ +# GLOOK-25: Project Drill-Down + +## Goal + +Let users click any project card on the home page to see the exact Jiras, PRs, and commits the LLM attributed to that project — with Jiras grouped by epic theme. + +## POC Findings + +Validated during brainstorming (June 2026): + +1. **Sending all data to the LLM works.** Sending all 733 commits + 303 PRs + 362 Jiras (~200K tokens) produces better clustering than the previous Jira-only approach. The LLM uses commit messages (which contain Jira keys) to correlate PRs and commits to projects. A new project — "Jobs Service & MT Router" (52 jiras, 55 PRs) — only emerged when commit data was included. + +2. **LLM should enumerate what it attributed.** The prompt asks the LLM to return `jira_keys`, `groups` (epic themes), `pr_numbers`, and `commit_shas` per project. The backend enriches those keys/numbers with full DB details. + +3. **Jira key as attribution signal.** Each Jira key and PR number appears in exactly one project (enforced by prompt). The DB enrichment joins `jira_keys` → full issue details and `pr_numbers` → commit rows. + +4. **Inline expand (not modal).** Clicking a project row opens an inline panel below it with three tabs: Jiras / PRs / Commits. Other projects remain visible. + +--- + +## Architecture + +### Prompt changes (`src/app/api/project-insights/route.ts`) + +**New data sent:** +- Replace "top 30 no-Jira commits" with **all commits** (`commit_sha|pr_number|repo|login|message|+add/-remove`) +- Add **PR summaries** (`pr_number|repo|login|commit_count|+add/-remove|first_message`) + +**New fields returned per project:** +```json +{ + "jira_keys": ["PROJ-123"], + "groups": [{ "name": "Theme Name", "jira_keys": ["PROJ-123"] }], + "pr_numbers": [101, 102], + "commit_shas": ["abc1234"] +} +``` + +**Token limit:** bump to 12000 (response is larger due to enumerated keys/numbers). + +### Backend enrichment + +After LLM response: +1. `jira_keys` → join to `jira_issues` → full `jira_details` (key, summary, type, assignee) +2. `groups[].jira_keys` → same enrichment within each group +3. `pr_numbers` → look up commits by `pr_number` in `commit_analyses` → dedupe into PR rows (pr, repo, login, commit_count, lines_added, lines_removed, first_message) +4. `commit_shas` (bare commits, no PR) → look up by SHA in `commit_analyses` +5. Fetch all commits (`commit_analyses`) once at start, indexed by SHA and pr_number + +Stored in cache as `{ _v: 3, projects, untracked_work }` — bump version to invalidate v2 rows. + +### Data types (`src/components/ProjectsCard.tsx`) + +Extend `ProjectsCardItem`: + +```typescript +export interface JiraDetail { + key: string; + summary: string; + type: string | null; + assignee: string | null; +} + +export interface ProjectGroup { + name: string; + jira_details: JiraDetail[]; +} + +export interface PrDetail { + pr: number; + repo: string; + login: string; + msg: string; + commits: number; + added: number; + removed: number; +} + +export interface CommitDetail { + sha: string; + repo: string; + login: string; + msg: string; + pr: number | null; + added: number; + removed: number; +} + +// Add to ProjectsCardItem: +jira_details?: JiraDetail[]; +groups?: ProjectGroup[]; +prs?: PrDetail[]; +commits?: CommitDetail[]; +``` + +### Frontend (`src/components/ProjectsCard.tsx`) + +`ProjectsBody` renders each project row. When a project has `jira_details` or `prs` or `commits`, the row becomes expandable: + +- Click the row → toggle inline expand panel below +- Expand panel has three tabs: **Jiras** / **PRs** / **Commits** +- **Jiras tab**: groups shown as collapsible sections (epic name + issue list). Each issue shows key, summary, assignee avatar. If no groups, flat list. +- **PRs tab**: list of PRs (number, first message truncated, repo, author avatar, commit count, +lines/-lines) +- **Commits tab**: list of commits (7-char SHA, message, PR badge if linked, author avatar, repo, +lines/-lines) +- Truncate to 12 items per tab with "+ N more" note + +`ProjectsCardProps` gets no new props — the expandability is inferred from whether the data is present. + +### Commit stats badge on Jira rows + +Each Jira row in the Jiras tab shows a small cyan badge with the count of commits and PRs that reference its key in their message (`REGEXP_SUBSTR(commit_message, '[A-Z]+-[0-9]+') = key`). Computed server-side and added to `jira_details`. + +Add to `JiraDetail`: +```typescript +linked_commits?: number; +linked_prs?: number; +``` + +Computed during enrichment by scanning `allCommitRows` for each key. + +--- + +## Files Changed + +| File | Change | +|---|---| +| `src/app/api/project-insights/route.ts` | Send all commits + PRs; return `jira_keys/groups/pr_numbers/commit_shas`; enrich with full details + commit stats badges; cache `_v: 3` | +| `src/components/ProjectsCard.tsx` | Extend types; add inline expand with 3 tabs | +| `src/lib/__tests__/unit/project-insights.test.ts` | Update snapshot / mocks for new fields if applicable | + +--- + +## Cache + +Bump `_v` from `2` → `3` in `project-insights/route.ts`. Old rows without `_v: 3` regenerate on next page load (one-time burst). + +--- + +## Performance Notes + +- LLM call: ~30–60s first time (large context), cached thereafter +- All-commits DB fetch: single query, indexed on `report_id`, ~500 rows max +- Enrichment: O(commits × projects) in-memory, negligible From 717c0cc63c83636c68113e7b57f9b59a902b015b Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 13:34:17 -0400 Subject: [PATCH 02/12] docs(plan): GLOOK-25 project drill-down implementation plan --- .../2026-06-23-glook-25-project-drilldown.md | 703 ++++++++++++++++++ 1 file changed, 703 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-glook-25-project-drilldown.md diff --git a/docs/superpowers/plans/2026-06-23-glook-25-project-drilldown.md b/docs/superpowers/plans/2026-06-23-glook-25-project-drilldown.md new file mode 100644 index 0000000..1da066f --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-glook-25-project-drilldown.md @@ -0,0 +1,703 @@ +# GLOOK-25: Project Drill-Down 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:** Add inline drill-down to project cards showing LLM-attributed Jiras (grouped by epic), PRs, and commits per project. + +**Architecture:** Two tasks: (1) productionize the POC `project-insights/route.ts` (fix duplicate DB query, add linked_commits/prs badges, bump cache to `_v: 3`); (2) add inline expand with 3-tab panel to `ProjectsCard.tsx` using the new data fields. + +**Tech Stack:** TypeScript, Next.js 15, React, Tailwind CSS, Jest + ts-jest + +--- + +## File Map + +| File | Change | +|---|---| +| `src/app/api/project-insights/route.ts` | Fix duplicate allCommitRows fetch; add linked_commits/prs to jira_details; bump cache to `_v: 3` | +| `src/components/ProjectsCard.tsx` | Add new types; inline expand panel with Jiras/PRs/Commits tabs | + +--- + +### Task 1: Productionize `project-insights/route.ts` + +**Files:** +- Modify: `src/app/api/project-insights/route.ts` + +**Context:** The POC code is already in the working tree (uncommitted). It works but has two issues: +1. `allCommitRows` is fetched **twice** — once at line ~84 for the LLM prompt, and again inside the `try` block at line ~267 for enrichment. The second fetch is wasted; use the first. +2. `jira_details` objects don't include `linked_commits`/`linked_prs` (the commit-stat badges shown in the mockup). These should be computed during enrichment by counting how many commits have each Jira key in their message. +3. Cache version is still `_v: 2` — must be `_v: 3` to invalidate old rows that lack the new fields. +4. Cache read checks `data._v === 2` — must accept `_v === 3`. + +- [ ] **Step 1: Fix the duplicate allCommitRows fetch and add linked_commits/prs** + +Replace the entire content of `src/app/api/project-insights/route.ts` with the corrected version below. Key changes vs the POC: +- Remove the second `allCommitRows` fetch inside the `try` block; use the outer `allCommitRows` directly +- Build `commitBySha` and `commitsByPr` indexes from the outer `allCommitRows` (before the LLM call) +- Add `linked_commits`/`linked_prs` to each jira_detail by scanning `allCommitRows` for key references +- Change `_v: 2` → `_v: 3` in both cache read and cache write +- Remove the `devData` variable (was "developer stats" section; replaced by full commit data) + +```typescript +import { NextResponse } from 'next/server'; +import db from '@/lib/db'; +import { getLLMClient, LLM_MODEL, extraBodyProps, tokenLimit } from '@/lib/llm-provider'; +import { withRequestLog } from '@/lib/logger'; +import { renderInflightBlock } from '@/lib/team-pulse/render'; +import type { TeamProjectInflightPr, TeamProjectInflightBranch } from '@/lib/team-pulse/data'; + +const INSIGHTS_CACHE_VERSION = 3; + +async function getHandler() { + const [latestRows] = await db.execute( + `SELECT id, org, period_days, created_at FROM reports + WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1`, + [], + ) as [any[], any]; + if (!latestRows.length) return NextResponse.json({ available: false }); + const report = latestRows[0]; + + const [jiraCount] = await db.execute( + `SELECT COUNT(*) as cnt FROM jira_issues WHERE report_id = ?`, + [report.id], + ) as [any[], any]; + if (!jiraCount[0]?.cnt || Number(jiraCount[0].cnt) === 0) { + return NextResponse.json({ available: false }); + } + + const [totalsRows] = await db.execute( + `SELECT COALESCE(SUM(total_commits), 0) AS commits, COALESCE(SUM(total_prs), 0) AS prs + FROM developer_stats WHERE report_id = ?`, + [report.id], + ) as [any[], any]; + const totals = { + commits: Number(totalsRows[0]?.commits ?? 0), + prs: Number(totalsRows[0]?.prs ?? 0), + jiras: Number(jiraCount[0]?.cnt ?? 0), + }; + + const [cached] = await db.execute( + `SELECT highlights_json FROM report_comparisons WHERE report_id_a = ? AND report_id_b = ?`, + [report.id, report.id], + ) as [any[], any]; + if (cached.length > 0) { + let data: any = null; + try { + data = typeof cached[0].highlights_json === 'string' + ? JSON.parse(cached[0].highlights_json) + : cached[0].highlights_json; + } catch { /* malformed — fall through */ } + if (data && !Array.isArray(data) && data._v === INSIGHTS_CACHE_VERSION) { + const { _v: _, ...rest } = data; + return NextResponse.json({ + available: true, + report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at }, + ...rest, + totals, + cached: true, + }); + } + } + + // ── Fetch all data once ────────────────────────────────────────────────── + const [jiraRows] = await db.execute( + `SELECT issue_key, project_key, issue_type, github_login, LEFT(summary, 80) as summary + FROM jira_issues WHERE report_id = ? ORDER BY project_key, issue_key`, + [report.id], + ) as [any[], any]; + + const [allCommitRows] = await db.execute( + `SELECT commit_sha, pr_number, repo, github_login, + LEFT(commit_message, 80) AS msg, + lines_added, lines_removed, committed_at + FROM commit_analyses WHERE report_id = ? ORDER BY committed_at DESC`, + [report.id], + ) as [any[], any]; + + // Build indexes for enrichment (used after LLM response) + const commitBySha = new Map( + allCommitRows.map((c: any) => [c.commit_sha, c]), + ); + const commitsByPr = new Map(); + for (const c of allCommitRows) { + if (!c.pr_number) continue; + const arr = commitsByPr.get(c.pr_number) ?? []; + arr.push(c); + commitsByPr.set(c.pr_number, arr); + } + // For linked_commits/prs badges: count commits whose message contains each Jira key + const commitsByJiraKey = new Map }>(); + for (const c of allCommitRows) { + const match = (c.msg as string)?.match(/[A-Z]+-\d+/); + if (!match) continue; + const key = match[0]; + const entry = commitsByJiraKey.get(key) ?? { commits: 0, prs: new Set() }; + entry.commits++; + if (c.pr_number) entry.prs.add(c.pr_number); + commitsByJiraKey.set(key, entry); + } + + // ── Build LLM inputs ───────────────────────────────────────────────────── + const jiraData = jiraRows.map((r: any) => + `${r.issue_key}|${r.project_key}|${r.issue_type || ''}|${r.github_login}|${r.summary || ''}` + ).join('\n'); + + const commitData = allCommitRows.map((c: any) => + `${c.commit_sha?.slice(0,7)}|${c.pr_number ?? ''}|${c.repo}|${c.github_login}|${(c.msg || '').replace(/\|/g, ' ')}|+${c.lines_added ?? 0}/-${c.lines_removed ?? 0}` + ).join('\n'); + + const prSummaryMap = new Map(); + for (const c of allCommitRows) { + if (!c.pr_number) continue; + const ex = prSummaryMap.get(c.pr_number); + if (ex) { + ex.commits++; + ex.added += Number(c.lines_added ?? 0); + ex.removed += Number(c.lines_removed ?? 0); + } else { + prSummaryMap.set(c.pr_number, { + pr: c.pr_number, repo: c.repo, login: c.github_login, + commits: 1, added: Number(c.lines_added ?? 0), removed: Number(c.lines_removed ?? 0), + msg: (c.msg || '').split('\n')[0].slice(0, 80).replace(/\|/g, ' '), + }); + } + } + const prData = [...prSummaryMap.values()] + .map(p => `${p.pr}|${p.repo}|${p.login}|${p.commits}c|+${p.added}/-${p.removed}|${p.msg}`) + .join('\n'); + + const noJiraData = allCommitRows + .filter((c: any) => !c.pr_number) + .slice(0, 30) + .map((c: any) => `${c.repo}|${c.github_login}|${(c.msg || '').slice(0, 60)}`).join('\n'); + + const [inflightPrRows] = await db.execute( + `SELECT repo, pr_title, github_login, is_draft, + COALESCE(pr_additions, 0) AS pr_additions, + COALESCE(pr_deletions, 0) AS pr_deletions + FROM unmerged_prs WHERE report_id = ? + ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC LIMIT 30`, + [report.id], + ) as [any[], any]; + const [inflightBranchRows] = await db.execute( + `SELECT repo, branch, github_login, + COUNT(*) AS commit_count, SUM(lines_added + lines_removed) AS total_lines + FROM unmerged_commits WHERE report_id = ? AND pr_number IS NULL + GROUP BY repo, branch, github_login ORDER BY total_lines DESC LIMIT 10`, + [report.id], + ) as [any[], any]; + const inflightPrs: TeamProjectInflightPr[] = inflightPrRows.map((r: any) => ({ + repo: String(r.repo ?? ''), title: String(r.pr_title ?? ''), author: String(r.github_login ?? ''), + additions: Number(r.pr_additions ?? 0), deletions: Number(r.pr_deletions ?? 0), + is_draft: r.is_draft === 1 || r.is_draft === true, + })); + const inflightBranches: TeamProjectInflightBranch[] = inflightBranchRows.map((r: any) => ({ + repo: String(r.repo ?? ''), branch: String(r.branch ?? ''), author: String(r.github_login ?? ''), + commit_count: Number(r.commit_count ?? 0), lines: Number(r.total_lines ?? 0), + })); + const inflightBlock = renderInflightBlock(inflightPrs, inflightBranches); + + // ── LLM call ───────────────────────────────────────────────────────────── + const systemPrompt = `You are an engineering analytics assistant. Analyze Jira issues, all GitHub commits, and all PRs from a single report period to identify the top projects the team is working on. + +You will receive: +1. Jira issues: key|project_key|type|developer|summary +2. All commits: sha|pr_number|repo|developer|message|+add/-remove (commit messages often contain Jira keys — use this to link commits to issues) +3. PR summaries: pr_number|repo|developer|commit_count|+add/-remove|first_message +4. Untracked work (commits with no PR): repo|developer|message + +Your task: +1. Identify the top 10 ACTUAL projects being worked on. Use ALL three signals — Jira issues, commit messages, and PR titles — to cluster related work. Name projects descriptively (e.g. "Keycloak 26 Migration", not "AUT"). +2. For each project ENUMERATE exactly what you are attributing to it: + - jira_keys: all Jira keys from JIRA ISSUES that belong to this cluster + - groups: group those jira_keys into 2-4 named epic themes + - pr_numbers: all PR numbers from PR SUMMARIES that belong to this cluster + - commit_shas: any commit SHAs (7-char prefix) from UNTRACKED WORK that belong to this cluster +3. Write a one-sentence summary of what the project achieves. +4. Identify up to 5 significant GitHub efforts with NO Jira tickets (from untracked work). + +IMPORTANT: Each Jira key and each PR number must appear in exactly ONE project. No duplicates. + +Return JSON: +{ + "projects": [ + { + "name": "Descriptive Project Name", + "developers": ["login1", "login2"], + "summary": "One sentence about what this project achieves", + "jira_keys": ["PROJ-123", "PROJ-456"], + "groups": [{ "name": "Epic theme name", "jira_keys": ["PROJ-123"] }], + "pr_numbers": [101, 102, 103], + "commit_shas": ["abc1234", "def5678"] + } + ], + "untracked_work": [ + { "name": "Descriptive name", "repo": "repo-name", "developers": ["login1"], "commits": 10, "summary": "What this is about" } + ] +} + +Rules: +- Cluster by feature, not by Jira project key prefix +- Each Jira key and each PR number appears in exactly ONE project +- jira_keys: only keys from JIRA ISSUES; pr_numbers: only numbers from PR SUMMARIES; commit_shas: only 7-char SHAs from UNTRACKED WORK +- Keep summaries under 20 words +- Return ONLY raw JSON`; + + const userMessage = `JIRA ISSUES (${jiraRows.length} total): +${jiraData} + +ALL COMMITS (${allCommitRows.length} total — sha|pr|repo|developer|message|+add/-remove): +${commitData} + +PR SUMMARIES (${prSummaryMap.size} total — pr|repo|developer|commits|+add/-remove|message): +${prData} + +UNTRACKED WORK (commits with no PR): +${noJiraData}${inflightBlock}`; + + try { + const client = await getLLMClient(); + const response = await client.chat.completions.create({ + model: LLM_MODEL, + temperature: 0.3, + ...tokenLimit(12000), + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + ...extraBodyProps(), + } as any); + + const raw = response.choices[0].message.content || '{}'; + const cleaned = raw.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim(); + let parsed: any; + try { parsed = JSON.parse(cleaned); } catch { parsed = { projects: [], untracked_work: [] }; } + + // ── Enrich each project using the pre-built indexes ─────────────────── + const jiraByKey = new Map(jiraRows.map((r: any) => [r.issue_key, r])); + + const enrichedProjects = (parsed.projects || []).map((p: any) => { + const llmPrNums = new Set((p.pr_numbers ?? []).map(Number)); + const llmShas = new Set(p.commit_shas ?? []); + + // Gather all commits: by SHA (bare commits) + by PR number + const projCommitMap = new Map(); + for (const sha of llmShas) { + const c = commitBySha.get(sha); + if (c) projCommitMap.set(sha, c); + } + for (const prNum of llmPrNums) { + for (const c of commitsByPr.get(prNum) ?? []) { + projCommitMap.set(c.commit_sha, c); + } + } + const projCommits = [...projCommitMap.values()] + .sort((a, b) => new Date(b.committed_at).getTime() - new Date(a.committed_at).getTime()) + .slice(0, 20); + + // Derive distinct PRs from attributed commits + const prMap = new Map(); + for (const c of projCommitMap.values()) { + if (!c.pr_number) continue; + const ex = prMap.get(c.pr_number); + if (ex) { + ex.commits++; + ex.added += Number(c.lines_added ?? 0); + ex.removed += Number(c.lines_removed ?? 0); + } else { + prMap.set(c.pr_number, { + pr: c.pr_number, repo: c.repo, login: c.github_login, + msg: (c.msg as string)?.split('\n')[0]?.slice(0, 80) ?? '', + commits: 1, added: Number(c.lines_added ?? 0), removed: Number(c.lines_removed ?? 0), + }); + } + } + const projPrs = [...prMap.values()] + .sort((a, b) => (b.added + b.removed) - (a.added + a.removed)) + .slice(0, 20); + + // Enrich a single Jira key into a full detail object including commit stats + const enrichKey = (key: string) => { + const r = jiraByKey.get(key); + const stats = commitsByJiraKey.get(key); + return { + key, + summary: r?.summary ?? null, + type: r?.issue_type ?? null, + assignee: r?.github_login ?? null, + linked_commits: stats?.commits ?? 0, + linked_prs: stats?.prs.size ?? 0, + }; + }; + + const enrichedGroups = Array.isArray(p.groups) + ? p.groups.map((g: any) => ({ + name: g.name, + jira_details: (g.jira_keys || []).map(enrichKey), + })) + : []; + + return { + ...p, + jira_count: Array.isArray(p.jira_keys) ? p.jira_keys.length : 0, + jira_details: Array.isArray(p.jira_keys) ? p.jira_keys.map(enrichKey) : [], + groups: enrichedGroups, + commits: projCommits.map((c: any) => ({ + sha: c.commit_sha?.slice(0, 7), + repo: c.repo, msg: c.msg, pr: c.pr_number, login: c.github_login, + added: Number(c.lines_added ?? 0), removed: Number(c.lines_removed ?? 0), + })), + prs: projPrs, + }; + }); + + // Also compute "Other" totals: jiras/PRs not attributed to any project + const attributedJiraKeys = new Set(enrichedProjects.flatMap((p: any) => p.jira_keys ?? [])); + const attributedPrNums = new Set(enrichedProjects.flatMap((p: any) => p.pr_numbers ?? []).map(Number)); + const otherTotals = { + jiras: jiraRows.filter((r: any) => !attributedJiraKeys.has(r.issue_key)).length, + prs: [...prSummaryMap.keys()].filter(pr => !attributedPrNums.has(pr)).length, + }; + + const toCache = { _v: INSIGHTS_CACHE_VERSION, projects: enrichedProjects, untracked_work: parsed.untracked_work || [], otherTotals }; + await db.execute( + `INSERT INTO report_comparisons (report_id_a, report_id_b, highlights_json) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE highlights_json = VALUES(highlights_json), generated_at = NOW()`, + [report.id, report.id, JSON.stringify(toCache)], + ); + + return NextResponse.json({ + available: true, + report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at }, + projects: toCache.projects, + untracked_work: toCache.untracked_work, + otherTotals, + totals, + cached: false, + }); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 }); + } +} + +export const GET = withRequestLog(getHandler); +``` + +- [ ] **Step 2: Run tests** + +```bash +npm test --no-coverage +``` + +Expected: All tests pass. The route has no unit tests so no new failures. TypeScript should compile clean. + +```bash +npx tsc --noEmit 2>&1 | grep "error TS" | grep -v " 2\.ts" +``` + +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/api/project-insights/route.ts +git commit -m "feat(glook-25): productionize project-insights route — full commit/PR context, linked stats, _v:3" +``` + +--- + +### Task 2: Inline expand with 3-tab drill-down in `ProjectsCard.tsx` + +**Files:** +- Modify: `src/components/ProjectsCard.tsx` + +**Context:** `ProjectsCard` is a `'use client'` component used on both the home page (standalone) and the team page (collapsible). `ProjectsBody` renders the project list. After this task, each project row is clickable to expand an inline panel with three tabs: **Jiras** (grouped by epic) / **PRs** / **Commits**. + +The component needs `useState` (for which project is expanded and which tab is active) and `useMemo` (already imported from GLOOK-23). + +`ProjectsCardItem` already has `estimated_prs`, `jira_count`, `estimated_commits` from GLOOK-23. The new fields `jira_details`, `groups`, `prs`, `commits` are optional — existing callers (team page) won't pass them and the expand panel simply won't appear. + +- [ ] **Step 1: Add new type definitions** + +At the top of `src/components/ProjectsCard.tsx`, after the existing imports, add: + +```typescript +export interface JiraDetail { + key: string; + summary: string | null; + type: string | null; + assignee: string | null; + linked_commits?: number; + linked_prs?: number; +} + +export interface ProjectGroup { + name: string; + jira_details: JiraDetail[]; +} + +export interface PrDetail { + pr: number; + repo: string; + login: string; + msg: string; + commits: number; + added: number; + removed: number; +} + +export interface CommitDetail { + sha: string; + repo: string; + msg: string; + pr: number | null; + login: string; + added: number; + removed: number; +} +``` + +Add optional fields to `ProjectsCardItem`: + +```typescript +export interface ProjectsCardItem { + name: string; + summary: string; + developers: string[]; + jira_count: number; + estimated_commits: number; + estimated_prs: number; + last_activity?: string; + jira_details?: JiraDetail[]; + groups?: ProjectGroup[]; + prs?: PrDetail[]; + commits?: CommitDetail[]; +} +``` + +- [ ] **Step 2: Add expand state and tab state to `ProjectsBody`** + +`ProjectsBody` is a plain function (no hooks). Add `useState` for `expandedIdx` and `activeTab`: + +```typescript +function ProjectsBody({ + projects, + loading, + emptyMessage, + developerHref, + variant, + actualTotals, +}: { /* same as before */ }) { + const [expandedIdx, setExpandedIdx] = useState(null); + const [activeTab, setActiveTab] = useState<'jiras' | 'prs' | 'commits'>('jiras'); + // ... rest of the function +``` + +`useState` is NOT yet imported in `ProjectsCard.tsx` — add it. Change the existing import line from: +```typescript +import { useMemo } from 'react'; +``` +to: +```typescript +import { useState, useMemo } from 'react'; +``` + +- [ ] **Step 3: Make project rows expandable and add the drill-down panel** + +Inside `sorted.map((p, i) => { ... })`, replace the current row `
` with: + +```tsx +{sorted.map((p, i) => { + const ago = timeAgo((p as TeamProject).last_activity); + const totalVol = p.estimated_prs + p.jira_count + p.estimated_commits; + const barPct = (totalVol / maxVolume) * 100; + const isExpanded = expandedIdx === i; + const hasDetail = !!(p.jira_details?.length || p.prs?.length || p.commits?.length); + return ( +
+ {/* Project row — clickable if detail data is present */} +
{ + if (!hasDetail) return; + if (isExpanded) { setExpandedIdx(null); } else { setExpandedIdx(i); setActiveTab('jiras'); } + }} + > +
+
+ {i + 1} + {p.name} + {hasDetail && ( + + )} +
+
+ {p.jira_count} jiras + ~{p.estimated_commits} commits + ~{p.estimated_prs} PRs + {ago && · {ago}} +
+
+ + {totalVol > 0 && ( +
+
+
+
+
+
+
+
+
+ )} + +

{p.summary}

+
+ {p.developers.map(d => + developerHref ? ( + @{d} + ) : ( + @{d} + ), + )} +
+
+ + {/* Inline expand panel */} + {isExpanded && hasDetail && ( +
+ {/* Tabs */} +
+ {(['jiras', 'prs', 'commits'] as const).map(tab => { + const count = tab === 'jiras' ? (p.jira_details?.length ?? 0) : tab === 'prs' ? (p.prs?.length ?? 0) : (p.commits?.length ?? 0); + if (count === 0) return null; + return ( + + ); + })} +
+ + {/* Jiras tab */} + {activeTab === 'jiras' && ( +
+ {(p.groups?.length ? p.groups : [{ name: '', jira_details: p.jira_details ?? [] }]).map((g, gi) => ( +
+ {g.name &&

{g.name}

} +
+ {g.jira_details.slice(0, 8).map(j => ( +
+ {j.key} + {j.summary} +
+ {j.assignee ? `@${j.assignee.slice(0,8)}` : ''} + {(j.linked_commits ?? 0) > 0 && ( + {j.linked_commits}c {j.linked_prs}pr + )} +
+
+ ))} + {g.jira_details.length > 8 &&

+ {g.jira_details.length - 8} more

} +
+
+ ))} +
+ )} + + {/* PRs tab */} + {activeTab === 'prs' && ( +
+ {(p.prs ?? []).slice(0, 12).map(pr => ( +
+ #{pr.pr} + {pr.msg} +
+ {pr.repo.split('/').pop()?.slice(0, 14)} + {pr.commits}c + +{pr.added} + -{pr.removed} +
+
+ ))} + {(p.prs ?? []).length > 12 &&

+ {(p.prs ?? []).length - 12} more

} +
+ )} + + {/* Commits tab */} + {activeTab === 'commits' && ( +
+ {(p.commits ?? []).slice(0, 12).map(c => ( +
+ {c.sha} + {c.pr && #{c.pr}} + {c.msg} +
+ {c.repo.split('/').pop()?.slice(0, 14)} + +{c.added} + -{c.removed} +
+
+ ))} + {(p.commits ?? []).length > 12 &&

+ {(p.commits ?? []).length - 12} more

} +
+ )} +
+ )} +
+ ); +})} +``` + +- [ ] **Step 4: Run tests** + +```bash +npm test --no-coverage +``` + +Expected: All tests pass. + +```bash +npx tsc --noEmit 2>&1 | grep "error TS" | grep -v " 2\.ts" +``` + +Expected: No TypeScript errors. + +- [ ] **Step 5: Start local container and verify in browser** + +Rebuild and restart the local container (native ARM build for Mac): + +```bash +rsync -a --exclude='.next' --exclude='node_modules' --exclude='.git' --exclude='glooker.db' /Users/msogin/Desktop/claudecode/glooker/ /tmp/glooker-build/ +podman build -t localhost/glooker_app:latest /tmp/glooker-build/ +podman rm -f glooker_app_1 +# run with same env vars as before +``` + +Open **http://localhost:3000** and: +- Verify project cards on the home page still sort by PRs+Jiras with volume bars ✓ +- Click "AI Translation" project → inline panel opens with tabs +- Click **Jiras** tab → grouped by epic theme, cyan `2c 1pr` badges on issues with commits +- Click **PRs** tab → list of PR numbers with repo, commit count, lines changed +- Click **Commits** tab → list of commits with SHA, message, PR badge if linked +- Click the row again → panel closes +- Verify team page Current Projects card still works (no expand — `jira_details` absent) + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ProjectsCard.tsx +git commit -m "feat(glook-25): inline drill-down with Jiras/PRs/Commits tabs on project cards" +``` From 08625f373765ce41fca6e0a2f1911958bf72ad47 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 13:38:37 -0400 Subject: [PATCH 03/12] =?UTF-8?q?feat(glook-25):=20productionize=20project?= =?UTF-8?q?-insights=20route=20=E2=80=94=20full=20commit/PR=20context,=20l?= =?UTF-8?q?inked=20stats,=20=5Fv:3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/app/api/project-insights/route.ts | 291 +++++++++++++++++--------- 1 file changed, 196 insertions(+), 95 deletions(-) diff --git a/src/app/api/project-insights/route.ts b/src/app/api/project-insights/route.ts index 062da50..1852ac6 100644 --- a/src/app/api/project-insights/route.ts +++ b/src/app/api/project-insights/route.ts @@ -5,30 +5,25 @@ import { withRequestLog } from '@/lib/logger'; import { renderInflightBlock } from '@/lib/team-pulse/render'; import type { TeamProjectInflightPr, TeamProjectInflightBranch } from '@/lib/team-pulse/data'; +const INSIGHTS_CACHE_VERSION = 3; + async function getHandler() { - // Find latest completed report const [latestRows] = await db.execute( `SELECT id, org, period_days, created_at FROM reports WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1`, [], ) as [any[], any]; - if (!latestRows.length) return NextResponse.json({ available: false }); const report = latestRows[0]; - // Check if Jira data exists for this report const [jiraCount] = await db.execute( `SELECT COUNT(*) as cnt FROM jira_issues WHERE report_id = ?`, [report.id], ) as [any[], any]; - if (!jiraCount[0]?.cnt || Number(jiraCount[0].cnt) === 0) { return NextResponse.json({ available: false }); } - // Actual org totals — used by the client to render an "Other" row showing - // activity not captured in the top-10 project clusters. Fetched before the - // cache check so both the cache-hit and fresh-generation paths can return them. const [totalsRows] = await db.execute( `SELECT COALESCE(SUM(total_commits), 0) AS commits, COALESCE(SUM(total_prs), 0) AS prs FROM developer_stats WHERE report_id = ?`, @@ -40,18 +35,18 @@ async function getHandler() { jiras: Number(jiraCount[0]?.cnt ?? 0), }; - // Check cache (reuse report_comparisons table — use report.id for both a and b to distinguish from real comparisons) - // Real comparisons always have different a and b. Project insights use same id for both. const [cached] = await db.execute( `SELECT highlights_json FROM report_comparisons WHERE report_id_a = ? AND report_id_b = ?`, [report.id, report.id], ) as [any[], any]; - if (cached.length > 0) { - const data = typeof cached[0].highlights_json === 'string' - ? JSON.parse(cached[0].highlights_json) - : cached[0].highlights_json; - if (data._v === 2) { + let data: any = null; + try { + data = typeof cached[0].highlights_json === 'string' + ? JSON.parse(cached[0].highlights_json) + : cached[0].highlights_json; + } catch { /* malformed — fall through */ } + if (data && !Array.isArray(data) && data._v === INSIGHTS_CACHE_VERSION) { const { _v: _, ...rest } = data; return NextResponse.json({ available: true, @@ -61,100 +56,127 @@ async function getHandler() { cached: true, }); } - // _v !== 2: stale cache (no in-flight data) — fall through to regenerate. - // Once all active orgs have a _v:2 row this guard becomes pure overhead; - // safe to remove after all legacy rows have naturally expired or been overwritten. + // _v !== INSIGHTS_CACHE_VERSION: stale cache — fall through to regenerate. } - // Gather data for LLM - // 1. All Jira issues (compact) + // ── Fetch all data once ────────────────────────────────────────────────── const [jiraRows] = await db.execute( `SELECT issue_key, project_key, issue_type, github_login, LEFT(summary, 80) as summary FROM jira_issues WHERE report_id = ? ORDER BY project_key, issue_key`, [report.id], ) as [any[], any]; + const [allCommitRows] = await db.execute( + `SELECT commit_sha, pr_number, repo, github_login, + LEFT(commit_message, 80) AS msg, + lines_added, lines_removed, committed_at + FROM commit_analyses WHERE report_id = ? ORDER BY committed_at DESC`, + [report.id], + ) as [any[], any]; + + // Build indexes for enrichment (used after LLM response — no second DB fetch needed) + const commitBySha = new Map( + allCommitRows.map((c: any) => [c.commit_sha, c]), + ); + const commitsByPr = new Map(); + for (const c of allCommitRows) { + if (!c.pr_number) continue; + const arr = commitsByPr.get(c.pr_number) ?? []; + arr.push(c); + commitsByPr.set(c.pr_number, arr); + } + // For linked_commits/prs badges: count commits whose message contains each Jira key + const commitsByJiraKey = new Map }>(); + for (const c of allCommitRows) { + const match = (c.msg as string)?.match(/[A-Z]+-\d+/); + if (!match) continue; + const key = match[0]; + const entry = commitsByJiraKey.get(key) ?? { commits: 0, prs: new Set() }; + entry.commits++; + if (c.pr_number) entry.prs.add(c.pr_number); + commitsByJiraKey.set(key, entry); + } + + // ── Build LLM inputs ───────────────────────────────────────────────────── const jiraData = jiraRows.map((r: any) => `${r.issue_key}|${r.project_key}|${r.issue_type || ''}|${r.github_login}|${r.summary || ''}` ).join('\n'); - // 2. Developer stats - const [devStats] = await db.execute( - `SELECT github_login, total_commits, total_prs FROM developer_stats WHERE report_id = ? ORDER BY github_login`, - [report.id], - ) as [any[], any]; - - const devData = devStats.map((d: any) => `${d.github_login}\t${d.total_commits}\t${d.total_prs}`).join('\n'); + const commitData = allCommitRows.map((c: any) => + `${c.commit_sha?.slice(0,7)}|${c.pr_number ?? ''}|${c.repo}|${c.github_login}|${(c.msg || '').replace(/\|/g, ' ')}|+${c.lines_added ?? 0}/-${c.lines_removed ?? 0}` + ).join('\n'); - // 3. Commits without Jira (top 30 by lines changed) - const [noJiraCommits] = await db.execute( - `SELECT ca.repo, ca.github_login, LEFT(ca.commit_message, 60) as msg - FROM commit_analyses ca - WHERE ca.report_id = ? - AND ca.github_login NOT IN (SELECT DISTINCT github_login FROM jira_issues WHERE report_id = ?) - ORDER BY ca.lines_added + ca.lines_removed DESC - LIMIT 30`, - [report.id, report.id], - ) as [any[], any]; + const prSummaryMap = new Map(); + for (const c of allCommitRows) { + if (!c.pr_number) continue; + const ex = prSummaryMap.get(c.pr_number); + if (ex) { + ex.commits++; + ex.added += Number(c.lines_added ?? 0); + ex.removed += Number(c.lines_removed ?? 0); + } else { + prSummaryMap.set(c.pr_number, { + pr: c.pr_number, repo: c.repo, login: c.github_login, + commits: 1, added: Number(c.lines_added ?? 0), removed: Number(c.lines_removed ?? 0), + msg: (c.msg || '').split('\n')[0].slice(0, 80).replace(/\|/g, ' '), + }); + } + } + const prData = [...prSummaryMap.values()] + .map(p => `${p.pr}|${p.repo}|${p.login}|${p.commits}c|+${p.added}/-${p.removed}|${p.msg}`) + .join('\n'); - const noJiraData = noJiraCommits.map((c: any) => `${c.repo}|${c.github_login}|${c.msg || ''}`).join('\n'); + const noJiraData = allCommitRows + .filter((c: any) => !c.pr_number) + .slice(0, 30) + .map((c: any) => `${c.repo}|${c.github_login}|${(c.msg || '').slice(0, 60)}`).join('\n'); - // In-flight: open PRs (top 30 by size) const [inflightPrRows] = await db.execute( `SELECT repo, pr_title, github_login, is_draft, COALESCE(pr_additions, 0) AS pr_additions, COALESCE(pr_deletions, 0) AS pr_deletions - FROM unmerged_prs - WHERE report_id = ? - ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC - LIMIT 30`, + FROM unmerged_prs WHERE report_id = ? + ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC LIMIT 30`, [report.id], ) as [any[], any]; - - // In-flight: bare branches (top 10 by total lines) const [inflightBranchRows] = await db.execute( `SELECT repo, branch, github_login, - COUNT(*) AS commit_count, - SUM(lines_added + lines_removed) AS total_lines - FROM unmerged_commits - WHERE report_id = ? AND pr_number IS NULL - GROUP BY repo, branch, github_login - ORDER BY total_lines DESC - LIMIT 10`, + COUNT(*) AS commit_count, SUM(lines_added + lines_removed) AS total_lines + FROM unmerged_commits WHERE report_id = ? AND pr_number IS NULL + GROUP BY repo, branch, github_login ORDER BY total_lines DESC LIMIT 10`, [report.id], ) as [any[], any]; - - // Map raw DB rows to the shared typed interface so renderInflightBlock - // handles formatting and length-capping consistently across both surfaces. const inflightPrs: TeamProjectInflightPr[] = inflightPrRows.map((r: any) => ({ - repo: String(r.repo ?? ''), - title: String(r.pr_title ?? ''), - author: String(r.github_login ?? ''), - additions: Number(r.pr_additions ?? 0), - deletions: Number(r.pr_deletions ?? 0), + repo: String(r.repo ?? ''), title: String(r.pr_title ?? ''), author: String(r.github_login ?? ''), + additions: Number(r.pr_additions ?? 0), deletions: Number(r.pr_deletions ?? 0), is_draft: r.is_draft === 1 || r.is_draft === true, })); const inflightBranches: TeamProjectInflightBranch[] = inflightBranchRows.map((r: any) => ({ - repo: String(r.repo ?? ''), - branch: String(r.branch ?? ''), - author: String(r.github_login ?? ''), - commit_count: Number(r.commit_count ?? 0), - lines: Number(r.total_lines ?? 0), + repo: String(r.repo ?? ''), branch: String(r.branch ?? ''), author: String(r.github_login ?? ''), + commit_count: Number(r.commit_count ?? 0), lines: Number(r.total_lines ?? 0), })); const inflightBlock = renderInflightBlock(inflightPrs, inflightBranches); - const systemPrompt = `You are an engineering analytics assistant. Analyze Jira issues and GitHub commits from a single report period to identify the top projects the team is working on. + // ── LLM call ───────────────────────────────────────────────────────────── + const systemPrompt = `You are an engineering analytics assistant. Analyze Jira issues, all GitHub commits, and all PRs from a single report period to identify the top projects the team is working on. You will receive: 1. Jira issues: key|project_key|type|developer|summary -2. Developer stats: login, total_commits, total_prs -3. GitHub commits with no Jira coverage: repo|developer|message +2. All commits: sha|pr_number|repo|developer|message|+add/-remove (commit messages often contain Jira keys — use this to link commits to issues) +3. PR summaries: pr_number|repo|developer|commit_count|+add/-remove|first_message +4. Untracked work (commits with no PR): repo|developer|message Your task: -1. Identify the top 10 ACTUAL projects being worked on. Cluster related Jira issues and commits into logical projects. Name them descriptively using the actual feature/product names from issues (e.g. "Braze Content Blocks Migration", not "BRZ" or "Braze Connector"). -2. For each project: list developers, total jiras, and estimate commits/PRs attributed to this project (use developer stats and proportional allocation based on issue count). +1. Identify the top 10 ACTUAL projects being worked on. Use ALL three signals — Jira issues, commit messages, and PR titles — to cluster related work. Name projects descriptively (e.g. "Keycloak 26 Migration", not "AUT"). +2. For each project ENUMERATE exactly what you are attributing to it: + - jira_keys: all Jira keys from JIRA ISSUES that belong to this cluster + - groups: group those jira_keys into 2-4 named epic themes + - pr_numbers: all PR numbers from PR SUMMARIES that belong to this cluster + - commit_shas: any commit SHAs (7-char prefix) from UNTRACKED WORK that belong to this cluster 3. Write a one-sentence summary of what the project achieves. -4. Identify up to 5 significant GitHub efforts with NO Jira tickets. +4. Identify up to 5 significant GitHub efforts with NO Jira tickets (from untracked work). + +IMPORTANT: Each Jira key and each PR number must appear in exactly ONE project. No duplicates. Return JSON: { @@ -163,42 +185,34 @@ Return JSON: "name": "Descriptive Project Name", "developers": ["login1", "login2"], "summary": "One sentence about what this project achieves", - "jira_count": 5, - "estimated_commits": 30, - "estimated_prs": 12 + "jira_keys": ["PROJ-123", "PROJ-456"], + "groups": [{ "name": "Epic theme name", "jira_keys": ["PROJ-123"] }], + "pr_numbers": [101, 102, 103], + "commit_shas": ["abc1234", "def5678"] } ], "untracked_work": [ - { - "name": "Descriptive name for the work", - "repo": "repo-name", - "developers": ["login1"], - "commits": 10, - "summary": "What this work appears to be about" - } + { "name": "Descriptive name", "repo": "repo-name", "developers": ["login1"], "commits": 10, "summary": "What this is about" } ] } Rules: -- Be specific in project names — use actual feature/product names from the issues -- Don't just group by Jira project key — look at what's actually being built -- A single Jira project might contain multiple distinct projects -- For estimated_commits/prs: if a dev has 50 commits and works on 2 projects with equal issues, attribute ~25 each +- Cluster by feature, not by Jira project key prefix +- Each Jira key and each PR number appears in exactly ONE project +- jira_keys: only keys from JIRA ISSUES; pr_numbers: only numbers from PR SUMMARIES; commit_shas: only 7-char SHAs from UNTRACKED WORK - Keep summaries under 20 words -- Return ONLY raw JSON -- If IN-FLIGHT WORK is present at the end of the user message, use it to enrich project identification — treat open PRs and bare branches as signals of ongoing work. Mix them into existing project clusters where they clearly fit, or create a project if in-flight work has no committed counterpart. Draft PRs are included.`; - // Note: the in-flight instruction lives in systemPrompt while the in-flight data - // is in userMessage. This follows the standard system/user role separation for - // instructions vs data. The team-page surface embeds both in the system prompt - // template (team-pulse-projects.txt) — the difference is cosmetic for LLM behaviour. +- Return ONLY raw JSON`; const userMessage = `JIRA ISSUES (${jiraRows.length} total): ${jiraData} -DEVELOPER STATS (login | commits | PRs): -${devData} +ALL COMMITS (${allCommitRows.length} total — sha|pr|repo|developer|message|+add/-remove): +${commitData} -GITHUB COMMITS WITHOUT JIRA (top 30 by size): +PR SUMMARIES (${prSummaryMap.size} total — pr|repo|developer|commits|+add/-remove|message): +${prData} + +UNTRACKED WORK (commits with no PR): ${noJiraData}${inflightBlock}`; try { @@ -206,7 +220,7 @@ ${noJiraData}${inflightBlock}`; const response = await client.chat.completions.create({ model: LLM_MODEL, temperature: 0.3, - ...tokenLimit(2048), + ...tokenLimit(12000), response_format: { type: 'json_object' }, messages: [ { role: 'system', content: systemPrompt }, @@ -220,7 +234,93 @@ ${noJiraData}${inflightBlock}`; let parsed: any; try { parsed = JSON.parse(cleaned); } catch { parsed = { projects: [], untracked_work: [] }; } - const toCache = { _v: 2, projects: parsed.projects || [], untracked_work: parsed.untracked_work || [] }; + // ── Enrich each project using the pre-built indexes ─────────────────── + const jiraByKey = new Map(jiraRows.map((r: any) => [r.issue_key, r])); + + const enrichedProjects = (parsed.projects || []).map((p: any) => { + const llmPrNums = new Set((p.pr_numbers ?? []).map(Number)); + const llmShas = new Set(p.commit_shas ?? []); + + // Gather all commits: by SHA (bare commits) + by PR number + const projCommitMap = new Map(); + for (const sha of llmShas) { + const c = commitBySha.get(sha); + if (c) projCommitMap.set(sha, c); + } + for (const prNum of llmPrNums) { + for (const c of commitsByPr.get(prNum) ?? []) { + projCommitMap.set(c.commit_sha, c); + } + } + const projCommits = [...projCommitMap.values()] + .sort((a, b) => new Date(b.committed_at).getTime() - new Date(a.committed_at).getTime()) + .slice(0, 20); + + // Derive distinct PRs from attributed commits + const prMap = new Map(); + for (const c of projCommitMap.values()) { + if (!c.pr_number) continue; + const ex = prMap.get(c.pr_number); + if (ex) { + ex.commits++; + ex.added += Number(c.lines_added ?? 0); + ex.removed += Number(c.lines_removed ?? 0); + } else { + prMap.set(c.pr_number, { + pr: c.pr_number, repo: c.repo, login: c.github_login, + msg: (c.msg as string)?.split('\n')[0]?.slice(0, 80) ?? '', + commits: 1, added: Number(c.lines_added ?? 0), removed: Number(c.lines_removed ?? 0), + }); + } + } + const projPrs = [...prMap.values()] + .sort((a, b) => (b.added + b.removed) - (a.added + a.removed)) + .slice(0, 20); + + // Enrich a single Jira key into a full detail object including commit stats + const enrichKey = (key: string) => { + const r = jiraByKey.get(key); + const stats = commitsByJiraKey.get(key); + return { + key, + summary: r?.summary ?? null, + type: r?.issue_type ?? null, + assignee: r?.github_login ?? null, + linked_commits: stats?.commits ?? 0, + linked_prs: stats?.prs.size ?? 0, + }; + }; + + const enrichedGroups = Array.isArray(p.groups) + ? p.groups.map((g: any) => ({ + name: g.name, + jira_details: (g.jira_keys || []).map(enrichKey), + })) + : []; + + return { + ...p, + jira_count: Array.isArray(p.jira_keys) ? p.jira_keys.length : 0, + jira_details: Array.isArray(p.jira_keys) ? p.jira_keys.map(enrichKey) : [], + groups: enrichedGroups, + commits: projCommits.map((c: any) => ({ + sha: c.commit_sha?.slice(0, 7), + repo: c.repo, msg: c.msg, pr: c.pr_number, login: c.github_login, + added: Number(c.lines_added ?? 0), removed: Number(c.lines_removed ?? 0), + })), + prs: projPrs, + }; + }); + + // Compute "Other" totals: jiras/PRs not attributed to any project + const attributedJiraKeys = new Set(enrichedProjects.flatMap((p: any) => p.jira_keys ?? [])); + const attributedPrNums = new Set(enrichedProjects.flatMap((p: any) => (p.pr_numbers ?? []).map(Number))); + const otherTotals = { + jiras: jiraRows.filter((r: any) => !attributedJiraKeys.has(r.issue_key)).length, + prs: [...prSummaryMap.keys()].filter(pr => !attributedPrNums.has(pr)).length, + }; + + const toCache = { _v: INSIGHTS_CACHE_VERSION, projects: enrichedProjects, untracked_work: parsed.untracked_work || [], otherTotals }; await db.execute( `INSERT INTO report_comparisons (report_id_a, report_id_b, highlights_json) VALUES (?, ?, ?) @@ -233,6 +333,7 @@ ${noJiraData}${inflightBlock}`; report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at }, projects: toCache.projects, untracked_work: toCache.untracked_work, + otherTotals, totals, cached: false, }); From 1d2c14b33053f0fc1e894ce2044c92ffb980c7a8 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 13:51:10 -0400 Subject: [PATCH 04/12] fix(glook-25): SHA prefix fix, estimated_commits/prs, wire otherTotals --- src/app/api/project-insights/route.ts | 21 ++++++++++++++------- src/app/llm-findings.tsx | 3 +++ src/components/ProjectsCard.tsx | 3 +++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/app/api/project-insights/route.ts b/src/app/api/project-insights/route.ts index 1852ac6..961ab9f 100644 --- a/src/app/api/project-insights/route.ts +++ b/src/app/api/project-insights/route.ts @@ -74,9 +74,10 @@ async function getHandler() { [report.id], ) as [any[], any]; - // Build indexes for enrichment (used after LLM response — no second DB fetch needed) + // Build indexes for enrichment (used after LLM response — no second DB fetch needed). + // Keyed by 7-char prefix because that is what the LLM prompt sends and returns. const commitBySha = new Map( - allCommitRows.map((c: any) => [c.commit_sha, c]), + allCommitRows.map((c: any) => [c.commit_sha?.slice(0, 7), c]), ); const commitsByPr = new Map(); for (const c of allCommitRows) { @@ -298,16 +299,22 @@ ${noJiraData}${inflightBlock}`; })) : []; + const enrichedCommits = projCommits.map((c: any) => ({ + sha: c.commit_sha?.slice(0, 7), + repo: c.repo, msg: c.msg, pr: c.pr_number, login: c.github_login, + added: Number(c.lines_added ?? 0), removed: Number(c.lines_removed ?? 0), + })); + return { ...p, jira_count: Array.isArray(p.jira_keys) ? p.jira_keys.length : 0, + // Keep estimated_commits/prs for backward compat with ProjectsCard bar/sort logic. + // Derived from the attributed arrays rather than LLM estimation. + estimated_commits: projCommits.length, + estimated_prs: projPrs.length, jira_details: Array.isArray(p.jira_keys) ? p.jira_keys.map(enrichKey) : [], groups: enrichedGroups, - commits: projCommits.map((c: any) => ({ - sha: c.commit_sha?.slice(0, 7), - repo: c.repo, msg: c.msg, pr: c.pr_number, login: c.github_login, - added: Number(c.lines_added ?? 0), removed: Number(c.lines_removed ?? 0), - })), + commits: enrichedCommits, prs: projPrs, }; }); diff --git a/src/app/llm-findings.tsx b/src/app/llm-findings.tsx index e285bb4..697155a 100644 --- a/src/app/llm-findings.tsx +++ b/src/app/llm-findings.tsx @@ -36,6 +36,7 @@ export default function LlmFindings() { const [untrackedWork, setUntrackedWork] = useState([]); const [projectsMeta, setProjectsMeta] = useState<{ id: string; org: string; periodDays: number; createdAt: string } | null>(null); const [projectTotals, setProjectTotals] = useState<{ commits: number; prs: number; jiras: number } | null>(null); + const [otherTotals, setOtherTotals] = useState<{ jiras: number; prs: number } | null>(null); const [projectsLoading, setProjectsLoading] = useState(true); // Release notes @@ -87,6 +88,7 @@ export default function LlmFindings() { setUntrackedWork(data.untracked_work || []); setProjectsMeta({ id: data.report.id, org: data.report.org, periodDays: data.report.periodDays, createdAt: data.report.createdAt }); if (data.totals) setProjectTotals(data.totals); + if (data.otherTotals) setOtherTotals(data.otherTotals); } }) .catch(() => {}) @@ -146,6 +148,7 @@ export default function LlmFindings() { subtitle={projectsMeta ? `${projectsMeta.org} · ${projectsMeta.periodDays}d · ${formatDate(projectsMeta.createdAt)}` : undefined} developerHref={projectsMeta ? (login) => `/report/${projectsMeta.id}/dev/${login}` : undefined} actualTotals={projectTotals ?? undefined} + otherTotals={otherTotals ?? undefined} /> )} diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index 7ed2b45..2aa9dda 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -34,6 +34,9 @@ export interface ProjectsCardProps { * activity not captured by the project clusters. Shown as a peer row with * the same bar format so users can compare scale directly. */ actualTotals?: { commits: number; prs: number; jiras: number }; + /** Server-computed unattributed counts (Jiras and PRs not in any top project). + * When present, used directly for the "Other" row instead of deriving from actualTotals. */ + otherTotals?: { jiras: number; prs: number }; /** Collapsible mode (GLOOK-11): header becomes a toggle button styled to * match , body hidden when collapsed. Controlled — parent * owns `expanded` state via `onExpandedChange`. */ From 296971c4627c0904217835ab1d4dcc839f603797 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 13:54:42 -0400 Subject: [PATCH 05/12] feat(glook-25): inline drill-down with Jiras/PRs/Commits tabs on project cards Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/ProjectsCard.tsx | 239 +++++++++++++++++++++++++------- 1 file changed, 191 insertions(+), 48 deletions(-) diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index 2aa9dda..d611d0f 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useMemo } from 'react'; +import { useState, useMemo } from 'react'; import type { TeamProject } from '@/lib/team-pulse/types'; // Segment colors used in both the legend swatches and the bar segments. @@ -9,6 +9,40 @@ const SEGMENT_COLORS = { commits: 'rgba(255,255,255,0.10)', } as const; +export interface JiraDetail { + key: string; + summary: string | null; + type: string | null; + assignee: string | null; + linked_commits?: number; + linked_prs?: number; +} + +export interface ProjectGroup { + name: string; + jira_details: JiraDetail[]; +} + +export interface PrDetail { + pr: number; + repo: string; + login: string; + msg: string; + commits: number; + added: number; + removed: number; +} + +export interface CommitDetail { + sha: string; + repo: string; + msg: string; + pr: number | null; + login: string; + added: number; + removed: number; +} + /** Older shape used by the home-page LLM project insights — superset of TeamProject. * Allows the same component to serve both surfaces without a refactor of the home payload. */ export interface ProjectsCardItem { @@ -19,6 +53,10 @@ export interface ProjectsCardItem { estimated_commits: number; estimated_prs: number; last_activity?: string; // optional: only the team variant sets this + jira_details?: JiraDetail[]; + groups?: ProjectGroup[]; + prs?: PrDetail[]; + commits?: CommitDetail[]; } export interface ProjectsCardProps { @@ -64,6 +102,7 @@ function ProjectsBody({ developerHref, variant, actualTotals, + otherTotals, }: { projects: ProjectsCardItem[] | TeamProject[]; loading?: boolean; @@ -71,7 +110,11 @@ function ProjectsBody({ developerHref?: (login: string) => string; variant: 'standalone' | 'collapsible'; actualTotals?: { commits: number; prs: number; jiras: number }; + otherTotals?: { jiras: number; prs: number }; }) { + const [expandedIdx, setExpandedIdx] = useState(null); + const [activeTab, setActiveTab] = useState<'jiras' | 'prs' | 'commits'>('jiras'); + if (loading) { return (
@@ -98,6 +141,12 @@ function ProjectsBody({ // "Other" row: activity not attributed to any named project cluster. // Only shown when actualTotals is provided (home page passes org totals from devStats). const other = useMemo(() => { + // Use server-computed otherTotals when available (more accurate than client subtraction) + if (otherTotals) { + return (otherTotals.jiras + otherTotals.prs) > 0 + ? { commits: 0, prs: otherTotals.prs, jiras: otherTotals.jiras } + : null; + } if (!actualTotals) return null; const sumCommits = sorted.reduce((s, p) => s + p.estimated_commits, 0); const sumPrs = sorted.reduce((s, p) => s + p.estimated_prs, 0); @@ -108,7 +157,7 @@ function ProjectsBody({ jiras: Math.max(0, actualTotals.jiras - sumJiras), }; return (o.commits + o.prs + o.jiras) > 0 ? o : null; - }, [sorted, actualTotals]); + }, [sorted, actualTotals, otherTotals]); // Bar width = total volume (PRs + Jiras + Commits) / max across all projects. // Commits are intentionally included here even though they are excluded from the @@ -141,62 +190,153 @@ function ProjectsBody({
- {sorted.map((p, i) => { - const ago = timeAgo((p as TeamProject).last_activity); + {sorted.map((rawP, i) => { + const p = rawP as ProjectsCardItem; + const ago = timeAgo((rawP as TeamProject).last_activity); const totalVol = p.estimated_prs + p.jira_count + p.estimated_commits; const barPct = (totalVol / maxVolume) * 100; + const isExpanded = expandedIdx === i; + const hasDetail = !!(p.jira_details?.length || p.prs?.length || p.commits?.length); return ( -
-
-
- {i + 1} - {p.name} +
+ {/* Project row */} +
{ + if (!hasDetail) return; + if (isExpanded) { setExpandedIdx(null); } else { setExpandedIdx(i); setActiveTab('jiras'); } + }} + > +
+
+ {i + 1} + {p.name} + {hasDetail && ( + + )} +
+
+ {p.jira_count} jiras + ~{p.estimated_commits} commits + ~{p.estimated_prs} PRs + {ago && · {ago}} +
-
- {p.jira_count} jiras - ~{p.estimated_commits} commits - ~{p.estimated_prs} PRs - {ago && · {ago}} + + {totalVol > 0 && ( +
+
+
+
+
+
+
+
+
+ )} + +

{p.summary}

+
+ {p.developers.map(d => + developerHref ? ( + @{d} + ) : ( + @{d} + ), + )}
- {/* Volume bar: width = total / max, segments = PRs + Jiras + Commits (ghost) */} - {totalVol > 0 && ( -
-
-
-
-
-
+ {/* Inline expand panel */} + {isExpanded && hasDetail && ( +
+ {/* Tabs */} +
+ {(['jiras', 'prs', 'commits'] as const).map(tab => { + const count = tab === 'jiras' ? (p.jira_details?.length ?? 0) : tab === 'prs' ? (p.prs?.length ?? 0) : (p.commits?.length ?? 0); + if (count === 0) return null; + return ( + + ); + })}
+ + {/* Jiras tab — grouped by epic */} + {activeTab === 'jiras' && ( +
+ {(p.groups?.length ? p.groups : [{ name: '', jira_details: p.jira_details ?? [] }]).map((g: ProjectGroup, gi: number) => ( +
+ {g.name &&

{g.name}

} +
+ {g.jira_details.slice(0, 8).map((j: JiraDetail) => ( +
+ {j.key} + {j.summary} +
+ {j.assignee && @{j.assignee.slice(0, 10)}} + {(j.linked_commits ?? 0) > 0 && ( + {j.linked_commits}c {j.linked_prs}pr + )} +
+
+ ))} + {g.jira_details.length > 8 &&

+ {g.jira_details.length - 8} more

} +
+
+ ))} +
+ )} + + {/* PRs tab */} + {activeTab === 'prs' && ( +
+ {(p.prs ?? []).slice(0, 12).map((pr: PrDetail) => ( +
+ #{pr.pr} + {pr.msg} +
+ {pr.repo.split('/').pop()?.slice(0, 14)} + {pr.commits}c + +{pr.added} + -{pr.removed} +
+
+ ))} + {(p.prs ?? []).length > 12 &&

+ {(p.prs ?? []).length - 12} more PRs

} +
+ )} + + {/* Commits tab */} + {activeTab === 'commits' && ( +
+ {(p.commits ?? []).slice(0, 12).map((c: CommitDetail) => ( +
+ {c.sha} + {c.pr && #{c.pr}} + {c.msg} +
+ {c.repo.split('/').pop()?.slice(0, 14)} + +{c.added} + -{c.removed} +
+
+ ))} + {(p.commits ?? []).length > 12 &&

+ {(p.commits ?? []).length - 12} more commits

} +
+ )}
-
)} - -

{p.summary}

-
- {p.developers.map(d => - developerHref ? ( - @{d} - ) : ( - @{d} - ), - )} -
); })} @@ -253,6 +393,7 @@ export default function ProjectsCard({ emptyMessage = 'No active projects in this window.', developerHref, actualTotals, + otherTotals, collapsible = false, expanded = true, onExpandedChange, @@ -296,6 +437,7 @@ export default function ProjectsCard({ developerHref={developerHref} variant="collapsible" actualTotals={actualTotals} + otherTotals={otherTotals} />
)} @@ -321,6 +463,7 @@ export default function ProjectsCard({ developerHref={developerHref} variant="standalone" actualTotals={actualTotals} + otherTotals={otherTotals} />
); From 8eb76a700340c57160647a057ec5d52ddc3a74bd Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 13:57:24 -0400 Subject: [PATCH 06/12] fix(glook-25): default to first non-empty tab on expand, hide 0pr from badge --- src/components/ProjectsCard.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index d611d0f..884a138 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -204,7 +204,14 @@ function ProjectsBody({ className={`bg-white/[0.02] rounded-lg p-3 ${isExpanded ? 'rounded-b-none' : ''} ${hasDetail ? 'cursor-pointer hover:bg-white/[0.035] transition-colors' : ''}`} onClick={() => { if (!hasDetail) return; - if (isExpanded) { setExpandedIdx(null); } else { setExpandedIdx(i); setActiveTab('jiras'); } + if (isExpanded) { setExpandedIdx(null); } else { + setExpandedIdx(i); + // Default to first non-empty tab so users don't land on a blank panel + const firstTab = (p as ProjectsCardItem).jira_details?.length ? 'jiras' + : (p as ProjectsCardItem).prs?.length ? 'prs' + : 'commits'; + setActiveTab(firstTab); + } }} >
@@ -286,7 +293,9 @@ function ProjectsBody({
{j.assignee && @{j.assignee.slice(0, 10)}} {(j.linked_commits ?? 0) > 0 && ( - {j.linked_commits}c {j.linked_prs}pr + + {j.linked_commits}c{(j.linked_prs ?? 0) > 0 ? ` ${j.linked_prs}pr` : ''} + )}
From 8c59885ce5b727ac1a538f4f61721188a3f1b467 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 14:55:41 -0400 Subject: [PATCH 07/12] fix(glook-25): move early returns after all hooks (React error #310) --- src/components/ProjectsCard.tsx | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index 884a138..f547e86 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -115,21 +115,7 @@ function ProjectsBody({ const [expandedIdx, setExpandedIdx] = useState(null); const [activeTab, setActiveTab] = useState<'jiras' | 'prs' | 'commits'>('jiras'); - if (loading) { - return ( -
- - - - - Analyzing projects… -
- ); - } - if (projects.length === 0) { - return

{emptyMessage}

; - } - + // All hooks must be declared before any early returns (Rules of Hooks). // Sort by meaningful output (PRs + Jiras). Commits excluded from sort key // because they are squashed into PRs — including them would inflate rank for // commit-heavy, low-PR projects. Commits are still shown in the bar for context. @@ -173,6 +159,22 @@ function ProjectsBody({ // Hide Jira legend entry when Jira is disabled (all counts are 0). const hasJira = useMemo(() => sorted.some(p => p.jira_count > 0) || (other?.jiras ?? 0) > 0, [sorted, other]); + // Early returns after all hooks (Rules of Hooks: hooks must not be conditional). + if (loading) { + return ( +
+ + + + + Analyzing projects… +
+ ); + } + if (projects.length === 0) { + return

{emptyMessage}

; + } + return (
{/* Legend */} From b08c2c79bfcc2d2dbe71f4c19b36205cfd8153cd Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 15:14:03 -0400 Subject: [PATCH 08/12] feat(glook-25): expandable Other row with Jiras/PRs drill-down --- src/app/api/project-insights/route.ts | 48 ++++++---- src/app/llm-findings.tsx | 3 + src/components/ProjectsCard.tsx | 133 ++++++++++++++++++++------ 3 files changed, 136 insertions(+), 48 deletions(-) diff --git a/src/app/api/project-insights/route.ts b/src/app/api/project-insights/route.ts index 961ab9f..b1ca894 100644 --- a/src/app/api/project-insights/route.ts +++ b/src/app/api/project-insights/route.ts @@ -238,6 +238,20 @@ ${noJiraData}${inflightBlock}`; // ── Enrich each project using the pre-built indexes ─────────────────── const jiraByKey = new Map(jiraRows.map((r: any) => [r.issue_key, r])); + // Hoisted so it can also be used for the "Other" row details below. + const enrichKey = (key: string) => { + const r = jiraByKey.get(key); + const stats = commitsByJiraKey.get(key); + return { + key, + summary: r?.summary ?? null, + type: r?.issue_type ?? null, + assignee: r?.github_login ?? null, + linked_commits: stats?.commits ?? 0, + linked_prs: stats?.prs.size ?? 0, + }; + }; + const enrichedProjects = (parsed.projects || []).map((p: any) => { const llmPrNums = new Set((p.pr_numbers ?? []).map(Number)); const llmShas = new Set(p.commit_shas ?? []); @@ -278,20 +292,6 @@ ${noJiraData}${inflightBlock}`; .sort((a, b) => (b.added + b.removed) - (a.added + a.removed)) .slice(0, 20); - // Enrich a single Jira key into a full detail object including commit stats - const enrichKey = (key: string) => { - const r = jiraByKey.get(key); - const stats = commitsByJiraKey.get(key); - return { - key, - summary: r?.summary ?? null, - type: r?.issue_type ?? null, - assignee: r?.github_login ?? null, - linked_commits: stats?.commits ?? 0, - linked_prs: stats?.prs.size ?? 0, - }; - }; - const enrichedGroups = Array.isArray(p.groups) ? p.groups.map((g: any) => ({ name: g.name, @@ -319,15 +319,22 @@ ${noJiraData}${inflightBlock}`; }; }); - // Compute "Other" totals: jiras/PRs not attributed to any project + // Compute "Other" — jiras/PRs not attributed to any project const attributedJiraKeys = new Set(enrichedProjects.flatMap((p: any) => p.jira_keys ?? [])); const attributedPrNums = new Set(enrichedProjects.flatMap((p: any) => (p.pr_numbers ?? []).map(Number))); - const otherTotals = { - jiras: jiraRows.filter((r: any) => !attributedJiraKeys.has(r.issue_key)).length, - prs: [...prSummaryMap.keys()].filter(pr => !attributedPrNums.has(pr)).length, - }; - const toCache = { _v: INSIGHTS_CACHE_VERSION, projects: enrichedProjects, untracked_work: parsed.untracked_work || [], otherTotals }; + const otherJiraDetails = jiraRows + .filter((r: any) => !attributedJiraKeys.has(r.issue_key)) + .map((r: any) => enrichKey(r.issue_key)); + + const otherPrs = [...prSummaryMap.values()] + .filter(p => !attributedPrNums.has(p.pr)) + .sort((a, b) => (b.added + b.removed) - (a.added + a.removed)); + + const otherTotals = { jiras: otherJiraDetails.length, prs: otherPrs.length }; + const otherDetails = { jira_details: otherJiraDetails, prs: otherPrs }; + + const toCache = { _v: INSIGHTS_CACHE_VERSION, projects: enrichedProjects, untracked_work: parsed.untracked_work || [], otherTotals, otherDetails }; await db.execute( `INSERT INTO report_comparisons (report_id_a, report_id_b, highlights_json) VALUES (?, ?, ?) @@ -341,6 +348,7 @@ ${noJiraData}${inflightBlock}`; projects: toCache.projects, untracked_work: toCache.untracked_work, otherTotals, + otherDetails, totals, cached: false, }); diff --git a/src/app/llm-findings.tsx b/src/app/llm-findings.tsx index 697155a..0632a38 100644 --- a/src/app/llm-findings.tsx +++ b/src/app/llm-findings.tsx @@ -37,6 +37,7 @@ export default function LlmFindings() { const [projectsMeta, setProjectsMeta] = useState<{ id: string; org: string; periodDays: number; createdAt: string } | null>(null); const [projectTotals, setProjectTotals] = useState<{ commits: number; prs: number; jiras: number } | null>(null); const [otherTotals, setOtherTotals] = useState<{ jiras: number; prs: number } | null>(null); + const [otherDetails, setOtherDetails] = useState<{ jira_details: any[]; prs: any[] } | null>(null); const [projectsLoading, setProjectsLoading] = useState(true); // Release notes @@ -89,6 +90,7 @@ export default function LlmFindings() { setProjectsMeta({ id: data.report.id, org: data.report.org, periodDays: data.report.periodDays, createdAt: data.report.createdAt }); if (data.totals) setProjectTotals(data.totals); if (data.otherTotals) setOtherTotals(data.otherTotals); + if (data.otherDetails) setOtherDetails(data.otherDetails); } }) .catch(() => {}) @@ -149,6 +151,7 @@ export default function LlmFindings() { developerHref={projectsMeta ? (login) => `/report/${projectsMeta.id}/dev/${login}` : undefined} actualTotals={projectTotals ?? undefined} otherTotals={otherTotals ?? undefined} + otherDetails={otherDetails ?? undefined} /> )} diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index f547e86..444fd3b 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -75,6 +75,8 @@ export interface ProjectsCardProps { /** Server-computed unattributed counts (Jiras and PRs not in any top project). * When present, used directly for the "Other" row instead of deriving from actualTotals. */ otherTotals?: { jiras: number; prs: number }; + /** Full detail for the "Other" row — enables drill-down on unattributed activity. */ + otherDetails?: { jira_details: JiraDetail[]; prs: PrDetail[] }; /** Collapsible mode (GLOOK-11): header becomes a toggle button styled to * match , body hidden when collapsed. Controlled — parent * owns `expanded` state via `onExpandedChange`. */ @@ -103,6 +105,7 @@ function ProjectsBody({ variant, actualTotals, otherTotals, + otherDetails, }: { projects: ProjectsCardItem[] | TeamProject[]; loading?: boolean; @@ -111,9 +114,12 @@ function ProjectsBody({ variant: 'standalone' | 'collapsible'; actualTotals?: { commits: number; prs: number; jiras: number }; otherTotals?: { jiras: number; prs: number }; + otherDetails?: { jira_details: JiraDetail[]; prs: PrDetail[] }; }) { const [expandedIdx, setExpandedIdx] = useState(null); const [activeTab, setActiveTab] = useState<'jiras' | 'prs' | 'commits'>('jiras'); + const [otherExpanded, setOtherExpanded] = useState(false); + const [otherTab, setOtherTab] = useState<'jiras' | 'prs'>('jiras'); // All hooks must be declared before any early returns (Rules of Hooks). // Sort by meaningful output (PRs + Jiras). Commits excluded from sort key @@ -352,43 +358,112 @@ function ProjectsBody({ ); })} - {/* "Other" row — same visual style as a project, italic label, no summary/devs */} + {/* "Other" row — expandable when otherDetails is present */} {other && (() => { const otherTotal = other.commits + other.prs + other.jiras; const otherBarPct = (otherTotal / maxVolume) * 100; + const hasOtherDetail = !!(otherDetails?.jira_details?.length || otherDetails?.prs?.length); return ( -
-
-
- ~ - Other (not in top {sorted.length}) -
-
- ~{other.jiras} jiras - ~{other.commits} commits - ~{other.prs} PRs +
+
{ if (hasOtherDetail) { setOtherExpanded(e => !e); setOtherTab('jiras'); } }} + > +
+
+ ~ + Other (not in top {sorted.length}) + {hasOtherDetail && ( + + )} +
+
+ ~{other.jiras} jiras + ~{other.prs} PRs +
-
- {otherTotal > 0 && ( -
-
-
-
-
-
+ {otherTotal > 0 && ( +
+
+
+
+
+
+
+ )} +
Approximate remainder — actual totals minus LLM cluster estimates
+
+ + {/* Inline expand panel for Other */} + {otherExpanded && hasOtherDetail && ( +
+
+ {(['jiras', 'prs'] as const).map(tab => { + const count = tab === 'jiras' ? (otherDetails?.jira_details?.length ?? 0) : (otherDetails?.prs?.length ?? 0); + if (count === 0) return null; + return ( + + ); + })} +
+ + {otherTab === 'jiras' && ( +
+ {(otherDetails?.jira_details ?? []).slice(0, 12).map((j: JiraDetail) => ( +
+ {j.key} + {j.summary} +
+ {j.assignee && @{j.assignee.slice(0, 10)}} + {(j.linked_commits ?? 0) > 0 && ( + + {j.linked_commits}c{(j.linked_prs ?? 0) > 0 ? ` ${j.linked_prs}pr` : ''} + + )} +
+
+ ))} + {(otherDetails?.jira_details?.length ?? 0) > 12 && ( +

+ {(otherDetails?.jira_details?.length ?? 0) - 12} more

+ )} +
+ )} + + {otherTab === 'prs' && ( +
+ {(otherDetails?.prs ?? []).slice(0, 12).map((pr: PrDetail) => ( +
+ #{pr.pr} + {pr.msg} +
+ {pr.repo.split('/').pop()?.slice(0, 14)} + {pr.commits}c + +{pr.added} + -{pr.removed} +
+
+ ))} + {(otherDetails?.prs?.length ?? 0) > 12 && ( +

+ {(otherDetails?.prs?.length ?? 0) - 12} more PRs

+ )} +
+ )}
)} -
Approximate remainder — actual totals minus LLM cluster estimates
); })()} @@ -405,6 +480,7 @@ export default function ProjectsCard({ developerHref, actualTotals, otherTotals, + otherDetails, collapsible = false, expanded = true, onExpandedChange, @@ -449,6 +525,7 @@ export default function ProjectsCard({ variant="collapsible" actualTotals={actualTotals} otherTotals={otherTotals} + otherDetails={otherDetails} />
)} From 8864b3fd095c4dbdcdeb96b9a46a5be33f9f1f16 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 15:21:37 -0400 Subject: [PATCH 09/12] fix(glook-25): add JiraDetail/PrDetail/CommitDetail to ProjectInsight type so fields flow to ProjectsCard --- src/app/llm-findings.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/llm-findings.tsx b/src/app/llm-findings.tsx index 0632a38..2a51df7 100644 --- a/src/app/llm-findings.tsx +++ b/src/app/llm-findings.tsx @@ -9,6 +9,8 @@ interface Highlight { sentiment: 'positive' | 'neutral' | 'warning'; } +import type { JiraDetail, ProjectGroup, PrDetail, CommitDetail } from '@/components/ProjectsCard'; + interface ProjectInsight { name: string; developers: string[]; @@ -16,6 +18,10 @@ interface ProjectInsight { jira_count: number; estimated_commits: number; estimated_prs: number; + jira_details?: JiraDetail[]; + groups?: ProjectGroup[]; + prs?: PrDetail[]; + commits?: CommitDetail[]; } interface UntrackedWork { From 1f471cc688f25353bcb56cfd5a512daeebc10f4c Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 15:24:16 -0400 Subject: [PATCH 10/12] fix(glook-25): pass otherDetails to standalone ProjectsBody (home page) --- src/components/ProjectsCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index 444fd3b..5669b49 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -552,6 +552,7 @@ export default function ProjectsCard({ variant="standalone" actualTotals={actualTotals} otherTotals={otherTotals} + otherDetails={otherDetails} />
); From 29a06acb306e0ee44860ea3bcd7a4b8314f30875 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 16:27:30 -0400 Subject: [PATCH 11/12] feat(glook-25): show-all expand on + N more, group Other Jiras by project key Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/ProjectsCard.tsx | 251 ++++++++++++++++++++------------ 1 file changed, 160 insertions(+), 91 deletions(-) diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index 5669b49..c24606a 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -120,6 +120,13 @@ function ProjectsBody({ const [activeTab, setActiveTab] = useState<'jiras' | 'prs' | 'commits'>('jiras'); const [otherExpanded, setOtherExpanded] = useState(false); const [otherTab, setOtherTab] = useState<'jiras' | 'prs'>('jiras'); + // Set of "projectIdx:tab" strings where the full list is shown (no truncation) + const [showAll, setShowAll] = useState>(new Set()); + const toggleShowAll = (key: string) => setShowAll(prev => { + const next = new Set(prev); + next.has(key) ? next.delete(key) : next.add(key); + return next; + }); // All hooks must be declared before any early returns (Rules of Hooks). // Sort by meaningful output (PRs + Jiras). Commits excluded from sort key @@ -290,68 +297,97 @@ function ProjectsBody({ {/* Jiras tab — grouped by epic */} {activeTab === 'jiras' && (
- {(p.groups?.length ? p.groups : [{ name: '', jira_details: p.jira_details ?? [] }]).map((g: ProjectGroup, gi: number) => ( -
- {g.name &&

{g.name}

} -
- {g.jira_details.slice(0, 8).map((j: JiraDetail) => ( -
- {j.key} - {j.summary} -
- {j.assignee && @{j.assignee.slice(0, 10)}} - {(j.linked_commits ?? 0) > 0 && ( - - {j.linked_commits}c{(j.linked_prs ?? 0) > 0 ? ` ${j.linked_prs}pr` : ''} - - )} + {(p.groups?.length ? p.groups : [{ name: '', jira_details: p.jira_details ?? [] }]).map((g: ProjectGroup, gi: number) => { + const showAllKey = `${i}-jiras-${gi}`; + const all = showAll.has(showAllKey); + const visible = all ? g.jira_details : g.jira_details.slice(0, 8); + return ( +
+ {g.name &&

{g.name}

} +
+ {visible.map((j: JiraDetail) => ( +
+ {j.key} + {j.summary} +
+ {j.assignee && @{j.assignee.slice(0, 10)}} + {(j.linked_commits ?? 0) > 0 && ( + + {j.linked_commits}c{(j.linked_prs ?? 0) > 0 ? ` ${j.linked_prs}pr` : ''} + + )} +
-
- ))} - {g.jira_details.length > 8 &&

+ {g.jira_details.length - 8} more

} + ))} + {g.jira_details.length > 8 && ( + + )} +
-
- ))} + ); + })}
)} {/* PRs tab */} - {activeTab === 'prs' && ( -
- {(p.prs ?? []).slice(0, 12).map((pr: PrDetail) => ( -
- #{pr.pr} - {pr.msg} -
- {pr.repo.split('/').pop()?.slice(0, 14)} - {pr.commits}c - +{pr.added} - -{pr.removed} + {activeTab === 'prs' && (() => { + const showAllKey = `${i}-prs`; + const all = showAll.has(showAllKey); + const prs = p.prs ?? []; + const visible = all ? prs : prs.slice(0, 12); + return ( +
+ {visible.map((pr: PrDetail) => ( +
+ #{pr.pr} + {pr.msg} +
+ {pr.repo.split('/').pop()?.slice(0, 14)} + {pr.commits}c + +{pr.added} + -{pr.removed} +
-
- ))} - {(p.prs ?? []).length > 12 &&

+ {(p.prs ?? []).length - 12} more PRs

} -
- )} + ))} + {prs.length > 12 && ( + + )} +
+ ); + })()} {/* Commits tab */} - {activeTab === 'commits' && ( -
- {(p.commits ?? []).slice(0, 12).map((c: CommitDetail) => ( -
- {c.sha} - {c.pr && #{c.pr}} - {c.msg} -
- {c.repo.split('/').pop()?.slice(0, 14)} - +{c.added} - -{c.removed} + {activeTab === 'commits' && (() => { + const showAllKey = `${i}-commits`; + const all = showAll.has(showAllKey); + const commits = p.commits ?? []; + const visible = all ? commits : commits.slice(0, 12); + return ( +
+ {visible.map((c: CommitDetail) => ( +
+ {c.sha} + {c.pr && #{c.pr}} + {c.msg} +
+ {c.repo.split('/').pop()?.slice(0, 14)} + +{c.added} + -{c.removed} +
-
- ))} - {(p.commits ?? []).length > 12 &&

+ {(p.commits ?? []).length - 12} more commits

} -
- )} + ))} + {commits.length > 12 && ( + + )} +
+ ); + })()}
)}
@@ -421,47 +457,80 @@ function ProjectsBody({ })}
- {otherTab === 'jiras' && ( -
- {(otherDetails?.jira_details ?? []).slice(0, 12).map((j: JiraDetail) => ( -
- {j.key} - {j.summary} -
- {j.assignee && @{j.assignee.slice(0, 10)}} - {(j.linked_commits ?? 0) > 0 && ( - - {j.linked_commits}c{(j.linked_prs ?? 0) > 0 ? ` ${j.linked_prs}pr` : ''} - - )} -
-
- ))} - {(otherDetails?.jira_details?.length ?? 0) > 12 && ( -

+ {(otherDetails?.jira_details?.length ?? 0) - 12} more

- )} -
- )} + {otherTab === 'jiras' && (() => { + // Group by project key prefix (e.g., JOBS, DT, RPS, HUM) + const groups = new Map(); + for (const j of (otherDetails?.jira_details ?? [])) { + const prefix = j.key.split('-')[0]; + const arr = groups.get(prefix) ?? []; + arr.push(j); + groups.set(prefix, arr); + } + const sortedGroups = [...groups.entries()].sort((a, b) => b[1].length - a[1].length); + return ( +
+ {sortedGroups.map(([prefix, jiras]) => { + const showAllKey = `other-jiras-${prefix}`; + const all = showAll.has(showAllKey); + const visible = all ? jiras : jiras.slice(0, 8); + return ( +
+

{prefix} ({jiras.length})

+
+ {visible.map((j: JiraDetail) => ( +
+ {j.key} + {j.summary} +
+ {j.assignee && @{j.assignee.slice(0, 10)}} + {(j.linked_commits ?? 0) > 0 && ( + + {j.linked_commits}c{(j.linked_prs ?? 0) > 0 ? ` ${j.linked_prs}pr` : ''} + + )} +
+
+ ))} + {jiras.length > 8 && ( + + )} +
+
+ ); + })} +
+ ); + })()} - {otherTab === 'prs' && ( -
- {(otherDetails?.prs ?? []).slice(0, 12).map((pr: PrDetail) => ( -
- #{pr.pr} - {pr.msg} -
- {pr.repo.split('/').pop()?.slice(0, 14)} - {pr.commits}c - +{pr.added} - -{pr.removed} + {otherTab === 'prs' && (() => { + const showAllKey = 'other-prs'; + const all = showAll.has(showAllKey); + const prs = otherDetails?.prs ?? []; + const visible = all ? prs : prs.slice(0, 12); + return ( +
+ {visible.map((pr: PrDetail) => ( +
+ #{pr.pr} + {pr.msg} +
+ {pr.repo.split('/').pop()?.slice(0, 14)} + {pr.commits}c + +{pr.added} + -{pr.removed} +
-
- ))} - {(otherDetails?.prs?.length ?? 0) > 12 && ( -

+ {(otherDetails?.prs?.length ?? 0) - 12} more PRs

- )} -
- )} + ))} + {prs.length > 12 && ( + + )} +
+ ); + })()}
)}
From 3e0549689a1fc6d5d973801bbe89ef5aea03ba88 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 23 Jun 2026 16:48:04 -0400 Subject: [PATCH 12/12] =?UTF-8?q?fix(glook-25):=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20SHA=20dedup,=20regex=20/g=20flag,=20typed=20otherDetails,=20?= =?UTF-8?q?import=20order,=20otherTab=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/project-insights/route.ts | 23 +++++++++++++---------- src/app/llm-findings.tsx | 5 ++--- src/components/ProjectsCard.tsx | 2 +- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/app/api/project-insights/route.ts b/src/app/api/project-insights/route.ts index b1ca894..56994b0 100644 --- a/src/app/api/project-insights/route.ts +++ b/src/app/api/project-insights/route.ts @@ -86,16 +86,17 @@ async function getHandler() { arr.push(c); commitsByPr.set(c.pr_number, arr); } - // For linked_commits/prs badges: count commits whose message contains each Jira key + // For linked_commits/prs badges: count commits whose message contains each Jira key. + // Use /g flag to capture all keys in a single message (e.g. "Fix BRZ-190 and BRZ-191"). const commitsByJiraKey = new Map }>(); for (const c of allCommitRows) { - const match = (c.msg as string)?.match(/[A-Z]+-\d+/); - if (!match) continue; - const key = match[0]; - const entry = commitsByJiraKey.get(key) ?? { commits: 0, prs: new Set() }; - entry.commits++; - if (c.pr_number) entry.prs.add(c.pr_number); - commitsByJiraKey.set(key, entry); + const keys = (c.msg as string)?.match(/[A-Z]+-\d+/g) ?? []; + for (const key of keys) { + const entry = commitsByJiraKey.get(key) ?? { commits: 0, prs: new Set() }; + entry.commits++; + if (c.pr_number) entry.prs.add(c.pr_number); + commitsByJiraKey.set(key, entry); + } } // ── Build LLM inputs ───────────────────────────────────────────────────── @@ -256,11 +257,13 @@ ${noJiraData}${inflightBlock}`; const llmPrNums = new Set((p.pr_numbers ?? []).map(Number)); const llmShas = new Set(p.commit_shas ?? []); - // Gather all commits: by SHA (bare commits) + by PR number + // Gather all commits: by SHA (bare commits) + by PR number. + // Always key by full commit_sha so a commit reachable via both paths + // (in llmShas AND in a llmPrNum) is deduplicated correctly. const projCommitMap = new Map(); for (const sha of llmShas) { const c = commitBySha.get(sha); - if (c) projCommitMap.set(sha, c); + if (c) projCommitMap.set(c.commit_sha, c); } for (const prNum of llmPrNums) { for (const c of commitsByPr.get(prNum) ?? []) { diff --git a/src/app/llm-findings.tsx b/src/app/llm-findings.tsx index 2a51df7..55b5f71 100644 --- a/src/app/llm-findings.tsx +++ b/src/app/llm-findings.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import ProjectsCard from '@/components/ProjectsCard'; +import type { JiraDetail, ProjectGroup, PrDetail, CommitDetail } from '@/components/ProjectsCard'; interface Highlight { icon: string; @@ -9,8 +10,6 @@ interface Highlight { sentiment: 'positive' | 'neutral' | 'warning'; } -import type { JiraDetail, ProjectGroup, PrDetail, CommitDetail } from '@/components/ProjectsCard'; - interface ProjectInsight { name: string; developers: string[]; @@ -43,7 +42,7 @@ export default function LlmFindings() { const [projectsMeta, setProjectsMeta] = useState<{ id: string; org: string; periodDays: number; createdAt: string } | null>(null); const [projectTotals, setProjectTotals] = useState<{ commits: number; prs: number; jiras: number } | null>(null); const [otherTotals, setOtherTotals] = useState<{ jiras: number; prs: number } | null>(null); - const [otherDetails, setOtherDetails] = useState<{ jira_details: any[]; prs: any[] } | null>(null); + const [otherDetails, setOtherDetails] = useState<{ jira_details: JiraDetail[]; prs: PrDetail[] } | null>(null); const [projectsLoading, setProjectsLoading] = useState(true); // Release notes diff --git a/src/components/ProjectsCard.tsx b/src/components/ProjectsCard.tsx index c24606a..1c5df4b 100644 --- a/src/components/ProjectsCard.tsx +++ b/src/components/ProjectsCard.tsx @@ -404,7 +404,7 @@ function ProjectsBody({
{ if (hasOtherDetail) { setOtherExpanded(e => !e); setOtherTab('jiras'); } }} + onClick={() => { if (hasOtherDetail) { const expanding = !otherExpanded; setOtherExpanded(expanding); if (expanding) setOtherTab('jiras'); } }} >