Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe("buildSprintPulse", () => {
assert.equal(result.summary.achievements, 2);
assert.equal(result.summary.jira, 1);
assert.equal(result.summary.bitbucket, 1);
assert.equal(result.summary.github, 0);
assert.equal(result.summary.feedback, 1);
assert.equal(result.summary.openConcerns, 1);
assert.equal(result.summary.staleCitePeople, 1);
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function buildSprintPulse(args: {
let achievements = 0;
let jira = 0;
let bitbucket = 0;
let github = 0;
let feedback = 0;
let documents = 0;
let openConcerns = 0;
Expand All @@ -79,6 +80,7 @@ export function buildSprintPulse(args: {
const docIn = p.documents.filter((d) => inRange(d.occurredAt, args.from, args.to));
const jiraN = achIn.filter((a) => a.sourceProvider === "jira").length;
const bbN = achIn.filter((a) => a.sourceProvider === "bitbucket").length;
const ghN = achIn.filter((a) => a.sourceProvider === "github").length;
const themes: string[] = [];
for (const f of fbIn) {
for (const t of f.themes) {
Expand Down Expand Up @@ -109,6 +111,7 @@ export function buildSprintPulse(args: {
achievements += achIn.length;
jira += jiraN;
bitbucket += bbN;
github += ghN;
feedback += fbIn.length;
documents += docIn.length;
openConcerns += p.openConcernsInWindow;
Expand All @@ -121,6 +124,7 @@ export function buildSprintPulse(args: {
achievements: achIn.length,
jira: jiraN,
bitbucket: bbN,
github: ghN,
feedback: fbIn.length,
documents: docIn.length,
openConcerns: p.openConcernsInWindow,
Expand Down Expand Up @@ -148,6 +152,7 @@ export function buildSprintPulse(args: {
achievements,
jira,
bitbucket,
github,
feedback,
documents,
openConcerns,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/analyticsGather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export function loadPersonTimeline(db: Db, personId: string): PersonTimelineDTO
const provider = a.sourceProvider ? ` · ${a.sourceProvider}` : "";
entries.push({
at: a.occurredAt,
kind: a.sourceProvider === "jira" || a.sourceProvider === "bitbucket" ? a.sourceProvider : "achievement",
kind: a.sourceProvider === "jira" || a.sourceProvider === "bitbucket" || a.sourceProvider === "github" ? a.sourceProvider : "achievement",
title: a.title + provider,
detail: a.description,
sourceId: a.id,
Expand Down
160 changes: 156 additions & 4 deletions apps/api/src/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ export type BitbucketPullRequest = {
url: string;
commentCount: number;
taskCount: number;
/** Lines added (when diffstat available). */
linesAdded: number | null;
linesRemoved: number | null;
filesChanged: number | null;
/** Coarse size band — not a performance score. */
sizeBand: "XS" | "S" | "M" | "L" | "XL" | null;
daysToMerge: number | null;
looksLikeRevert: boolean;
reviewParticipation: {
approvals: number;
reviewers: number;
} | null;
};

function authHeader(cfg: BitbucketConfig) {
Expand Down Expand Up @@ -122,10 +134,90 @@ async function listWorkspaceRepos(cfg: BitbucketConfig, maxRepos = 30): Promise<
return repos;
}

function sizeBand(files: number | null, churn: number | null): BitbucketPullRequest["sizeBand"] {
const f = files ?? 0;
const c = churn ?? 0;
if (f <= 2 && c <= 40) return "XS";
if (f <= 8 && c <= 200) return "S";
if (f <= 20 && c <= 600) return "M";
if (f <= 40 && c <= 1500) return "L";
if (f > 0 || c > 0) return "XL";
return null;
}

async function enrichPrQuality(
cfg: BitbucketConfig,
repo: { fullName: string; slug: string },
pr: BitbucketPullRequest,
): Promise<BitbucketPullRequest> {
let linesAdded: number | null = null;
let linesRemoved: number | null = null;
let filesChanged: number | null = null;
let reviewParticipation: BitbucketPullRequest["reviewParticipation"] = null;
try {
const diff = await bbFetch<{
values?: Array<{ lines_added?: number; lines_removed?: number }>;
size?: number;
}>(
cfg,
`/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests/${pr.id}/diffstat?pagelen=100`,
);
let add = 0;
let rem = 0;
let files = 0;
for (const row of diff.values ?? []) {
files += 1;
add += row.lines_added ?? 0;
rem += row.lines_removed ?? 0;
}
linesAdded = add;
linesRemoved = rem;
filesChanged = files || diff.size || null;
} catch {
/* optional */
}
try {
const detail = await bbFetch<{
participants?: Array<{ role?: string; approved?: boolean }>;
}>(
cfg,
`/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests/${pr.id}`,
);
const reviewers = (detail.participants ?? []).filter((p) => p.role === "REVIEWER");
reviewParticipation = {
reviewers: reviewers.length,
approvals: reviewers.filter((p) => p.approved).length,
};
} catch {
/* optional */
}

let daysToMerge: number | null = null;
if (pr.createdOn && pr.mergedOn) {
const a = Date.parse(pr.createdOn);
const b = Date.parse(pr.mergedOn);
if (!Number.isNaN(a) && !Number.isNaN(b) && b >= a) {
daysToMerge = Math.round((b - a) / (1000 * 60 * 60 * 24));
}
}
const looksLikeRevert = /\brevert\b/i.test(pr.title);

return {
...pr,
linesAdded,
linesRemoved,
filesChanged,
sizeBand: sizeBand(filesChanged, (linesAdded ?? 0) + (linesRemoved ?? 0)),
daysToMerge,
looksLikeRevert,
reviewParticipation,
};
}

export async function bitbucketMergedPrsForAuthor(
cfg: BitbucketConfig,
authorUuid: string,
opts: { maxRepos?: number; maxPrs?: number } = {},
opts: { maxRepos?: number; maxPrs?: number; windowStart?: string; windowEnd?: string } = {},
): Promise<BitbucketPullRequest[]> {
const maxRepos = opts.maxRepos ?? 25;
const maxPrs = opts.maxPrs ?? 50;
Expand Down Expand Up @@ -155,7 +247,12 @@ export async function bitbucketMergedPrsForAuthor(
`/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests?q=${encodeURIComponent(q)}&pagelen=50`,
);
for (const pr of data.values ?? []) {
results.push({
const mergedOn = pr.merged_on
? pr.merged_on.slice(0, 10)
: (pr.updated_on ?? "").slice(0, 10) || null;
if (opts.windowStart && mergedOn && mergedOn < opts.windowStart) continue;
if (opts.windowEnd && mergedOn && mergedOn > opts.windowEnd) continue;
let row: BitbucketPullRequest = {
id: pr.id,
title: pr.title ?? `PR #${pr.id}`,
state: pr.state ?? "MERGED",
Expand All @@ -164,11 +261,20 @@ export async function bitbucketMergedPrsForAuthor(
authorDisplay: pr.author?.display_name ?? null,
createdOn: (pr.created_on ?? "").slice(0, 10),
updatedOn: (pr.updated_on ?? "").slice(0, 10),
mergedOn: pr.merged_on ? pr.merged_on.slice(0, 10) : (pr.updated_on ?? "").slice(0, 10) || null,
mergedOn,
url: pr.links?.html?.href ?? `https://bitbucket.org/${repo.fullName}/pull-requests/${pr.id}`,
commentCount: pr.comment_count ?? 0,
taskCount: pr.task_count ?? 0,
});
linesAdded: null,
linesRemoved: null,
filesChanged: null,
sizeBand: null,
daysToMerge: null,
looksLikeRevert: /\brevert\b/i.test(pr.title ?? ""),
reviewParticipation: null,
};
row = await enrichPrQuality(cfg, repo, row);
results.push(row);
if (results.length >= maxPrs) break;
}
} catch {
Expand All @@ -179,3 +285,49 @@ export async function bitbucketMergedPrsForAuthor(
results.sort((a, b) => (b.mergedOn ?? "").localeCompare(a.mergedOn ?? ""));
return results;
}

/** PRs where this user participated as a reviewer (gave reviews). */
export async function bitbucketReviewsGiven(
cfg: BitbucketConfig,
reviewerUuid: string,
opts: { maxRepos?: number; maxPrs?: number; windowStart?: string; windowEnd?: string } = {},
): Promise<Array<{ id: number; title: string; repoFullName: string; url: string; updatedOn: string }>> {
const maxRepos = opts.maxRepos ?? 15;
const maxPrs = opts.maxPrs ?? 30;
const repos = await listWorkspaceRepos(cfg, maxRepos);
const uuid = reviewerUuid.includes("{") ? reviewerUuid : `{${reviewerUuid}}`;
const out: Array<{ id: number; title: string; repoFullName: string; url: string; updatedOn: string }> = [];
for (const repo of repos) {
if (out.length >= maxPrs) break;
const q = `reviewers.uuid="${uuid}" AND state="MERGED"`;
try {
const data = await bbFetch<{
values?: Array<{
id: number;
title?: string;
updated_on?: string;
links?: { html?: { href?: string } };
}>;
}>(
cfg,
`/repositories/${encodeURIComponent(cfg.workspace)}/${encodeURIComponent(repo.slug)}/pullrequests?q=${encodeURIComponent(q)}&pagelen=30`,
);
for (const pr of data.values ?? []) {
const updatedOn = (pr.updated_on ?? "").slice(0, 10);
if (opts.windowStart && updatedOn && updatedOn < opts.windowStart) continue;
if (opts.windowEnd && updatedOn && updatedOn > opts.windowEnd) continue;
out.push({
id: pr.id,
title: pr.title ?? `PR #${pr.id}`,
repoFullName: repo.fullName,
url: pr.links?.html?.href ?? `https://bitbucket.org/${repo.fullName}/pull-requests/${pr.id}`,
updatedOn,
});
if (out.length >= maxPrs) break;
}
} catch {
/* skip */
}
}
return out;
}
111 changes: 111 additions & 0 deletions apps/api/src/deliveryRollup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildDeliveryRollup } from "./deliveryRollup.js";

describe("buildDeliveryRollup", () => {
it("aggregates done issues, PRs, cites, and thin-evidence flags", () => {
const rollup = buildDeliveryRollup({
personId: "p1",
cycleId: "c1",
windowStart: "2026-01-01",
windowEnd: "2026-06-30",
dossierNonIntegrationCount: 0,
achievements: [
{
id: "ach_1",
title: "[Jira ENG-1] Ship it",
description: null,
occurredAt: "2026-03-01",
sourceProvider: "jira",
externalId: "ENG-1",
externalUrl: "https://x/browse/ENG-1",
externalPayloadJson: JSON.stringify({
isDone: true,
isInProgress: false,
isBug: false,
storyPoints: 5,
labels: ["reliability"],
epicName: "Platform",
}),
},
{
id: "ach_2",
title: "[Jira ENG-2] WIP",
description: null,
occurredAt: "2026-03-10",
sourceProvider: "jira",
externalId: "ENG-2",
externalUrl: null,
externalPayloadJson: JSON.stringify({
isDone: false,
isInProgress: true,
isBug: true,
}),
},
{
id: "ach_3",
title: "[BB PR #9] Fix",
description: null,
occurredAt: "2026-04-01",
sourceProvider: "bitbucket",
externalId: "acme/repo#9",
externalUrl: null,
externalPayloadJson: JSON.stringify({ sizeBand: "S", looksLikeRevert: false }),
},
{
id: "ach_4",
title: "[GH review] Other",
description: null,
occurredAt: "2026-04-02",
sourceProvider: "github",
externalId: "review:org/r#1",
externalUrl: null,
externalPayloadJson: JSON.stringify({ kind: "review_given" }),
},
{
id: "ach_old",
title: "Old",
description: null,
occurredAt: "2025-01-01",
sourceProvider: "jira",
externalId: "ENG-0",
externalUrl: null,
externalPayloadJson: JSON.stringify({ isDone: true }),
},
],
});

assert.equal(rollup.issuesDone, 1);
assert.equal(rollup.issuesInProgress, 1);
assert.equal(rollup.openBugs, 1);
assert.equal(rollup.storyPointsDone, 5);
assert.equal(rollup.prsMerged, 1);
assert.equal(rollup.reviewsGiven, 1);
assert.equal(rollup.sizeBands.S, 1);
assert.ok(rollup.citeSuggestions.some((c) => c.citeToken === "[ach_1]"));
assert.equal(rollup.flags.thinDeliveryEvidence, "jira_rich_dossier_thin");
});

it("flags dossier-rich empty window", () => {
const rollup = buildDeliveryRollup({
personId: "p1",
cycleId: "c1",
windowStart: "2026-01-01",
windowEnd: "2026-06-30",
dossierNonIntegrationCount: 5,
achievements: [
{
id: "ach_old",
title: "Old jira",
description: null,
occurredAt: "2024-01-01",
sourceProvider: "jira",
externalId: "ENG-9",
externalUrl: null,
externalPayloadJson: JSON.stringify({ isDone: true }),
},
],
});
assert.equal(rollup.flags.thinDeliveryEvidence, "dossier_rich_integrations_empty_window");
});
});
Loading
Loading