diff --git a/.github/scripts/project-report/post-to-slack.js b/.github/scripts/project-report/post-to-slack.js new file mode 100644 index 00000000..58ca4b50 --- /dev/null +++ b/.github/scripts/project-report/post-to-slack.js @@ -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) => `• ${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)} ${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}`); diff --git a/.github/scripts/project-report/prompt.md b/.github/scripts/project-report/prompt.md new file mode 100644 index 00000000..ae84096c --- /dev/null +++ b/.github/scripts/project-report/prompt.md @@ -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 (Monday–Sunday). If today is +Monday, that means last Mon–Sun. If today is any other day, it is the +Monday–Sunday 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 Monday–Sunday 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")`. diff --git a/.github/workflows/weekly-project-report.yml b/.github/workflows/weekly-project-report.yml new file mode 100644 index 00000000..bc0f3c18 --- /dev/null +++ b/.github/workflows/weekly-project-report.yml @@ -0,0 +1,55 @@ +name: Weekly Project Report + +on: + schedule: + # Every Monday at 9 AM UTC + - cron: '0 9 * * 1' + workflow_dispatch: + # Manual trigger for testing + +permissions: + actions: read + contents: read + issues: read + pull-requests: read + id-token: write + +jobs: + report: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Generate and post weekly report + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + claude_args: | + --model sonnet + --max-turns 25 + --allowedTools "Bash(gh:*)" "Bash(jq:*)" "Bash(node:*)" "Bash(cat:*)" "Bash(echo:*)" "Read" "Write" + prompt: | + Read the instructions in `.github/scripts/project-report/prompt.md` and follow them. + + The Slack posting script is at `.github/scripts/project-report/post-to-slack.js`. + The webhook URL is in the WEEKLY_REPORT_SLACK_WEBHOOK_URL environment variable. + Use GH_TOKEN for authenticated gh commands — it is already set. + + Generate the weekly report, write the JSON to /tmp/report.json, then post it to Slack. + env: + # PAT with read:project scope — required for org-level Projects v2 GraphQL. + # The automatic GITHUB_TOKEN does not have project board access. + GH_TOKEN: ${{ secrets.PROJECT_GH_TOKEN }} + WEEKLY_REPORT_SLACK_WEBHOOK_URL: ${{ secrets.WEEKLY_REPORT_SLACK_WEBHOOK_URL }} + + - name: Alert Slack on failure + if: failure() + env: + WEEKLY_REPORT_SLACK_WEBHOOK_URL: ${{ secrets.WEEKLY_REPORT_SLACK_WEBHOOK_URL }} + run: | + curl -sf -X POST "$WEEKLY_REPORT_SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + -d '{"text":"⚠️ Weekly project report workflow failed. Check GitHub Actions: https://github.com/videojs/v10/actions"}'