mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-23 21:05:08 +00:00
Self-Review Checklist: - [ x] I've reviewed my own diff for quality, security, and reliability - [ x] Unsafe blocks (if any) have justifying comments - [ n/a] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ n/a] Tests cover the new/changed behavior - [ n/a] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A
858 lines
29 KiB
Python
858 lines
29 KiB
Python
#!/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 '<none>'})"
|
|
|
|
|
|
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())
|