| name | github-activity-metrics |
| description | Use when the deliverable is a COUNT, RATE or AVERAGE computed from a repository's pull requests and issues over a date window, gathered with the `gh` CLI or the GitHub REST/GraphQL API — "how many PRs were opened in December", "average time to merge in days", "who opened the most PRs", "how many bug issues were closed that month", a monthly community-pulse report. Fires on "gh pr list", "gh issue list", "gh api", "repo analytics", or a JSON activity report checked against exact numbers. Carries the pagination defaults that silently truncate the answer, the PR/issue state semantics, and the two distinct date windows.
|
GitHub activity metrics
Every number in this class of task comes out plausible and wrong for the same two reasons: the list
commands return a truncated page by default, and they return open items only by default. Both
failures produce a tidy report that a human would not question, and both are graded to zero
tolerance.
What transfers: the completeness discipline, the state semantics, the two-window rule, and the
timestamp arithmetic. What is per-task: the repository, the date range, the field names in the
requested JSON schema, and the definition of a "bug" label. Take those from the task statement.
When to use this skill
- The answer is a number about a repository's activity over a stated period, written to a file with
a fixed schema.
- The task defines its own category rule ("a bug is any issue whose label contains
bug") that
differs from a stock filter.
- A previously produced count is suspiciously round — 30, 100, 1000.
Not for creating or editing PRs and issues, CI triage, code review, or repository content analysis.
Procedure
-
Defeat the default caps first. These are the two flags that decide the whole task:
| Command | Silent default | Required |
|---|
gh pr list / gh issue list | --limit 30 | --limit well above the plausible count |
gh pr list / gh issue list | --state open | --state all |
gh api /repos/.../pulls | per_page=30, page 1 | --paginate -f per_page=100 |
A busy repository produces far more than 30 items a month, so an un-flagged gh pr list returns
exactly 30 and every downstream statistic is computed on a truncated sample.
-
Filter the creation window in the query, not afterwards. --search created:START..END is
inclusive of both endpoints and is evaluated server-side, so it composes with --limit
correctly:
gh pr list -R OWNER/REPO --state all --limit 1000 \
--search 'created:2024-12-01..2024-12-31' \
--json number,state,author,createdAt,mergedAt
Search-backed listing tops out at 1000 results; if a window could exceed that, split it into
shorter sub-windows or page with gh api --paginate and merge.
-
Select every field you will need in --json. A field not named is simply absent — no error,
just a KeyError or a silent None later.
-
Use the real state semantics. In --json state the values are upper-case, and a merged pull
request is MERGED, not CLOSED:
| Question | Correct predicate |
|---|
| how many merged | state == "MERGED" and mergedAt is set |
| how many closed (not merged) | state == "CLOSED" |
| time to merge | mergedAt - createdAt |
| when an issue was resolved | closedAt |
closedAt is populated for merged PRs too, so using it as the closed filter double-counts
merges. Keep the two counts disjoint unless the task says otherwise.
-
Keep the two date windows separate. "PRs created during the month" is a creation-window
filter; "bug issues closed during the month" is a different filter on closedAt, applied to
the created-in-window set or to all issues depending on what the task says. Implement the closure
test as a half-open interval start <= closed_at < next_month_start so the boundary day is not
counted twice.
-
Parse timestamps defensively. The API returns ISO-8601 with a trailing Z, which older
datetime.fromisoformat rejects:
from datetime import datetime
def ts(s): return datetime.fromisoformat(s.replace("Z", "+00:00"))
days = (ts(pr["mergedAt"]) - ts(pr["createdAt"])).total_seconds() / 86400
Average the raw float days and round once at the end, to the requested precision.
-
Implement category rules literally. "At least one label whose name contains bug" means a
case-insensitive substring test over every label — it matches bug, type: bug, kind/bug. The
--label bug flag is an exact-name filter and misses the other two.
-
Guard the nulls. author is null for deleted accounts, mergedAt is null for unmerged PRs,
and an empty set makes a mean divide by zero — emit 0 rather than null or a traceback.
-
Self-check for truncation before writing the file. A result length equal to 30, 100 or the
limit you passed is truncated until proven otherwise: re-run with a larger limit and confirm the
count is stable. Then check the schema — integers for counts, a float at the stated rounding for
durations, a bare login (no @) for a contributor, and exactly the keys asked for.
Worked example
A monthly pulse report: PR totals and merge latency, plus bug-issue resolution.
import json, subprocess
from collections import Counter
from datetime import datetime
REPO, RANGE = "cli/cli", "2024-12-01..2024-12-31"
START, END = datetime.fromisoformat("2024-12-01T00:00:00+00:00"), \
datetime.fromisoformat("2025-01-01T00:00:00+00:00")
def gh(cmd):
out = subprocess.run(f"gh {cmd}".split(), capture_output=True, text=True).stdout
return json.loads(out) if out.strip() else []
def ts(s): return datetime.fromisoformat(s.replace("Z", "+00:00"))
prs = gh(f"pr list -R {REPO} --state all --limit 1000 --search created:{RANGE} "
f"--json number,state,author,createdAt,mergedAt")
assert len(prs) not in (30, 1000), "result looks truncated - raise --limit"
merged = [p for p in prs if p["state"] == "MERGED" and p.get("mergedAt")]
authors = Counter(p["author"]["login"] for p in prs if p.get("author"))
issues = gh(f"issue list -R {REPO} --state all --limit 1000 --search created:{RANGE} "
f"--json number,state,labels,closedAt")
bugs = [i for i in issues
if any("bug" in l["name"].lower() for l in i.get("labels", []))]
resolved = [i for i in bugs
if i.get("closedAt") and START <= ts(i["closedAt"]) < END]
report = {
"pr": {
"total": len(prs),
"merged": len(merged),
"closed": sum(1 for p in prs if p["state"] == "CLOSED"),
"avg_merge_days": round(sum((ts(p["mergedAt"]) - ts(p["createdAt"])).total_seconds()
for p in merged) / 86400 / len(merged), 1) if merged else 0,
"top_contributor": authors.most_common(1)[0][0] if authors else "",
},
"issue": {"total": len(issues), "bug": len(bugs), "resolved_bugs": len(resolved)},
}
open("/app/report.json", "w").write(json.dumps(report, indent=2))
Note the three places the naive version goes wrong: no --state all (misses every merged PR), no
--limit (stops at 30), and counting CLOSED as "not merged" only works because MERGED is its own
state — reversing that assumption double-counts every merge.
References