diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index db1868c..e0deccb 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -37,6 +37,15 @@ "skills": [ "./skills/automation-health-dashboard" ] + }, + { + "name": "github-weekly-report", + "description": "Generate comprehensive weekly GitHub org activity reports with active-epic tracking", + "source": "./", + "strict": false, + "skills": [ + "./skills/github-weekly-report" + ] } ] } diff --git a/README.md b/README.md index cee3da7..ad36d1a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Built to the [Agent Skills specification](https://agentskills.io/specification). | [dep-bump-scanner](skills/dep-bump-scanner/) | Monitor Dependabot PRs, classify by severity, flag SLA breaches | | [dep-bump-fixer](skills/dep-bump-fixer/) | Analyze stale Dependabot PRs and post commentary to accelerate review | | [automation-health-dashboard](skills/automation-health-dashboard/) | Generate executive-facing dashboard combining all program metrics | +| [github-weekly-report](skills/github-weekly-report/) | Generate weekly org activity reports with merged PRs, CI health, and active-epic tracking | ## Installation diff --git a/skills/github-weekly-report/SKILL.md b/skills/github-weekly-report/SKILL.md index c6e592d..0a6e42a 100644 --- a/skills/github-weekly-report/SKILL.md +++ b/skills/github-weekly-report/SKILL.md @@ -16,7 +16,7 @@ The report is produced in **two phases**: - **Python 3.8+** - **GitHub CLI (`gh`)** — authenticated with access to the target org -- **Token scope**: `read:project` recommended for GitHub Projects v2 status enrichment (optional — falls back to activity-based detection) +- **Token scope**: `read:project` is optional — it only enriches the epic Status column from the Projects v2 board. Active-epic detection is sub-issue-based and works without it. ## Quick Start @@ -54,19 +54,19 @@ The script generates a baseline Active Epics table from `epic-tracker.py` output | Epic | Lead | Key Result | This Week | |------|------|------------|-----------| -| [kagenti/kagenti#1789](https://github.com/kagenti/kagenti/issues/1789) OPA Authorization | @davidhadas | KR2: Zero-trust agent auth | 3 PRs merged | -| [kagenti/kagenti-extensions#501](https://github.com/kagenti/kagenti-extensions/issues/501) Session Mgmt | @sahilsuneja1 | KR1: Stateful agent infra | 1 PR merged, design review | +| [kagenti/kagenti#1789](https://github.com/kagenti/kagenti/issues/1789) OPA Authorization | @davidhadas | KR2: Zero-trust agent auth | 3 sub-issues closed, 4 open | +| [kagenti/kagenti-extensions#501](https://github.com/kagenti/kagenti-extensions/issues/501) Session Mgmt | @sahilsuneja1 | KR1: Stateful agent infra | 2 open, 1 PR merged | Rules: -- Scan for `epic` label across all repos in the org (not just kagenti/kagenti) -- Show epics with Status = "In Progress" (from project board) or with activity this week +- Scan for the `epic` label across all repos in the org +- **Detection is sub-issue-based** (the primary signal): an epic is active if it has open sub-issues (backlog / in-progress) OR a sub-issue closed within the reporting window. This does not depend on the project board and needs no `read:project` scope. A merged-PR cross-reference in the window also counts. +- Board Status (Projects v2), when available, is **display-only enrichment** — it fills the status label but never gates inclusion. Teams often park active epics in an "Epics" column rather than "In Progress", so board status alone misses them. - Lead = first assignee on the epic issue - Epic reference = full `org/repo#N` with link to the source repo - Key Result = extracted from epic body (`## Key Results` section) or inferred from title/labels -- "This Week" = brief summary of PRs merged / issues closed referencing this epic -- Cap at 10 epics; sort by activity count descending -- If an epic has no activity this week but is "In Progress", still show it (with "no activity" note) -- If the script produced a fallback-mode table (no Projects v2 access), note this but do not remove the section +- "This Week" = brief summary of sub-issues closed / open, plus any PRs merged referencing this epic +- Cap the list (configurable via `--max-epics`, default 10); sort by recent activity (sub-issues closed this week, then open count) descending to keep the section a focused planning input +- If the script produced a fallback-mode table (no Projects v2 access), the status column is derived from sub-issue state — keep the section ### Cross-Repo Highlights diff --git a/skills/github-weekly-report/scripts/epic-tracker.py b/skills/github-weekly-report/scripts/epic-tracker.py index 2f97347..2c79051 100644 --- a/skills/github-weekly-report/scripts/epic-tracker.py +++ b/skills/github-weekly-report/scripts/epic-tracker.py @@ -117,6 +117,73 @@ def get_activity_via_timeline(org, repo, epic_number, since, until): return {'prs_merged': 0, 'issues_closed': 0, 'pr_numbers': []} +def get_sub_issue_activity(org, repo, epic_number, since, until): + """Count an epic's sub-issues that are open or were closed in the window. + + This is the PRIMARY activity signal: teams often park active epics in an + "Epics" board column rather than "In Progress", making board Status an + unreliable gate. Sub-issues come from the plain REST sub_issues endpoint, + which needs no read:project scope. An epic is "active" if it has any open + sub-issue (backlog / in-progress) or any sub-issue closed within + [since, until] inclusive. + + Fetches the sub_issues endpoint once (a single --paginate call) projecting + only the three fields we need into one compact object per line, then filters + locally. Projecting server-side keeps us safe from control chars in raw + sub-issue bodies (which break client-side json parsing) while avoiding the + 4x API cost — and mid-run snapshot skew — of separate calls per metric. + Returns {open, closed_recent, closed_recent_numbers, latest_closed_at, total}. + latest_closed_at is the most recent in-window closure timestamp ("" if none) + — used for recency-based ranking so a single bulk-close epic does not crowd + out low-volume but freshly-active epics. + """ + until_end = f"{until}T23:59:59Z" + since_start = f"{since}T00:00:00Z" + empty = {'open': 0, 'closed_recent': 0, 'closed_recent_numbers': [], + 'latest_closed_at': '', 'total': 0} + try: + result = subprocess.run( + ['gh', 'api', f'repos/{org}/{repo}/issues/{epic_number}/sub_issues', + '--paginate', '--jq', '.[] | {number, state, closed_at}'], + capture_output=True, text=True, timeout=20 + ) + if result.returncode != 0: + return empty + + # One compact JSON object per line (NDJSON across all pages). + rows = [] + for line in result.stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue + + # closed_at is an ISO-8601 UTC timestamp (fixed-width, Z-suffixed), so a + # plain string range check is a correct in-window test. A null/missing + # closed_at ("") is lexically below since_start and thus excluded. + def closed_in_window(row): + if row.get('state') != 'closed': + return False + ts = row.get('closed_at') or '' + return since_start <= ts <= until_end + + open_count = sum(1 for r in rows if r.get('state') == 'open') + closed_numbers = sorted({r['number'] for r in rows if closed_in_window(r)}) + latest = max((r['closed_at'] for r in rows if closed_in_window(r)), default='') + return { + 'open': open_count, + 'closed_recent': len(closed_numbers), + 'closed_recent_numbers': closed_numbers, + 'latest_closed_at': latest, + 'total': len(rows), + } + except (subprocess.TimeoutExpired, ValueError): + return empty + + PROJECT_NUMBER = 8 # "Kagenti Issue Prioritization" — the board clawgenti can access @@ -229,22 +296,31 @@ def main(): key_result = extract_key_result(epic.get('body', '')) updated_at = (epic.get('updatedAt') or '')[:10] - status = "" + board_status = "" if status_map and url in status_map: - status = status_map[url] + board_status = status_map[url] + # Primary signal: sub-issue activity (no read:project scope needed). + sub = get_sub_issue_activity(args.org, epic['repo'], epic['number'], since, until) activity = get_activity_via_timeline(args.org, epic['repo'], epic['number'], since, until) - has_activity = activity['prs_merged'] > 0 - updated_in_period = updated_at >= since - if not fallback_mode and status_map: - is_tracked = status.lower() in ('in progress', 'in-progress', 'active', 'epics') - if not is_tracked and not has_activity: - continue + # An epic is active if it has open sub-issues (backlog / in-progress), + # a sub-issue closed this window, or a merged-PR cross-reference this + # window. Board Status is display-only enrichment — never a gate. + is_active = sub['open'] > 0 or sub['closed_recent'] > 0 or activity['prs_merged'] > 0 + if not is_active: + continue + + # Prefer the board's own label when present; otherwise derive from + # sub-issue state so the status column is still meaningful. + if board_status: + status = board_status + elif sub['closed_recent'] > 0: + status = "Active" + elif sub['open'] > 0: + status = "In progress" else: - if not updated_in_period and not has_activity: - continue - status = "Active" if has_activity else "Updated" + status = "Updated" results.append({ 'number': epic['number'], @@ -257,11 +333,19 @@ def main(): 'status': status, 'key_result': key_result, 'activity_this_week': activity, + 'sub_issues': sub, 'updated_at': updated_at, 'labels': labels, }) + # Rank by RECENCY, not volume: any epic with a sub-issue closed this window + # ranks above those with none (by freshest closure), so a single bulk-close + # epic cannot crowd out low-volume but freshly-active epics. Among epics with + # no recent closure, those with open sub-issues rank next, then by update. results.sort(key=lambda e: ( + 1 if e['sub_issues']['latest_closed_at'] else 0, + e['sub_issues']['latest_closed_at'], + 1 if e['sub_issues']['open'] else 0, e['activity_this_week']['prs_merged'], e['updated_at'], ), reverse=True) diff --git a/skills/github-weekly-report/scripts/report.py b/skills/github-weekly-report/scripts/report.py index 472af5f..b005a5b 100644 --- a/skills/github-weekly-report/scripts/report.py +++ b/skills/github-weekly-report/scripts/report.py @@ -179,7 +179,7 @@ def render_active_epics_section(data): return lines if data.get('fallback_mode'): - lines.append("*Note: Using activity-based detection (Projects v2 status unavailable).*") + lines.append("*Note: Projects v2 status unavailable; the Status column is derived from sub-issue state.*") lines.append("") lines.append("| Epic | Lead | Key Result | This Week |") @@ -190,8 +190,15 @@ def render_active_epics_section(data): title_short = title_clean[:50] + '...' if len(title_clean) > 53 else title_clean lead = f"@{e['lead']}" if e['lead'] else "unassigned" kr = e.get('key_result', '') or "—" + # Sub-issue activity is the primary signal; PRs merged is supplementary. + sub = e.get('sub_issues', {}) act = e.get('activity_this_week', {}) parts = [] + if sub.get('closed_recent'): + n = sub['closed_recent'] + parts.append(f"{n} sub-issue{'s' if n != 1 else ''} closed") + if sub.get('open'): + parts.append(f"{sub['open']} open") if act.get('prs_merged'): parts.append(f"{act['prs_merged']} PR{'s' if act['prs_merged'] != 1 else ''} merged") if act.get('issues_closed'):