Skip to content

Instantly share code, notes, and snippets.

@gregjkal
Created August 16, 2026 20:15
Show Gist options
  • Select an option

  • Save gregjkal/3a41c09906daccf28637c1f38057e1d6 to your computer and use it in GitHub Desktop.

Select an option

Save gregjkal/3a41c09906daccf28637c1f38057e1d6 to your computer and use it in GitHub Desktop.
Rebuild GitHub contribution graph data via the gh CLI (works around the private-profile API blackout) for display on gregkaleka.com
#!/usr/bin/env bash
#
# fetch-github-activity.sh
#
# Rebuilds GitHub contribution-graph data for the authenticated user and
# writes it as JSON for Hugo to render (data/github_activity.json).
#
# Why this exists: my GitHub profile is set to private, which blanks the
# contributionsCollection GraphQL API even for my own token. The underlying
# activity is still visible to an authenticated token through repo-scoped
# search, so this script reassembles the contribution calendar and activity
# overview from primary sources:
#
# - commits: GET /search/commits (author:me, bucketed by author date;
# commit search indexes default branches, same as the graph)
# - pull requests: GET /search/issues (type:pr, bucketed by created date)
# - issues: GET /search/issues (type:issue, bucketed by created date)
# - reviews: GET /search/issues (reviewed-by:me) to find PRs, then
# GraphQL to get each review's submittedAt date
#
# Counts are a close approximation of GitHub's own graph, not a byte-for-byte
# copy (GitHub also counts things like repository creation, and buckets by the
# viewer's timezone).
#
# Requirements: gh (authenticated, repo scope), jq.
# Usage: ./scripts/fetch-github-activity.sh [output.json]
set -euo pipefail
OUT="${1:-data/github_activity.json}"
USER=$(gh api user --jq .login)
# BSD (macOS) and GNU date compatibility
if date -v -1d +%F >/dev/null 2>&1; then
days_ago() { date -v -"$1"d +%F; }
else
days_ago() { date -d "$1 days ago" +%F; }
fi
END=$(date +%F)
TODAY_DOW=$(date +%w) # 0 = Sunday
# Match GitHub's grid: 52 full Sun-Sat weeks plus the current partial week,
# so the first column starts on a Sunday.
START=$(days_ago $((364 + TODAY_DOW)))
echo "Fetching contribution data for @$USER ($START .. $END)" >&2
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
echo " commits..." >&2
gh api -X GET search/commits -f q="author:$USER author-date:$START..$END" \
-f per_page=100 -f sort=author-date --paginate \
--jq '.items[].commit.author.date[:10]' > "$tmp/commits.txt"
echo " pull requests..." >&2
gh api -X GET search/issues -f q="author:$USER type:pr created:$START..$END" \
-f advanced_search=true -f per_page=100 --paginate \
--jq '.items[].created_at[:10]' > "$tmp/prs.txt"
echo " issues..." >&2
gh api -X GET search/issues -f q="author:$USER type:issue created:$START..$END" \
-f advanced_search=true -f per_page=100 --paginate \
--jq '.items[].created_at[:10]' > "$tmp/issues.txt"
echo " reviewed PRs..." >&2
# A review submission bumps a PR's updated date, so updated:>=START finds
# every PR that could hold a review inside the window.
gh api -X GET search/issues -f q="reviewed-by:$USER -author:$USER type:pr updated:>=$START" \
-f advanced_search=true -f per_page=100 --paginate \
--jq '.items[].node_id' > "$tmp/review_pr_ids.txt"
echo " review dates ($(wc -l < "$tmp/review_pr_ids.txt" | tr -d ' ') PRs)..." >&2
: > "$tmp/reviews.txt"
if [ -s "$tmp/review_pr_ids.txt" ]; then
split -l 50 "$tmp/review_pr_ids.txt" "$tmp/id_batch_"
for batch in "$tmp"/id_batch_*; do
args=()
while IFS= read -r id; do
args+=(-f "ids[]=$id")
done < "$batch"
gh api graphql \
-f query='query($ids: [ID!]!, $login: String!) {
nodes(ids: $ids) {
... on PullRequest {
reviews(first: 100, author: $login) { nodes { submittedAt } }
}
}
}' \
-f login="$USER" "${args[@]}" \
--jq '.data.nodes[].reviews.nodes[].submittedAt // empty | .[:10]' >> "$tmp/reviews.txt"
done
fi
for f in commits prs issues reviews; do
jq -R -s 'split("\n") | map(select(. != ""))' < "$tmp/$f.txt" > "$tmp/$f.json"
done
jq -n \
--arg user "$USER" \
--arg start "$START" \
--arg end "$END" \
--slurpfile commits "$tmp/commits.json" \
--slurpfile prs "$tmp/prs.json" \
--slurpfile issues "$tmp/issues.json" \
--slurpfile reviews "$tmp/reviews.json" \
'
def bucket: reduce .[] as $d ({}; .[$d] = (.[$d] // 0) + 1);
($start | strptime("%Y-%m-%d") | mktime) as $s |
($end | strptime("%Y-%m-%d") | mktime) as $e |
([range($s; $e + 86400; 86400) | strftime("%Y-%m-%d")]) as $dates |
# Clamp every source to the window (reviews especially can fall outside it)
def clip: map(select(. >= $start and . <= $end));
($commits[0] | clip) as $c |
($prs[0] | clip) as $p |
($issues[0] | clip) as $i |
($reviews[0] | clip) as $r |
($c | bucket) as $cb | ($p | bucket) as $pb |
($i | bucket) as $ib | ($r | bucket) as $rb |
($dates | map({
date: .,
count: (($cb[.] // 0) + ($pb[.] // 0) + ($ib[.] // 0) + ($rb[.] // 0))
})) as $days |
# GitHub-style intensity levels: quartiles over the nonzero days
([$days[].count | select(. > 0)] | sort) as $nz |
(if ($nz | length) == 0 then [1, 2, 3]
else [ $nz[(($nz | length) * 0.25 | floor)],
$nz[(($nz | length) * 0.50 | floor)],
$nz[(($nz | length) * 0.75 | floor)] ]
end) as $q |
($days | map(. + {
level: (if .count == 0 then 0
elif .count <= $q[0] then 1
elif .count <= $q[1] then 2
elif .count <= $q[2] then 3
else 4 end)
})) as $days |
{
username: $user,
fetched_at: ($e | strftime("%Y-%m-%d")),
range: { start: $start, end: $end },
total_contributions: ([$days[].count] | add),
counts: {
commits: ($c | length),
pull_requests: ($p | length),
issues: ($i | length),
code_reviews: ($r | length)
},
weeks: ([range(0; ($days | length); 7)] | map($days[. : . + 7]))
}
' > "$OUT"
echo "Wrote $OUT" >&2
jq -r '" \(.total_contributions) contributions | commits \(.counts.commits), PRs \(.counts.pull_requests), issues \(.counts.issues), reviews \(.counts.code_reviews)"' "$OUT" >&2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment