Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

224 lines
8.1 KiB
Python

"""Assign and notify **new** contributors on `good first issue` threads.
Here *new* means the commenter has **no merged PRs and no open PRs** in this repo where
they are the PR author (GitHub Search API). **One or more merged PRs** means they are not
treated as a first-time contributor for this automation (GitHub's ``FIRST_TIME_CONTRIBUTOR``
/ ``FIRST_TIMER`` flags are not used).
Also skips repo insiders (OWNER / MEMBER / COLLABORATOR), bots, closed issues,
comments on **pull request** threads (``issue_comment`` fires for PRs too; those
use ``issue.pull_request``), and commenters already listed as assignees.
**One open assignment per eligible new contributor** in this repo: if they already
have another **open** issue assigned (Search API), they cannot be auto-assigned here.
At most **one** auto-assignment per issue: if anyone else is already an assignee,
further eligible commenters are skipped (manual pre-assignments count).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
GOOD_FIRST_LABEL = "good first issue"
# Do not auto-assign maintainers/collaborators;
# eligibility is 0 merged + 0 open PRs as author + not insider.
EXCLUDED_COMMENTER_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
GITHUB_API = "https://api.github.com"
def _github_api_url(path: str, query: dict[str, str]) -> str:
encoded = urllib.parse.urlencode(query)
return f"{GITHUB_API}{path}?{encoded}"
def screen_event_without_api(event: dict[str, Any]) -> str | None:
"""Return a skip reason before calling the GitHub API, or None if checks should continue."""
issue = event.get("issue") or {}
comment = event.get("comment") or {}
if issue.get("pull_request") is not None:
return "comment_on_pull_request"
if issue.get("state") != "open":
return "issue_not_open"
labels = issue.get("labels") or []
if not isinstance(labels, list):
return "invalid_labels"
names = {item.get("name") for item in labels if isinstance(item, dict)}
if GOOD_FIRST_LABEL not in names:
return "not_good_first_issue"
c_user = comment.get("user") or {}
if c_user.get("type") == "Bot":
return "bot_commenter"
c_login = c_user.get("login") or ""
if not c_login:
return "missing_commenter_login"
c_assoc = comment.get("author_association") or ""
if c_assoc in EXCLUDED_COMMENTER_ASSOCIATIONS:
return "commenter_repo_insider"
assignees = issue.get("assignees") or []
if isinstance(assignees, list):
assigned_logins = {
a.get("login") for a in assignees if isinstance(a, dict) and a.get("login")
}
if c_login in assigned_logins:
return "already_assignee"
if assigned_logins:
return "issue_already_claimed"
return None
def assign_decision(
*,
skip_reason_pre_api: str | None,
merged_pr_count_for_commenter: int,
open_pr_count_for_commenter: int,
open_assigned_issue_count_for_commenter: int,
) -> tuple[bool, str]:
"""Return (should_assign_and_comment, skip_reason_or_empty).
Eligible "new contributor" means ``merged_pr_count_for_commenter == 0`` and
``open_pr_count_for_commenter == 0`` (PR author in this repo, via Search API).
They must also have no other open issues assigned in this repo
(``open_assigned_issue_count_for_commenter == 0``).
"""
if skip_reason_pre_api is not None:
return False, skip_reason_pre_api
if open_pr_count_for_commenter > 0:
return False, "has_open_prs"
if merged_pr_count_for_commenter > 0:
return False, "has_merged_prs"
if open_assigned_issue_count_for_commenter > 0:
return False, "already_has_open_assigned_issue"
return True, ""
def _request_json(url: str, token: str) -> Any:
parsed = urllib.parse.urlparse(url)
if parsed.scheme != "https" or parsed.netloc != "api.github.com":
raise ValueError("GitHub API URL must target https://api.github.com")
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
method="GET",
)
with (
urllib.request.urlopen(req, timeout=60) as resp
): # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return json.loads(resp.read().decode("utf-8"))
def _search_issue_total_count(query: str, token: str) -> int:
url = _github_api_url("/search/issues", {"q": query})
try:
data = _request_json(url, token)
except urllib.error.URLError as exc:
print(f"GitHub search failed: {exc}", file=sys.stderr)
raise
total = data.get("total_count")
if not isinstance(total, int):
return 0
return total
def fetch_merged_pr_count(owner: str, repo: str, login: str, token: str) -> int:
q = f"repo:{owner}/{repo} is:pr is:merged author:{login}"
return _search_issue_total_count(q, token)
def fetch_open_pr_count(owner: str, repo: str, login: str, token: str) -> int:
q = f"repo:{owner}/{repo} is:pr is:open author:{login}"
return _search_issue_total_count(q, token)
def fetch_open_assigned_issue_count(owner: str, repo: str, login: str, token: str) -> int:
"""Count open issues in this repo where ``login`` is an assignee."""
q = f"repo:{owner}/{repo} is:issue is:open assignee:{login}"
return _search_issue_total_count(q, token)
def build_assign_notice_body(*, assignee_login: str) -> str:
return f"@{assignee_login} You've been **assigned** to this issue. Thanks for picking it up."
def set_github_output(name: str, value: str) -> None:
path = os.environ.get("GITHUB_OUTPUT")
if not path:
return
with open(path, "a", encoding="utf-8") as fh:
fh.write(f"{name}={value}\n")
def main() -> int:
event_path = os.environ.get("GITHUB_EVENT_PATH")
repository = os.environ.get("GITHUB_REPOSITORY")
token = os.environ.get("GITHUB_TOKEN")
if not event_path or not repository or not token:
print("Missing GITHUB_EVENT_PATH, GITHUB_REPOSITORY, or GITHUB_TOKEN.", file=sys.stderr)
return 1
raw = Path(event_path).read_text(encoding="utf-8")
event = json.loads(raw)
pre = screen_event_without_api(event)
merged_count = 0
open_count = 0
open_assigned_issue_count = 0
if pre is None:
owner, _, repo = repository.partition("/")
if not owner or not repo:
print("Invalid GITHUB_REPOSITORY.", file=sys.stderr)
return 1
comment = event.get("comment") or {}
c_login = (comment.get("user") or {}).get("login") or ""
try:
merged_count = fetch_merged_pr_count(owner, repo, c_login, token)
open_count = fetch_open_pr_count(owner, repo, c_login, token)
open_assigned_issue_count = fetch_open_assigned_issue_count(owner, repo, c_login, token)
except urllib.error.URLError as exc:
print(f"GitHub API request failed: {exc}", file=sys.stderr)
return 1
should, reason = assign_decision(
skip_reason_pre_api=pre,
merged_pr_count_for_commenter=merged_count,
open_pr_count_for_commenter=open_count,
open_assigned_issue_count_for_commenter=open_assigned_issue_count,
)
if not should:
print(f"Skip: {reason}")
set_github_output("should_assign", "false")
return 0
comment_user = (event.get("comment") or {}).get("user") or {}
login = comment_user.get("login") if isinstance(comment_user, dict) else ""
if not isinstance(login, str) or not login:
print("Missing commenter login.", file=sys.stderr)
return 1
body = build_assign_notice_body(assignee_login=login)
Path("assign_comment.md").write_text(body, encoding="utf-8")
set_github_output("should_assign", "true")
print("Wrote assign_comment.md; assignment will be applied in workflow.")
return 0
if __name__ == "__main__":
raise SystemExit(main())