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
5 changes: 5 additions & 0 deletions skills/github-weekly-report/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,18 @@ python3 {baseDir}/scripts/report.py --org <org> --output report.md --json-output
# Report on specific date range
python3 {baseDir}/scripts/report.py --org <org> --since 2026-03-23 --until 2026-03-30 --output report.md --json-output report-data.json

# Report on an explicit repo set (owner-qualified); skips org-wide discovery
python3 {baseDir}/scripts/report.py --org <org> --repos <org>/repo-a <org>/repo-b --output report.md --json-output report-data.json

# Run epic tracker standalone (for debugging)
python3 {baseDir}/scripts/epic-tracker.py --org <org> --since 2026-03-23 --until 2026-03-30

# Post report to a GitHub issue
gh issue create -R <org>/<repo> --title "Weekly Report $(date +%Y-%m-%d)" --body-file report.md
```

When `--repos` is omitted, the report discovers all repos in the org (the unchanged default). Automation deployments pass the curated core-repo allowlist via the `weekly-report.sh` wrapper, which resolves the list and calls `report.py --repos` for you.

## What the Report Includes

1. **Org-Wide Summary** — table with merged PRs, open PRs, open issues, new issues, CI pass rate, and status per repo
Expand Down
10 changes: 8 additions & 2 deletions skills/github-weekly-report/scripts/epic-tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,13 +258,19 @@ def main():
p.add_argument('--until', help='End of reporting period (YYYY-MM-DD)')
p.add_argument('--max-epics', type=int, default=10, help='Maximum epics to include')
p.add_argument('--skip-projects', action='store_true', help='Skip GitHub Projects v2 query')
p.add_argument('--repos', nargs='+', metavar='OWNER/REPO',
help='Explicit owner-qualified repo list; when set, skips org-wide discovery')
args = p.parse_args()

since = args.since or (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
until = args.until or datetime.now().strftime('%Y-%m-%d')

print(f"Fetching repos for {args.org}...", file=sys.stderr)
repos = get_org_repos(args.org)
if args.repos:
# Owner-qualified -> bare names; get_epics builds 'org/repo' from --org.
repos = [item.rpartition('/')[2] or item for item in args.repos]
else:
print(f"Fetching repos for {args.org}...", file=sys.stderr)
repos = get_org_repos(args.org)
if not repos:
print("Error: no repos found", file=sys.stderr)
json.dump({'epics': [], 'fallback_mode': True, 'period': {'since': since, 'until': until}}, sys.stdout, indent=2)
Expand Down
63 changes: 53 additions & 10 deletions skills/github-weekly-report/scripts/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@ def run_gh(args):

def get_repos(org):
return run_gh(['repo', 'list', org, '--limit', '100', '--json', 'name'])

def normalize_repo_args(repo_args, org):
"""Turn --repos values into the bare-name dicts get_repos returns, scoped to
a single owner. Accepts a bare name ('operator') or an owner-qualified name
whose owner matches --org ('rossoctl/operator'); both yield {'name': 'operator'}.

Every downstream fetch rebuilds the slug as f'{org}/{name}', so an entry whose
owner differs from --org would be silently reported against the wrong owner.
To keep the report header and the queried data consistent, reject any entry
that is owner-qualified with a different owner, or that is malformed (empty,
or more than one '/'). Order and duplicates are preserved as given.
"""
names = []
for item in repo_args:
item = item.strip()
if not item:
continue
parts = item.split('/')
if len(parts) == 1:
name = parts[0]
elif len(parts) == 2 and parts[0] and parts[1]:
owner, name = parts
if owner != org:
sys.exit(
f"--repos entry '{item}' is not in --org '{org}'. "
f"Entries must be a bare repo name or '{org}/<repo>'."
)
else:
sys.exit(f"--repos entry '{item}' is malformed; expected '<repo>' or '{org}/<repo>'.")
names.append({'name': name})
return names

def get_merged_prs(org, repo, since, until):
return run_gh(['pr', 'list', '-R', f'{org}/{repo}', '--search', f'merged:{since}..{until}', '--state', 'merged', '--limit', '500', '--json', 'number,title,author,mergedAt'])
def get_open_prs(org, repo):
Expand Down Expand Up @@ -140,7 +172,7 @@ def generate_action_items(repos_data):
lines.append("")
return lines

def run_epic_tracker(org, since, until):
def run_epic_tracker(org, since, until, repos=None):
"""Run epic-tracker.py once and return its parsed JSON.

Returns a dict on success, or a dict with an 'error' key describing why
Expand All @@ -153,10 +185,10 @@ def run_epic_tracker(org, since, until):
return {'error': 'Epic tracker not available.'}

try:
result = subprocess.run(
[sys.executable, tracker, '--org', org, '--since', since, '--until', until],
capture_output=True, text=True, timeout=120
)
cmd = [sys.executable, tracker, '--org', org, '--since', since, '--until', until]
if repos:
cmd += ['--repos', *[r['name'] for r in repos]]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
if result.stderr:
print(f"epic-tracker stderr: {result.stderr}", file=sys.stderr)
Expand Down Expand Up @@ -209,9 +241,16 @@ def render_active_epics_section(data):
lines.append("")
return lines

def generate_report(org, since, until, enhanced=False):
lines = [f"# Org Weekly Report: {since} -- {until}", "", f"*Generated for [{org}](https://github.com/{org})*", ""]
repos = get_repos(org)
def generate_report(org, since, until, enhanced=False, repos=None):
lines = [
f"# Org Weekly Report: {since} -- {until}",
"",
f"*Generated for [{org}](https://github.com/{org}) by the "
"[github-weekly-report](https://github.com/rossoctl/agent-skills/tree/main/skills/github-weekly-report) "
"skill.*",
"",
]
repos = repos if repos is not None else get_repos(org)
repos_data = []
for repo in repos:
name = repo['name']
Expand Down Expand Up @@ -298,7 +337,7 @@ def generate_report(org, since, until, enhanced=False):
lines.append("")

# Active Epics — run the tracker once and reuse for both markdown and JSON
epic_data = run_epic_tracker(org, since, until)
epic_data = run_epic_tracker(org, since, until, repos=repos)
lines += render_active_epics_section(epic_data)
lines.append("")

Expand Down Expand Up @@ -465,11 +504,15 @@ def main():
p.add_argument('--output')
p.add_argument('--json-output', metavar='PATH', help='Write structured JSON data for AI synthesis')
p.add_argument('--enhanced', action='store_true', help='Include additional metrics (reserved for future use)')
p.add_argument('--repos', nargs='+', metavar='REPO',
help="Explicit repo list scoped to --org (bare '<repo>' or "
"'<org>/<repo>'); when set, skips org-wide discovery")
args = p.parse_args()
since = args.since or (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
until = args.until or datetime.now().strftime('%Y-%m-%d')

report, repos_data, epic_data = generate_report(args.org, since, until, args.enhanced)
repos = normalize_repo_args(args.repos, args.org) if args.repos else None
report, repos_data, epic_data = generate_report(args.org, since, until, args.enhanced, repos=repos)

if args.output:
with open(args.output, 'w') as f:
Expand Down
Loading