From a948ebdfb8d4eda21cda67ac60dc61bab9b76549 Mon Sep 17 00:00:00 2001 From: Brad House Date: Tue, 4 Aug 2026 20:11:14 +0000 Subject: [PATCH 1/3] [GHA] Add workflow to assign per-company triage labels to PRs Adds a GitHub Actions workflow that labels each new pull request with two "triage-" labels, spreading triage load across Arista, Cisco, Microsoft and Nexthop. The two companies are drawn deterministically from a SHA-256 seed of "/#", so no persistent state or tracking is needed and re-running the workflow on the same PR always yields the same result. Distribution is even: over 40k synthetic PRs each company lands within 0.5% of an even share, and all six company pairs within ~2%. The PR author's own company is excluded from the draw. Author affiliation is resolved in order: 1. an "overrides" map in companies.yml, for people the contributor map has not caught up with 2. contributors.json from sonic-net/sonic-contributor-map 3. a username suffix heuristic (e.g. "someone-arista" -> Arista) Note that sonic-net/sonic-contributor-map is a private repository, so the default GITHUB_TOKEN cannot read it. Reading it requires a CONTRIBUTOR_MAP_TOKEN secret with read access to that repository. Without the secret the workflow still runs, falling back to the suffix heuristic. Besides running on pull_request_target, the workflow runs weekly and can be triggered manually to scan open PRs that carry no company label yet. This backstops PRs opened while the workflow was failing or disabled, and allows labelling historic PRs. Manual runs accept a dry_run input to preview the assignments without applying them. The triage-* labels do not exist in the repository yet; the workflow creates any that are missing on first run. Signed-off-by: Brad House --- .github/triage-assign/assign_triage_labels.py | 246 ++++++++++++++++++ .github/triage-assign/companies.yml | 50 ++++ .github/workflows/assign_triage_labels.yml | 53 ++++ 3 files changed, 349 insertions(+) create mode 100644 .github/triage-assign/assign_triage_labels.py create mode 100644 .github/triage-assign/companies.yml create mode 100644 .github/workflows/assign_triage_labels.yml diff --git a/.github/triage-assign/assign_triage_labels.py b/.github/triage-assign/assign_triage_labels.py new file mode 100644 index 00000000000..5366de98851 --- /dev/null +++ b/.github/triage-assign/assign_triage_labels.py @@ -0,0 +1,246 @@ +"""Assign per-company triage labels to pull requests. + +Each pull request is labelled with `assignments_per_pr` company labels drawn at +random from the companies configured in companies.yml, excluding the company +the PR author belongs to. + +The draw is seeded from the repository name and the PR number, so it needs no +persistent state: the same PR always yields the same companies, which makes the +backstop scan idempotent, while the distribution across PRs stays even. + +Author company is resolved in this order: + 1. the `overrides` map in companies.yml + 2. contributors.json from sonic-net/sonic-contributor-map (best effort; that + repository is private, so this needs CONTRIBUTOR_MAP_TOKEN to be set) + 3. a username suffix heuristic (e.g. "someone-arista" -> Arista) + +Modes: + PR_NUMBER set -> label that single pull request + PR_NUMBER unset -> scan open pull requests and label the ones that carry no + company label yet +""" + +import hashlib +import json +import os +import random +import sys +import urllib.error +import urllib.request + +import yaml + +from github import Auth, Github + +GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] +GITHUB_REPOSITORY = os.environ["GITHUB_REPOSITORY"] +PR_NUMBER = os.environ.get("PR_NUMBER", "").strip() + +CONFIG_PATH = os.environ.get("TRIAGE_CONFIG_PATH", ".github/triage-assign/companies.yml") +CONTRIBUTOR_MAP_TOKEN = os.environ.get("CONTRIBUTOR_MAP_TOKEN", "").strip() +CONTRIBUTOR_MAP_URL = os.environ.get( + "CONTRIBUTOR_MAP_URL", + "https://api.github.com/repos/sonic-net/sonic-contributor-map/contents/contributors.json", +) +DRY_RUN = os.environ.get("DRY_RUN", "false").strip().lower() in ("true", "t", "1", "yes", "y", "on") +SCAN_LIMIT = int(os.environ.get("SCAN_LIMIT", "0")) + +DEFAULT_LABEL_COLOR = "c5def5" + + +def load_config(config_path: str) -> dict: + with open(config_path, "r", encoding="utf-8") as config_file: + config = yaml.safe_load(config_file) or {} + + companies = config.get("companies") or [] + if len(companies) < 2: + raise SystemExit(f"{config_path}: at least two companies must be configured") + + return { + "label_prefix": str(config.get("label_prefix", "triage-")), + "assignments_per_pr": int(config.get("assignments_per_pr", 2)), + "companies": [ + { + "name": str(company["name"]), + "organizations": [ + str(org).strip().lower() + for org in (company.get("organizations") or [company["name"]]) + ], + "username_suffixes": [ + str(suffix).strip().lower() + for suffix in (company.get("username_suffixes") or []) + ], + "label_color": str(company.get("label_color", DEFAULT_LABEL_COLOR)), + } + for company in companies + ], + "overrides": { + str(user).strip().lower(): str(company).strip() + for user, company in (config.get("overrides") or {}).items() + }, + } + + +def load_contributor_map() -> dict[str, set[str]]: + """Return {github username (lowercase): {organization (lowercase), ...}}. + + Returns an empty map, rather than failing, when the contributor map cannot + be read: it is a best-effort input and the suffix heuristic covers for it. + """ + if not CONTRIBUTOR_MAP_TOKEN: + print( + "NOTE: CONTRIBUTOR_MAP_TOKEN is not set; sonic-contributor-map is a private " + "repository, so falling back to the username suffix heuristic.", + file=sys.stderr, + ) + return {} + + request = urllib.request.Request( + CONTRIBUTOR_MAP_URL, + headers={ + "Authorization": f"Bearer {CONTRIBUTOR_MAP_TOKEN}", + "Accept": "application/vnd.github.raw+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "sonic-mgmt-triage-assign", + }, + ) + + try: + with urllib.request.urlopen(request, timeout=30) as response: + contributors = json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, ValueError) as exc: + print( + f"WARNING: could not read {CONTRIBUTOR_MAP_URL} ({exc}); falling back to the " + "username suffix heuristic.", + file=sys.stderr, + ) + return {} + + contributor_map: dict[str, set[str]] = {} + for entry in contributors: + if not isinstance(entry, dict): + continue + user = str(entry.get("Id", "")).strip().lower() + organization = str(entry.get("Organization", "")).strip().lower() + if user and organization: + contributor_map.setdefault(user, set()).add(organization) + + print(f"Loaded {len(contributor_map)} contributors from the contributor map.") + return contributor_map + + +def author_companies(author: str, config: dict, contributor_map: dict[str, set[str]]) -> set[str]: + """Companies the PR author belongs to, and so must not be assigned to.""" + author_key = author.strip().lower() + companies = config["companies"] + + override = config["overrides"].get(author_key) + if override: + matched = {company["name"] for company in companies if company["name"] == override} + if matched: + return matched + print(f"WARNING: override for '{author}' names unknown company '{override}'.", file=sys.stderr) + + organizations = contributor_map.get(author_key, set()) + if organizations: + matched = { + company["name"] + for company in companies + if organizations.intersection(company["organizations"]) + } + # The author is in the map: trust it even when their organization is + # not one of the configured companies (an empty result is meaningful). + return matched + + return { + company["name"] + for company in companies + if any(author_key.endswith(suffix) for suffix in company["username_suffixes"]) + } + + +def pick_companies(repository: str, pr_number: int, candidates: list[str], count: int) -> list[str]: + """Deterministically draw `count` companies for this PR, without state.""" + seed = int.from_bytes(hashlib.sha256(f"{repository}#{pr_number}".encode("utf-8")).digest(), "big") + return sorted(random.Random(seed).sample(sorted(candidates), min(count, len(candidates)))) + + +def ensure_labels_exist(repo, config: dict) -> None: + existing = {label.name for label in repo.get_labels()} + for company in config["companies"]: + name = f"{config['label_prefix']}{company['name']}" + if name not in existing: + print(f"Creating missing label '{name}'.") + if not DRY_RUN: + repo.create_label(name=name, color=company["label_color"]) + + +def assign_labels(repo, pull_request, config: dict, contributor_map: dict[str, set[str]]) -> bool: + """Label a single pull request. Returns True when labels were assigned.""" + prefix = config["label_prefix"] + company_labels = {f"{prefix}{company['name']}" for company in config["companies"]} + + already_assigned = {label.name for label in pull_request.labels}.intersection(company_labels) + if already_assigned: + print(f"PR #{pull_request.number}: already labelled ({', '.join(sorted(already_assigned))}), skipping.") + return False + + author = pull_request.user.login if pull_request.user else "" + if not author: + print(f"PR #{pull_request.number}: no author, skipping.", file=sys.stderr) + return False + + excluded = author_companies(author, config, contributor_map) + candidates = [company["name"] for company in config["companies"] if company["name"] not in excluded] + + if len(candidates) < config["assignments_per_pr"]: + print( + f"PR #{pull_request.number}: only {len(candidates)} companies remain after excluding " + f"the author's ({', '.join(sorted(excluded)) or 'none'}); assigning all of them.", + file=sys.stderr, + ) + + selected = pick_companies(repo.full_name, pull_request.number, candidates, config["assignments_per_pr"]) + if not selected: + print(f"PR #{pull_request.number}: no eligible company to assign.", file=sys.stderr) + return False + + labels = [f"{prefix}{name}" for name in selected] + print( + f"PR #{pull_request.number} by @{author} " + f"(author companies: {', '.join(sorted(excluded)) or 'unknown'}) -> {', '.join(labels)}" + ) + if not DRY_RUN: + pull_request.add_to_labels(*labels) + return True + + +def main() -> None: + config = load_config(CONFIG_PATH) + contributor_map = load_contributor_map() + + github = Github(auth=Auth.Token(GITHUB_TOKEN)) + repo = github.get_repo(GITHUB_REPOSITORY) + + ensure_labels_exist(repo, config) + + if PR_NUMBER: + assign_labels(repo, repo.get_pull(int(PR_NUMBER)), config, contributor_map) + return + + print("Scanning open pull requests for missing triage labels...") + scanned = 0 + assigned = 0 + for pull_request in repo.get_pulls(state="open", sort="created", direction="desc"): + if SCAN_LIMIT and scanned >= SCAN_LIMIT: + print(f"Reached SCAN_LIMIT of {SCAN_LIMIT} pull requests, stopping.") + break + scanned += 1 + if assign_labels(repo, pull_request, config, contributor_map): + assigned += 1 + + print(f"Scanned {scanned} open pull requests, labelled {assigned}.") + + +if __name__ == "__main__": + main() diff --git a/.github/triage-assign/companies.yml b/.github/triage-assign/companies.yml new file mode 100644 index 00000000000..c7a37ecb2b9 --- /dev/null +++ b/.github/triage-assign/companies.yml @@ -0,0 +1,50 @@ +# Configuration for the "Assign Triage Labels" workflow +# (.github/workflows/assign_triage_labels.yml). +# +# Every pull request gets `assignments_per_pr` labels of the form +# "", picked at random from the companies listed +# below, excluding the company the PR author belongs to. + +label_prefix: "triage-" +assignments_per_pr: 2 + +companies: + - name: Arista + # Matched (case-insensitively) against the "Organization" field of + # contributors.json in sonic-net/sonic-contributor-map. + organizations: + - Arista + # Fallback heuristic used when the contributor map is unavailable or does + # not list the PR author: a GitHub username ending in one of these + # suffixes is treated as belonging to this company. + username_suffixes: + - "-arista" + + - name: Cisco + organizations: + - Cisco + username_suffixes: + - "-cisco" + - "-csco" + + - name: Microsoft + organizations: + - Microsoft + username_suffixes: + - "-microsoft" + - "-msft" + + - name: Nexthop + organizations: + - Nexthop + - Nexthop Systems + username_suffixes: + - "-nexthop" + +# Extra author -> company mappings applied on top of the contributor map. +# Useful for people the (possibly stale) map has not caught up with yet, and +# for people whose username carries no company suffix. +# Keys are GitHub usernames (case-insensitive), values are company names from +# the list above. +overrides: {} + # octocat: Nexthop diff --git a/.github/workflows/assign_triage_labels.yml b/.github/workflows/assign_triage_labels.yml new file mode 100644 index 00000000000..8cabc051ae5 --- /dev/null +++ b/.github/workflows/assign_triage_labels.yml @@ -0,0 +1,53 @@ +name: "Assign Triage Labels" + +on: + pull_request_target: + types: [opened, reopened] + # Backstop: catches PRs opened while this workflow was failing or disabled, + # and lets historic PRs be labelled on demand. + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + inputs: + scan_limit: + description: 'Maximum number of open PRs to scan (0 = no limit)' + required: false + default: '0' + dry_run: + description: 'Log the assignments without applying them' + type: boolean + required: false + default: false + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + assign_triage_labels: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install dependencies + run: python -m pip install --upgrade pip pyaml PyGithub + - name: Assign triage labels + run: python .github/triage-assign/assign_triage_labels.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Empty for the scheduled/manual runs, which scan open PRs instead. + PR_NUMBER: ${{ github.event.pull_request.number }} + # sonic-net/sonic-contributor-map is a private repository, so the + # default GITHUB_TOKEN cannot read it. Without this secret the script + # falls back to the username suffix heuristic in companies.yml. + CONTRIBUTOR_MAP_TOKEN: ${{ secrets.CONTRIBUTOR_MAP_TOKEN }} + TRIAGE_CONFIG_PATH: .github/triage-assign/companies.yml + SCAN_LIMIT: ${{ github.event.inputs.scan_limit || '0' }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + + - name: Cleanup the checked out repo + run: git clean -fdx From a5c826a07aa78039d4906cb856b9b51b220c5d8d Mon Sep 17 00:00:00 2001 From: Brad House Date: Tue, 4 Aug 2026 20:24:20 +0000 Subject: [PATCH 2/3] [GHA] Address Semgrep findings in triage label workflow Semgrep flagged three blocking findings on the new files: - dynamic-urllib-use-detected: urllib supports 'file://' schemes, so a dynamic URL is treated as a file-read risk. Switch the contributor map fetch to requests, which is the remediation the rule itself suggests. requests is already an indirect dependency via PyGithub, but install it explicitly rather than rely on that. - github-actions-mutable-action-tag (x2): pin actions/checkout and actions/setup-python to full commit SHAs, since tags can be silently repointed by the action owner. Other workflows in this repository still use mutable tags, but Semgrep only scans changed files, so new workflows are held to the stricter bar. Verified the assignments are unchanged after the refactor, and that an unset token, an HTTP 401 and an unreachable host all still fall back to the username suffix heuristic without raising. Signed-off-by: Brad House --- .github/triage-assign/assign_triage_labels.py | 29 +++++++++---------- .github/workflows/assign_triage_labels.yml | 8 +++-- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.github/triage-assign/assign_triage_labels.py b/.github/triage-assign/assign_triage_labels.py index 5366de98851..93b2cc0f8b5 100644 --- a/.github/triage-assign/assign_triage_labels.py +++ b/.github/triage-assign/assign_triage_labels.py @@ -21,13 +21,11 @@ """ import hashlib -import json import os import random import sys -import urllib.error -import urllib.request +import requests import yaml from github import Auth, Github @@ -95,20 +93,19 @@ def load_contributor_map() -> dict[str, set[str]]: ) return {} - request = urllib.request.Request( - CONTRIBUTOR_MAP_URL, - headers={ - "Authorization": f"Bearer {CONTRIBUTOR_MAP_TOKEN}", - "Accept": "application/vnd.github.raw+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "sonic-mgmt-triage-assign", - }, - ) - try: - with urllib.request.urlopen(request, timeout=30) as response: - contributors = json.loads(response.read().decode("utf-8")) - except (urllib.error.URLError, ValueError) as exc: + response = requests.get( + CONTRIBUTOR_MAP_URL, + headers={ + "Authorization": f"Bearer {CONTRIBUTOR_MAP_TOKEN}", + "Accept": "application/vnd.github.raw+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout=30, + ) + response.raise_for_status() + contributors = response.json() + except (requests.RequestException, ValueError) as exc: print( f"WARNING: could not read {CONTRIBUTOR_MAP_URL} ({exc}); falling back to the " "username suffix heuristic.", diff --git a/.github/workflows/assign_triage_labels.yml b/.github/workflows/assign_triage_labels.yml index 8cabc051ae5..39d5c1f0b16 100644 --- a/.github/workflows/assign_triage_labels.yml +++ b/.github/workflows/assign_triage_labels.yml @@ -28,13 +28,15 @@ jobs: assign_triage_labels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + # Actions are pinned to full commit SHAs: mutable tags can be silently + # repointed by the action owner (enforced by the Semgrep CI check). + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.x' - name: Install dependencies - run: python -m pip install --upgrade pip pyaml PyGithub + run: python -m pip install --upgrade pip pyaml PyGithub requests - name: Assign triage labels run: python .github/triage-assign/assign_triage_labels.py env: From eca2054845137fd7dda5deb113ee58fe78240461 Mon Sep 17 00:00:00 2001 From: Brad House Date: Fri, 7 Aug 2026 18:13:05 +0000 Subject: [PATCH 3/3] [GHA] Resolve PR author company from the public sonic-tsc author map Replace the private sonic-net/sonic-contributor-map contributors.json with sii_author_predict.csv from sonic-net/sonic-tsc, which is public and needs no credentials. This drops the CONTRIBUTOR_MAP_TOKEN secret, and with it the dependency on sonic-net admin rights before the workflow can resolve authors. The CSV files 842 of its 1765 rows under the organization "Others", which means "unknown" rather than "a company other than these". Treating a map hit as authoritative is right for Nvidia or Broadcom, but applying it to "Others" would drop the 31 authors whose username carries a company suffix yet sit in that bucket (aajith-arista, bining-nexthop, balram-csco, ...). Those organizations are listed under the new unknown_organizations key in companies.yml and filtered out at load, so the suffix heuristic still sees them. Three authors appear twice with conflicting organizations, one of them under the placeholder value "author_org_dup"; the row with the highest Score wins. Verified against the live CSV: 921 authors carry a known organization, of which 393 resolve to the four configured companies (Microsoft 137, Cisco 113, Arista 72, Nexthop 71), and no CSV organization contradicts the suffix heuristic. Draw distribution and determinism are unchanged. Signed-off-by: Brad House --- .github/triage-assign/assign_triage_labels.py | 109 ++++++++++-------- .github/triage-assign/companies.yml | 20 +++- .github/workflows/assign_triage_labels.yml | 4 - 3 files changed, 75 insertions(+), 58 deletions(-) diff --git a/.github/triage-assign/assign_triage_labels.py b/.github/triage-assign/assign_triage_labels.py index 93b2cc0f8b5..2ca4ee34d29 100644 --- a/.github/triage-assign/assign_triage_labels.py +++ b/.github/triage-assign/assign_triage_labels.py @@ -10,8 +10,8 @@ Author company is resolved in this order: 1. the `overrides` map in companies.yml - 2. contributors.json from sonic-net/sonic-contributor-map (best effort; that - repository is private, so this needs CONTRIBUTOR_MAP_TOKEN to be set) + 2. sii_author_predict.csv from sonic-net/sonic-tsc (best effort; that + repository is public, so this needs no credentials) 3. a username suffix heuristic (e.g. "someone-arista" -> Arista) Modes: @@ -20,7 +20,9 @@ company label yet """ +import csv import hashlib +import io import os import random import sys @@ -35,10 +37,9 @@ PR_NUMBER = os.environ.get("PR_NUMBER", "").strip() CONFIG_PATH = os.environ.get("TRIAGE_CONFIG_PATH", ".github/triage-assign/companies.yml") -CONTRIBUTOR_MAP_TOKEN = os.environ.get("CONTRIBUTOR_MAP_TOKEN", "").strip() -CONTRIBUTOR_MAP_URL = os.environ.get( - "CONTRIBUTOR_MAP_URL", - "https://api.github.com/repos/sonic-net/sonic-contributor-map/contents/contributors.json", +AUTHOR_MAP_URL = os.environ.get( + "AUTHOR_MAP_URL", + "https://raw.githubusercontent.com/sonic-net/sonic-tsc/master/sii_author_predict.csv", ) DRY_RUN = os.environ.get("DRY_RUN", "false").strip().lower() in ("true", "t", "1", "yes", "y", "on") SCAN_LIMIT = int(os.environ.get("SCAN_LIMIT", "0")) @@ -76,57 +77,67 @@ def load_config(config_path: str) -> dict: str(user).strip().lower(): str(company).strip() for user, company in (config.get("overrides") or {}).items() }, + "unknown_organizations": { + str(organization).strip().lower() + for organization in (config.get("unknown_organizations") or []) + }, } -def load_contributor_map() -> dict[str, set[str]]: - """Return {github username (lowercase): {organization (lowercase), ...}}. +def load_author_map(config: dict) -> dict[str, str]: + """Return {github username (lowercase): organization (lowercase)}. + + Parsed from sii_author_predict.csv (columns: Author, Organization, Score). + A handful of authors appear more than once with conflicting organizations; + the row with the highest Score wins. Organizations listed under + `unknown_organizations` in companies.yml (the CSV's "Others" bucket) mean + "not known", not "not one of these companies", so they are dropped here and + left to the suffix heuristic. - Returns an empty map, rather than failing, when the contributor map cannot - be read: it is a best-effort input and the suffix heuristic covers for it. + Returns an empty map, rather than failing, when the CSV cannot be read: it + is a best-effort input and the suffix heuristic covers for it. """ - if not CONTRIBUTOR_MAP_TOKEN: + try: + response = requests.get(AUTHOR_MAP_URL, timeout=30) + response.raise_for_status() + rows = list(csv.DictReader(io.StringIO(response.text))) + except (requests.RequestException, csv.Error) as exc: print( - "NOTE: CONTRIBUTOR_MAP_TOKEN is not set; sonic-contributor-map is a private " - "repository, so falling back to the username suffix heuristic.", + f"WARNING: could not read {AUTHOR_MAP_URL} ({exc}); falling back to the " + "username suffix heuristic.", file=sys.stderr, ) return {} - try: - response = requests.get( - CONTRIBUTOR_MAP_URL, - headers={ - "Authorization": f"Bearer {CONTRIBUTOR_MAP_TOKEN}", - "Accept": "application/vnd.github.raw+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - timeout=30, - ) - response.raise_for_status() - contributors = response.json() - except (requests.RequestException, ValueError) as exc: + if rows and not {"Author", "Organization"}.issubset(rows[0].keys()): print( - f"WARNING: could not read {CONTRIBUTOR_MAP_URL} ({exc}); falling back to the " - "username suffix heuristic.", + f"WARNING: {AUTHOR_MAP_URL} has no Author/Organization columns; falling back " + "to the username suffix heuristic.", file=sys.stderr, ) return {} - contributor_map: dict[str, set[str]] = {} - for entry in contributors: - if not isinstance(entry, dict): + unknown = config["unknown_organizations"] + best_score: dict[str, float] = {} + author_map: dict[str, str] = {} + for row in rows: + author = str(row.get("Author") or "").strip().lower() + organization = str(row.get("Organization") or "").strip().lower() + if not author or not organization or organization in unknown: continue - user = str(entry.get("Id", "")).strip().lower() - organization = str(entry.get("Organization", "")).strip().lower() - if user and organization: - contributor_map.setdefault(user, set()).add(organization) + try: + score = float(row.get("Score") or 0) + except ValueError: + score = 0.0 + if score > best_score.get(author, float("-inf")): + best_score[author] = score + author_map[author] = organization - print(f"Loaded {len(contributor_map)} contributors from the contributor map.") - return contributor_map + print(f"Loaded {len(author_map)} authors from {AUTHOR_MAP_URL}.") + return author_map -def author_companies(author: str, config: dict, contributor_map: dict[str, set[str]]) -> set[str]: +def author_companies(author: str, config: dict, author_map: dict[str, str]) -> set[str]: """Companies the PR author belongs to, and so must not be assigned to.""" author_key = author.strip().lower() companies = config["companies"] @@ -138,15 +149,17 @@ def author_companies(author: str, config: dict, contributor_map: dict[str, set[s return matched print(f"WARNING: override for '{author}' names unknown company '{override}'.", file=sys.stderr) - organizations = contributor_map.get(author_key, set()) - if organizations: + organization = author_map.get(author_key) + if organization: matched = { company["name"] for company in companies - if organizations.intersection(company["organizations"]) + if organization in company["organizations"] } - # The author is in the map: trust it even when their organization is - # not one of the configured companies (an empty result is meaningful). + # The author is in the map with a known organization: trust it even + # when that organization is not one of the configured companies (an + # empty result is meaningful, and stops a coincidental username ending + # from wrongly excluding a company). return matched return { @@ -172,7 +185,7 @@ def ensure_labels_exist(repo, config: dict) -> None: repo.create_label(name=name, color=company["label_color"]) -def assign_labels(repo, pull_request, config: dict, contributor_map: dict[str, set[str]]) -> bool: +def assign_labels(repo, pull_request, config: dict, author_map: dict[str, str]) -> bool: """Label a single pull request. Returns True when labels were assigned.""" prefix = config["label_prefix"] company_labels = {f"{prefix}{company['name']}" for company in config["companies"]} @@ -187,7 +200,7 @@ def assign_labels(repo, pull_request, config: dict, contributor_map: dict[str, s print(f"PR #{pull_request.number}: no author, skipping.", file=sys.stderr) return False - excluded = author_companies(author, config, contributor_map) + excluded = author_companies(author, config, author_map) candidates = [company["name"] for company in config["companies"] if company["name"] not in excluded] if len(candidates) < config["assignments_per_pr"]: @@ -214,7 +227,7 @@ def assign_labels(repo, pull_request, config: dict, contributor_map: dict[str, s def main() -> None: config = load_config(CONFIG_PATH) - contributor_map = load_contributor_map() + author_map = load_author_map(config) github = Github(auth=Auth.Token(GITHUB_TOKEN)) repo = github.get_repo(GITHUB_REPOSITORY) @@ -222,7 +235,7 @@ def main() -> None: ensure_labels_exist(repo, config) if PR_NUMBER: - assign_labels(repo, repo.get_pull(int(PR_NUMBER)), config, contributor_map) + assign_labels(repo, repo.get_pull(int(PR_NUMBER)), config, author_map) return print("Scanning open pull requests for missing triage labels...") @@ -233,7 +246,7 @@ def main() -> None: print(f"Reached SCAN_LIMIT of {SCAN_LIMIT} pull requests, stopping.") break scanned += 1 - if assign_labels(repo, pull_request, config, contributor_map): + if assign_labels(repo, pull_request, config, author_map): assigned += 1 print(f"Scanned {scanned} open pull requests, labelled {assigned}.") diff --git a/.github/triage-assign/companies.yml b/.github/triage-assign/companies.yml index c7a37ecb2b9..6bfdff3c0ff 100644 --- a/.github/triage-assign/companies.yml +++ b/.github/triage-assign/companies.yml @@ -8,15 +8,23 @@ label_prefix: "triage-" assignments_per_pr: 2 +# Organization values in the author map that mean "unknown", rather than "some +# company other than the ones below". Authors carrying one of these are treated +# as absent from the map, so the username suffix heuristic still gets a shot -- +# without this, the ~30 suffixed authors the CSV files under "Others" would +# never be matched to their company. +unknown_organizations: + - Others + companies: - name: Arista - # Matched (case-insensitively) against the "Organization" field of - # contributors.json in sonic-net/sonic-contributor-map. + # Matched (case-insensitively) against the "Organization" column of + # sii_author_predict.csv in sonic-net/sonic-tsc. organizations: - Arista - # Fallback heuristic used when the contributor map is unavailable or does - # not list the PR author: a GitHub username ending in one of these - # suffixes is treated as belonging to this company. + # Fallback heuristic used when the author map is unavailable or does not + # list the PR author: a GitHub username ending in one of these suffixes is + # treated as belonging to this company. username_suffixes: - "-arista" @@ -41,7 +49,7 @@ companies: username_suffixes: - "-nexthop" -# Extra author -> company mappings applied on top of the contributor map. +# Extra author -> company mappings applied on top of the author map. # Useful for people the (possibly stale) map has not caught up with yet, and # for people whose username carries no company suffix. # Keys are GitHub usernames (case-insensitive), values are company names from diff --git a/.github/workflows/assign_triage_labels.yml b/.github/workflows/assign_triage_labels.yml index 39d5c1f0b16..82bfcf0eba0 100644 --- a/.github/workflows/assign_triage_labels.yml +++ b/.github/workflows/assign_triage_labels.yml @@ -43,10 +43,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Empty for the scheduled/manual runs, which scan open PRs instead. PR_NUMBER: ${{ github.event.pull_request.number }} - # sonic-net/sonic-contributor-map is a private repository, so the - # default GITHUB_TOKEN cannot read it. Without this secret the script - # falls back to the username suffix heuristic in companies.yml. - CONTRIBUTOR_MAP_TOKEN: ${{ secrets.CONTRIBUTOR_MAP_TOKEN }} TRIAGE_CONFIG_PATH: .github/triage-assign/companies.yml SCAN_LIMIT: ${{ github.event.inputs.scan_limit || '0' }} DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}