Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 9 additions & 9 deletions skills/github-weekly-report/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
106 changes: 95 additions & 11 deletions skills/github-weekly-report/scripts/epic-tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): intentional behavior change worth a conscious confirm. The old fallback path included any epic updated_in_period, but is_active now requires open sub-issues, an in-window sub-issue closure, or a merged PR. An older-style epic tracked as a plain issue (no sub-issues feature) that only saw comment/label activity this week — with no merged PR — will now drop off the report. That matches the PR's stated intent of making sub-issues the primary signal, so no change needed if all active epics use sub-issues; just flagging it since the updated_in_period inclusion path is gone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes ,that is the intended new behavior.

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'],
Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion skills/github-weekly-report/scripts/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 |")
Expand All @@ -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'):
Expand Down
Loading