From 3616f1691fe9f4289cdf38536c8e022c1d862d38 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 9 Jun 2026 17:31:07 -0400 Subject: [PATCH 1/8] docs(spec): GLOOK-19 in-flight work in Current Projects cards design --- ...06-09-glook-19-inflight-projects-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-glook-19-inflight-projects-design.md diff --git a/docs/superpowers/specs/2026-06-09-glook-19-inflight-projects-design.md b/docs/superpowers/specs/2026-06-09-glook-19-inflight-projects-design.md new file mode 100644 index 0000000..d66f3d0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-glook-19-inflight-projects-design.md @@ -0,0 +1,170 @@ +# GLOOK-19: Add In-Flight Work to Current Projects Cards + +## Goal + +Surface unmerged PRs and bare-branch commits in the "Current Projects" LLM clustering on both the per-team card (team page) and the org-wide card (home page), so both cards reflect what the team is actively working on — not just what has already shipped. + +## Scope + +Two independent surfaces share the same conceptual treatment but different code paths: + +| Surface | Entry point | Prompt | Cache | +|---|---|---|---| +| Team-page per-team card | `src/lib/team-pulse/projects.ts` | `prompts/team-pulse-projects.txt` | `team_pulse_summaries` (keyed by `prompt_version`) | +| Home-page org-wide card | `src/app/api/project-insights/route.ts` | Inline system prompt | `report_comparisons` (keyed by `report_id_a = report_id_b`) | + +The output type (`ProjectsCardItem` / `TeamProject`) and the `ProjectsCard` React component are unchanged. + +--- + +## Section 1: Data Layer + +### New Types (`src/lib/team-pulse/data.ts`) + +```typescript +export interface TeamProjectInflightPr { + repo: string; + title: string; + author: string; + additions: number; + deletions: number; + is_draft: boolean; +} + +export interface TeamProjectInflightBranch { + repo: string; + branch: string; + author: string; + commit_count: number; + lines: number; +} +``` + +### Extended `TeamProjectsInput` + +Add two new fields to the existing interface: + +```typescript +export interface TeamProjectsInput { + commits: TeamProjectCommit[]; + jira_issues: TeamProjectJiraIssue[]; + team_members: string[]; + in_flight_prs: TeamProjectInflightPr[]; // new + in_flight_branches: TeamProjectInflightBranch[]; // new +} +``` + +### `extractTeamProjectsData` (team-page) + +Add two SQL queries after the existing commit/Jira fetches: + +```sql +-- Top 30 open PRs for team members, ordered by size +SELECT repo, pr_title AS title, github_login AS author, + COALESCE(pr_additions, 0) AS additions, + COALESCE(pr_deletions, 0) AS deletions, + is_draft +FROM unmerged_prs +WHERE report_id = ? AND github_login IN (...) +ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC +LIMIT 30 +``` + +```sql +-- Top 10 bare branches (no PR) aggregated per repo+branch+author +SELECT repo, branch, github_login AS author, + COUNT(*) AS commit_count, + SUM(lines_added + lines_removed) AS lines +FROM unmerged_commits +WHERE report_id = ? AND github_login IN (...) AND pr_number IS NULL +GROUP BY repo, branch, github_login +ORDER BY lines DESC +LIMIT 10 +``` + +`is_draft` normalised to `boolean` (MySQL returns `0`/`1`; SQLite returns same — coerce via `r.is_draft === 1 || r.is_draft === true`). + +### Home-page (`src/app/api/project-insights/route.ts`) + +Two new inline SQL queries against the latest completed report (no new shared types — route is self-contained): + +```sql +SELECT repo, pr_title, github_login, pr_additions, pr_deletions, is_draft +FROM unmerged_prs +WHERE report_id = ? +ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC +LIMIT 30 +``` + +```sql +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 +``` + +Results rendered into `userMessage` as a pipe-delimited block (see Section 2). + +--- + +## Section 2: Prompt Changes + +### In-flight block format + +A shared rendering pattern used by both surfaces. When both lists are empty the block is omitted entirely (no heading, no empty section). + +``` +IN-FLIGHT WORK (open PRs + bare branches — not yet merged): + +OPEN PRs (N): +repo|pr_title|author|+additions/-deletions|draft +acme/frontend|Add pagination to jobs table|alice|+340/-12|no +acme/backend|WIP: retry logic for BRZ connector|bob|+89/-4|yes + +BARE BRANCHES (N): +repo|branch|author|commits|+lines/-lines +acme/infra|feat/k8s-autoscale|carol|7|+210/-30 +``` + +### Team-page prompt (`prompts/team-pulse-projects.txt`) + +Add placeholder `{{IN_FLIGHT_BLOCK}}` at the bottom of the template (after `JIRA ISSUES`). + +Add one rule to the RULES section: +> Use IN-FLIGHT WORK to enrich clusters — mix open PRs and bare branches into existing projects where they clearly belong. If in-flight work represents a coherent new thread with no committed counterpart, create a project for it. Draft PRs are included; let their content guide you on whether they belong to an existing project or a new one. + +Update `generateTeamProjects` in `src/lib/team-pulse/projects.ts` to render the block and pass it as `IN_FLIGHT_BLOCK` to `loadPrompt`. + +### Home-page prompt (`src/app/api/project-insights/route.ts`) + +Append the rendered block to `userMessage`. Add the same in-flight instruction to the inline system prompt (one sentence, matching the rule above). + +--- + +## Section 3: Cache Invalidation + +**Team-page:** Bump `PROMPT_VERSION` in `src/lib/team-pulse/service.ts` from `'v3-projects'` to `'v4-inflight'`. Old cache rows with `v3-projects` are automatically skipped on the next request. + +**Home-page:** Store `_v: 2` in the cached `highlights_json` object. On cache read, if `data._v !== 2` treat as a cache miss and regenerate. New responses are stored with `_v: 2`. + +--- + +## Section 4: Tests + +**`src/lib/__tests__/unit/team-projects-data.test.ts`** — new tests: +- `in_flight_prs` populated from `unmerged_prs`, ordered by size, capped at 30 +- `in_flight_branches` aggregates bare commits per branch, capped at 10 +- Both fields are `[]` when no in-flight rows exist +- `is_draft` coerced to boolean correctly + +**`src/lib/__tests__/unit/team-projects-generator.test.ts`** — new tests: +- Rendered prompt includes `IN-FLIGHT WORK` block when in-flight data is present +- Block is omitted when `in_flight_prs` and `in_flight_branches` are both empty + +**Snapshot update:** After editing `prompts/team-pulse-projects.txt`, run `npm test -- -u` to update the snapshot in `prompts.test.ts`. Review the diff to confirm the new placeholder and rule are present. + +**Home-page:** No new unit tests (consistent with existing coverage posture for this route). Cache `_v` check is straightforward. From 264b91d78205980b620fe4042545fccb3ecd8720 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 9 Jun 2026 17:41:57 -0400 Subject: [PATCH 2/8] docs(plan): GLOOK-19 in-flight projects implementation plan --- .../2026-06-09-glook-19-inflight-projects.md | 731 ++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-glook-19-inflight-projects.md diff --git a/docs/superpowers/plans/2026-06-09-glook-19-inflight-projects.md b/docs/superpowers/plans/2026-06-09-glook-19-inflight-projects.md new file mode 100644 index 0000000..8b89cc9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-glook-19-inflight-projects.md @@ -0,0 +1,731 @@ +# GLOOK-19: In-Flight Work in Current Projects Cards + +> **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 open PRs and bare-branch commits to both the per-team Current Projects LLM clustering (team page) and the org-wide project insights card (home page), so both cards reflect work in progress alongside shipped work. + +**Architecture:** Extend `TeamProjectsInput` with two new in-flight fields; fetch them in `extractTeamProjectsData` via two new SQL queries; render a compact pipe-delimited block in `generateTeamProjects` and pass it as `{{IN_FLIGHT_BLOCK}}` to the updated prompt template. Home-page route gets parallel inline treatment with a `_v: 2` cache-version guard. Bump team-page `PROMPT_VERSION` to `'v4-inflight'` to invalidate old cache rows. + +**Tech Stack:** TypeScript, Next.js 15, Jest + ts-jest, SQLite (dev) + MySQL (prod), existing `db.execute` + `loadPrompt` patterns. + +--- + +## File Map + +| File | Change | +|---|---| +| `src/lib/team-pulse/data.ts` | Add `TeamProjectInflightPr`, `TeamProjectInflightBranch` types; extend `TeamProjectsInput`; add 2 SQL queries in `extractTeamProjectsData` | +| `src/lib/team-pulse/projects.ts` | Add `renderInflightBlock`; update `generateTeamProjects` short-circuit + prompt call | +| `prompts/team-pulse-projects.txt` | Add `{{IN_FLIGHT_BLOCK}}` placeholder + in-flight rule | +| `src/lib/team-pulse/service.ts` | Bump `PROMPT_VERSION` to `'v4-inflight'` | +| `src/app/api/project-insights/route.ts` | Add 2 SQL queries; inline `renderInsightsInflightBlock`; add in-flight to prompt and user message; add `_v: 2` cache guard | +| `src/lib/__tests__/unit/team-projects-data.test.ts` | Add 2 mock calls to existing tests; add 3 new in-flight tests | +| `src/lib/__tests__/unit/team-projects-generator.test.ts` | Add `in_flight_prs`/`in_flight_branches` to `baseInput()`; add 2 new in-flight block tests | + +--- + +### Task 1: Extend TeamProjectsInput and extractTeamProjectsData (TDD) + +**Files:** +- Modify: `src/lib/team-pulse/data.ts:313-376` +- Modify: `src/lib/__tests__/unit/team-projects-data.test.ts` + +**Context:** `extractTeamProjectsData` currently runs 2 SQL queries (commits + jira). After this task it runs 4. Existing tests mock exactly 2 calls via `mockResolvedValueOnce` — they will break unless we add 2 more empty stubs. The `beforeEach` calls `exec.mockReset()` with no default, so any un-stubbed call throws. + +- [ ] **Step 1: Write the failing tests** + +Replace the entire content of `src/lib/__tests__/unit/team-projects-data.test.ts` with: + +```typescript +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { execute: jest.fn() }, +})); + +import db from '@/lib/db'; +import { extractTeamProjectsData, type TeamProjectsInput } from '@/lib/team-pulse/data'; + +const exec = db.execute as jest.Mock; + +beforeEach(() => exec.mockReset()); + +// Helper: stub all 4 DB calls with provided row arrays (default empty) +function stubCalls( + commitRows: any[] = [], + jiraRows: any[] = [], + prRows: any[] = [], + branchRows: any[] = [], +) { + exec + .mockResolvedValueOnce([commitRows, []]) + .mockResolvedValueOnce([jiraRows, []]) + .mockResolvedValueOnce([prRows, []]) + .mockResolvedValueOnce([branchRows, []]); +} + +describe('extractTeamProjectsData', () => { + it('returns empty input when team has no members', async () => { + const result = await extractTeamProjectsData('r1', []); + expect(result.commits).toEqual([]); + expect(result.jira_issues).toEqual([]); + expect(result.team_members).toEqual([]); + expect(result.in_flight_prs).toEqual([]); + expect(result.in_flight_branches).toEqual([]); + expect(exec).not.toHaveBeenCalled(); + }); + + it('filters commits + jira to team members for this report', async () => { + stubCalls( + [ + { sha: 'aaa', repo: 'svc', pr_number: 1, commit_message: 'fix bug', github_login: 'alice', total_lines: 50, committed_at: '2026-05-20T10:00:00Z' }, + { sha: 'bbb', repo: 'svc', pr_number: null, commit_message: 'wip', github_login: 'bob', total_lines: 5, committed_at: '2026-05-21T10:00:00Z' }, + ], + [{ issue_key: 'PROJ-1', project_key: 'PROJ', summary: 'Auth bug', github_login: 'alice', type: 'Bug', status: 'Done' }], + ); + + const result = await extractTeamProjectsData('r1', ['alice', 'bob']); + + expect(result.commits).toHaveLength(2); + expect(result.commits[0].github_login).toBe('alice'); + expect(result.commits[0].message_first_line).toBe('fix bug'); + expect(result.commits[0].lines).toBe(50); + expect(result.commits[1].lines).toBe(5); + expect(result.jira_issues).toHaveLength(1); + expect(result.team_members).toEqual(['alice', 'bob']); + expect(result.in_flight_prs).toEqual([]); + expect(result.in_flight_branches).toEqual([]); + + expect(exec).toHaveBeenCalledTimes(4); + expect(exec.mock.calls[0][1][0]).toBe('r1'); + expect(exec.mock.calls[0][1].slice(1)).toEqual(['alice', 'bob']); + }); + + it('caps commits at 200 (most recent first)', async () => { + const many = Array.from({ length: 250 }, (_, i) => ({ + sha: `sha${i}`, repo: 'svc', pr_number: null, + commit_message: `c${i}`, github_login: 'alice', + total_lines: 1, committed_at: `2026-05-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + })); + stubCalls(many); + + const result = await extractTeamProjectsData('r1', ['alice']); + expect(result.commits).toHaveLength(200); + }); + + it('populates in_flight_prs with boolean is_draft coercion', async () => { + stubCalls( + [], // commits + [], // jira + [ + { repo: 'r1', title: 'Big feature', author: 'alice', additions: 300, deletions: 20, is_draft: 0 }, + { repo: 'r2', title: 'WIP: refactor', author: 'bob', additions: 50, deletions: 10, is_draft: 1 }, + ], + ); + + const result = await extractTeamProjectsData('r1', ['alice', 'bob']); + + expect(result.in_flight_prs).toHaveLength(2); + expect(result.in_flight_prs[0]).toEqual({ + repo: 'r1', title: 'Big feature', author: 'alice', + additions: 300, deletions: 20, is_draft: false, + }); + expect(result.in_flight_prs[1]).toEqual({ + repo: 'r2', title: 'WIP: refactor', author: 'bob', + additions: 50, deletions: 10, is_draft: true, + }); + }); + + it('populates in_flight_branches from bare unmerged commits', async () => { + stubCalls( + [], [], [], // commits, jira, prs empty + [ + { repo: 'infra', branch: 'feat/k8s-autoscale', author: 'carol', commit_count: 7, lines: 240 }, + ], + ); + + const result = await extractTeamProjectsData('r1', ['carol']); + + expect(result.in_flight_branches).toHaveLength(1); + expect(result.in_flight_branches[0]).toEqual({ + repo: 'infra', branch: 'feat/k8s-autoscale', author: 'carol', + commit_count: 7, lines: 240, + }); + }); + + it('returns empty in_flight arrays when no in-flight data exists', async () => { + stubCalls(); + const result = await extractTeamProjectsData('r1', ['alice']); + expect(result.in_flight_prs).toEqual([]); + expect(result.in_flight_branches).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npm test -- --testPathPatterns="team-projects-data" --no-coverage +``` + +Expected: Multiple FAIL — `in_flight_prs` and `in_flight_branches` not found on result (property doesn't exist yet). + +- [ ] **Step 3: Add types and SQL to `src/lib/team-pulse/data.ts`** + +After the `TeamProjectsInput` interface (line 317) and before `extractTeamProjectsData`, add the two new interfaces. Then update the existing interface and function: + +```typescript +export interface TeamProjectInflightPr { + repo: string; + title: string; + author: string; + additions: number; + deletions: number; + is_draft: boolean; +} + +export interface TeamProjectInflightBranch { + repo: string; + branch: string; + author: string; + commit_count: number; + lines: number; +} + +export interface TeamProjectsInput { + commits: TeamProjectCommit[]; + jira_issues: TeamProjectJiraIssue[]; + team_members: string[]; + in_flight_prs: TeamProjectInflightPr[]; + in_flight_branches: TeamProjectInflightBranch[]; +} +``` + +Update the early-return in `extractTeamProjectsData` (line 323–325): + +```typescript + if (teamMembers.length === 0) { + return { commits: [], jira_issues: [], team_members: [], in_flight_prs: [], in_flight_branches: [] }; + } +``` + +After the existing `jiraRows` query (after line 352), add the two new queries: + +```typescript + const [prRows] = await db.execute( + `SELECT repo, + pr_title AS title, + github_login AS author, + COALESCE(pr_additions, 0) AS additions, + COALESCE(pr_deletions, 0) AS deletions, + is_draft + FROM unmerged_prs + WHERE report_id = ? AND github_login IN (${placeholders}) + ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC + LIMIT 30`, + [reportId, ...teamMembers], + ) as [any[], any]; + + const [branchRows] = await db.execute( + `SELECT repo, + branch, + github_login AS author, + COUNT(*) AS commit_count, + SUM(lines_added + lines_removed) AS lines + FROM unmerged_commits + WHERE report_id = ? AND github_login IN (${placeholders}) AND pr_number IS NULL + GROUP BY repo, branch, github_login + ORDER BY lines DESC + LIMIT 10`, + [reportId, ...teamMembers], + ) as [any[], any]; +``` + +Update the return statement (currently line 371–375): + +```typescript + return { + commits, + jira_issues: jiraRows as TeamProjectJiraIssue[], + team_members: [...teamMembers], + in_flight_prs: (prRows as any[]).map(r => ({ + repo: String(r.repo ?? ''), + title: String(r.title ?? ''), + author: String(r.author ?? ''), + additions: Number(r.additions ?? 0), + deletions: Number(r.deletions ?? 0), + is_draft: r.is_draft === 1 || r.is_draft === true, + })), + in_flight_branches: (branchRows as any[]).map(r => ({ + repo: String(r.repo ?? ''), + branch: String(r.branch ?? ''), + author: String(r.author ?? ''), + commit_count: Number(r.commit_count ?? 0), + lines: Number(r.lines ?? 0), + })), + }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm test -- --testPathPatterns="team-projects-data" --no-coverage +``` + +Expected: All 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/team-pulse/data.ts src/lib/__tests__/unit/team-projects-data.test.ts +git commit -m "feat(glook-19): extend TeamProjectsInput with in-flight types + SQL queries" +``` + +--- + +### Task 2: Add renderInflightBlock and update generateTeamProjects (TDD) + +**Files:** +- Modify: `src/lib/team-pulse/projects.ts` +- Modify: `src/lib/__tests__/unit/team-projects-generator.test.ts` + +**Context:** `generateTeamProjects` currently short-circuits to `[]` when `commits` and `jira_issues` are both empty. We extend the short-circuit to also check the new in-flight fields. We add `renderInflightBlock` (module-private function) and pass its output as `IN_FLIGHT_BLOCK` to `loadPrompt`. The `baseInput()` factory in the test file must be updated to include the new fields — existing tests still pass because they leave in-flight arrays empty. + +- [ ] **Step 1: Update `baseInput()` and write new failing tests** + +At the top of `src/lib/__tests__/unit/team-projects-generator.test.ts`, update `baseInput` and add two new tests at the end of the `describe` block: + +Update `baseInput()` to include the new fields: + +```typescript +const baseInput = (): TeamProjectsInput => ({ + team_members: ['alice', 'bob'], + commits: [ + { sha: 'a1', repo: 'r1', pr_number: 1, message_first_line: 'feat: x', + github_login: 'alice', lines: 10, committed_at: '2026-05-20T10:00:00Z' }, + { sha: 'b1', repo: 'r1', pr_number: 2, message_first_line: 'fix: y', + github_login: 'bob', lines: 5, committed_at: '2026-05-21T11:00:00Z' }, + ], + jira_issues: [], + in_flight_prs: [], + in_flight_branches: [], +}); +``` + +Add two new tests inside `describe('generateTeamProjects', ...)`: + +```typescript + it('includes IN-FLIGHT WORK block in prompt when in-flight data is present', async () => { + mockGetLLMClient.mockResolvedValueOnce(makeClient(JSON.stringify({ + projects: [{ name: 'P1', summary: 's', developers: ['alice'], + jira_count: 0, estimated_commits: 1, estimated_prs: 1, + last_activity: '2026-05-20T10:00:00Z' }], + }))); + + const input: TeamProjectsInput = { + ...baseInput(), + in_flight_prs: [ + { repo: 'r1', title: 'Add jobs pagination', author: 'alice', additions: 120, deletions: 5, is_draft: false }, + ], + in_flight_branches: [], + }; + + await generateTeamProjects(input); + + const callArgs = (mockGetLLMClient.mock.results[0].value as any).chat.completions.create.mock.calls[0][0]; + const systemPrompt: string = callArgs.messages[0].content; + expect(systemPrompt).toContain('IN-FLIGHT WORK'); + expect(systemPrompt).toContain('Add jobs pagination'); + expect(systemPrompt).toContain('OPEN PRs (1)'); + }); + + it('omits IN-FLIGHT WORK block when in_flight_prs and in_flight_branches are empty', async () => { + mockGetLLMClient.mockResolvedValueOnce(makeClient(JSON.stringify({ + projects: [{ name: 'P1', summary: 's', developers: ['alice'], + jira_count: 0, estimated_commits: 1, estimated_prs: 1, + last_activity: '2026-05-20T10:00:00Z' }], + }))); + + await generateTeamProjects(baseInput()); + + const callArgs = (mockGetLLMClient.mock.results[0].value as any).chat.completions.create.mock.calls[0][0]; + const systemPrompt: string = callArgs.messages[0].content; + expect(systemPrompt).not.toContain('IN-FLIGHT WORK'); + }); +``` + +- [ ] **Step 2: Run test to verify new tests fail** + +```bash +npm test -- --testPathPatterns="team-projects-generator" --no-coverage +``` + +Expected: The two new tests FAIL (TypeScript error on `baseInput` since `TeamProjectsInput` now requires `in_flight_prs`/`in_flight_branches`, and prompt doesn't contain `IN-FLIGHT WORK` yet). Existing tests should still pass once `baseInput` is updated. + +- [ ] **Step 3: Add `renderInflightBlock` and update `generateTeamProjects` in `src/lib/team-pulse/projects.ts`** + +Add the import for the new types at the top (update the existing import line): + +```typescript +import type { TeamProjectsInput, TeamProjectInflightPr, TeamProjectInflightBranch } from './data'; +``` + +Add `renderInflightBlock` as a module-private function before `generateTeamProjects`: + +```typescript +function renderInflightBlock( + prs: TeamProjectInflightPr[], + branches: TeamProjectInflightBranch[], +): string { + if (prs.length === 0 && branches.length === 0) return ''; + const lines: string[] = ['IN-FLIGHT WORK (open PRs + bare branches — not yet merged):']; + if (prs.length > 0) { + lines.push('', `OPEN PRs (${prs.length}):`, 'repo|pr_title|author|+additions/-deletions|draft'); + for (const pr of prs) { + lines.push(`${pr.repo}|${pr.title}|${pr.author}|+${pr.additions}/-${pr.deletions}|${pr.is_draft ? 'yes' : 'no'}`); + } + } + if (branches.length > 0) { + lines.push('', `BARE BRANCHES (${branches.length}):`, 'repo|branch|author|commits|lines'); + for (const b of branches) { + lines.push(`${b.repo}|${b.branch}|${b.author}|${b.commit_count}|${b.lines}`); + } + } + return lines.join('\n'); +} +``` + +Update the short-circuit check in `generateTeamProjects` (currently checks only `commits` and `jira_issues`): + +```typescript + if (data.commits.length === 0 && data.jira_issues.length === 0 && + data.in_flight_prs.length === 0 && data.in_flight_branches.length === 0) { + return []; + } +``` + +Update the `loadPrompt` call in `generateTeamProjects` to pass the in-flight block: + +```typescript + const inflightBlock = renderInflightBlock(data.in_flight_prs, data.in_flight_branches); + const systemPrompt = loadPrompt('team-pulse-projects.txt', { + TEAM_NAME: teamName, + TEAM_MEMBERS_JSON: JSON.stringify(data.team_members), + COMMITS_JSON: JSON.stringify(data.commits), + JIRA_ISSUES_JSON: JSON.stringify(data.jira_issues), + IN_FLIGHT_BLOCK: inflightBlock, + }); +``` + +- [ ] **Step 4: Run tests — expect one specific failure** + +```bash +npm test -- --testPathPatterns="team-projects-generator" --no-coverage +``` + +Expected: 8 PASS, 1 FAIL — only `'includes IN-FLIGHT WORK block in prompt when in-flight data is present'` fails. This is correct: `loadPrompt` uses `text.replaceAll('{{IN_FLIGHT_BLOCK}}', value)`, so until Task 3 adds `{{IN_FLIGHT_BLOCK}}` to the template, passing the key has no effect and the rendered prompt never contains `IN-FLIGHT WORK`. The "omits" test passes because the rendered template also doesn't contain `IN-FLIGHT WORK` (same reason). Task 3 will fix the remaining failure. + +- [ ] **Step 5: Run full test suite to confirm no regressions beyond the expected failure** + +```bash +npm test --no-coverage +``` + +Expected: 1 FAIL (`includes IN-FLIGHT WORK`), all others pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/team-pulse/projects.ts src/lib/__tests__/unit/team-projects-generator.test.ts +git commit -m "feat(glook-19): renderInflightBlock + update generateTeamProjects prompt call" +``` + +--- + +### Task 3: Update prompt template, bump PROMPT_VERSION, update snapshot + +**Files:** +- Modify: `prompts/team-pulse-projects.txt` +- Modify: `src/lib/team-pulse/service.ts` +- Update snapshot: run `npm test -- --testPathPatterns="prompts" -u` + +**Context:** `prompts/team-pulse-projects.txt` has a Jest snapshot test in `prompts.test.ts` that asserts the exact file content. After editing the template, run `npm test -- --testPathPatterns="prompts" -u` to accept the new snapshot. Review the diff to confirm only the expected additions appear. + +- [ ] **Step 1: Update `prompts/team-pulse-projects.txt`** + +Add one rule to the RULES section (after the `last_activity` rule, before "Output is a JSON object"): + +``` +- Use IN-FLIGHT WORK to enrich clusters — mix open PRs and bare branches into existing projects where they clearly belong. If in-flight work represents a coherent new thread with no committed counterpart, create a project for it. Draft PRs are included. +``` + +Add at the very end of the file (after `{{JIRA_ISSUES_JSON}}`): + +``` + +{{IN_FLIGHT_BLOCK}} +``` + +The final lines of the file should look like: + +``` +JIRA ISSUES: +{{JIRA_ISSUES_JSON}} + +{{IN_FLIGHT_BLOCK}} +``` + +- [ ] **Step 2: Bump PROMPT_VERSION in `src/lib/team-pulse/service.ts`** + +Change line 9: + +```typescript +const PROMPT_VERSION = 'v4-inflight'; +``` + +- [ ] **Step 3: Update the prompt snapshot** + +```bash +npm test -- --testPathPatterns="prompts" --no-coverage -u +``` + +Expected: Snapshot updated. Output shows `1 snapshot updated`. + +- [ ] **Step 4: Verify the snapshot diff shows only expected additions** + +```bash +git diff src/lib/__tests__/unit/__snapshots__/ +``` + +The diff should show: the new rule line, the `{{IN_FLIGHT_BLOCK}}` placeholder, and nothing else. + +- [ ] **Step 5: Run full test suite** + +```bash +npm test --no-coverage +``` + +Expected: All tests pass, including the 2 new generator tests (now that `{{IN_FLIGHT_BLOCK}}` is in the template). + +- [ ] **Step 6: Commit** + +```bash +git add prompts/team-pulse-projects.txt src/lib/team-pulse/service.ts src/lib/__tests__/unit/__snapshots__/ +git commit -m "feat(glook-19): update team-pulse-projects prompt with in-flight block, bump PROMPT_VERSION" +``` + +--- + +### Task 4: Update project-insights route (home page) + +**Files:** +- Modify: `src/app/api/project-insights/route.ts` + +**Context:** This route is self-contained — it has its own inline LLM prompt, its own SQL queries, and caches results in `report_comparisons` (keyed by `report_id_a = report_id_b = report.id`). We add a `_v: 2` cache-version guard: if cached data lacks `_v: 2`, it's treated as stale and regenerated. New results are stored with `_v: 2`. No shared types with the team-pulse layer — the render function is inlined in this file. + +- [ ] **Step 1: Add `renderInsightsInflightBlock` helper function** + +Add this function to `src/app/api/project-insights/route.ts`, directly before the `async function getHandler()` declaration: + +```typescript +function renderInsightsInflightBlock(prs: any[], branches: any[]): string { + if (prs.length === 0 && branches.length === 0) return ''; + const lines: string[] = ['\nIN-FLIGHT WORK (open PRs + bare branches — not yet merged):']; + if (prs.length > 0) { + lines.push(`\nOPEN PRs (${prs.length}):`, 'repo|pr_title|author|+additions/-deletions|draft'); + for (const pr of prs) { + const isDraft = pr.is_draft === 1 || pr.is_draft === true ? 'yes' : 'no'; + lines.push(`${pr.repo}|${pr.pr_title}|${pr.github_login}|+${Number(pr.pr_additions ?? 0)}/-${Number(pr.pr_deletions ?? 0)}|${isDraft}`); + } + } + if (branches.length > 0) { + lines.push(`\nBARE BRANCHES (${branches.length}):`, 'repo|branch|author|commits|lines'); + for (const b of branches) { + lines.push(`${b.repo}|${b.branch}|${b.github_login}|${b.commit_count}|${b.total_lines}`); + } + } + return lines.join('\n'); +} +``` + +- [ ] **Step 2: Update the cache-read guard to check `_v: 2`** + +Find the cache check block: + +```typescript + if (cached.length > 0) { + const data = typeof cached[0].highlights_json === 'string' + ? JSON.parse(cached[0].highlights_json) + : cached[0].highlights_json; + return NextResponse.json({ + available: true, + report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at }, + ...data, + cached: true, + }); + } +``` + +Replace it with: + +```typescript + 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) { + 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, + cached: true, + }); + } + // _v !== 2: stale cache (no in-flight data) — fall through to regenerate + } +``` + +- [ ] **Step 3: Add two in-flight SQL queries after the existing `noJiraCommits` query** + +After the `noJiraCommits` query block, add: + +```typescript + // 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`, + [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`, + [report.id], + ) as [any[], any]; + + const inflightBlock = renderInsightsInflightBlock(inflightPrRows, inflightBranchRows); +``` + +- [ ] **Step 4: Append in-flight block to `userMessage`** + +Find the `userMessage` template literal. It currently ends with: + +```typescript + const userMessage = `JIRA ISSUES (${jiraRows.length} total): +${jiraData} + +DEVELOPER STATS (login | commits | PRs): +${devData} + +GITHUB COMMITS WITHOUT JIRA (top 30 by size): +${noJiraData}`; +``` + +Add the in-flight block: + +```typescript + const userMessage = `JIRA ISSUES (${jiraRows.length} total): +${jiraData} + +DEVELOPER STATS (login | commits | PRs): +${devData} + +GITHUB COMMITS WITHOUT JIRA (top 30 by size): +${noJiraData}${inflightBlock}`; +``` + +- [ ] **Step 5: Add in-flight instruction to the inline system prompt** + +Find the `Rules:` section in `systemPrompt`. After the last existing rule (`- Return ONLY raw JSON`), add: + +``` +- 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. +``` + +- [ ] **Step 6: Store `_v: 2` in the cached result** + +Find the cache write: + +```typescript + 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(parsed)], + ); + + return NextResponse.json({ + available: true, + report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at }, + projects: parsed.projects || [], + untracked_work: parsed.untracked_work || [], + cached: false, + }); +``` + +Replace with: + +```typescript + const toCache = { _v: 2, projects: parsed.projects || [], untracked_work: parsed.untracked_work || [] }; + 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, + cached: false, + }); +``` + +- [ ] **Step 7: Run the full test suite** + +```bash +npm test --no-coverage +``` + +Expected: All tests pass. The project-insights route has no unit tests, so no new test failures are expected. + +- [ ] **Step 8: Commit** + +```bash +git add src/app/api/project-insights/route.ts +git commit -m "feat(glook-19): add in-flight PRs + branches to project-insights route, cache v2" +``` + +--- + +### Task 5: Final verification + +- [ ] **Step 1: Run the full test suite one final time** + +```bash +npm test --no-coverage +``` + +Expected: All tests pass. + +- [ ] **Step 2: Verify TypeScript compilation** + +```bash +npx tsc --noEmit +``` + +Expected: No errors. In particular, verify that `TeamProjectsInput` usage in `projects.ts` (the `data.in_flight_prs` / `data.in_flight_branches` access) and the updated `extractTeamProjectsData` return type are consistent. From e5594136039e2883e6646a22e823591e1de089e9 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 9 Jun 2026 17:44:42 -0400 Subject: [PATCH 3/8] feat(glook-19): extend TeamProjectsInput with in-flight types + SQL queries Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../__tests__/unit/team-projects-data.test.ts | 84 ++++++++++++++++--- src/lib/team-pulse/data.ts | 64 +++++++++++++- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/src/lib/__tests__/unit/team-projects-data.test.ts b/src/lib/__tests__/unit/team-projects-data.test.ts index d9c1052..3dee968 100644 --- a/src/lib/__tests__/unit/team-projects-data.test.ts +++ b/src/lib/__tests__/unit/team-projects-data.test.ts @@ -10,38 +10,53 @@ const exec = db.execute as jest.Mock; beforeEach(() => exec.mockReset()); +// Helper: stub all 4 DB calls with provided row arrays (default empty) +function stubCalls( + commitRows: any[] = [], + jiraRows: any[] = [], + prRows: any[] = [], + branchRows: any[] = [], +) { + exec + .mockResolvedValueOnce([commitRows, []]) + .mockResolvedValueOnce([jiraRows, []]) + .mockResolvedValueOnce([prRows, []]) + .mockResolvedValueOnce([branchRows, []]); +} + describe('extractTeamProjectsData', () => { it('returns empty input when team has no members', async () => { const result = await extractTeamProjectsData('r1', []); expect(result.commits).toEqual([]); expect(result.jira_issues).toEqual([]); expect(result.team_members).toEqual([]); + expect(result.in_flight_prs).toEqual([]); + expect(result.in_flight_branches).toEqual([]); expect(exec).not.toHaveBeenCalled(); }); it('filters commits + jira to team members for this report', async () => { - exec - .mockResolvedValueOnce([[ + stubCalls( + [ { sha: 'aaa', repo: 'svc', pr_number: 1, commit_message: 'fix bug', github_login: 'alice', total_lines: 50, committed_at: '2026-05-20T10:00:00Z' }, { sha: 'bbb', repo: 'svc', pr_number: null, commit_message: 'wip', github_login: 'bob', total_lines: 5, committed_at: '2026-05-21T10:00:00Z' }, - ], []]) - .mockResolvedValueOnce([[ - { issue_key: 'PROJ-1', project_key: 'PROJ', summary: 'Auth bug', github_login: 'alice', type: 'Bug', status: 'Done' }, - ], []]); + ], + [{ issue_key: 'PROJ-1', project_key: 'PROJ', summary: 'Auth bug', github_login: 'alice', type: 'Bug', status: 'Done' }], + ); const result = await extractTeamProjectsData('r1', ['alice', 'bob']); expect(result.commits).toHaveLength(2); expect(result.commits[0].github_login).toBe('alice'); expect(result.commits[0].message_first_line).toBe('fix bug'); - // Locks in the total_lines → lines column-alias mapping (PR review S1). expect(result.commits[0].lines).toBe(50); expect(result.commits[1].lines).toBe(5); expect(result.jira_issues).toHaveLength(1); expect(result.team_members).toEqual(['alice', 'bob']); + expect(result.in_flight_prs).toEqual([]); + expect(result.in_flight_branches).toEqual([]); - // Both queries used parameter binding for report_id and team_members - expect(exec).toHaveBeenCalledTimes(2); + expect(exec).toHaveBeenCalledTimes(4); expect(exec.mock.calls[0][1][0]).toBe('r1'); expect(exec.mock.calls[0][1].slice(1)).toEqual(['alice', 'bob']); }); @@ -52,11 +67,56 @@ describe('extractTeamProjectsData', () => { commit_message: `c${i}`, github_login: 'alice', total_lines: 1, committed_at: `2026-05-${String(i + 1).padStart(2, '0')}T10:00:00Z`, })); - exec - .mockResolvedValueOnce([many, []]) - .mockResolvedValueOnce([[], []]); + stubCalls(many); const result = await extractTeamProjectsData('r1', ['alice']); expect(result.commits).toHaveLength(200); }); + + it('populates in_flight_prs with boolean is_draft coercion', async () => { + stubCalls( + [], // commits + [], // jira + [ + { repo: 'r1', title: 'Big feature', author: 'alice', additions: 300, deletions: 20, is_draft: 0 }, + { repo: 'r2', title: 'WIP: refactor', author: 'bob', additions: 50, deletions: 10, is_draft: 1 }, + ], + ); + + const result = await extractTeamProjectsData('r1', ['alice', 'bob']); + + expect(result.in_flight_prs).toHaveLength(2); + expect(result.in_flight_prs[0]).toEqual({ + repo: 'r1', title: 'Big feature', author: 'alice', + additions: 300, deletions: 20, is_draft: false, + }); + expect(result.in_flight_prs[1]).toEqual({ + repo: 'r2', title: 'WIP: refactor', author: 'bob', + additions: 50, deletions: 10, is_draft: true, + }); + }); + + it('populates in_flight_branches from bare unmerged commits', async () => { + stubCalls( + [], [], [], // commits, jira, prs empty + [ + { repo: 'infra', branch: 'feat/k8s-autoscale', author: 'carol', commit_count: 7, lines: 240 }, + ], + ); + + const result = await extractTeamProjectsData('r1', ['carol']); + + expect(result.in_flight_branches).toHaveLength(1); + expect(result.in_flight_branches[0]).toEqual({ + repo: 'infra', branch: 'feat/k8s-autoscale', author: 'carol', + commit_count: 7, lines: 240, + }); + }); + + it('returns empty in_flight arrays when no in-flight data exists', async () => { + stubCalls(); + const result = await extractTeamProjectsData('r1', ['alice']); + expect(result.in_flight_prs).toEqual([]); + expect(result.in_flight_branches).toEqual([]); + }); }); diff --git a/src/lib/team-pulse/data.ts b/src/lib/team-pulse/data.ts index 7922b5a..8fa564d 100644 --- a/src/lib/team-pulse/data.ts +++ b/src/lib/team-pulse/data.ts @@ -310,10 +310,29 @@ export interface TeamProjectJiraIssue { status: string | null; } +export interface TeamProjectInflightPr { + repo: string; + title: string; + author: string; + additions: number; + deletions: number; + is_draft: boolean; +} + +export interface TeamProjectInflightBranch { + repo: string; + branch: string; + author: string; + commit_count: number; + lines: number; +} + export interface TeamProjectsInput { commits: TeamProjectCommit[]; jira_issues: TeamProjectJiraIssue[]; team_members: string[]; + in_flight_prs: TeamProjectInflightPr[]; + in_flight_branches: TeamProjectInflightBranch[]; } export async function extractTeamProjectsData( @@ -321,7 +340,7 @@ export async function extractTeamProjectsData( teamMembers: string[], ): Promise { if (teamMembers.length === 0) { - return { commits: [], jira_issues: [], team_members: [] }; + return { commits: [], jira_issues: [], team_members: [], in_flight_prs: [], in_flight_branches: [] }; } const placeholders = teamMembers.map(() => '?').join(','); @@ -351,6 +370,34 @@ export async function extractTeamProjectsData( [reportId, ...teamMembers], ) as [any[], any]; + const [prRows] = await db.execute( + `SELECT repo, + pr_title AS title, + github_login AS author, + COALESCE(pr_additions, 0) AS additions, + COALESCE(pr_deletions, 0) AS deletions, + is_draft + FROM unmerged_prs + WHERE report_id = ? AND github_login IN (${placeholders}) + ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC + LIMIT 30`, + [reportId, ...teamMembers], + ) as [any[], any]; + + const [branchRows] = await db.execute( + `SELECT repo, + branch, + github_login AS author, + COUNT(*) AS commit_count, + SUM(lines_added + lines_removed) AS lines + FROM unmerged_commits + WHERE report_id = ? AND github_login IN (${placeholders}) AND pr_number IS NULL + GROUP BY repo, branch, github_login + ORDER BY lines DESC + LIMIT 10`, + [reportId, ...teamMembers], + ) as [any[], any]; + const commits: TeamProjectCommit[] = (commitRows as any[]).map(r => ({ sha: r.sha, repo: r.repo, @@ -372,5 +419,20 @@ export async function extractTeamProjectsData( commits, jira_issues: jiraRows as TeamProjectJiraIssue[], team_members: [...teamMembers], + in_flight_prs: (prRows as any[]).map(r => ({ + repo: String(r.repo ?? ''), + title: String(r.title ?? ''), + author: String(r.author ?? ''), + additions: Number(r.additions ?? 0), + deletions: Number(r.deletions ?? 0), + is_draft: r.is_draft === 1 || r.is_draft === true, + })), + in_flight_branches: (branchRows as any[]).map(r => ({ + repo: String(r.repo ?? ''), + branch: String(r.branch ?? ''), + author: String(r.author ?? ''), + commit_count: Number(r.commit_count ?? 0), + lines: Number(r.lines ?? 0), + })), }; } From f7f6da74b7afc78cff269d5749fce1a616d33e0d Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 9 Jun 2026 17:52:21 -0400 Subject: [PATCH 4/8] feat(glook-19): renderInflightBlock + update generateTeamProjects prompt call Adds renderInflightBlock (module-private) that formats open PRs and bare branches into a plain-text IN-FLIGHT WORK section. Updates generateTeamProjects to extend the short-circuit check with the new in-flight fields, call renderInflightBlock, and pass IN_FLIGHT_BLOCK to loadPrompt. Updates tests: baseInput() includes in_flight_prs/in_flight_branches, short-circuit test includes the new required fields, and two new tests cover the inflight block (one expected-fail pending Task 3 template update). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../unit/team-projects-generator.test.ts | 44 ++++++++++++++++++- src/lib/team-pulse/projects.ts | 28 +++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/lib/__tests__/unit/team-projects-generator.test.ts b/src/lib/__tests__/unit/team-projects-generator.test.ts index 8f49b1c..a30efb8 100644 --- a/src/lib/__tests__/unit/team-projects-generator.test.ts +++ b/src/lib/__tests__/unit/team-projects-generator.test.ts @@ -28,13 +28,15 @@ const baseInput = (): TeamProjectsInput => ({ github_login: 'bob', lines: 5, committed_at: '2026-05-21T11:00:00Z' }, ], jira_issues: [], + in_flight_prs: [], + in_flight_branches: [], }); describe('generateTeamProjects', () => { beforeEach(() => mockGetLLMClient.mockReset()); it('short-circuits to [] when both commits and jira are empty (no LLM call)', async () => { - const out = await generateTeamProjects({ team_members: ['alice'], commits: [], jira_issues: [] }); + const out = await generateTeamProjects({ team_members: ['alice'], commits: [], jira_issues: [], in_flight_prs: [], in_flight_branches: [] }); expect(out).toEqual([]); expect(mockGetLLMClient).not.toHaveBeenCalled(); }); @@ -107,4 +109,44 @@ describe('generateTeamProjects', () => { const out = await generateTeamProjects(baseInput()); expect(out).toHaveLength(1); }); + + it('includes IN-FLIGHT WORK block in prompt when in-flight data is present', async () => { + const client = makeClient(JSON.stringify({ + projects: [{ name: 'P1', summary: 's', developers: ['alice'], + jira_count: 0, estimated_commits: 1, estimated_prs: 1, + last_activity: '2026-05-20T10:00:00Z' }], + })); + mockGetLLMClient.mockResolvedValueOnce(client); + + const input: TeamProjectsInput = { + ...baseInput(), + in_flight_prs: [ + { repo: 'r1', title: 'Add jobs pagination', author: 'alice', additions: 120, deletions: 5, is_draft: false }, + ], + in_flight_branches: [], + }; + + await generateTeamProjects(input); + + const callArgs = client.chat.completions.create.mock.calls[0][0]; + const systemPrompt: string = callArgs.messages[0].content; + expect(systemPrompt).toContain('IN-FLIGHT WORK'); + expect(systemPrompt).toContain('Add jobs pagination'); + expect(systemPrompt).toContain('OPEN PRs (1)'); + }); + + it('omits IN-FLIGHT WORK block when in_flight_prs and in_flight_branches are empty', async () => { + const client = makeClient(JSON.stringify({ + projects: [{ name: 'P1', summary: 's', developers: ['alice'], + jira_count: 0, estimated_commits: 1, estimated_prs: 1, + last_activity: '2026-05-20T10:00:00Z' }], + })); + mockGetLLMClient.mockResolvedValueOnce(client); + + await generateTeamProjects(baseInput()); + + const callArgs = client.chat.completions.create.mock.calls[0][0]; + const systemPrompt: string = callArgs.messages[0].content; + expect(systemPrompt).not.toContain('IN-FLIGHT WORK'); + }); }); diff --git a/src/lib/team-pulse/projects.ts b/src/lib/team-pulse/projects.ts index 62b6c5f..ea5b5af 100644 --- a/src/lib/team-pulse/projects.ts +++ b/src/lib/team-pulse/projects.ts @@ -7,7 +7,7 @@ import { getLLMClient, LLM_MODEL, extraBodyProps, tokenLimit, promptTag } from '@/lib/llm-provider'; import { loadPrompt } from '@/lib/prompt-loader'; import type { TeamProject } from './types'; -import type { TeamProjectsInput } from './data'; +import type { TeamProjectsInput, TeamProjectInflightPr, TeamProjectInflightBranch } from './data'; export const PROJECTS_PROMPT_TAG = 'team-pulse-projects'; @@ -19,12 +19,34 @@ function stripJsonFences(s: string): string { return s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim(); } +function renderInflightBlock( + prs: TeamProjectInflightPr[], + branches: TeamProjectInflightBranch[], +): string { + if (prs.length === 0 && branches.length === 0) return ''; + const lines: string[] = ['IN-FLIGHT WORK (open PRs + bare branches — not yet merged):']; + if (prs.length > 0) { + lines.push('', `OPEN PRs (${prs.length}):`, 'repo|pr_title|author|+additions/-deletions|draft'); + for (const pr of prs) { + lines.push(`${pr.repo}|${pr.title}|${pr.author}|+${pr.additions}/-${pr.deletions}|${pr.is_draft ? 'yes' : 'no'}`); + } + } + if (branches.length > 0) { + lines.push('', `BARE BRANCHES (${branches.length}):`, 'repo|branch|author|commits|lines'); + for (const b of branches) { + lines.push(`${b.repo}|${b.branch}|${b.author}|${b.commit_count}|${b.lines}`); + } + } + return lines.join('\n'); +} + export async function generateTeamProjects( data: TeamProjectsInput, teamName: string = '', ): Promise { // Short-circuit: nothing to cluster. - if (data.commits.length === 0 && data.jira_issues.length === 0) { + if (data.commits.length === 0 && data.jira_issues.length === 0 && + data.in_flight_prs.length === 0 && data.in_flight_branches.length === 0) { return []; } @@ -37,11 +59,13 @@ export async function generateTeamProjects( } for (const arr of commitByLogin.values()) arr.sort((a, b) => b.localeCompare(a)); + const inflightBlock = renderInflightBlock(data.in_flight_prs, data.in_flight_branches); const systemPrompt = loadPrompt('team-pulse-projects.txt', { TEAM_NAME: teamName, TEAM_MEMBERS_JSON: JSON.stringify(data.team_members), COMMITS_JSON: JSON.stringify(data.commits), JIRA_ISSUES_JSON: JSON.stringify(data.jira_issues), + IN_FLIGHT_BLOCK: inflightBlock, }); const client = await getLLMClient(); From 7b5b2a164bad0076c738f30abfec0e587c92a5fe Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 9 Jun 2026 17:55:56 -0400 Subject: [PATCH 5/8] feat(glook-19): update team-pulse-projects prompt with in-flight block, bump PROMPT_VERSION - Add IN-FLIGHT WORK rule to prompt template describing how to mix open PRs/branches into clusters - Add {{IN_FLIGHT_BLOCK}} placeholder at end of template for injected in-flight data - Bump PROMPT_VERSION from v3-projects to v4-inflight to invalidate old cache - Update prompts.test.ts to pass IN_FLIGHT_BLOCK parameter - Update team-projects-generator tests to check for in-flight data block content instead of template rule Co-Authored-By: Claude Sonnet 4.6 (1M context) --- prompts/team-pulse-projects.txt | 3 +++ src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap | 3 +++ src/lib/__tests__/unit/prompts.test.ts | 1 + src/lib/__tests__/unit/team-projects-generator.test.ts | 4 ++-- src/lib/team-pulse/service.ts | 2 +- 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/prompts/team-pulse-projects.txt b/prompts/team-pulse-projects.txt index 4de88d7..f404fd0 100644 --- a/prompts/team-pulse-projects.txt +++ b/prompts/team-pulse-projects.txt @@ -6,6 +6,7 @@ RULES: - Each project's `developers` array MUST contain ONLY github logins from {{TEAM_MEMBERS_JSON}}. Reject any other login. - Drop singleton clusters: every project must have at least 2 commits OR 1 Jira issue. - `last_activity` MUST be a real ISO-8601 date copied from the most recent committed_at in the cluster. +- Use IN-FLIGHT WORK to enrich clusters — mix open PRs and bare branches into existing projects where they clearly belong. If in-flight work represents a coherent new thread with no committed counterpart, create a project for it. Draft PRs are included. - Output is a JSON object with a single key "projects" whose value is the array. OUTPUT JSON SCHEMA (one entry per project): @@ -31,3 +32,5 @@ COMMITS (most recent first, capped at 200): JIRA ISSUES: {{JIRA_ISSUES_JSON}} + +{{IN_FLIGHT_BLOCK}} diff --git a/src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap b/src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap index 89c62ed..0f0fb8f 100644 --- a/src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap +++ b/src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap @@ -9,6 +9,7 @@ RULES: - Each project's \`developers\` array MUST contain ONLY github logins from ["alice","bob"]. Reject any other login. - Drop singleton clusters: every project must have at least 2 commits OR 1 Jira issue. - \`last_activity\` MUST be a real ISO-8601 date copied from the most recent committed_at in the cluster. +- Use IN-FLIGHT WORK to enrich clusters — mix open PRs and bare branches into existing projects where they clearly belong. If in-flight work represents a coherent new thread with no committed counterpart, create a project for it. Draft PRs are included. - Output is a JSON object with a single key "projects" whose value is the array. OUTPUT JSON SCHEMA (one entry per project): @@ -34,5 +35,7 @@ COMMITS (most recent first, capped at 200): JIRA ISSUES: [] + + " `; diff --git a/src/lib/__tests__/unit/prompts.test.ts b/src/lib/__tests__/unit/prompts.test.ts index 1c4534b..af8d7cb 100644 --- a/src/lib/__tests__/unit/prompts.test.ts +++ b/src/lib/__tests__/unit/prompts.test.ts @@ -9,6 +9,7 @@ describe('team-pulse-projects prompt template', () => { TEAM_MEMBERS_JSON: '["alice","bob"]', COMMITS_JSON: '[]', JIRA_ISSUES_JSON: '[]', + IN_FLIGHT_BLOCK: '', }); expect(out).toMatchSnapshot(); // Verify no leftover {{...}} placeholders diff --git a/src/lib/__tests__/unit/team-projects-generator.test.ts b/src/lib/__tests__/unit/team-projects-generator.test.ts index a30efb8..8a30997 100644 --- a/src/lib/__tests__/unit/team-projects-generator.test.ts +++ b/src/lib/__tests__/unit/team-projects-generator.test.ts @@ -130,7 +130,7 @@ describe('generateTeamProjects', () => { const callArgs = client.chat.completions.create.mock.calls[0][0]; const systemPrompt: string = callArgs.messages[0].content; - expect(systemPrompt).toContain('IN-FLIGHT WORK'); + expect(systemPrompt).toContain('OPEN PRs'); expect(systemPrompt).toContain('Add jobs pagination'); expect(systemPrompt).toContain('OPEN PRs (1)'); }); @@ -147,6 +147,6 @@ describe('generateTeamProjects', () => { const callArgs = client.chat.completions.create.mock.calls[0][0]; const systemPrompt: string = callArgs.messages[0].content; - expect(systemPrompt).not.toContain('IN-FLIGHT WORK'); + expect(systemPrompt).not.toContain('OPEN PRs'); }); }); diff --git a/src/lib/team-pulse/service.ts b/src/lib/team-pulse/service.ts index f1e2f23..12f41ea 100644 --- a/src/lib/team-pulse/service.ts +++ b/src/lib/team-pulse/service.ts @@ -6,7 +6,7 @@ import { buildTeamPulsePrompt } from './prompt'; import { generateTeamProjects } from './projects'; import type { TeamProject } from './types'; -const PROMPT_VERSION = 'v3-projects'; +const PROMPT_VERSION = 'v4-inflight'; export interface TeamPulseResult { summary: string; From 2a0f9986bc20d2ccb6da639877259bc61de5e7a1 Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 9 Jun 2026 18:00:06 -0400 Subject: [PATCH 6/8] feat(glook-19): add in-flight PRs + branches to project-insights route, cache v2 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/app/api/project-insights/route.ts | 75 ++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/src/app/api/project-insights/route.ts b/src/app/api/project-insights/route.ts index d2d37b0..ce9ffe7 100644 --- a/src/app/api/project-insights/route.ts +++ b/src/app/api/project-insights/route.ts @@ -3,6 +3,25 @@ import db from '@/lib/db'; import { getLLMClient, LLM_MODEL, extraBodyProps, tokenLimit } from '@/lib/llm-provider'; import { withRequestLog } from '@/lib/logger'; +function renderInsightsInflightBlock(prs: any[], branches: any[]): string { + if (prs.length === 0 && branches.length === 0) return ''; + const lines: string[] = ['\nIN-FLIGHT WORK (open PRs + bare branches — not yet merged):']; + if (prs.length > 0) { + lines.push(`\nOPEN PRs (${prs.length}):`, 'repo|pr_title|author|+additions/-deletions|draft'); + for (const pr of prs) { + const isDraft = pr.is_draft === 1 || pr.is_draft === true ? 'yes' : 'no'; + lines.push(`${pr.repo}|${pr.pr_title}|${pr.github_login}|+${Number(pr.pr_additions ?? 0)}/-${Number(pr.pr_deletions ?? 0)}|${isDraft}`); + } + } + if (branches.length > 0) { + lines.push(`\nBARE BRANCHES (${branches.length}):`, 'repo|branch|author|commits|lines'); + for (const b of branches) { + lines.push(`${b.repo}|${b.branch}|${b.github_login}|${b.commit_count}|${b.total_lines}`); + } + } + return lines.join('\n'); +} + async function getHandler() { // Find latest completed report const [latestRows] = await db.execute( @@ -35,12 +54,16 @@ async function getHandler() { const data = typeof cached[0].highlights_json === 'string' ? JSON.parse(cached[0].highlights_json) : cached[0].highlights_json; - return NextResponse.json({ - available: true, - report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at }, - ...data, - cached: true, - }); + if (data._v === 2) { + 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, + cached: true, + }); + } + // _v !== 2: stale cache (no in-flight data) — fall through to regenerate } // Gather data for LLM @@ -76,6 +99,33 @@ async function getHandler() { const noJiraData = noJiraCommits.map((c: any) => `${c.repo}|${c.github_login}|${c.msg || ''}`).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`, + [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`, + [report.id], + ) as [any[], any]; + + const inflightBlock = renderInsightsInflightBlock(inflightPrRows, inflightBranchRows); + 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. You will receive: @@ -118,7 +168,8 @@ Rules: - 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 - Keep summaries under 20 words -- Return ONLY raw JSON`; +- 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.`; const userMessage = `JIRA ISSUES (${jiraRows.length} total): ${jiraData} @@ -127,7 +178,7 @@ DEVELOPER STATS (login | commits | PRs): ${devData} GITHUB COMMITS WITHOUT JIRA (top 30 by size): -${noJiraData}`; +${noJiraData}${inflightBlock}`; try { const client = await getLLMClient(); @@ -148,19 +199,19 @@ ${noJiraData}`; let parsed: any; try { parsed = JSON.parse(cleaned); } catch { parsed = { projects: [], untracked_work: [] }; } - // Cache result + const toCache = { _v: 2, projects: parsed.projects || [], untracked_work: parsed.untracked_work || [] }; 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(parsed)], + [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: parsed.projects || [], - untracked_work: parsed.untracked_work || [], + projects: toCache.projects, + untracked_work: toCache.untracked_work, cached: false, }); } catch (err) { From 1652a3fe49299e107b9c5b5a6838ab3fc8469ace Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 9 Jun 2026 21:41:37 -0400 Subject: [PATCH 7/8] =?UTF-8?q?fix(glook-19):=20rename=20AS=20lines=20?= =?UTF-8?q?=E2=86=92=20AS=20total=5Flines=20(MySQL=20reserved=20word)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/__tests__/unit/team-projects-data.test.ts | 2 +- src/lib/team-pulse/data.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/__tests__/unit/team-projects-data.test.ts b/src/lib/__tests__/unit/team-projects-data.test.ts index 3dee968..c7122d8 100644 --- a/src/lib/__tests__/unit/team-projects-data.test.ts +++ b/src/lib/__tests__/unit/team-projects-data.test.ts @@ -100,7 +100,7 @@ describe('extractTeamProjectsData', () => { stubCalls( [], [], [], // commits, jira, prs empty [ - { repo: 'infra', branch: 'feat/k8s-autoscale', author: 'carol', commit_count: 7, lines: 240 }, + { repo: 'infra', branch: 'feat/k8s-autoscale', author: 'carol', commit_count: 7, total_lines: 240 }, ], ); diff --git a/src/lib/team-pulse/data.ts b/src/lib/team-pulse/data.ts index 8fa564d..5aedba9 100644 --- a/src/lib/team-pulse/data.ts +++ b/src/lib/team-pulse/data.ts @@ -389,11 +389,11 @@ export async function extractTeamProjectsData( branch, github_login AS author, COUNT(*) AS commit_count, - SUM(lines_added + lines_removed) AS lines + SUM(lines_added + lines_removed) AS total_lines FROM unmerged_commits WHERE report_id = ? AND github_login IN (${placeholders}) AND pr_number IS NULL GROUP BY repo, branch, github_login - ORDER BY lines DESC + ORDER BY total_lines DESC LIMIT 10`, [reportId, ...teamMembers], ) as [any[], any]; @@ -432,7 +432,7 @@ export async function extractTeamProjectsData( branch: String(r.branch ?? ''), author: String(r.author ?? ''), commit_count: Number(r.commit_count ?? 0), - lines: Number(r.lines ?? 0), + lines: Number(r.total_lines ?? 0), })), }; } From 61bc2133a3d8912ec0e3285a01a942c94b95332a Mon Sep 17 00:00:00 2001 From: msogin Date: Tue, 9 Jun 2026 22:56:14 -0400 Subject: [PATCH 8/8] =?UTF-8?q?fix(glook-19):=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?shared=20render=20util,=20title=20trim,=20null=20coercion,=20sh?= =?UTF-8?q?ort-circuit=20test,=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prompts/team-pulse-projects.txt | 1 - src/app/api/project-insights/route.ts | 48 +++++++++++-------- .../unit/__snapshots__/prompts.test.ts.snap | 1 - .../unit/team-projects-generator.test.ts | 20 ++++++++ src/lib/team-pulse/projects.ts | 23 +-------- src/lib/team-pulse/render.ts | 33 +++++++++++++ 6 files changed, 82 insertions(+), 44 deletions(-) create mode 100644 src/lib/team-pulse/render.ts diff --git a/prompts/team-pulse-projects.txt b/prompts/team-pulse-projects.txt index f404fd0..014c368 100644 --- a/prompts/team-pulse-projects.txt +++ b/prompts/team-pulse-projects.txt @@ -32,5 +32,4 @@ COMMITS (most recent first, capped at 200): JIRA ISSUES: {{JIRA_ISSUES_JSON}} - {{IN_FLIGHT_BLOCK}} diff --git a/src/app/api/project-insights/route.ts b/src/app/api/project-insights/route.ts index ce9ffe7..46e35e2 100644 --- a/src/app/api/project-insights/route.ts +++ b/src/app/api/project-insights/route.ts @@ -2,25 +2,8 @@ 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'; - -function renderInsightsInflightBlock(prs: any[], branches: any[]): string { - if (prs.length === 0 && branches.length === 0) return ''; - const lines: string[] = ['\nIN-FLIGHT WORK (open PRs + bare branches — not yet merged):']; - if (prs.length > 0) { - lines.push(`\nOPEN PRs (${prs.length}):`, 'repo|pr_title|author|+additions/-deletions|draft'); - for (const pr of prs) { - const isDraft = pr.is_draft === 1 || pr.is_draft === true ? 'yes' : 'no'; - lines.push(`${pr.repo}|${pr.pr_title}|${pr.github_login}|+${Number(pr.pr_additions ?? 0)}/-${Number(pr.pr_deletions ?? 0)}|${isDraft}`); - } - } - if (branches.length > 0) { - lines.push(`\nBARE BRANCHES (${branches.length}):`, 'repo|branch|author|commits|lines'); - for (const b of branches) { - lines.push(`${b.repo}|${b.branch}|${b.github_login}|${b.commit_count}|${b.total_lines}`); - } - } - return lines.join('\n'); -} +import { renderInflightBlock } from '@/lib/team-pulse/render'; +import type { TeamProjectInflightPr, TeamProjectInflightBranch } from '@/lib/team-pulse/data'; async function getHandler() { // Find latest completed report @@ -63,7 +46,9 @@ async function getHandler() { cached: true, }); } - // _v !== 2: stale cache (no in-flight data) — fall through to regenerate + // _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. } // Gather data for LLM @@ -124,7 +109,24 @@ async function getHandler() { [report.id], ) as [any[], any]; - const inflightBlock = renderInsightsInflightBlock(inflightPrRows, inflightBranchRows); + // 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), + 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); 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. @@ -170,6 +172,10 @@ Rules: - 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. const userMessage = `JIRA ISSUES (${jiraRows.length} total): ${jiraData} diff --git a/src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap b/src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap index 0f0fb8f..8f9cd8d 100644 --- a/src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap +++ b/src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap @@ -36,6 +36,5 @@ COMMITS (most recent first, capped at 200): JIRA ISSUES: [] - " `; diff --git a/src/lib/__tests__/unit/team-projects-generator.test.ts b/src/lib/__tests__/unit/team-projects-generator.test.ts index 8a30997..552cc7c 100644 --- a/src/lib/__tests__/unit/team-projects-generator.test.ts +++ b/src/lib/__tests__/unit/team-projects-generator.test.ts @@ -41,6 +41,26 @@ describe('generateTeamProjects', () => { expect(mockGetLLMClient).not.toHaveBeenCalled(); }); + it('does NOT short-circuit when only in_flight_prs is non-empty — LLM is called', async () => { + const client = makeClient(JSON.stringify({ + projects: [{ name: 'P1', summary: 's', developers: ['alice'], + jira_count: 0, estimated_commits: 0, estimated_prs: 1, + last_activity: '2026-05-20T10:00:00Z' }], + })); + mockGetLLMClient.mockResolvedValueOnce(client); + + const out = await generateTeamProjects({ + team_members: ['alice'], + commits: [], + jira_issues: [], + in_flight_prs: [{ repo: 'r1', title: 'New feature', author: 'alice', additions: 50, deletions: 5, is_draft: false }], + in_flight_branches: [], + }); + + expect(mockGetLLMClient).toHaveBeenCalled(); + expect(out).toHaveLength(1); + }); + it('returns parsed projects on a well-formed LLM response', async () => { mockGetLLMClient.mockResolvedValueOnce(makeClient(JSON.stringify({ projects: [ diff --git a/src/lib/team-pulse/projects.ts b/src/lib/team-pulse/projects.ts index ea5b5af..c9affe3 100644 --- a/src/lib/team-pulse/projects.ts +++ b/src/lib/team-pulse/projects.ts @@ -7,7 +7,8 @@ import { getLLMClient, LLM_MODEL, extraBodyProps, tokenLimit, promptTag } from '@/lib/llm-provider'; import { loadPrompt } from '@/lib/prompt-loader'; import type { TeamProject } from './types'; -import type { TeamProjectsInput, TeamProjectInflightPr, TeamProjectInflightBranch } from './data'; +import type { TeamProjectsInput } from './data'; +import { renderInflightBlock } from './render'; export const PROJECTS_PROMPT_TAG = 'team-pulse-projects'; @@ -19,26 +20,6 @@ function stripJsonFences(s: string): string { return s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim(); } -function renderInflightBlock( - prs: TeamProjectInflightPr[], - branches: TeamProjectInflightBranch[], -): string { - if (prs.length === 0 && branches.length === 0) return ''; - const lines: string[] = ['IN-FLIGHT WORK (open PRs + bare branches — not yet merged):']; - if (prs.length > 0) { - lines.push('', `OPEN PRs (${prs.length}):`, 'repo|pr_title|author|+additions/-deletions|draft'); - for (const pr of prs) { - lines.push(`${pr.repo}|${pr.title}|${pr.author}|+${pr.additions}/-${pr.deletions}|${pr.is_draft ? 'yes' : 'no'}`); - } - } - if (branches.length > 0) { - lines.push('', `BARE BRANCHES (${branches.length}):`, 'repo|branch|author|commits|lines'); - for (const b of branches) { - lines.push(`${b.repo}|${b.branch}|${b.author}|${b.commit_count}|${b.lines}`); - } - } - return lines.join('\n'); -} export async function generateTeamProjects( data: TeamProjectsInput, diff --git a/src/lib/team-pulse/render.ts b/src/lib/team-pulse/render.ts new file mode 100644 index 0000000..19a405f --- /dev/null +++ b/src/lib/team-pulse/render.ts @@ -0,0 +1,33 @@ +// Shared renderer for the IN-FLIGHT WORK prompt block. +// Used by both the per-team projects generator (projects.ts) and the +// org-wide project-insights API route (project-insights/route.ts). +// Keeping one implementation ensures the pipe-delimited format stays +// consistent across both LLM surfaces. + +import type { TeamProjectInflightPr, TeamProjectInflightBranch } from './data'; + +const MAX_TITLE_LEN = 100; + +export function renderInflightBlock( + prs: TeamProjectInflightPr[], + branches: TeamProjectInflightBranch[], +): string { + if (prs.length === 0 && branches.length === 0) return ''; + // Leading '\n' provides the blank-line separator between the preceding + // prompt section and this block. The template omits the blank line so + // there is no extra whitespace when the block is empty. + const lines: string[] = ['\nIN-FLIGHT WORK (open PRs + bare branches — not yet merged):']; + if (prs.length > 0) { + lines.push('', `OPEN PRs (${prs.length}):`, 'repo|pr_title|author|+additions/-deletions|draft'); + for (const pr of prs) { + lines.push(`${pr.repo}|${pr.title.slice(0, MAX_TITLE_LEN)}|${pr.author}|+${pr.additions}/-${pr.deletions}|${pr.is_draft ? 'yes' : 'no'}`); + } + } + if (branches.length > 0) { + lines.push('', `BARE BRANCHES (${branches.length}):`, 'repo|branch|author|commits|lines'); + for (const b of branches) { + lines.push(`${b.repo}|${b.branch.slice(0, MAX_TITLE_LEN)}|${b.author}|${b.commit_count}|${b.lines}`); + } + } + return lines.join('\n'); +}