Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/goodbounties/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
61 changes: 61 additions & 0 deletions .github/goodbounties/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# GoodBounties reviewer inventory

This reads GitHub and produces evidence for the GoodBounties manager. It does not assign reviewers, send messages or run the manager. Those connections are still to be made.

## One config file

Edit [config.json](config.json):

| Field | Purpose |
| --- | --- |
| `repositories` | Add/remove participating repositories. The current six are proposals. |
| `opted_in_reviewers` | Accepted GitHub usernames. Empty means no candidates. |
| `history_days` | How far back to count submitted reviews (currently 90 days). |
| `review_owners` | Optional map of PR to confirmed reviewer, e.g. `"GoodDollar/GoodWidget#123": "alice"`. |

There is no separate designation ledger. Record confirmation or reassignment in the Git review of the config change; Git history supplies the audit trail. Keep closed entries so reopening preserves the same owner. The script never writes this map.

**Recording an owner is required to guarantee that exact person's continuity.** Without an entry, existing requests or submitted reviews cause a reconciliation prompt. A request removed before anyone submits a review cannot be recovered from the snapshot. Removing this map entirely would lose that guarantee; this small manual step remains until the manager has an integrated, durable ownership store.

## Four rules to review

Start at `decide_review()` at the top of [inventory.py](inventory.py):

1. **Recorded owner:** keep them through new commits, reviews and draft transitions. Flag lost eligibility; do not replace them.
2. **Draft without an owner:** wait.
3. **Existing requested/submitted review activity:** ask the maintainer who owns it.
4. **Otherwise ready:** show opted-in outside collaborators with effective Triage role, excluding the author. The manager must still check availability, co-authorship, QA separation and conflicts.

If any repository cannot be read, all candidate lists are withheld. Partial workload could make a busy reviewer look free. Workload counts distinct PRs; it can include maintainer first impressions and excludes unattributed team work and work outside the listed repositories.

`github_read.py` contains GET requests, pagination and response checking. The two Python files use only the standard library. JSON report version 3 stores a `designated_reviewer` username, candidate usernames and shared workload evidence; Markdown displays the same decisions.

## Tests and live runs are separate

[Check GoodBounties changes](../workflows/goodbounties-check.yml) runs offline tests and validates the actual config on relevant PRs and pushes to `master`. It has no inventory credential. Local equivalent:

```sh
python3 -m unittest discover -s .github/goodbounties/tests -v
```

[The inventory workflow](../workflows/goodbounties-review-inventory.yml) does only checkout → live collection → upload. Runtime checks still validate configuration, access, response fields and pagination. It accepts a manual run or `goodbounties-review-refresh` dispatch; its schedule is commented out.

Before enabling, review the code, confirm the roster/owners and report destination, and provide `GOODBOUNTIES_READ_TOKEN` with **Metadata read + Pull requests read** on the listed repositories. User-based tokens also need appropriate organization/repository access. Set the single variable `GOODBOUNTIES_INVENTORY_ENABLED=true` after merge to the default branch. No credential or remote setting has been created here.

Reports are `report.md` and `report.json`, uploaded as `goodbounties-review-inventory-<run_id>-<run_attempt>` in the workflow run's Artifacts, retained for **7 days**. They are not sent to the manager. Check repository/artifact visibility before including private data. Failed collection saves an incomplete report; failures before collection may produce none. Exit 1 means failure.

For a local run with the token supplied securely in the environment:

```sh
python3 .github/goodbounties/inventory.py --output /tmp/goodbounties-report
```

## What remains to connect

`.github` does not automatically receive other repositories' PR events. The inactive [relay example](templates/review-refresh.yml.example) can send a constant refresh event after separate installation; it needs its own credential with **Contents write on GoodDollar/.github only**. It never runs PR code. Remove relay access when offboarding. A trusted App receiver could replace it later; none is implemented.

Every refresh rescans the configured list; duplicate events are harmless. The relay covers ready/open/reopen/commit/close/draft events, not submitted reviews. An optional hourly scan could cover missed changes. Add manager report retrieval/execution separately using [these instructions](manager-instructions.md).

The collector never requests reviews or changes permissions. It preserves evidence, not GitHub's reviewer sidebar. It rejects redirects and malformed data; scans have limits/timeouts. Reads are not atomic, so any future assignment writer must recheck live state. Actual hosted execution and token access still need a pilot check.

References: [GitHub roles/access](https://docs.github.com/en/rest/collaborators/collaborators#list-repository-collaborators), [review requests](https://docs.github.com/en/rest/pulls/review-requests#get-all-requested-reviewers-for-a-pull-request), [dispatch permissions](https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event).
13 changes: 13 additions & 0 deletions .github/goodbounties/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"repositories": [
"GoodDollar/GoodWidget",
"GoodDollar/GoodSDKs",
"GoodDollar/GoodWeb3-Mono",
"GoodDollar/GoodWallet",
"GoodDollar/GoodProtocolUI",
"GoodDollar/GoodCollective"
],
"history_days": 90,
"opted_in_reviewers": [],
"review_owners": {}
}
163 changes: 163 additions & 0 deletions .github/goodbounties/github_read.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""GitHub read-only plumbing; the reviewer rules live in inventory.py.

This file converts GitHub responses into the small evidence records used by those
rules. Nothing here assigns reviewers, follows redirects, or runs PR code.
"""
from datetime import datetime
import json
import re
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import HTTPRedirectHandler, Request, build_opener

LOGIN = r"[A-Za-z0-9-]+"


class InventoryError(Exception):
"""An operator-facing error written by us, safe to include in reports."""


def parse_time(value):
date = datetime.fromisoformat(value.replace("Z", "+00:00"))
if date.tzinfo is None:
raise ValueError("Timestamp needs a timezone")
return date


def matches(pattern, value):
return isinstance(value, str) and re.fullmatch(pattern, value, re.IGNORECASE)


class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
# A renamed repository needs a config update. Never forward its credential.
return None


class GitHub:
"""One GET-only client shared by all repository reads in this run."""
def __init__(self, token):
if not token:
raise InventoryError("Set GOODBOUNTIES_READ_TOKEN to a token with access to the listed repositories.")
self.token = token
self.opener = build_opener(NoRedirect())

def get(self, path, **params):
if not path.startswith("/repos/") or any(part in (".", "..") for part in path.split("/")):
raise InventoryError("Unexpected repository API path.")
url = "https://api.github.com" + path
if params:
url += "?" + urlencode(params)
headers = {
"Authorization": "Bearer " + self.token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "GoodBounties-read-only-inventory",
}
request = Request(url, method="GET", headers=headers)
try:
with self.opener.open(request, timeout=30) as response:
return json.load(response)
except HTTPError as error:
# Explain the problem without printing response bodies or credentials.
advice = {
401: "Token is invalid or expired.",
403: "Check token permissions, organization approval and GitHub rate limits.",
404: "Repository is missing or is not shared with this token.",
429: "GitHub rate limit reached; rerun later.",
}.get(error.code, "Check repository naming and GitHub availability; rerun after recovery.")
raise InventoryError(f"HTTP {error.code}: {advice}") from None
except (URLError, OSError):
raise InventoryError("Cannot reach GitHub; rerun after the connection recovers.") from None
Comment on lines +70 to +71

def pages(self, path, **params):
# GitHub returns at most 100 records per page. Stopping after page one
# would make the roster or workload look smaller than it really is.
# The cap bounds runaway scans: hitting it is an error, never a partial success.
for page in range(1, 101):
rows = self.get(path, per_page=100, page=page, **params)
if not isinstance(rows, list):
raise InventoryError("GitHub returned an unexpected list response.")
yield from rows
if len(rows) < 100:
return
raise InventoryError("Pagination limit reached (10,000 records); report is incomplete.")


def read_login(user):
login = user["login"]
if not matches(LOGIN + r"(?:\[bot\])?", login):
raise InventoryError("GitHub returned an invalid username.")
return login.lower() # GitHub usernames are case-insensitive. Normalize once.


def is_human(user):
return user is not None and user["type"] == "User"


def read_pull(api, repository, pull):
"""Keep only review evidence. Never copy PR text or execute contributor code."""
reviews = []
path = f"/repos/{repository}/pulls/{pull['number']}/reviews"
for review in api.pages(path):
if is_human(review["user"]) and review["submitted_at"]:
# Validate here so malformed dates fail this repository, not report rendering.
submitted_at = parse_time(review["submitted_at"]).isoformat()
reviews.append({
"login": read_login(review["user"]),
"state": review["state"],
"submitted_at": submitted_at,
"commit_id": review["commit_id"],
})
# Missing readiness/request fields are errors, not evidence of an unassigned PR.
for field in ("requested_reviewers", "requested_teams"):
if not isinstance(pull[field], list):
raise InventoryError("GitHub returned an invalid requested-reviewer list.")
if type(pull["draft"]) is not bool or pull["state"] not in ("open", "closed"):
raise InventoryError("GitHub returned an unknown PR readiness/state.")
return {
"key": f"{repository}#{pull['number']}",
"author": read_login(pull["user"]),
"state": pull["state"],
"draft": pull["draft"],
"head_sha": pull["head"]["sha"],
"requested_users": [read_login(u) for u in pull["requested_reviewers"] if is_human(u)],
"requested_teams": [team["slug"] for team in pull["requested_teams"]],
"reviews": reviews,
}


def collect_repository(api, repository, cutoff):
"""Fetch outside collaborators, open PRs and recently updated closed PRs."""
path = "/repos/" + repository
metadata = api.get(path)
if metadata["owner"]["type"] != "Organization":
raise InventoryError("Outside collaborator discovery needs an organization repository.")
if metadata["full_name"].lower() != repository.lower() or metadata["archived"]:
raise InventoryError("Repository renamed or archived; update config.json.")
collaborators = []
for user in api.pages(path + "/collaborators", affiliation="outside"):
if not isinstance(user["role_name"], str) or not user["role_name"]:
raise InventoryError("GitHub did not return an effective collaborator role.")
collaborators.append({
"login": read_login(user),
"type": user["type"],
"role_name": user["role_name"],
})
# Read open PRs regardless of age. The cutoff applies only to closed history:
# an old open PR can still be occupying a reviewer today.
pulls = list(api.pages(path + "/pulls", state="open", sort="created", direction="asc"))
for pull in api.pages(path + "/pulls", state="closed", sort="updated", direction="desc"):
if parse_time(pull["updated_at"]) < cutoff:
break # Older closed PRs cannot contain reviews submitted in this window.
pulls.append(pull)
# These reads are not one transaction. A PR can close between the open and
# closed queries, appearing twice. Keep the later closed snapshot so it does
# not continue to count as open work. Any future writer must recheck live state.
unique_pulls = {pull["number"]: pull for pull in pulls}
return {
"repository": repository,
"private": metadata["private"],
"outside_collaborators": sorted(collaborators, key=lambda user: user["login"]),
"pulls": [read_pull(api, repository, pull) for pull in unique_pulls.values()],
}
Loading
Loading