triage_project_sync.py

  1#!/usr/bin/env python3
  2"""
  3triage_project_sync.py
  4======================
  5
  6Sync triage state from `zed-industries/zed` issues into the
  7"Zed weekly triage" project (#84).
  8
  9Auto-derives `Status`, `Stale since`, `Aged?`, `Intake week` from issue labels
 10+ comment activity + assignees. Mutates the project to
 11reflect the derived state.
 12
 13The labels and the issue thread are the source of truth. The project is a
 14*derived view* β€” manual edits to the synced fields will be overwritten on the
 15next sync.
 16
 17Modes
 18-----
 19    --issue N        Sync a single issue. Used by GH Actions on issue events.
 20    --all            Sync every item currently in the project. Used by daily
 21                     cron as a safety net.
 22    --dry-run        Compute derivations and log them, but don't mutate the
 23                     project. Safe for local testing / first deploy.
 24
 25Auth
 26----
 27Reads `GITHUB_TOKEN` from env. For production, this is an installation token
 28from the `ZED_COMMUNITY_BOT_APP_ID` GitHub App, scoped to
 29`owner: zed-industries`, with `Organization Projects: Read and write`.
 30
 31For local `--dry-run` testing, a personal token with `repo, read:org,
 32read:project` is sufficient.
 33
 34Idempotency / safety
 35--------------------
 36- Every run re-derives all fields from current issue state. Running twice
 37  produces the same result as once.
 38- Failures on a single issue (in `--all` mode) are logged and the run
 39  continues. One bad item doesn't poison the batch.
 40- `--dry-run` makes no GraphQL mutations and no REST writes.
 41
 42Dependencies
 43------------
 44    pip install requests
 45"""
 46
 47from __future__ import annotations
 48
 49import argparse
 50import json
 51import os
 52import sys
 53import time
 54from dataclasses import dataclass
 55from datetime import datetime, timedelta, timezone
 56
 57import requests
 58
 59# ---------------------------------------------------------------------------
 60# Constants
 61
 62REPO_OWNER = "zed-industries"
 63REPO_NAME = "zed"
 64REPO = f"{REPO_OWNER}/{REPO_NAME}"
 65
 66PROJECT_NUMBER = 84
 67PROJECT_OWNER = REPO_OWNER
 68
 69STAFF_TEAM_SLUG = "staff"
 70
 71# Status names. MUST match the option names configured in project #84.
 72# (Casing matters β€” GH Projects single-select option matching is case-sensitive.)
 73STATUS_NEEDS_LABELS = "Needs labels"
 74STATUS_NEEDS_REPRO_ATTEMPT = "Needs repro attempt"
 75STATUS_NEEDS_ASK = "Needs ask"
 76STATUS_USER_REPLIED = "User replied (review)"
 77STATUS_AWAITING_USER = "Awaiting user"
 78STATUS_RESPONDED_NO_REPRO = "Responded, no repro"
 79STATUS_AWAITING_EXTERNAL_REPRO = "Awaiting external repro"  # not auto-set; placeholder
 80STATUS_REPRODUCIBLE = "Reproducible"
 81STATUS_HANDOFF = "Handoff"
 82STATUS_HANDOFF_INCOMPLETE = "Handoff (incomplete)"
 83STATUS_CLAIMED_COMMUNITY = "Claimed by community"
 84STATUS_CLOSED = "Closed"
 85STATUS_UNKNOWN = "Unknown"
 86
 87# Aging thresholds (days) per spec.
 88SUBSTANTIVE_COMMENT_MIN_LEN = 50
 89AGE_THRESHOLDS_DAYS = {
 90    STATUS_NEEDS_LABELS: 7,
 91    STATUS_NEEDS_REPRO_ATTEMPT: 7,
 92    STATUS_AWAITING_USER: 14,
 93    STATUS_USER_REPLIED: 3,
 94    # Needs ask is handled explicitly in derive_aged (always flagged), so
 95    # it doesn't need a threshold here.
 96}
 97
 98TERMINAL_OR_RESTING_STATUSES = {
 99    STATUS_REPRODUCIBLE,
100    STATUS_HANDOFF,
101    STATUS_CLOSED,
102    STATUS_RESPONDED_NO_REPRO,
103    STATUS_CLAIMED_COMMUNITY,
104}
105
106# Issue types that aren't triage work items β€” administrative collections,
107# dashboards, and trackers. The sync detects these and skips field updates;
108# they remain in the project (auto-add put them there) but with empty fields,
109# invisible in any status-filtered view. Manually remove them in the UI if
110# they're cluttering the all-items list.
111SKIP_ISSUE_TYPES = {"Meta", "Tracking"}
112
113REST_API = "https://api.github.com"
114GRAPHQL_API = "https://api.github.com/graphql"
115
116NOW = datetime.now(timezone.utc)
117
118
119# ---------------------------------------------------------------------------
120# Logging
121
122
123def log(msg: str, level: str = "INFO") -> None:
124    ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
125    print(f"[{ts}] [{level}] {msg}", file=sys.stderr, flush=True)
126
127
128# ---------------------------------------------------------------------------
129# Auth
130
131
132def get_token() -> str:
133    token = os.environ.get("GITHUB_TOKEN", "").strip()
134    if not token:
135        sys.exit("ERROR: GITHUB_TOKEN env var is required")
136    return token
137
138
139_TOKEN: str | None = None
140
141
142def headers_rest() -> dict[str, str]:
143    return {
144        "Authorization": f"Bearer {_TOKEN}",
145        "Accept": "application/vnd.github+json",
146        "X-GitHub-Api-Version": "2022-11-28",
147    }
148
149
150def headers_graphql() -> dict[str, str]:
151    return {"Authorization": f"Bearer {_TOKEN}", "Content-Type": "application/json"}
152
153
154# ---------------------------------------------------------------------------
155# REST
156
157
158def rest_get(path: str, params: dict | None = None, retries: int = 3) -> dict | list:
159    url = f"{REST_API}/{path.lstrip('/')}"
160    last_err: Exception | None = None
161    for attempt in range(retries):
162        try:
163            r = requests.get(url, headers=headers_rest(), params=params, timeout=30)
164            if r.status_code == 200:
165                return r.json()
166            if r.status_code in (429, 502, 503, 504):
167                wait = 2**attempt * 2
168                log(f"REST {r.status_code} on {path}; retry in {wait}s", "WARN")
169                time.sleep(wait)
170                continue
171            log(f"REST GET {path} failed: {r.status_code} {r.text[:200]}", "ERROR")
172            r.raise_for_status()
173        except requests.RequestException as e:
174            last_err = e
175            wait = 2**attempt * 2
176            log(f"REST GET {path} threw {e}; retry in {wait}s", "WARN")
177            time.sleep(wait)
178    raise RuntimeError(f"REST GET {path} failed after {retries} retries: {last_err}")
179
180
181def rest_get_paginated(path: str, params: dict | None = None, max_pages: int = 20) -> list:
182    p = dict(params or {})
183    p["per_page"] = 100
184    out: list = []
185    for page in range(1, max_pages + 1):
186        p["page"] = page
187        items = rest_get(path, p)
188        if not items:
189            break
190        if not isinstance(items, list):
191            log(f"REST {path} page {page} returned non-list", "WARN")
192            break
193        out.extend(items)
194        if len(items) < 100:
195            break
196    return out
197
198
199# ---------------------------------------------------------------------------
200# GraphQL
201
202
203def graphql(query: str, variables: dict | None = None, retries: int = 3) -> dict:
204    payload = {"query": query, "variables": variables or {}}
205    last_err: Exception | None = None
206    for attempt in range(retries):
207        try:
208            r = requests.post(GRAPHQL_API, headers=headers_graphql(), json=payload, timeout=30)
209            if r.status_code == 200:
210                data = r.json()
211                if "errors" in data:
212                    log(f"GraphQL errors: {json.dumps(data['errors'])[:400]}", "ERROR")
213                    raise RuntimeError("GraphQL returned errors")
214                return data["data"]
215            if r.status_code in (429, 502, 503, 504):
216                wait = 2**attempt * 2
217                log(f"GraphQL {r.status_code}; retry in {wait}s", "WARN")
218                time.sleep(wait)
219                continue
220            log(f"GraphQL HTTP {r.status_code}: {r.text[:300]}", "ERROR")
221            r.raise_for_status()
222        except requests.RequestException as e:
223            last_err = e
224            wait = 2**attempt * 2
225            log(f"GraphQL threw {e}; retry in {wait}s", "WARN")
226            time.sleep(wait)
227    raise RuntimeError(f"GraphQL failed after {retries} retries: {last_err}")
228
229
230# ---------------------------------------------------------------------------
231# Issue data fetch
232
233
234@dataclass
235class IssueData:
236    number: int
237    node_id: str
238    title: str
239    state: str  # "open" / "closed"
240    closed_at: datetime | None
241    created_at: datetime
242    reporter: str
243    assignees: list[str]
244    labels: list[str]
245    issue_type: str | None  # e.g. "Bug", "Crash", "Meta", "Tracking", or None
246    is_pull_request: bool
247    comments: list[dict]
248
249
250def parse_dt(s: str | None) -> datetime | None:
251    if not s:
252        return None
253    return datetime.fromisoformat(s.replace("Z", "+00:00"))
254
255
256def fetch_issue(number: int) -> IssueData:
257    issue = rest_get(f"repos/{REPO}/issues/{number}")
258    if not isinstance(issue, dict):
259        raise RuntimeError(f"unexpected response for issue {number}")
260    comments = rest_get_paginated(f"repos/{REPO}/issues/{number}/comments")
261    created_at = parse_dt(issue["created_at"])
262    if created_at is None:
263        raise RuntimeError(f"issue {number} has no created_at")
264    issue_type = None
265    if isinstance(issue.get("type"), dict):
266        issue_type = issue["type"].get("name")
267    return IssueData(
268        number=number,
269        node_id=issue["node_id"],
270        title=issue["title"],
271        state=issue["state"],
272        closed_at=parse_dt(issue.get("closed_at")),
273        created_at=created_at,
274        reporter=issue["user"]["login"],
275        assignees=[a["login"] for a in (issue.get("assignees") or [])],
276        labels=[l["name"] for l in issue["labels"]],
277        issue_type=issue_type,
278        is_pull_request="pull_request" in issue,
279        comments=comments,
280    )
281
282
283# ---------------------------------------------------------------------------
284# Staff team
285
286
287_STAFF: set[str] | None = None
288
289
290def fetch_staff() -> set[str]:
291    global _STAFF
292    if _STAFF is not None:
293        return _STAFF
294    members = rest_get_paginated(f"orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members")
295    _STAFF = {m["login"] for m in members}
296    log(f"loaded {len(_STAFF)} staff members")
297    return _STAFF
298
299
300def is_bot(user: dict) -> bool:
301    return user.get("type") == "Bot" or user.get("login", "").endswith("[bot]")
302
303
304def is_substantive_staff_comment(comment: dict, staff: set[str]) -> bool:
305    user = comment.get("user", {})
306    if user.get("login") not in staff or is_bot(user):
307        return False
308    body = comment.get("body") or ""
309    if len(body) >= SUBSTANTIVE_COMMENT_MIN_LEN:
310        return True
311    # Cheap attachment heuristic: looks for media tokens or attachment hosts.
312    if any(
313        m in body
314        for m in (
315            "user-attachments/assets",
316            ".png",
317            ".jpg",
318            ".jpeg",
319            ".gif",
320            ".mp4",
321            ".webm",
322            ".mov",
323        )
324    ):
325        return True
326    return False
327
328
329def latest_reporter_activity(issue: IssueData) -> datetime:
330    times = [issue.created_at]
331    for c in issue.comments:
332        if c["user"]["login"] == issue.reporter:
333            t = parse_dt(c["created_at"])
334            if t:
335                times.append(t)
336    return max(times)
337
338
339# ---------------------------------------------------------------------------
340# Derivation rules
341# (Mirrors the spec's R0-R6 cascade. Keep in sync with
342# spec.md β†’ "Status derivation rules".)
343
344
345def derive_status(issue: IssueData, staff: set[str]) -> tuple[str, str, str]:
346    """Returns (status, rule_id, why)."""
347    L = set(issue.labels)
348
349    if issue.closed_at is not None:
350        return STATUS_CLOSED, "R1", "issue is closed"
351
352    if "state:claimed by community" in L:
353        return STATUS_CLAIMED_COMMUNITY, "R0", "state:claimed by community label"
354
355    if "state:reproducible" in L:
356        if issue.assignees:
357            return STATUS_REPRODUCIBLE, "R2a", f"reproducible, assignee={','.join(issue.assignees)}"
358        # R2b vs R2c: any substantive staff comment in the thread?
359        substantive = None
360        for c in issue.comments:
361            if is_substantive_staff_comment(c, staff):
362                substantive = c
363        if substantive:
364            return (
365                STATUS_HANDOFF,
366                "R2b",
367                f"reproducible, no assignee, staff context @ {substantive['created_at']} "
368                f"({len(substantive['body'])} chars by @{substantive['user']['login']})",
369            )
370        return (
371            STATUS_HANDOFF_INCOMPLETE,
372            "R2c",
373            "reproducible, no assignee, no substantive staff comment β€” close the loop",
374        )
375
376    # R4 (state:needs info) and R5 (state:needs repro) intentionally come
377    # before R3 (state:needs triage). Per the team's actual practice,
378    # state:needs triage is often left on while triage is in progress; only
379    # when no other state label is more specific should we treat the issue
380    # as "needs initial labels."
381    if "state:needs info" in L:
382        # R4 splits into three sub-cases based on whether we've actually
383        # asked anything (substantive staff comment) and whether the reporter
384        # or a third-party has responded.
385        substantive_staff = None
386        for c in issue.comments:
387            if is_substantive_staff_comment(c, staff):
388                substantive_staff = c
389        if substantive_staff is None:
390            # state:needs info applied without an actual question to the user.
391            # Runbook violation β€” we owe the reporter a comment explaining
392            # what info we need.
393            return (
394                STATUS_NEEDS_ASK,
395                "R4c",
396                "state:needs info present but no substantive staff comment exists β€” we haven't asked anything",
397            )
398        last_comment = issue.comments[-1] if issue.comments else None
399        if last_comment is not None:
400            author = last_comment["user"]["login"]
401            non_staff = author not in staff and not is_bot(last_comment["user"])
402            if non_staff:
403                ct = parse_dt(last_comment["created_at"])
404                st = parse_dt(substantive_staff["created_at"])
405                if ct and st and ct > st:
406                    relation = "reporter" if author == issue.reporter else "third-party"
407                    return (
408                        STATUS_USER_REPLIED,
409                        "R4b",
410                        f"{relation} (@{author}) replied {ct.isoformat()} after substantive staff @ {st.isoformat()}",
411                    )
412        return (
413            STATUS_AWAITING_USER,
414            "R4a",
415            f"substantive staff comment @ {substantive_staff['created_at']}, no non-staff reply since",
416        )
417
418    if "state:needs repro" in L:
419        cutoff = latest_reporter_activity(issue)
420        for c in reversed(issue.comments):
421            ct = parse_dt(c["created_at"])
422            if ct and ct > cutoff and is_substantive_staff_comment(c, staff):
423                return (
424                    STATUS_RESPONDED_NO_REPRO,
425                    "R5b",
426                    f"staff comment {len(c['body'])} chars by @{c['user']['login']} @ {c['created_at']}",
427                )
428        return STATUS_NEEDS_REPRO_ATTEMPT, "R5a", "no substantive staff comment after reporter's last activity"
429
430    # R3 (state:needs triage) is checked LAST among recognized state labels.
431    # If state:needs triage is the only state label, the issue genuinely needs
432    # initial labeling. If any other state label is also present, that state
433    # has already been matched above and won.
434    if "state:needs triage" in L:
435        return STATUS_NEEDS_LABELS, "R3", "state:needs triage label present (no other state:* matched)"
436
437    return STATUS_UNKNOWN, "R6", f"open with no recognized state label (labels: {sorted(L) or '<none>'})"
438
439
440def derive_stale_since(
441    issue: IssueData, status: str, staff: set[str]
442) -> datetime | None:
443    """Returns the timestamp anchor used to measure aging, or None."""
444    if status in TERMINAL_OR_RESTING_STATUSES or status == STATUS_UNKNOWN:
445        return None
446    if status == STATUS_NEEDS_LABELS:
447        return issue.created_at
448    if status == STATUS_NEEDS_REPRO_ATTEMPT:
449        return latest_reporter_activity(issue)
450    if status == STATUS_NEEDS_ASK:
451        # Anchor on issue creation β€” measures how long the runbook violation
452        # has gone unaddressed. Aging threshold is 0 (always flagged).
453        return issue.created_at
454    if status == STATUS_AWAITING_USER:
455        # Anchor on the most recent SUBSTANTIVE staff comment (the actual
456        # "ask"), consistent with R4's substantive-comment requirement.
457        substantive_staff = None
458        for c in issue.comments:
459            if is_substantive_staff_comment(c, staff):
460                substantive_staff = c
461        return parse_dt(substantive_staff["created_at"]) if substantive_staff else issue.created_at
462    if status == STATUS_USER_REPLIED:
463        last_non_staff = None
464        for c in issue.comments:
465            u = c["user"]
466            if u["login"] not in staff and not is_bot(u):
467                last_non_staff = c
468        return parse_dt(last_non_staff["created_at"]) if last_non_staff else None
469    if status == STATUS_HANDOFF_INCOMPLETE:
470        # Spec: when state:reproducible was applied. Approximation for v0:
471        # issue.created_at as a weak proxy. Replacing with timeline event lookup
472        # is a "parked" item.
473        return issue.created_at
474    return None
475
476
477def derive_aged(status: str, stale_since: datetime | None) -> tuple[str, str]:
478    """Returns ('Yes' | 'No', why)."""
479    if status == STATUS_HANDOFF_INCOMPLETE:
480        return "Yes", "always-flagged for loop closure"
481    if status == STATUS_NEEDS_ASK:
482        return "Yes", "always-flagged: state:needs info applied without a substantive staff comment"
483    if status in TERMINAL_OR_RESTING_STATUSES or status == STATUS_UNKNOWN:
484        return "No", "terminal/resting"
485    if not stale_since:
486        return "No", "no stale_since (status not aged-tracked)"
487    if status not in AGE_THRESHOLDS_DAYS:
488        return "No", f"status {status} not aged-tracked"
489    age = NOW - stale_since
490    threshold = AGE_THRESHOLDS_DAYS[status]
491    if age > timedelta(days=threshold):
492        return "Yes", f"{status} for {age.days}d (>{threshold}d)"
493    return "No", f"{status} for {age.days}d (≀{threshold}d)"
494
495
496# ---------------------------------------------------------------------------
497# Project schema cache
498# Discovered at runtime by name so the script doesn't break if field IDs
499# change (e.g., project recreated). Project number is stable config.
500
501
502_PROJECT_SCHEMA: dict | None = None
503
504
505def fetch_project_schema() -> dict:
506    """Returns {'id', 'fields_by_name'} where fields_by_name maps name β†’ field dict."""
507    global _PROJECT_SCHEMA
508    if _PROJECT_SCHEMA is not None:
509        return _PROJECT_SCHEMA
510    query = """
511    query($owner: String!, $number: Int!) {
512      organization(login: $owner) {
513        projectV2(number: $number) {
514          id
515          fields(first: 30) {
516            nodes {
517              __typename
518              ... on ProjectV2Field { id name dataType }
519              ... on ProjectV2SingleSelectField {
520                id name dataType options { id name }
521              }
522              ... on ProjectV2IterationField {
523                id name dataType
524                configuration {
525                  duration startDay
526                  iterations { id title startDate duration }
527                  completedIterations { id title startDate duration }
528                }
529              }
530            }
531          }
532        }
533      }
534    }
535    """
536    data = graphql(query, {"owner": PROJECT_OWNER, "number": PROJECT_NUMBER})
537    proj = data["organization"]["projectV2"]
538    if not proj:
539        sys.exit(f"ERROR: project #{PROJECT_NUMBER} not found in {PROJECT_OWNER}")
540    fields_by_name = {f["name"]: f for f in proj["fields"]["nodes"]}
541    required = ["Status", "Intake week", "Stale since", "Aged?"]
542    missing = [n for n in required if n not in fields_by_name]
543    if missing:
544        sys.exit(f"ERROR: project missing required fields: {missing}")
545    _PROJECT_SCHEMA = {"id": proj["id"], "fields_by_name": fields_by_name}
546    log(f"loaded project schema: id={proj['id']}, fields={list(fields_by_name)}")
547    return _PROJECT_SCHEMA
548
549
550def status_option_id(status_name: str) -> str | None:
551    schema = fetch_project_schema()
552    for opt in schema["fields_by_name"]["Status"]["options"]:
553        if opt["name"] == status_name:
554            return opt["id"]
555    return None
556
557
558def aged_option_id(value: str) -> str | None:
559    schema = fetch_project_schema()
560    for opt in schema["fields_by_name"]["Aged?"]["options"]:
561        if opt["name"] == value:
562            return opt["id"]
563    return None
564
565
566def iteration_id_for_date(d: datetime) -> str | None:
567    schema = fetch_project_schema()
568    field = schema["fields_by_name"]["Intake week"]
569    cfg = field["configuration"]
570    iterations = list(cfg.get("iterations") or []) + list(cfg.get("completedIterations") or [])
571    for it in iterations:
572        start = parse_dt(it["startDate"] + "T00:00:00+00:00")
573        if start is None:
574            continue
575        end = start + timedelta(days=int(it["duration"]))
576        if start <= d < end:
577            return it["id"]
578    return None
579
580
581# ---------------------------------------------------------------------------
582# Project item lookup / mutation
583
584
585def get_project_item_id(issue_node_id: str) -> str | None:
586    """Returns the ProjectV2Item.id for the issue in our project, or None."""
587    schema = fetch_project_schema()
588    project_id = schema["id"]
589    query = """
590    query($issueId: ID!) {
591      node(id: $issueId) {
592        ... on Issue {
593          projectItems(first: 100) {
594            pageInfo { hasNextPage }
595            nodes { id project { id } }
596          }
597        }
598      }
599    }
600    """
601    data = graphql(query, {"issueId": issue_node_id})
602    node = data["node"]
603    if not node:
604        return None
605    items_block = node["projectItems"]
606    for item in items_block["nodes"]:
607        if item["project"]["id"] == project_id:
608            return item["id"]
609    if items_block["pageInfo"]["hasNextPage"]:
610        # Issue is on >100 projects; very unlikely. Log + return None.
611        log(f"issue {issue_node_id} on >100 projects, can't find ours in first page", "WARN")
612    return None
613
614
615def add_to_project(issue_node_id: str) -> str:
616    schema = fetch_project_schema()
617    mutation = """
618    mutation($projectId: ID!, $issueId: ID!) {
619      addProjectV2ItemById(input: { projectId: $projectId, contentId: $issueId }) {
620        item { id }
621      }
622    }
623    """
624    data = graphql(mutation, {"projectId": schema["id"], "issueId": issue_node_id})
625    return data["addProjectV2ItemById"]["item"]["id"]
626
627
628
629
630def update_single_select(item_id: str, field_id: str, option_id: str, dry_run: bool) -> None:
631    if dry_run:
632        log(f"  [DRY] single-select field={field_id} option={option_id} on item={item_id}")
633        return
634    schema = fetch_project_schema()
635    mutation = """
636    mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
637      updateProjectV2ItemFieldValue(input: {
638        projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
639        value: { singleSelectOptionId: $optionId }
640      }) { projectV2Item { id } }
641    }
642    """
643    graphql(
644        mutation,
645        {
646            "projectId": schema["id"],
647            "itemId": item_id,
648            "fieldId": field_id,
649            "optionId": option_id,
650        },
651    )
652
653
654def update_date(item_id: str, field_id: str, date_iso: str, dry_run: bool) -> None:
655    if dry_run:
656        log(f"  [DRY] date field={field_id} value={date_iso} on item={item_id}")
657        return
658    schema = fetch_project_schema()
659    mutation = """
660    mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
661      updateProjectV2ItemFieldValue(input: {
662        projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
663        value: { date: $date }
664      }) { projectV2Item { id } }
665    }
666    """
667    graphql(
668        mutation,
669        {"projectId": schema["id"], "itemId": item_id, "fieldId": field_id, "date": date_iso},
670    )
671
672
673def update_iteration(item_id: str, field_id: str, iteration_id: str, dry_run: bool) -> None:
674    if dry_run:
675        log(f"  [DRY] iteration field={field_id} value={iteration_id} on item={item_id}")
676        return
677    schema = fetch_project_schema()
678    mutation = """
679    mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $iterId: String!) {
680      updateProjectV2ItemFieldValue(input: {
681        projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
682        value: { iterationId: $iterId }
683      }) { projectV2Item { id } }
684    }
685    """
686    graphql(
687        mutation,
688        {
689            "projectId": schema["id"],
690            "itemId": item_id,
691            "fieldId": field_id,
692            "iterId": iteration_id,
693        },
694    )
695
696
697# ---------------------------------------------------------------------------
698# Sync
699
700
701def sync_issue(number: int, dry_run: bool = False) -> None:
702    """Sync a single issue. Adds to project if missing, then updates fields.
703
704    Idempotent β€” running twice with the same issue state has no effect after
705    the first run.
706    """
707    log(f"sync #{number} (dry_run={dry_run})")
708    issue = fetch_issue(number)
709
710    if issue.is_pull_request:
711        log(f"  #{number} is a PR; skipping (project tracks issues)")
712        return
713
714    # Skip administrative issue types (Meta, Tracking, etc.). These are
715    # collections / dashboards, not triage work. The script doesn't have
716    # permission to remove items from the project (intentional β€” narrows blast
717    # radius). Existing Meta/Tracking items in the project should be removed
718    # manually one-time; new ones get auto-added by the project's auto-add
719    # workflow but the sync below skips them, so they sit with no Status /
720    # Aged? / Stale since fields set and don't appear in any status-filtered
721    # view.
722    if issue.issue_type in SKIP_ISSUE_TYPES:
723        log(f"  #{number} is type={issue.issue_type}; not a triage item, skipping fields")
724        return
725
726    staff = fetch_staff()
727
728    status, rule, why = derive_status(issue, staff)
729    stale_since = derive_stale_since(issue, status, staff)
730    aged, aged_why = derive_aged(status, stale_since)
731    intake_iter_id = iteration_id_for_date(issue.created_at)
732
733    log(f"  status={status} ({rule}: {why})")
734    log(f"  stale_since={stale_since.isoformat() if stale_since else 'none'}")
735    log(f"  aged={aged} ({aged_why})")
736    log(f"  intake_iteration_id={intake_iter_id or 'none (created_at outside iteration range)'}")
737
738    schema = fetch_project_schema()
739    item_id = get_project_item_id(issue.node_id)
740    if not item_id:
741        if dry_run:
742            log("  [DRY] would add to project (item not yet present)")
743            return
744        item_id = add_to_project(issue.node_id)
745        log(f"  added to project as item={item_id}")
746
747    # Status (always set)
748    sid = status_option_id(status)
749    if not sid:
750        log(f"  ERROR: no Status option named '{status}' in project; skipping status update", "ERROR")
751    else:
752        update_single_select(
753            item_id, schema["fields_by_name"]["Status"]["id"], sid, dry_run
754        )
755
756    # Aged? (always set)
757    aged_id = aged_option_id(aged)
758    if not aged_id:
759        log(f"  ERROR: no Aged? option named '{aged}'; skipping", "ERROR")
760    else:
761        update_single_select(
762            item_id, schema["fields_by_name"]["Aged?"]["id"], aged_id, dry_run
763        )
764
765    # Stale since (only set when meaningful)
766    if stale_since:
767        update_date(
768            item_id,
769            schema["fields_by_name"]["Stale since"]["id"],
770            stale_since.date().isoformat(),
771            dry_run,
772        )
773
774    # Intake week (only set when an iteration covers the created_at)
775    if intake_iter_id:
776        update_iteration(
777            item_id,
778            schema["fields_by_name"]["Intake week"]["id"],
779            intake_iter_id,
780            dry_run,
781        )
782
783
784def sync_all(dry_run: bool = False) -> None:
785    """Sync every item currently in the project. Cron mode."""
786    log("fetching all project items…")
787    cursor: str | None = None
788    total = 0
789    failed = 0
790    while True:
791        query = """
792        query($owner: String!, $number: Int!, $cursor: String) {
793          organization(login: $owner) {
794            projectV2(number: $number) {
795              items(first: 100, after: $cursor) {
796                pageInfo { hasNextPage endCursor }
797                nodes {
798                  id
799                  content {
800                    __typename
801                    ... on Issue { number }
802                    ... on PullRequest { number }
803                  }
804                }
805              }
806            }
807          }
808        }
809        """
810        data = graphql(
811            query, {"owner": PROJECT_OWNER, "number": PROJECT_NUMBER, "cursor": cursor}
812        )
813        items_block = data["organization"]["projectV2"]["items"]
814        for item in items_block["nodes"]:
815            content = item.get("content")
816            if not content:
817                continue
818            if content["__typename"] != "Issue":
819                continue
820            num = content["number"]
821            try:
822                sync_issue(num, dry_run=dry_run)
823            except Exception as e:
824                log(f"sync #{num} failed: {e}", "ERROR")
825                failed += 1
826            total += 1
827        if not items_block["pageInfo"]["hasNextPage"]:
828            break
829        cursor = items_block["pageInfo"]["endCursor"]
830    log(f"done: synced {total} items, {failed} failed")
831
832
833# ---------------------------------------------------------------------------
834# Main
835
836
837def main() -> int:
838    global _TOKEN
839
840    ap = argparse.ArgumentParser(description=__doc__)
841    grp = ap.add_mutually_exclusive_group(required=True)
842    grp.add_argument("--issue", type=int, help="sync a single issue by number")
843    grp.add_argument("--all", action="store_true", help="sync every project item")
844    ap.add_argument("--dry-run", action="store_true", help="compute but don't mutate")
845    args = ap.parse_args()
846
847    _TOKEN = get_token()
848
849    if args.issue:
850        sync_issue(args.issue, dry_run=args.dry_run)
851    elif args.all:
852        sync_all(dry_run=args.dry_run)
853
854    return 0
855
856
857if __name__ == "__main__":
858    sys.exit(main())