github-repo-stats #177
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: github-repo-stats | |
| on: | |
| schedule: | |
| # Run this once per day, towards the end of the day for keeping the most | |
| # recent data point most meaningful (hours are interpreted in UTC). | |
| - cron: "0 23 * * *" | |
| workflow_dispatch: # Allow for running this manually. | |
| permissions: | |
| contents: read | |
| jobs: | |
| j1: | |
| name: github-repo-stats | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: generate app token | |
| id: app-token | |
| uses: actions/create-github-app-token@v3 | |
| with: | |
| app-id: 3189942 | |
| private-key: ${{ secrets.POWER_PLATFORM_SKILLS_APP_PRIVATE_KEY }} | |
| permission-administration: read | |
| permission-contents: write | |
| permission-metadata: read | |
| - name: verify traffic API access | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| set -euo pipefail | |
| response_headers="$(mktemp)" | |
| trap 'rm -f "${response_headers}"' EXIT | |
| status="$( | |
| curl -sS -o /dev/null -D "${response_headers}" -w "%{http_code}" \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "https://api.github.com/repos/${{ github.repository }}/traffic/popular/referrers" | |
| )" | |
| if [ "${status}" != "200" ] && [ "${status}" != "204" ]; then | |
| echo "GitHub traffic API preflight failed with HTTP ${status}." | |
| echo "The github-repo-stats action needs an installation token with repository Administration: read access." | |
| awk 'BEGIN { IGNORECASE = 1 } /^x-accepted-github-permissions:|^x-oauth-scopes:|^x-github-api-version-selected:|^x-ratelimit-/ { print }' "${response_headers}" | |
| exit 1 | |
| fi | |
| - name: checkout repository | |
| uses: actions/checkout@v4 | |
| - name: checkout github-repo-stats action | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: jgehrcke/github-repo-stats | |
| ref: RELEASE | |
| path: .github/actions/github-repo-stats | |
| - name: patch github-repo-stats stargazer fetch | |
| shell: bash | |
| run: | | |
| python3 <<'PY' | |
| from pathlib import Path | |
| path = Path(".github/actions/github-repo-stats/fetch.py") | |
| text = path.read_text() | |
| import_line = "from github import Github, Repository # type: ignore" | |
| if import_line not in text: | |
| raise RuntimeError("Expected PyGithub import line was not found") | |
| text = text.replace( | |
| import_line, | |
| "from github import Github, Repository, GithubException # type: ignore", | |
| ) | |
| old_stargazer_fetch = "\n".join( | |
| [ | |
| " # TODO for addressing the 10ks challenge: save state to disk, and refresh", | |
| " # using reverse order iteration. See for repo in user.get_repos().reversed", | |
| " for count, gazer in enumerate(repo.get_stargazers_with_dates(), 1):", | |
| " # Store `PullRequest` object with integer key in dictionary.", | |
| " gazers.append(gazer)", | |
| " if count % 200 == 0:", | |
| ' log.info("%s gazers fetched", count)', | |
| ] | |
| ) + "\n" | |
| if old_stargazer_fetch not in text: | |
| raise RuntimeError("Expected REST stargazer fetch block was not found") | |
| new_stargazer_fetch = "\n".join( | |
| [ | |
| " # GitHub restricted the REST stargazers listing in July 2026 to admins and", | |
| " # collaborators. Installation tokens with repository Administration access", | |
| " # can still read the same timestamp data through GraphQL, so keep the", | |
| " # upstream REST path for normal tokens and fall back only for that specific", | |
| " # restriction.", | |
| " # See:", | |
| " # - https://docs.github.com/en/rest/activity/starring#list-stargazers", | |
| " # - https://github.blog/changelog/2026-06-30-upcoming-access-restrictions-to-public-api-endpoints-and-ui-views/", | |
| " try:", | |
| " # TODO for addressing the 10ks challenge: save state to disk, and refresh", | |
| " # using reverse order iteration. See for repo in user.get_repos().reversed", | |
| " for count, gazer in enumerate(repo.get_stargazers_with_dates(), 1):", | |
| " gazers.append(gazer)", | |
| " if count % 200 == 0:", | |
| ' log.info("%s gazers fetched", count)', | |
| " except GithubException as exc:", | |
| ' if exc.status != 403 or "permission to view the stargazers" not in str(exc):', | |
| " raise", | |
| ' log.warning("REST stargazer endpoint denied access; retry via GraphQL")', | |
| " gazers = get_stargazers_with_dates_graphql(repo.full_name)", | |
| ] | |
| ) + "\n" | |
| text = text.replace(old_stargazer_fetch, new_stargazer_fetch) | |
| graphql_helper = r''' | |
| def get_stargazers_with_dates_graphql(repo_full_name): | |
| owner, name = repo_full_name.split("/", 1) | |
| query = """ | |
| query($owner: String!, $name: String!, $cursor: String) { | |
| repository(owner: $owner, name: $name) { | |
| stargazers( | |
| first: 100 | |
| after: $cursor | |
| orderBy: {field: STARRED_AT, direction: ASC} | |
| ) { | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| edges { | |
| starredAt | |
| } | |
| } | |
| } | |
| } | |
| """ | |
| class StargazerEvent: | |
| def __init__(self, starred_at): | |
| # Match PyGithub's REST shape here: get_stargazers_with_dates() | |
| # exposes starred_at as a UTC timestamp without tzinfo. The caller | |
| # localizes it to UTC before building the pandas index. | |
| self.starred_at = starred_at | |
| gazers = [] | |
| cursor = None | |
| while True: | |
| response = requests.post( | |
| "https://api.github.com/graphql", | |
| headers={ | |
| "Authorization": f"Bearer {os.environ['GHRS_GITHUB_API_TOKEN'].strip()}", | |
| "Content-Type": "application/json", | |
| }, | |
| json={"query": query, "variables": {"owner": owner, "name": name, "cursor": cursor}}, | |
| timeout=60, | |
| ) | |
| response.raise_for_status() | |
| payload = response.json() | |
| if payload.get("errors"): | |
| raise RuntimeError(f"GitHub GraphQL stargazer fetch failed: {payload['errors']}") | |
| stargazers = payload["data"]["repository"]["stargazers"] | |
| for edge in stargazers["edges"]: | |
| starred_at = datetime.fromisoformat( | |
| edge["starredAt"].replace("Z", "+00:00") | |
| ).replace(tzinfo=None) | |
| gazers.append(StargazerEvent(starred_at)) | |
| if len(gazers) % 200 == 0: | |
| log.info("%s gazers fetched", len(gazers)) | |
| if not stargazers["pageInfo"]["hasNextPage"]: | |
| return gazers | |
| cursor = stargazers["pageInfo"]["endCursor"] | |
| def handle_rate_limit_error(exc): | |
| ''' | |
| helper_insert_point = "\ndef handle_rate_limit_error(exc):\n" | |
| if helper_insert_point not in text: | |
| raise RuntimeError("Expected helper insertion point was not found") | |
| text = text.replace(helper_insert_point, "\n" + graphql_helper.strip() + "\n") | |
| path.write_text(text) | |
| PY | |
| - name: run-ghrs | |
| # Run the checked-out RELEASE action after applying the stargazer fallback patch above. | |
| uses: ./.github/actions/github-repo-stats | |
| with: | |
| repository: microsoft/power-platform-skills | |
| ghtoken: ${{ steps.app-token.outputs.token }} | |
| databranch: github-repo-stats | |
| ghpagesprefix: https://microsoft.github.io/power-platform-skills |