diff --git a/.github/workflows/triage_project_sync.yml b/.github/workflows/triage_project_sync.yml deleted file mode 100644 index 9b8500db98..0000000000 --- a/.github/workflows/triage_project_sync.yml +++ /dev/null @@ -1,174 +0,0 @@ -# Sync triage state into "Zed weekly triage" (project #84). -# -# Runs in two modes: -# 1. Event-driven (primary): fires on issue events + new issue comments. -# Re-derives Status / Stale since / Aged? / Intake week for that one -# issue. Latency: ~10–30 seconds end-to-end. -# 2. Daily cron (safety net): re-derives across all project items at 06:00 -# UTC. Catches any events that GH dropped under load. -# -# Auth: GitHub App `ZED_COMMUNITY_BOT_APP_ID` with -# `Organization Projects: Read and write` permission added. Token is -# requested with `owner: zed-industries` so it can mutate org-level project -# items (the default repo-scoped token is insufficient for org projects). -# -# This workflow only mutates the triage project (#84). It does not write -# labels, comments, or any issue metadata. Adding any other write capability -# requires a separate workflow. - -name: Triage Project Sync (#84) - -on: - issues: - types: - - opened - - reopened - - closed - - labeled - - unlabeled - - assigned - - unassigned - - edited - issue_comment: - types: [created] - schedule: - - cron: "0 6 * * *" # daily 06:00 UTC - workflow_dispatch: - inputs: - issue_number: - description: "Issue number to sync (leave blank to sync all)" - type: number - required: false - dry_run: - description: "Dry run (compute but don't mutate)" - type: boolean - default: false - -# Coalesce rapid event bursts on the same issue (e.g., 5 labels added at once -# = 5 events). Cancel any in-progress run for the same issue when a new event -# arrives — the latest run will compute the most up-to-date state. -concurrency: - group: triage-sync-${{ github.event.issue.number || github.run_id }} - cancel-in-progress: true - -# Default to no permissions for any job in this workflow. The single job below -# explicitly opts back in to `contents: read` for the sparse checkout. If a -# future job is added without its own `permissions:` block, it will inherit -# this empty default rather than the repo-wide token defaults. -permissions: {} - -jobs: - sync: - name: Sync triage project - # Run only on the canonical repo (not forks); skip PR comments since this - # workflow is for issues only. - if: | - github.repository == 'zed-industries/zed' && - (github.event_name != 'issue_comment' || github.event.issue.pull_request == null) - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - - steps: - - name: Checkout (sparse — script only) - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - sparse-checkout: script/triage_project_sync.py - sparse-checkout-cone-mode: false - # Don't write GITHUB_TOKEN into .git/config. We never push from this - # workflow; we only read one file. Keeps the token out of any - # filesystem state that subsequent steps could access. - persist-credentials: false - - - name: Get App installation token - id: token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} - private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} - # IMPORTANT: org-scoped token is required for org-level project - # mutations. Without `owner:`, the default token is repo-scoped and - # cannot write to org projects. - owner: zed-industries - repositories: zed - # Scope the token down to the minimum needed for this workflow. - # Even though the App may have broader permissions for other - # automations (e.g., Issues:Write for the dupe-bot), this token - # only carries what we list below. Per the action's docs, an - # unrequested permission is *not* available on the resulting token. - # - # Required: - # - organization-projects:write — mutate project items + read - # project schema - # - members:read — query the `staff` team membership - # - issues:read — fetch issue body, labels, comments - # - metadata:read — always required for any GH API access - permission-organization-projects: write - permission-members: read - permission-issues: read - permission-metadata: read - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Install dependencies - run: pip install requests - - - name: Sync (event-driven, single issue) - if: github.event_name == 'issues' || github.event_name == 'issue_comment' - env: - GITHUB_TOKEN: ${{ steps.token.outputs.token }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - python script/triage_project_sync.py --issue "$ISSUE_NUMBER" - - - name: Sync (cron, all items) - if: github.event_name == 'schedule' - env: - GITHUB_TOKEN: ${{ steps.token.outputs.token }} - run: | - python script/triage_project_sync.py --all - - - name: Sync (manual dispatch — single) - if: github.event_name == 'workflow_dispatch' && inputs.issue_number != '' - env: - GITHUB_TOKEN: ${{ steps.token.outputs.token }} - ISSUE_NUMBER: ${{ inputs.issue_number }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - if [ "$DRY_RUN" = "true" ]; then - python script/triage_project_sync.py --issue "$ISSUE_NUMBER" --dry-run - else - python script/triage_project_sync.py --issue "$ISSUE_NUMBER" - fi - - - name: Sync (manual dispatch — all) - if: github.event_name == 'workflow_dispatch' && inputs.issue_number == '' - env: - GITHUB_TOKEN: ${{ steps.token.outputs.token }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - if [ "$DRY_RUN" = "true" ]; then - python script/triage_project_sync.py --all --dry-run - else - python script/triage_project_sync.py --all - fi - - - name: Write summary - if: always() - env: - EVENT_NAME: ${{ github.event_name }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - { - echo "## Triage sync summary" - echo "" - echo "- Event: \`$EVENT_NAME\`" - if [ -n "$ISSUE_NUMBER" ]; then - echo "- Issue: #$ISSUE_NUMBER" - fi - echo "- Project: [#84 Zed weekly triage](https://github.com/orgs/zed-industries/projects/84)" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/script/triage_project_sync.py b/script/triage_project_sync.py deleted file mode 100644 index 8c5c75988e..0000000000 --- a/script/triage_project_sync.py +++ /dev/null @@ -1,858 +0,0 @@ -#!/usr/bin/env python3 -""" -triage_project_sync.py -====================== - -Sync triage state from `zed-industries/zed` issues into the -"Zed weekly triage" project (#84). - -Auto-derives `Status`, `Stale since`, `Aged?`, `Intake week` from issue labels -+ comment activity + assignees. Mutates the project to -reflect the derived state. - -The labels and the issue thread are the source of truth. The project is a -*derived view* — manual edits to the synced fields will be overwritten on the -next sync. - -Modes ------ - --issue N Sync a single issue. Used by GH Actions on issue events. - --all Sync every item currently in the project. Used by daily - cron as a safety net. - --dry-run Compute derivations and log them, but don't mutate the - project. Safe for local testing / first deploy. - -Auth ----- -Reads `GITHUB_TOKEN` from env. For production, this is an installation token -from the `ZED_COMMUNITY_BOT_APP_ID` GitHub App, scoped to -`owner: zed-industries`, with `Organization Projects: Read and write`. - -For local `--dry-run` testing, a personal token with `repo, read:org, -read:project` is sufficient. - -Idempotency / safety --------------------- -- Every run re-derives all fields from current issue state. Running twice - produces the same result as once. -- Failures on a single issue (in `--all` mode) are logged and the run - continues. One bad item doesn't poison the batch. -- `--dry-run` makes no GraphQL mutations and no REST writes. - -Dependencies ------------- - pip install requests -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import time -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone - -import requests - -# --------------------------------------------------------------------------- -# Constants - -REPO_OWNER = "zed-industries" -REPO_NAME = "zed" -REPO = f"{REPO_OWNER}/{REPO_NAME}" - -PROJECT_NUMBER = 84 -PROJECT_OWNER = REPO_OWNER - -STAFF_TEAM_SLUG = "staff" - -# Status names. MUST match the option names configured in project #84. -# (Casing matters — GH Projects single-select option matching is case-sensitive.) -STATUS_NEEDS_LABELS = "Needs labels" -STATUS_NEEDS_REPRO_ATTEMPT = "Needs repro attempt" -STATUS_NEEDS_ASK = "Needs ask" -STATUS_USER_REPLIED = "User replied (review)" -STATUS_AWAITING_USER = "Awaiting user" -STATUS_RESPONDED_NO_REPRO = "Responded, no repro" -STATUS_AWAITING_EXTERNAL_REPRO = "Awaiting external repro" # not auto-set; placeholder -STATUS_REPRODUCIBLE = "Reproducible" -STATUS_HANDOFF = "Handoff" -STATUS_HANDOFF_INCOMPLETE = "Handoff (incomplete)" -STATUS_CLAIMED_COMMUNITY = "Claimed by community" -STATUS_CLOSED = "Closed" -STATUS_UNKNOWN = "Unknown" - -# Aging thresholds (days) per spec. -SUBSTANTIVE_COMMENT_MIN_LEN = 50 -AGE_THRESHOLDS_DAYS = { - STATUS_NEEDS_LABELS: 7, - STATUS_NEEDS_REPRO_ATTEMPT: 7, - STATUS_AWAITING_USER: 14, - STATUS_USER_REPLIED: 3, - # Needs ask is handled explicitly in derive_aged (always flagged), so - # it doesn't need a threshold here. -} - -TERMINAL_OR_RESTING_STATUSES = { - STATUS_REPRODUCIBLE, - STATUS_HANDOFF, - STATUS_CLOSED, - STATUS_RESPONDED_NO_REPRO, - STATUS_CLAIMED_COMMUNITY, -} - -# Issue types that aren't triage work items — administrative collections, -# dashboards, and trackers. The sync detects these and skips field updates; -# they remain in the project (auto-add put them there) but with empty fields, -# invisible in any status-filtered view. Manually remove them in the UI if -# they're cluttering the all-items list. -SKIP_ISSUE_TYPES = {"Meta", "Tracking"} - -REST_API = "https://api.github.com" -GRAPHQL_API = "https://api.github.com/graphql" - -NOW = datetime.now(timezone.utc) - - -# --------------------------------------------------------------------------- -# Logging - - -def log(msg: str, level: str = "INFO") -> None: - ts = datetime.now(timezone.utc).strftime("%H:%M:%S") - print(f"[{ts}] [{level}] {msg}", file=sys.stderr, flush=True) - - -# --------------------------------------------------------------------------- -# Auth - - -def get_token() -> str: - token = os.environ.get("GITHUB_TOKEN", "").strip() - if not token: - sys.exit("ERROR: GITHUB_TOKEN env var is required") - return token - - -_TOKEN: str | None = None - - -def headers_rest() -> dict[str, str]: - return { - "Authorization": f"Bearer {_TOKEN}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - - -def headers_graphql() -> dict[str, str]: - return {"Authorization": f"Bearer {_TOKEN}", "Content-Type": "application/json"} - - -# --------------------------------------------------------------------------- -# REST - - -def rest_get(path: str, params: dict | None = None, retries: int = 3) -> dict | list: - url = f"{REST_API}/{path.lstrip('/')}" - last_err: Exception | None = None - for attempt in range(retries): - try: - r = requests.get(url, headers=headers_rest(), params=params, timeout=30) - if r.status_code == 200: - return r.json() - if r.status_code in (429, 502, 503, 504): - wait = 2**attempt * 2 - log(f"REST {r.status_code} on {path}; retry in {wait}s", "WARN") - time.sleep(wait) - continue - log(f"REST GET {path} failed: {r.status_code} {r.text[:200]}", "ERROR") - r.raise_for_status() - except requests.RequestException as e: - last_err = e - wait = 2**attempt * 2 - log(f"REST GET {path} threw {e}; retry in {wait}s", "WARN") - time.sleep(wait) - raise RuntimeError(f"REST GET {path} failed after {retries} retries: {last_err}") - - -def rest_get_paginated(path: str, params: dict | None = None, max_pages: int = 20) -> list: - p = dict(params or {}) - p["per_page"] = 100 - out: list = [] - for page in range(1, max_pages + 1): - p["page"] = page - items = rest_get(path, p) - if not items: - break - if not isinstance(items, list): - log(f"REST {path} page {page} returned non-list", "WARN") - break - out.extend(items) - if len(items) < 100: - break - return out - - -# --------------------------------------------------------------------------- -# GraphQL - - -def graphql(query: str, variables: dict | None = None, retries: int = 3) -> dict: - payload = {"query": query, "variables": variables or {}} - last_err: Exception | None = None - for attempt in range(retries): - try: - r = requests.post(GRAPHQL_API, headers=headers_graphql(), json=payload, timeout=30) - if r.status_code == 200: - data = r.json() - if "errors" in data: - log(f"GraphQL errors: {json.dumps(data['errors'])[:400]}", "ERROR") - raise RuntimeError("GraphQL returned errors") - return data["data"] - if r.status_code in (429, 502, 503, 504): - wait = 2**attempt * 2 - log(f"GraphQL {r.status_code}; retry in {wait}s", "WARN") - time.sleep(wait) - continue - log(f"GraphQL HTTP {r.status_code}: {r.text[:300]}", "ERROR") - r.raise_for_status() - except requests.RequestException as e: - last_err = e - wait = 2**attempt * 2 - log(f"GraphQL threw {e}; retry in {wait}s", "WARN") - time.sleep(wait) - raise RuntimeError(f"GraphQL failed after {retries} retries: {last_err}") - - -# --------------------------------------------------------------------------- -# Issue data fetch - - -@dataclass -class IssueData: - number: int - node_id: str - title: str - state: str # "open" / "closed" - closed_at: datetime | None - created_at: datetime - reporter: str - assignees: list[str] - labels: list[str] - issue_type: str | None # e.g. "Bug", "Crash", "Meta", "Tracking", or None - is_pull_request: bool - comments: list[dict] - - -def parse_dt(s: str | None) -> datetime | None: - if not s: - return None - return datetime.fromisoformat(s.replace("Z", "+00:00")) - - -def fetch_issue(number: int) -> IssueData: - issue = rest_get(f"repos/{REPO}/issues/{number}") - if not isinstance(issue, dict): - raise RuntimeError(f"unexpected response for issue {number}") - comments = rest_get_paginated(f"repos/{REPO}/issues/{number}/comments") - created_at = parse_dt(issue["created_at"]) - if created_at is None: - raise RuntimeError(f"issue {number} has no created_at") - issue_type = None - if isinstance(issue.get("type"), dict): - issue_type = issue["type"].get("name") - return IssueData( - number=number, - node_id=issue["node_id"], - title=issue["title"], - state=issue["state"], - closed_at=parse_dt(issue.get("closed_at")), - created_at=created_at, - reporter=issue["user"]["login"], - assignees=[a["login"] for a in (issue.get("assignees") or [])], - labels=[l["name"] for l in issue["labels"]], - issue_type=issue_type, - is_pull_request="pull_request" in issue, - comments=comments, - ) - - -# --------------------------------------------------------------------------- -# Staff team - - -_STAFF: set[str] | None = None - - -def fetch_staff() -> set[str]: - global _STAFF - if _STAFF is not None: - return _STAFF - members = rest_get_paginated(f"orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members") - _STAFF = {m["login"] for m in members} - log(f"loaded {len(_STAFF)} staff members") - return _STAFF - - -def is_bot(user: dict) -> bool: - return user.get("type") == "Bot" or user.get("login", "").endswith("[bot]") - - -def is_substantive_staff_comment(comment: dict, staff: set[str]) -> bool: - user = comment.get("user", {}) - if user.get("login") not in staff or is_bot(user): - return False - body = comment.get("body") or "" - if len(body) >= SUBSTANTIVE_COMMENT_MIN_LEN: - return True - # Cheap attachment heuristic: looks for media tokens or attachment hosts. - if any( - m in body - for m in ( - "user-attachments/assets", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".mp4", - ".webm", - ".mov", - ) - ): - return True - return False - - -def latest_reporter_activity(issue: IssueData) -> datetime: - times = [issue.created_at] - for c in issue.comments: - if c["user"]["login"] == issue.reporter: - t = parse_dt(c["created_at"]) - if t: - times.append(t) - return max(times) - - -# --------------------------------------------------------------------------- -# Derivation rules -# (Mirrors the spec's R0-R6 cascade. Keep in sync with -# spec.md → "Status derivation rules".) - - -def derive_status(issue: IssueData, staff: set[str]) -> tuple[str, str, str]: - """Returns (status, rule_id, why).""" - L = set(issue.labels) - - if issue.closed_at is not None: - return STATUS_CLOSED, "R1", "issue is closed" - - if "state:claimed by community" in L: - return STATUS_CLAIMED_COMMUNITY, "R0", "state:claimed by community label" - - if "state:reproducible" in L: - if issue.assignees: - return STATUS_REPRODUCIBLE, "R2a", f"reproducible, assignee={','.join(issue.assignees)}" - # R2b vs R2c: any substantive staff comment in the thread? - substantive = None - for c in issue.comments: - if is_substantive_staff_comment(c, staff): - substantive = c - if substantive: - return ( - STATUS_HANDOFF, - "R2b", - f"reproducible, no assignee, staff context @ {substantive['created_at']} " - f"({len(substantive['body'])} chars by @{substantive['user']['login']})", - ) - return ( - STATUS_HANDOFF_INCOMPLETE, - "R2c", - "reproducible, no assignee, no substantive staff comment — close the loop", - ) - - # R4 (state:needs info) and R5 (state:needs repro) intentionally come - # before R3 (state:needs triage). Per the team's actual practice, - # state:needs triage is often left on while triage is in progress; only - # when no other state label is more specific should we treat the issue - # as "needs initial labels." - if "state:needs info" in L: - # R4 splits into three sub-cases based on whether we've actually - # asked anything (substantive staff comment) and whether the reporter - # or a third-party has responded. - substantive_staff = None - for c in issue.comments: - if is_substantive_staff_comment(c, staff): - substantive_staff = c - if substantive_staff is None: - # state:needs info applied without an actual question to the user. - # Runbook violation — we owe the reporter a comment explaining - # what info we need. - return ( - STATUS_NEEDS_ASK, - "R4c", - "state:needs info present but no substantive staff comment exists — we haven't asked anything", - ) - last_comment = issue.comments[-1] if issue.comments else None - if last_comment is not None: - author = last_comment["user"]["login"] - non_staff = author not in staff and not is_bot(last_comment["user"]) - if non_staff: - ct = parse_dt(last_comment["created_at"]) - st = parse_dt(substantive_staff["created_at"]) - if ct and st and ct > st: - relation = "reporter" if author == issue.reporter else "third-party" - return ( - STATUS_USER_REPLIED, - "R4b", - f"{relation} (@{author}) replied {ct.isoformat()} after substantive staff @ {st.isoformat()}", - ) - return ( - STATUS_AWAITING_USER, - "R4a", - f"substantive staff comment @ {substantive_staff['created_at']}, no non-staff reply since", - ) - - if "state:needs repro" in L: - cutoff = latest_reporter_activity(issue) - for c in reversed(issue.comments): - ct = parse_dt(c["created_at"]) - if ct and ct > cutoff and is_substantive_staff_comment(c, staff): - return ( - STATUS_RESPONDED_NO_REPRO, - "R5b", - f"staff comment {len(c['body'])} chars by @{c['user']['login']} @ {c['created_at']}", - ) - return STATUS_NEEDS_REPRO_ATTEMPT, "R5a", "no substantive staff comment after reporter's last activity" - - # R3 (state:needs triage) is checked LAST among recognized state labels. - # If state:needs triage is the only state label, the issue genuinely needs - # initial labeling. If any other state label is also present, that state - # has already been matched above and won. - if "state:needs triage" in L: - return STATUS_NEEDS_LABELS, "R3", "state:needs triage label present (no other state:* matched)" - - return STATUS_UNKNOWN, "R6", f"open with no recognized state label (labels: {sorted(L) or ''})" - - -def derive_stale_since( - issue: IssueData, status: str, staff: set[str] -) -> datetime | None: - """Returns the timestamp anchor used to measure aging, or None.""" - if status in TERMINAL_OR_RESTING_STATUSES or status == STATUS_UNKNOWN: - return None - if status == STATUS_NEEDS_LABELS: - return issue.created_at - if status == STATUS_NEEDS_REPRO_ATTEMPT: - return latest_reporter_activity(issue) - if status == STATUS_NEEDS_ASK: - # Anchor on issue creation — measures how long the runbook violation - # has gone unaddressed. Aging threshold is 0 (always flagged). - return issue.created_at - if status == STATUS_AWAITING_USER: - # Anchor on the most recent SUBSTANTIVE staff comment (the actual - # "ask"), consistent with R4's substantive-comment requirement. - substantive_staff = None - for c in issue.comments: - if is_substantive_staff_comment(c, staff): - substantive_staff = c - return parse_dt(substantive_staff["created_at"]) if substantive_staff else issue.created_at - if status == STATUS_USER_REPLIED: - last_non_staff = None - for c in issue.comments: - u = c["user"] - if u["login"] not in staff and not is_bot(u): - last_non_staff = c - return parse_dt(last_non_staff["created_at"]) if last_non_staff else None - if status == STATUS_HANDOFF_INCOMPLETE: - # Spec: when state:reproducible was applied. Approximation for v0: - # issue.created_at as a weak proxy. Replacing with timeline event lookup - # is a "parked" item. - return issue.created_at - return None - - -def derive_aged(status: str, stale_since: datetime | None) -> tuple[str, str]: - """Returns ('Yes' | 'No', why).""" - if status == STATUS_HANDOFF_INCOMPLETE: - return "Yes", "always-flagged for loop closure" - if status == STATUS_NEEDS_ASK: - return "Yes", "always-flagged: state:needs info applied without a substantive staff comment" - if status in TERMINAL_OR_RESTING_STATUSES or status == STATUS_UNKNOWN: - return "No", "terminal/resting" - if not stale_since: - return "No", "no stale_since (status not aged-tracked)" - if status not in AGE_THRESHOLDS_DAYS: - return "No", f"status {status} not aged-tracked" - age = NOW - stale_since - threshold = AGE_THRESHOLDS_DAYS[status] - if age > timedelta(days=threshold): - return "Yes", f"{status} for {age.days}d (>{threshold}d)" - return "No", f"{status} for {age.days}d (≤{threshold}d)" - - -# --------------------------------------------------------------------------- -# Project schema cache -# Discovered at runtime by name so the script doesn't break if field IDs -# change (e.g., project recreated). Project number is stable config. - - -_PROJECT_SCHEMA: dict | None = None - - -def fetch_project_schema() -> dict: - """Returns {'id', 'fields_by_name'} where fields_by_name maps name → field dict.""" - global _PROJECT_SCHEMA - if _PROJECT_SCHEMA is not None: - return _PROJECT_SCHEMA - query = """ - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - fields(first: 30) { - nodes { - __typename - ... on ProjectV2Field { id name dataType } - ... on ProjectV2SingleSelectField { - id name dataType options { id name } - } - ... on ProjectV2IterationField { - id name dataType - configuration { - duration startDay - iterations { id title startDate duration } - completedIterations { id title startDate duration } - } - } - } - } - } - } - } - """ - data = graphql(query, {"owner": PROJECT_OWNER, "number": PROJECT_NUMBER}) - proj = data["organization"]["projectV2"] - if not proj: - sys.exit(f"ERROR: project #{PROJECT_NUMBER} not found in {PROJECT_OWNER}") - fields_by_name = {f["name"]: f for f in proj["fields"]["nodes"]} - required = ["Status", "Intake week", "Stale since", "Aged?"] - missing = [n for n in required if n not in fields_by_name] - if missing: - sys.exit(f"ERROR: project missing required fields: {missing}") - _PROJECT_SCHEMA = {"id": proj["id"], "fields_by_name": fields_by_name} - log(f"loaded project schema: id={proj['id']}, fields={list(fields_by_name)}") - return _PROJECT_SCHEMA - - -def status_option_id(status_name: str) -> str | None: - schema = fetch_project_schema() - for opt in schema["fields_by_name"]["Status"]["options"]: - if opt["name"] == status_name: - return opt["id"] - return None - - -def aged_option_id(value: str) -> str | None: - schema = fetch_project_schema() - for opt in schema["fields_by_name"]["Aged?"]["options"]: - if opt["name"] == value: - return opt["id"] - return None - - -def iteration_id_for_date(d: datetime) -> str | None: - schema = fetch_project_schema() - field = schema["fields_by_name"]["Intake week"] - cfg = field["configuration"] - iterations = list(cfg.get("iterations") or []) + list(cfg.get("completedIterations") or []) - for it in iterations: - start = parse_dt(it["startDate"] + "T00:00:00+00:00") - if start is None: - continue - end = start + timedelta(days=int(it["duration"])) - if start <= d < end: - return it["id"] - return None - - -# --------------------------------------------------------------------------- -# Project item lookup / mutation - - -def get_project_item_id(issue_node_id: str) -> str | None: - """Returns the ProjectV2Item.id for the issue in our project, or None.""" - schema = fetch_project_schema() - project_id = schema["id"] - query = """ - query($issueId: ID!) { - node(id: $issueId) { - ... on Issue { - projectItems(first: 100) { - pageInfo { hasNextPage } - nodes { id project { id } } - } - } - } - } - """ - data = graphql(query, {"issueId": issue_node_id}) - node = data["node"] - if not node: - return None - items_block = node["projectItems"] - for item in items_block["nodes"]: - if item["project"]["id"] == project_id: - return item["id"] - if items_block["pageInfo"]["hasNextPage"]: - # Issue is on >100 projects; very unlikely. Log + return None. - log(f"issue {issue_node_id} on >100 projects, can't find ours in first page", "WARN") - return None - - -def add_to_project(issue_node_id: str) -> str: - schema = fetch_project_schema() - mutation = """ - mutation($projectId: ID!, $issueId: ID!) { - addProjectV2ItemById(input: { projectId: $projectId, contentId: $issueId }) { - item { id } - } - } - """ - data = graphql(mutation, {"projectId": schema["id"], "issueId": issue_node_id}) - return data["addProjectV2ItemById"]["item"]["id"] - - - - -def update_single_select(item_id: str, field_id: str, option_id: str, dry_run: bool) -> None: - if dry_run: - log(f" [DRY] single-select field={field_id} option={option_id} on item={item_id}") - return - schema = fetch_project_schema() - mutation = """ - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, itemId: $itemId, fieldId: $fieldId, - value: { singleSelectOptionId: $optionId } - }) { projectV2Item { id } } - } - """ - graphql( - mutation, - { - "projectId": schema["id"], - "itemId": item_id, - "fieldId": field_id, - "optionId": option_id, - }, - ) - - -def update_date(item_id: str, field_id: str, date_iso: str, dry_run: bool) -> None: - if dry_run: - log(f" [DRY] date field={field_id} value={date_iso} on item={item_id}") - return - schema = fetch_project_schema() - mutation = """ - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, itemId: $itemId, fieldId: $fieldId, - value: { date: $date } - }) { projectV2Item { id } } - } - """ - graphql( - mutation, - {"projectId": schema["id"], "itemId": item_id, "fieldId": field_id, "date": date_iso}, - ) - - -def update_iteration(item_id: str, field_id: str, iteration_id: str, dry_run: bool) -> None: - if dry_run: - log(f" [DRY] iteration field={field_id} value={iteration_id} on item={item_id}") - return - schema = fetch_project_schema() - mutation = """ - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $iterId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, itemId: $itemId, fieldId: $fieldId, - value: { iterationId: $iterId } - }) { projectV2Item { id } } - } - """ - graphql( - mutation, - { - "projectId": schema["id"], - "itemId": item_id, - "fieldId": field_id, - "iterId": iteration_id, - }, - ) - - -# --------------------------------------------------------------------------- -# Sync - - -def sync_issue(number: int, dry_run: bool = False) -> None: - """Sync a single issue. Adds to project if missing, then updates fields. - - Idempotent — running twice with the same issue state has no effect after - the first run. - """ - log(f"sync #{number} (dry_run={dry_run})") - issue = fetch_issue(number) - - if issue.is_pull_request: - log(f" #{number} is a PR; skipping (project tracks issues)") - return - - # Skip administrative issue types (Meta, Tracking, etc.). These are - # collections / dashboards, not triage work. The script doesn't have - # permission to remove items from the project (intentional — narrows blast - # radius). Existing Meta/Tracking items in the project should be removed - # manually one-time; new ones get auto-added by the project's auto-add - # workflow but the sync below skips them, so they sit with no Status / - # Aged? / Stale since fields set and don't appear in any status-filtered - # view. - if issue.issue_type in SKIP_ISSUE_TYPES: - log(f" #{number} is type={issue.issue_type}; not a triage item, skipping fields") - return - - staff = fetch_staff() - - status, rule, why = derive_status(issue, staff) - stale_since = derive_stale_since(issue, status, staff) - aged, aged_why = derive_aged(status, stale_since) - intake_iter_id = iteration_id_for_date(issue.created_at) - - log(f" status={status} ({rule}: {why})") - log(f" stale_since={stale_since.isoformat() if stale_since else 'none'}") - log(f" aged={aged} ({aged_why})") - log(f" intake_iteration_id={intake_iter_id or 'none (created_at outside iteration range)'}") - - schema = fetch_project_schema() - item_id = get_project_item_id(issue.node_id) - if not item_id: - if dry_run: - log(" [DRY] would add to project (item not yet present)") - return - item_id = add_to_project(issue.node_id) - log(f" added to project as item={item_id}") - - # Status (always set) - sid = status_option_id(status) - if not sid: - log(f" ERROR: no Status option named '{status}' in project; skipping status update", "ERROR") - else: - update_single_select( - item_id, schema["fields_by_name"]["Status"]["id"], sid, dry_run - ) - - # Aged? (always set) - aged_id = aged_option_id(aged) - if not aged_id: - log(f" ERROR: no Aged? option named '{aged}'; skipping", "ERROR") - else: - update_single_select( - item_id, schema["fields_by_name"]["Aged?"]["id"], aged_id, dry_run - ) - - # Stale since (only set when meaningful) - if stale_since: - update_date( - item_id, - schema["fields_by_name"]["Stale since"]["id"], - stale_since.date().isoformat(), - dry_run, - ) - - # Intake week (only set when an iteration covers the created_at) - if intake_iter_id: - update_iteration( - item_id, - schema["fields_by_name"]["Intake week"]["id"], - intake_iter_id, - dry_run, - ) - - -def sync_all(dry_run: bool = False) -> None: - """Sync every item currently in the project. Cron mode.""" - log("fetching all project items…") - cursor: str | None = None - total = 0 - failed = 0 - while True: - query = """ - query($owner: String!, $number: Int!, $cursor: String) { - organization(login: $owner) { - projectV2(number: $number) { - items(first: 100, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - id - content { - __typename - ... on Issue { number } - ... on PullRequest { number } - } - } - } - } - } - } - """ - data = graphql( - query, {"owner": PROJECT_OWNER, "number": PROJECT_NUMBER, "cursor": cursor} - ) - items_block = data["organization"]["projectV2"]["items"] - for item in items_block["nodes"]: - content = item.get("content") - if not content: - continue - if content["__typename"] != "Issue": - continue - num = content["number"] - try: - sync_issue(num, dry_run=dry_run) - except Exception as e: - log(f"sync #{num} failed: {e}", "ERROR") - failed += 1 - total += 1 - if not items_block["pageInfo"]["hasNextPage"]: - break - cursor = items_block["pageInfo"]["endCursor"] - log(f"done: synced {total} items, {failed} failed") - - -# --------------------------------------------------------------------------- -# Main - - -def main() -> int: - global _TOKEN - - ap = argparse.ArgumentParser(description=__doc__) - grp = ap.add_mutually_exclusive_group(required=True) - grp.add_argument("--issue", type=int, help="sync a single issue by number") - grp.add_argument("--all", action="store_true", help="sync every project item") - ap.add_argument("--dry-run", action="store_true", help="compute but don't mutate") - args = ap.parse_args() - - _TOKEN = get_token() - - if args.issue: - sync_issue(args.issue, dry_run=args.dry_run) - elif args.all: - sync_all(dry_run=args.dry_run) - - return 0 - - -if __name__ == "__main__": - sys.exit(main())