zed/script/triage_project_sync.py
Lucas White 538151a55e
Rework GH Project status logic to reflect triage runbook (#55845)
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
2026-05-06 15:22:50 +00:00

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())