feat(ci): add weekly project report workflow (#665)

This commit is contained in:
rahim
2026-03-01 19:09:28 -08:00
committed by GitHub
parent 877e4d8b6c
commit 4a2958080c
3 changed files with 437 additions and 0 deletions
@@ -0,0 +1,208 @@
/**
* Reads a report JSON from stdin and posts it to Slack as a Block Kit message.
*
* Usage: cat /tmp/report.json | node post-to-slack.js
*
* Expects WEEKLY_REPORT_SLACK_WEBHOOK_URL environment variable.
* No external dependencies — uses only Node built-ins.
*/
import { readFileSync } from 'node:fs';
// ---------------------------------------------------------------------------
// Slack Block Kit helpers
// ---------------------------------------------------------------------------
const REPO = 'videojs/v10';
const BOARD_URL = 'https://github.com/orgs/videojs/projects/7/views/2';
/** Round SP values to avoid floating point artifacts like 303.669999... */
function sp(value) {
return Math.round(value);
}
function pct(value, total) {
if (total === 0) return '0%';
return `${Math.round((value / total) * 100)}%`;
}
function statusEmoji(status) {
if (status === 'Done') return '🟢';
if (status === 'Blocked') return '🔴';
if (['In progress', 'Up next', 'Ready for review'].includes(status)) return '🔵';
return '⚪';
}
function header(text) {
return { type: 'header', text: { type: 'plain_text', text, emoji: true } };
}
function section(text) {
return { type: 'section', text: { type: 'mrkdwn', text } };
}
function divider() {
return { type: 'divider' };
}
function context(text) {
return { type: 'context', elements: [{ type: 'mrkdwn', text }] };
}
// ---------------------------------------------------------------------------
// Build Slack blocks from report JSON
// ---------------------------------------------------------------------------
function buildBlocks(report) {
const blocks = [];
const { velocity: v, beta_progress: bp, without_spf: ns } = report;
// Header
blocks.push(header('Video.js 10 Weekly Report'));
blocks.push(context(`*${report.header}*`));
blocks.push(divider());
// Velocity
blocks.push(section(
`*This week*\n` +
`• Story Points completed: *${sp(v.sp_completed)}*\n` +
`• Items completed: *${v.items_completed}*\n` +
`• PRs merged: *${v.prs_merged}*\n` +
`• Issues closed: *${v.issues_closed}*`,
));
blocks.push(divider());
// Beta progress by status
const statusLines = bp.by_status
.sort((a, b) => b.sp - a.sp)
.map((s) => `${statusEmoji(s.status)} ${s.status}: *${sp(s.sp)} SP* (${pct(s.sp, bp.total_sp)}) — ${s.count} items`)
.join('\n');
blocks.push(section(`*Beta progress* — ${bp.total_items} items, ${sp(bp.total_sp)} SP\n${statusLines}`));
// Without SPF
blocks.push(section(
`*Without SPF* — ${sp(ns.total_sp)} SP\n` +
`🟢 Done: *${sp(ns.done_sp)} SP* (${pct(ns.done_sp, ns.total_sp)})\n` +
`🔵 Active: *${sp(ns.active_sp)} SP* (${pct(ns.active_sp, ns.total_sp)})\n` +
`🔴 Blocked: *${sp(ns.blocked_sp)} SP* (${pct(ns.blocked_sp, ns.total_sp)})\n` +
`⚪ Unplanned: *${sp(ns.unplanned_sp)} SP* (${pct(ns.unplanned_sp, ns.total_sp)})`,
));
blocks.push(divider());
// Workstreams
if (report.by_workstream.length > 0) {
const wsLines = report.by_workstream
.sort((a, b) => b.total - a.total)
.map((ws) => `${ws.name}: *${sp(ws.total)} SP* — ${pct(ws.done, ws.total)} done`)
.join('\n');
blocks.push(section(`*By workstream*\n${wsLines}`));
blocks.push(divider());
}
// Team breakdown
if (report.team_breakdown.length > 0) {
const teamLines = report.team_breakdown
.sort((a, b) => b.sp - a.sp)
.map((t) => `• @${t.assignee}: *${sp(t.sp)} SP* (${t.items} items, ${t.prs} PRs)`)
.join('\n');
blocks.push(section(`*Team breakdown*\n${teamLines}`));
blocks.push(divider());
}
// UC Epics
if (report.uc_epics.length > 0) {
const epicLines = report.uc_epics
.map((e) => `${e.id} ${e.name}: *${sp(e.done_sp)}/${sp(e.total_sp)} SP* (${pct(e.done_sp, e.total_sp)}) — ${e.owner}`)
.join('\n');
blocks.push(section(`*Use case epics*\n${epicLines}`));
blocks.push(divider());
}
// Blocked items
if (report.blocked_items.length > 0) {
const blockedLines = report.blocked_items
.map((i) => `• <https://github.com/${REPO}/issues/${i.number}|#${i.number}> ${i.title}`)
.join('\n');
blocks.push(section(`*Blocked*\n${blockedLines}`));
blocks.push(divider());
}
// What's next (cap at 10 to stay within block limits)
if (report.whats_next.length > 0) {
const items = report.whats_next.slice(0, 10);
const lines = items
.map((i) => {
const assignees = (i.assignees || []).map((a) => `@${a}`).join(', ') || 'unassigned';
return `${statusEmoji(i.status)} <https://github.com/${REPO}/issues/${i.number}|#${i.number}> ${i.title} (${sp(i.sp)} SP, ${assignees})`;
})
.join('\n');
const overflow = report.whats_next.length > 10
? `\n_…and ${report.whats_next.length - 10} more_`
: '';
blocks.push(section(`*What's next*\n${lines}${overflow}`));
blocks.push(divider());
}
// AI summary
if (report.ai_summary) {
blocks.push(section(`*Analysis*\n${report.ai_summary}`));
blocks.push(divider());
}
// Footer
blocks.push(context(`<${BOARD_URL}|View project board>`));
// Slack limit: 50 blocks max
if (blocks.length > 50) {
const truncated = blocks.slice(0, 49);
truncated.push(context('_Report truncated — too many sections._'));
return truncated;
}
return blocks;
}
// ---------------------------------------------------------------------------
// Post to Slack webhook
// ---------------------------------------------------------------------------
async function post(webhookUrl, payload) {
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Slack webhook failed (${res.status}): ${body}`);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const webhookUrl = process.env.WEEKLY_REPORT_SLACK_WEBHOOK_URL;
if (!webhookUrl) {
console.error('WEEKLY_REPORT_SLACK_WEBHOOK_URL is not set');
process.exit(1);
}
// Read JSON from stdin
const input = readFileSync('/dev/stdin', 'utf-8').trim();
let report;
try {
report = JSON.parse(input);
} catch (err) {
console.error('Failed to parse report JSON:', err.message);
console.error('Input (first 500 chars):', input.slice(0, 500));
process.exit(1);
}
const blocks = buildBlocks(report);
const fallback = `Video.js 10 Weekly Report — ${report.header}`;
await post(webhookUrl, { text: fallback, blocks });
console.log(`Posted to Slack: ${report.header}`);
+174
View File
@@ -0,0 +1,174 @@
You are generating the weekly progress report for **Video.js 10**.
## Step 1: Determine date range
Calculate the most recent completed full week (MondaySunday). If today is
Monday, that means last MonSun. If today is any other day, it is the
MondaySunday of the current or just-ended week.
The display label should show the full week: "Feb 23 Mar 1, 2026". For
GitHub search queries, use exactly that MondaySunday range.
## Step 2: Collect data from GitHub
Use `gh` CLI commands. Batch GraphQL queries with aliases to stay within
rate limits (5,000 points/hour).
### Project board items
Fetch ALL items with manual cursor pagination. **Do not use `--paginate`**
it duplicates GraphQL results. Use this pattern:
```bash
CURSOR=""
ALL_ITEMS="[]"
while true; do
if [ -z "$CURSOR" ]; then AFTER_ARG=""; else AFTER_ARG=", after: \"$CURSOR\""; fi
RESULT=$(gh api graphql -f query='{ node(id: "PVT_kwDOADIolc4BHP_1") { ... on ProjectV2 { items(first: 100'"$AFTER_ARG"') { pageInfo { hasNextPage endCursor } nodes { status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } points: fieldValueByName(name: "Story Points") { ... on ProjectV2ItemFieldNumberValue { number } } content { ... on Issue { number title state milestone { title } labels(first: 10) { nodes { name } } assignees(first: 5) { nodes { login } } } } } } } } }')
ITEMS=$(echo "$RESULT" | jq '.data.node.items.nodes')
ALL_ITEMS=$(echo "$ALL_ITEMS $ITEMS" | jq -s '.[0] + .[1]')
HAS_NEXT=$(echo "$RESULT" | jq -r '.data.node.items.pageInfo.hasNextPage')
CURSOR=$(echo "$RESULT" | jq -r '.data.node.items.pageInfo.endCursor')
if [ "$HAS_NEXT" != "true" ]; then break; fi
done
echo "$ALL_ITEMS" > /tmp/project_items.json
```
### Merged PRs and closed issues
```bash
gh pr list --repo videojs/v10 --state merged --search "merged:START..END" --limit 100 --json number,title,author
gh issue list --repo videojs/v10 --state closed --search "closed:START..END" --limit 100 --json number,title,closedAt
```
### UC epic sub-issues
Fetch sub-issues via REST, then batch story points with aliased GraphQL:
```bash
gh api repos/videojs/v10/issues/{NUMBER}/sub_issues --jq '[.[] | {number, title, state}]'
```
Known UC epics:
| Epic | Issue | Owner |
|------|-------|-------|
| UC-1 Core Playback UI | #489 | Rahim |
| UC-2 Adaptive Streaming | #353 | Wes |
| UC-3 Skins | #490 | Sam |
| UC-4 Captions & Subtitles | #491 | Rahim |
| UC-5 Accessibility | #492 | Rahim |
| UC-8 Keyboard & Power-User | #494 | Rahim |
Check for new epics each week:
```bash
gh issue list --repo videojs/v10 --label epic --milestone Beta --state all --json number,title
```
## Step 3: Calculate metrics
Apply these rules to the collected data:
- **Filter to Beta milestone only**
- **Exclude items labeled `epic`** — these are rollups that double-count SP
- **Status values are case-sensitive**: `Done`, `In progress`, `Up next`, `Ready for review`, `Blocked`
- **SPF dominates** (~66% of SP) — always compute a "Without SPF" breakdown
### Workstream label mapping
| Label | Workstream |
|-------|-----------|
| `spf` | SPF |
| `components` | UI Components |
| `skin` | Skins |
| `media` | Media |
| `a11y` | Accessibility |
| Any label starting with `docs` | Docs & Guides |
| `site` | Site |
| `compiler` | Compiler |
| `store`, `pkg:core`, `pkg:dom` | Core / Store |
| Everything else | Other |
Compute:
1. **Velocity** — SP and items completed THIS WEEK (closed in the date range)
2. **Beta progress** — all-time by status (Done, Active, Blocked, Unplanned)
3. **Without SPF** — same breakdown excluding `spf`-labeled items
4. **By workstream** — total, done, active, blocked per workstream
5. **Team breakdown** — this week's completed SP/items/PRs per assignee
6. **UC epics** — done SP / total SP per epic
7. **Blocked items** — anything with status "Blocked"
8. **What's next** — open items with status In progress, Up next, Ready for review
## Step 4: Post to Slack
Write the report as a JSON file at `/tmp/report.json` matching this exact structure,
then run the Slack posting script.
```json
{
"header": "Feb 23 Mar 1, 2026",
"velocity": {
"sp_completed": 42,
"items_completed": 8,
"prs_merged": 12,
"issues_closed": 8
},
"beta_progress": {
"total_items": 150,
"total_sp": 234,
"by_status": [
{ "status": "Done", "sp": 89, "count": 45 },
{ "status": "In progress", "sp": 30, "count": 12 }
]
},
"without_spf": {
"total_sp": 156,
"done_sp": 67,
"active_sp": 34,
"blocked_sp": 5,
"unplanned_sp": 50
},
"by_workstream": [
{ "name": "SPF", "total": 78, "done": 35, "active": 20, "blocked": 3 }
],
"team_breakdown": [
{ "assignee": "rahim", "sp": 18, "items": 5, "prs": 4 }
],
"uc_epics": [
{ "id": "UC-1", "name": "Core Playback UI", "owner": "Rahim", "done_sp": 10, "total_sp": 25 }
],
"blocked_items": [
{ "number": 142, "title": "Waiting on external API docs" }
],
"whats_next": [
{ "number": 200, "title": "Slider a11y audit", "status": "In progress", "assignees": ["rahim"], "sp": 5 }
],
"ai_summary": "2-3 paragraph narrative. See writing guidelines below."
}
```
Then post it:
```bash
cat /tmp/report.json | node .github/scripts/project-report/post-to-slack.js
```
## Writing style for ai_summary
- Direct, confident, friendly but not chatty
- Active voice, short sentences
- No filler: "In order to", "basically", "simply", "just", "very", "actually"
- No hedging: "might", "could", "perhaps"
- Use "we" and "our" for the project team
- Reference issues as #NUMBER, people as @name
- Cover: what shipped, velocity trends, risks/blockers, what to focus on next
## Gotchas
- **Status values are case-sensitive in jq.** Verify with `[.[].status.name] | unique`.
- **`--paginate` duplicates GraphQL results.** Always use manual cursor pagination.
- **Write jq to a file.** Shell quoting of `//` (jq's alternative operator) breaks in
bash inline strings. Save to `/tmp/calc.jq` and use `jq -f`.
- **`fieldValueByName` needs aliases.** Can't appear twice in one GraphQL selection.
Use `status: fieldValueByName(name: "Status")` and
`points: fieldValueByName(name: "Story Points")`.