Skip to content
Open
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
256 changes: 256 additions & 0 deletions .github/triage-assign/assign_triage_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
"""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. 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:
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 csv
import hashlib
import io
import os
import random
import sys

import requests
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")
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"))

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()
},
"unknown_organizations": {
str(organization).strip().lower()
for organization in (config.get("unknown_organizations") or [])
},
}


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 CSV cannot be read: it
is a best-effort input and the suffix heuristic covers for it.
"""
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(
f"WARNING: could not read {AUTHOR_MAP_URL} ({exc}); falling back to the "
"username suffix heuristic.",
file=sys.stderr,
)
return {}

if rows and not {"Author", "Organization"}.issubset(rows[0].keys()):
print(
f"WARNING: {AUTHOR_MAP_URL} has no Author/Organization columns; falling back "
"to the username suffix heuristic.",
file=sys.stderr,
)
return {}

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
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(author_map)} authors from {AUTHOR_MAP_URL}.")
return author_map


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"]

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)

organization = author_map.get(author_key)
if organization:
matched = {
company["name"]
for company in companies
if organization in company["organizations"]
}
# 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 {
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, 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"]}

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, author_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)
author_map = load_author_map(config)

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, author_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, author_map):
assigned += 1

print(f"Scanned {scanned} open pull requests, labelled {assigned}.")


if __name__ == "__main__":
main()
58 changes: 58 additions & 0 deletions .github/triage-assign/companies.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Configuration for the "Assign Triage Labels" workflow
# (.github/workflows/assign_triage_labels.yml).
#
# Every pull request gets `assignments_per_pr` labels of the form
# "<label_prefix><company name>", picked at random from the companies listed
# below, excluding the company the PR author belongs to.

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" column of
# sii_author_predict.csv in sonic-net/sonic-tsc.
organizations:
- Arista
# 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"

- 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 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
# the list above.
overrides: {}
# octocat: Nexthop
51 changes: 51 additions & 0 deletions .github/workflows/assign_triage_labels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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:
# 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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Install dependencies
run: python -m pip install --upgrade pip pyaml PyGithub requests
- 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 }}
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
Loading