From 9483c673f82d18481f843807f94f25661cdc4414 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Thu, 2 Jul 2026 16:56:48 -0400 Subject: [PATCH 1/2] fix: Detect active epics via sub-issue activity The weekly report gated epic inclusion on the project board Status column, so epics parked in an "Epics" column (not "In Progress") were dropped or shown as "no activity". Detection is now sub-issue-based: an epic is active if it has open sub-issues or a sub-issue closed within the reporting window. This uses the plain REST sub_issues endpoint and needs no read:project scope; board Status becomes display-only. Epics are ranked by recency of sub-issue closure so a single bulk-close epic does not crowd out low-volume but freshly-active epics. Also registers the github-weekly-report skill in marketplace.json and the README, which were missing it. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- .claude-plugin/marketplace.json | 9 ++ README.md | 1 + skills/github-weekly-report/SKILL.md | 18 +-- .../scripts/epic-tracker.py | 113 ++++++++++++++++-- skills/github-weekly-report/scripts/report.py | 9 +- 5 files changed, 129 insertions(+), 21 deletions(-) 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..8966f8f 100644 --- a/skills/github-weekly-report/scripts/epic-tracker.py +++ b/skills/github-weekly-report/scripts/epic-tracker.py @@ -117,6 +117,80 @@ 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. + + Server-side --jq is required: raw sub-issue bodies contain control chars + that break client-side json parsing (same reason as elsewhere in this file). + 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" + in_window = ( + f'select((.closed_at // "") >= "{since_start}" and (.closed_at // "") <= "{until_end}")' + ) + open_filter = '[.[] | select(.state == "open")] | length' + closed_filter = f'[.[] | select(.state == "closed") | {in_window} | .number]' + latest_filter = f'[.[] | select(.state == "closed") | {in_window} | .closed_at] | max // ""' + total_filter = 'length' + empty = {'open': 0, 'closed_recent': 0, 'closed_recent_numbers': [], + 'latest_closed_at': '', 'total': 0} + try: + base = ['gh', 'api', f'repos/{org}/{repo}/issues/{epic_number}/sub_issues', '--paginate'] + open_res = subprocess.run(base + ['--jq', open_filter], capture_output=True, text=True, timeout=20) + if open_res.returncode != 0: + return empty + closed_res = subprocess.run(base + ['--jq', closed_filter], capture_output=True, text=True, timeout=20) + latest_res = subprocess.run(base + ['--jq', latest_filter], capture_output=True, text=True, timeout=20) + total_res = subprocess.run(base + ['--jq', total_filter], capture_output=True, text=True, timeout=20) + + # --paginate concatenates one result per page; sum the per-page ints. + def _sum_ints(text): + return sum(int(x) for x in text.split() if x.strip().lstrip('-').isdigit()) + + def _collect_numbers(text): + nums = set() + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + nums.update(json.loads(line)) + except json.JSONDecodeError: + continue + return sorted(nums) + + open_count = _sum_ints(open_res.stdout) + total = _sum_ints(total_res.stdout) if total_res.returncode == 0 else open_count + closed_numbers = _collect_numbers(closed_res.stdout) if closed_res.returncode == 0 else [] + # max across pages: each page emits its own max line; take the overall max. + latest = "" + if latest_res.returncode == 0: + for line in latest_res.stdout.splitlines(): + line = line.strip().strip('"') + if line and line > latest: + latest = line + return { + 'open': open_count, + 'closed_recent': len(closed_numbers), + 'closed_recent_numbers': closed_numbers, + 'latest_closed_at': latest, + 'total': total, + } + except (subprocess.TimeoutExpired, ValueError): + return empty + + PROJECT_NUMBER = 8 # "Kagenti Issue Prioritization" — the board clawgenti can access @@ -229,22 +303,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 +340,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'): From 5b472467b5a4342df2708fb1c501cd6bddf34383 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Wed, 8 Jul 2026 07:34:20 -0400 Subject: [PATCH 2/2] refactor: Fetch epic sub-issues in a single API call Address PR review: get_sub_issue_activity made 4 separate paginated calls to the same sub_issues endpoint (open, closed numbers, latest, total), multiplying API usage 4x per epic and risking mid-run snapshot skew. Fetch once, projecting only {number, state, closed_at} server-side (keeps control-char safety for raw bodies), then filter locally via a closed_in_window helper. Also drops the negative-admitting isdigit guard the per-page summing needed. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- .../scripts/epic-tracker.py | 77 +++++++++---------- 1 file changed, 35 insertions(+), 42 deletions(-) diff --git a/skills/github-weekly-report/scripts/epic-tracker.py b/skills/github-weekly-report/scripts/epic-tracker.py index 8966f8f..2c79051 100644 --- a/skills/github-weekly-report/scripts/epic-tracker.py +++ b/skills/github-weekly-report/scripts/epic-tracker.py @@ -127,8 +127,11 @@ def get_sub_issue_activity(org, repo, epic_number, since, until): sub-issue (backlog / in-progress) or any sub-issue closed within [since, until] inclusive. - Server-side --jq is required: raw sub-issue bodies contain control chars - that break client-side json parsing (same reason as elsewhere in this file). + 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 @@ -136,56 +139,46 @@ def get_sub_issue_activity(org, repo, epic_number, since, until): """ until_end = f"{until}T23:59:59Z" since_start = f"{since}T00:00:00Z" - in_window = ( - f'select((.closed_at // "") >= "{since_start}" and (.closed_at // "") <= "{until_end}")' - ) - open_filter = '[.[] | select(.state == "open")] | length' - closed_filter = f'[.[] | select(.state == "closed") | {in_window} | .number]' - latest_filter = f'[.[] | select(.state == "closed") | {in_window} | .closed_at] | max // ""' - total_filter = 'length' empty = {'open': 0, 'closed_recent': 0, 'closed_recent_numbers': [], 'latest_closed_at': '', 'total': 0} try: - base = ['gh', 'api', f'repos/{org}/{repo}/issues/{epic_number}/sub_issues', '--paginate'] - open_res = subprocess.run(base + ['--jq', open_filter], capture_output=True, text=True, timeout=20) - if open_res.returncode != 0: + 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 - closed_res = subprocess.run(base + ['--jq', closed_filter], capture_output=True, text=True, timeout=20) - latest_res = subprocess.run(base + ['--jq', latest_filter], capture_output=True, text=True, timeout=20) - total_res = subprocess.run(base + ['--jq', total_filter], capture_output=True, text=True, timeout=20) - - # --paginate concatenates one result per page; sum the per-page ints. - def _sum_ints(text): - return sum(int(x) for x in text.split() if x.strip().lstrip('-').isdigit()) - def _collect_numbers(text): - nums = set() - for line in text.splitlines(): - line = line.strip() - if not line: - continue - try: - nums.update(json.loads(line)) - except json.JSONDecodeError: - continue - return sorted(nums) - - open_count = _sum_ints(open_res.stdout) - total = _sum_ints(total_res.stdout) if total_res.returncode == 0 else open_count - closed_numbers = _collect_numbers(closed_res.stdout) if closed_res.returncode == 0 else [] - # max across pages: each page emits its own max line; take the overall max. - latest = "" - if latest_res.returncode == 0: - for line in latest_res.stdout.splitlines(): - line = line.strip().strip('"') - if line and line > latest: - latest = line + # 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': total, + 'total': len(rows), } except (subprocess.TimeoutExpired, ValueError): return empty