Skip to content

Commit 2e50055

Browse files
authored
Merge pull request #586 from projectbluefin/feat/planned-vs-opportunistic-work
feat(reports): split monthly reports into planned vs opportunistic work
2 parents 2cce067 + b1ee776 commit 2e50055

File tree

6 files changed

+638
-41
lines changed

6 files changed

+638
-41
lines changed

reports/2025-12-31-report.mdx

Lines changed: 284 additions & 10 deletions
Large diffs are not rendered by default.

scripts/generate-report.js

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@
66
* Runs on the first Monday of each month
77
*/
88

9-
import { fetchProjectItems, filterByStatus } from "./lib/graphql-queries.js";
9+
import {
10+
fetchProjectItems,
11+
filterByStatus,
12+
fetchClosedItemsFromRepo,
13+
} from "./lib/graphql-queries.js";
1014
import { updateContributorHistory, isBot } from "./lib/contributor-tracker.js";
1115
import { generateReportMarkdown } from "./lib/markdown-generator.js";
1216
import { getCategoryForLabel } from "./lib/label-mapping.js";
17+
import { MONITORED_REPOS } from "./lib/monitored-repos.js";
1318
import { format, parseISO, isWithinInterval } from "date-fns";
1419
import { writeFile } from "fs/promises";
1520

@@ -137,11 +142,11 @@ async function generateReport() {
137142
try {
138143
// Fetch project board data
139144
log.info("Fetching project board data...");
140-
const allItems = await fetchProjectItems("projectbluefin", 2);
141-
log.info(`Total items on board: ${allItems.length}`);
145+
const boardItems = await fetchProjectItems("projectbluefin", 2);
146+
log.info(`Total items on board: ${boardItems.length}`);
142147

143148
// Filter by Status="Done" column
144-
const doneItems = filterByStatus(allItems, "Done");
149+
const doneItems = filterByStatus(boardItems, "Done");
145150
log.info(`Items in "Done" column: ${doneItems.length}`);
146151

147152
// Filter by date range (items updated within window)
@@ -150,34 +155,92 @@ async function generateReport() {
150155
.filter((item) => item.content && item.content.title && item.content.url); // Skip items without valid content
151156
log.info(`Items completed in window: ${itemsInWindow.length}`);
152157

158+
// Fetch opportunistic work from monitored repositories
159+
log.info("Fetching opportunistic work from monitored repositories...");
160+
const allOpportunisticItems = [];
161+
162+
for (const repo of MONITORED_REPOS) {
163+
const [owner, name] = repo.split("/");
164+
log.info(` Fetching from ${repo}...`);
165+
const repoItems = await fetchClosedItemsFromRepo(
166+
owner,
167+
name,
168+
startDate,
169+
endDate,
170+
);
171+
allOpportunisticItems.push(...repoItems);
172+
}
173+
174+
log.info(
175+
`Total closed items from monitored repos: ${allOpportunisticItems.length}`,
176+
);
177+
178+
// Extract URLs from project board items to identify opportunistic work
179+
const boardItemUrls = new Set(
180+
itemsInWindow.map((item) => item.content?.url).filter(Boolean),
181+
);
182+
183+
// Filter opportunistic items (not on project board)
184+
const opportunisticItems = allOpportunisticItems
185+
.filter((item) => !boardItemUrls.has(item.url))
186+
.map((item) => {
187+
// Transform to match board item structure for consistency
188+
return {
189+
content: {
190+
__typename: item.type, // "Issue" or "PullRequest"
191+
number: item.number,
192+
title: item.title,
193+
url: item.url,
194+
repository: { nameWithOwner: item.repository },
195+
labels: { nodes: item.labels },
196+
author: { login: item.author },
197+
},
198+
};
199+
});
200+
201+
log.info(
202+
`Opportunistic items (not on board): ${opportunisticItems.length}`,
203+
);
204+
153205
// Handle empty data period
154-
if (itemsInWindow.length === 0) {
206+
if (itemsInWindow.length === 0 && opportunisticItems.length === 0) {
155207
log.warn(
156208
"No items completed in this period - generating quiet period report",
157209
);
158210
github.warning("This was a quiet period with no completed items");
159211
}
160212

161-
// Separate human contributions from bot activity
162-
const humanItems = itemsInWindow.filter(
213+
// Separate human contributions from bot activity (both planned and opportunistic)
214+
const allItems = [...itemsInWindow, ...opportunisticItems];
215+
const humanItems = allItems.filter(
163216
(item) => !isBot(item.content?.author?.login || ""),
164217
);
165-
const botItems = itemsInWindow.filter((item) =>
218+
const botItems = allItems.filter((item) =>
166219
isBot(item.content?.author?.login || ""),
167220
);
168221

169-
log.info(`Human contributions: ${humanItems.length}`);
222+
// Separate planned vs opportunistic within human items
223+
const plannedHumanItems = itemsInWindow.filter(
224+
(item) => !isBot(item.content?.author?.login || ""),
225+
);
226+
const opportunisticHumanItems = opportunisticItems.filter(
227+
(item) => !isBot(item.content?.author?.login || ""),
228+
);
229+
230+
log.info(`Planned work (human): ${plannedHumanItems.length}`);
231+
log.info(`Opportunistic work (human): ${opportunisticHumanItems.length}`);
170232
log.info(`Bot contributions: ${botItems.length}`);
171233

172-
// Extract contributor usernames (human only)
234+
// Extract contributor usernames (human only, PRs only - people who wrote code)
173235
const contributors = [
174236
...new Set(
175237
humanItems
238+
.filter((item) => item.content?.__typename === "PullRequest")
176239
.map((item) => item.content?.author?.login)
177240
.filter((login) => login),
178241
),
179242
];
180-
log.info(`Unique contributors: ${contributors.length}`);
243+
log.info(`Unique contributors (PR authors): ${contributors.length}`);
181244

182245
// Track contributors and identify new ones (with error handling)
183246
log.info("Updating contributor history...");
@@ -204,7 +267,8 @@ async function generateReport() {
204267
// Generate markdown
205268
log.info("Generating markdown...");
206269
const markdown = generateReportMarkdown(
207-
humanItems,
270+
plannedHumanItems,
271+
opportunisticHumanItems,
208272
contributors,
209273
newContributors,
210274
botActivity,
@@ -217,14 +281,15 @@ async function generateReport() {
217281
await writeFile(filename, markdown, "utf8");
218282

219283
log.info(`✅ Report generated: ${filename}`);
220-
log.info(` ${humanItems.length} items completed`);
284+
log.info(` ${plannedHumanItems.length} planned work items`);
285+
log.info(` ${opportunisticHumanItems.length} opportunistic work items`);
221286
log.info(` ${contributors.length} contributors`);
222287
log.info(` ${newContributors.length} new contributors`);
223288
log.info(` ${botItems.length} bot PRs`);
224289

225290
// GitHub Actions summary annotation
226291
github.notice(
227-
`Report generated: ${humanItems.length} items, ${contributors.length} contributors, ${newContributors.length} new`,
292+
`Report generated: ${plannedHumanItems.length} planned + ${opportunisticHumanItems.length} opportunistic, ${contributors.length} contributors, ${newContributors.length} new`,
228293
);
229294
} catch (error) {
230295
log.error("Report generation failed");

scripts/lib/contributor-tracker.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const BOT_PATTERNS = [
1515
/^dependabot\[bot\]$/,
1616
/^renovate\[bot\]$/,
1717
/^github-actions\[bot\]$/,
18+
/^copilot-swe-agent$/,
1819
/^ubot-\d+$/,
1920
/bot$/i, // Catches most bot usernames
2021
];

scripts/lib/graphql-queries.js

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,4 +299,129 @@ export function getStatusValue(item) {
299299
return statusField?.name || null;
300300
}
301301

302-
export { PROJECT_QUERY, graphqlWithAuth };
302+
/**
303+
* GraphQL query to fetch closed issues and PRs from a repository
304+
*/
305+
const REPO_CLOSED_ITEMS_QUERY = `
306+
query($owner: String!, $name: String!, $since: DateTime!, $cursor: String) {
307+
repository(owner: $owner, name: $name) {
308+
issues(first: 100, after: $cursor, states: CLOSED, filterBy: {since: $since}) {
309+
pageInfo {
310+
hasNextPage
311+
endCursor
312+
}
313+
nodes {
314+
number
315+
title
316+
url
317+
closedAt
318+
labels(first: 10) {
319+
nodes {
320+
name
321+
color
322+
}
323+
}
324+
author {
325+
login
326+
}
327+
}
328+
}
329+
pullRequests(first: 100, after: $cursor, states: MERGED, orderBy: {field: UPDATED_AT, direction: DESC}) {
330+
pageInfo {
331+
hasNextPage
332+
endCursor
333+
}
334+
nodes {
335+
number
336+
title
337+
url
338+
mergedAt
339+
labels(first: 10) {
340+
nodes {
341+
name
342+
color
343+
}
344+
}
345+
author {
346+
login
347+
}
348+
}
349+
}
350+
}
351+
}
352+
`;
353+
354+
/**
355+
* Fetch closed issues and merged PRs from a repository within date range
356+
*
357+
* @param {string} owner - Repository owner (e.g., "ublue-os")
358+
* @param {string} name - Repository name (e.g., "bluefin")
359+
* @param {Date} startDate - Start of date range
360+
* @param {Date} endDate - End of date range
361+
* @returns {Promise<Array>} Array of closed issues and merged PRs
362+
*/
363+
export async function fetchClosedItemsFromRepo(
364+
owner,
365+
name,
366+
startDate,
367+
endDate,
368+
) {
369+
try {
370+
const result = await retryWithBackoff(async () => {
371+
return await graphqlWithAuth(REPO_CLOSED_ITEMS_QUERY, {
372+
owner,
373+
name,
374+
since: startDate.toISOString(),
375+
cursor: null,
376+
});
377+
});
378+
379+
const repo = result.repository;
380+
const allItems = [];
381+
382+
// Process closed issues
383+
const closedIssues = repo.issues.nodes
384+
.filter((issue) => {
385+
const closedAt = new Date(issue.closedAt);
386+
return closedAt >= startDate && closedAt <= endDate;
387+
})
388+
.map((issue) => ({
389+
type: "Issue",
390+
number: issue.number,
391+
title: issue.title,
392+
url: issue.url,
393+
closedAt: issue.closedAt,
394+
labels: issue.labels.nodes,
395+
author: issue.author?.login || "unknown",
396+
repository: `${owner}/${name}`,
397+
}));
398+
399+
// Process merged PRs
400+
const mergedPRs = repo.pullRequests.nodes
401+
.filter((pr) => {
402+
const mergedAt = new Date(pr.mergedAt);
403+
return mergedAt >= startDate && mergedAt <= endDate;
404+
})
405+
.map((pr) => ({
406+
type: "PullRequest",
407+
number: pr.number,
408+
title: pr.title,
409+
url: pr.url,
410+
closedAt: pr.mergedAt, // Use mergedAt for consistency
411+
labels: pr.labels.nodes,
412+
author: pr.author?.login || "unknown",
413+
repository: `${owner}/${name}`,
414+
}));
415+
416+
allItems.push(...closedIssues, ...mergedPRs);
417+
return allItems;
418+
} catch (error) {
419+
console.error(
420+
`Error fetching closed items from ${owner}/${name}: ${error.message}`,
421+
);
422+
// Return empty array instead of throwing - don't fail entire report for one repo
423+
return [];
424+
}
425+
}
426+
427+
export { PROJECT_QUERY, graphqlWithAuth, REPO_CLOSED_ITEMS_QUERY };

0 commit comments

Comments
 (0)