bug: define and enforce attributes for caller-owned output variables #592
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Project Intake | |
| on: | |
| issues: | |
| types: [opened, reopened, closed] | |
| workflow_dispatch: | |
| inputs: | |
| issue_number: | |
| description: Issue number to reconcile into the repo Project. | |
| required: true | |
| type: string | |
| permissions: | |
| contents: read | |
| issues: read | |
| concurrency: | |
| group: project-intake-${{ github.event.issue.number || inputs.issue_number || github.run_id }} | |
| cancel-in-progress: false | |
| jobs: | |
| sync: | |
| name: Sync issue Project fields | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| env: | |
| BASE_PROJECT_OWNER: ${{ github.repository_owner }} | |
| BASE_PROJECT_TITLE: ${{ github.event.repository.name }} | |
| BASE_PROJECT_ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }} | |
| BASE_PROJECT_DEFAULT_OPEN_STATUS: Backlog | |
| BASE_PROJECT_DEFAULT_CLOSED_STATUS: Done | |
| BASE_PROJECT_DEFAULT_PRIORITY: P2 | |
| BASE_PROJECT_DEFAULT_SIZE: S | |
| BASE_PROJECT_DEFAULT_AREA: Product | |
| BASE_PROJECT_DEFAULT_INITIATIVE: Adoption Polish | |
| BASE_PROJECT_NUMBER: 8 | |
| GH_TOKEN: ${{ secrets.BASE_PROJECT_TOKEN }} | |
| steps: | |
| - name: Reconcile Project item | |
| shell: python | |
| run: | | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import time | |
| def api(method, endpoint, *, payload=None, params=None, paginate=False): | |
| """Use only REST, keeping JSON stdout separate from gh diagnostics.""" | |
| command = ["gh", "api", "--method", method, endpoint, | |
| "-H", "Accept: application/vnd.github+json"] | |
| if paginate: | |
| command += ["--paginate", "--slurp"] | |
| for key, value in (params or {}).items(): | |
| command += ["-f", f"{key}={value}"] | |
| if payload is not None: | |
| command += ["--input", "-"] | |
| for attempt in range(3): | |
| try: | |
| result = subprocess.run( | |
| command, input=json.dumps(payload) if payload is not None else None, | |
| capture_output=True, text=True, timeout=60, check=False, | |
| ) | |
| if result.returncode == 0: | |
| data = json.loads(result.stdout) | |
| return [item for page in data for item in page] if paginate else data | |
| detail = result.stderr + "\n" + result.stdout | |
| except subprocess.TimeoutExpired: | |
| detail = "GitHub request timed out" | |
| retryable = re.search( | |
| r"HTTP (429|5\d\d)|rate limit|secondary rate|timed out|" | |
| r"Could not resolve host|connection (reset|refused)", detail, re.I, | |
| ) | |
| delay = 2 ** attempt | |
| retry_after = re.search(r"Retry-After:?\s*(\d+)", detail, re.I) | |
| if retry_after: | |
| delay = max(delay, int(retry_after[1])) | |
| # Never spin until an hourly quota reset or ignore a long Retry-After. | |
| if not retryable or attempt == 2 or delay > 30: | |
| raise RuntimeError(f"{method} {endpoint} failed: {detail.strip()}") | |
| print(f"Retrying {method} {endpoint} in {delay}s.", file=sys.stderr) | |
| time.sleep(delay) | |
| def main(): | |
| if not os.environ.get("GH_TOKEN"): | |
| raise RuntimeError("BASE_PROJECT_TOKEN is required for organization Project writes.") | |
| number = os.environ.get("BASE_PROJECT_ISSUE_NUMBER", "") | |
| if not re.fullmatch(r"[1-9][0-9]*", number): | |
| raise RuntimeError("Issue number must be a positive integer.") | |
| repo = os.environ["GITHUB_REPOSITORY"] | |
| owner = os.environ["BASE_PROJECT_OWNER"] | |
| project_number = os.environ["BASE_PROJECT_NUMBER"] | |
| if not re.fullmatch(r"[1-9][0-9]*", project_number): | |
| raise RuntimeError("Project number must be a positive integer.") | |
| project = f"orgs/{owner}/projectsV2/{project_number}" | |
| metadata = api("GET", project) | |
| if metadata.get("title") != os.environ["BASE_PROJECT_TITLE"]: | |
| raise RuntimeError("Configured Project does not match the repository Project title.") | |
| issue = api("GET", f"repos/{repo}/issues/{number}") | |
| if "pull_request" in issue or issue.get("state") not in ("open", "closed"): | |
| raise RuntimeError("Intake requires an open or closed issue.") | |
| defaults = { | |
| "Status": os.environ["BASE_PROJECT_DEFAULT_OPEN_STATUS"], | |
| "Priority": os.environ["BASE_PROJECT_DEFAULT_PRIORITY"], | |
| "Size": os.environ["BASE_PROJECT_DEFAULT_SIZE"], | |
| "Area": os.environ["BASE_PROJECT_DEFAULT_AREA"], | |
| "Initiative": os.environ["BASE_PROJECT_DEFAULT_INITIATIVE"], | |
| } | |
| fields = api("GET", f"{project}/fields", paginate=True, params={"per_page": "100"}) | |
| managed = {} | |
| for name in defaults: | |
| matches = [field for field in fields if field["name"] == name] | |
| if len(matches) != 1 or matches[0].get("data_type") != "single_select": | |
| raise RuntimeError(f"Expected exactly one single-select field: {name}") | |
| managed[name] = matches[0] | |
| field_ids = ",".join(str(field["id"]) for field in managed.values()) | |
| def find_item(): | |
| # Scan every Project page, avoiding search grammar or index | |
| # visibility dependencies, then match immutable issue identity. | |
| items = api("GET", f"{project}/items", paginate=True, | |
| params={"per_page": "100"}) | |
| matches = [item for item in items if item.get("content_type") == "Issue" | |
| and (item.get("content") or {}).get("id") == issue["id"]] | |
| if len(matches) > 1: | |
| raise RuntimeError("Multiple Project items match the exact issue.") | |
| return matches[0] if matches else None | |
| item = find_item() | |
| if item is None: | |
| try: | |
| added = api("POST", f"{project}/items", payload={"type": "Issue", "id": issue["id"]}) | |
| item = added.get("value", added) | |
| if not item.get("id"): | |
| raise RuntimeError("Project item add did not return an item id.") | |
| except RuntimeError as error: | |
| # Another intake/Project automation may win the add race. | |
| if "content already exists in this project" not in str(error).lower(): | |
| raise | |
| for attempt in range(3): | |
| item = find_item() | |
| if item is not None: | |
| break | |
| if attempt < 2: | |
| time.sleep(attempt + 1) | |
| if item is None: | |
| raise RuntimeError("Existing Project item is not visible after bounded retries.") from error | |
| item_endpoint = f"{project}/items/{item['id']}" | |
| def read_item(): | |
| for attempt in range(3): | |
| try: | |
| current = api("GET", item_endpoint, params={"fields": field_ids}) | |
| except RuntimeError as error: | |
| if "HTTP 404" not in str(error): | |
| raise | |
| if attempt == 2: | |
| raise RuntimeError("Project item is not visible after bounded retries.") from error | |
| else: | |
| if (current.get("content_type") != "Issue" | |
| or (current.get("content") or {}).get("id") != issue["id"]): | |
| raise RuntimeError("Project item identity did not match the requested issue.") | |
| return current | |
| time.sleep(attempt + 1) | |
| def values(current): | |
| return {field["id"]: (field.get("value") or {}).get("id") | |
| for field in current.get("fields", [])} | |
| def option_id(field, label): | |
| matches = [option["id"] for option in field.get("options", []) | |
| if (option["name"].get("raw") if isinstance(option["name"], dict) | |
| else option["name"]) == label] | |
| if len(matches) != 1: | |
| raise RuntimeError(f"Project field {field['name']!r} option {label!r} was not found uniquely.") | |
| return matches[0] | |
| current_values = values(read_item()) | |
| expected = {} | |
| updates = [] | |
| closed_status = os.environ["BASE_PROJECT_DEFAULT_CLOSED_STATUS"] | |
| for name, field in managed.items(): | |
| current = current_values.get(field["id"]) | |
| desired = current | |
| if name == "Status" and issue["state"] == "closed": | |
| desired = option_id(field, closed_status) | |
| elif name == "Status" and current == option_id(field, closed_status): | |
| desired = option_id(field, defaults[name]) | |
| elif not current: | |
| desired = option_id(field, defaults[name]) | |
| expected[field["id"]] = desired | |
| if current != desired: | |
| updates.append({"id": field["id"], "value": desired}) | |
| if updates: | |
| api("PATCH", item_endpoint, payload={"fields": updates}) | |
| status_field = managed["Status"] | |
| open_status_ids = {option["id"] for option in status_field["options"]} | |
| open_status_ids.discard(option_id(status_field, closed_status)) | |
| for attempt in range(3): | |
| verified = values(read_item()) | |
| verification_expected = dict(expected) | |
| # Linked-PR automation or a maintainer can advance an open | |
| # issue while intake initializes its other fields. Preserve | |
| # that valid status just as we preserve it before the write. | |
| observed_status = verified.get(status_field["id"]) | |
| if issue["state"] == "open" and observed_status in open_status_ids: | |
| verification_expected[status_field["id"]] = observed_status | |
| if all(verified.get(field_id) == value for field_id, value in verification_expected.items()): | |
| if verification_expected != expected: | |
| print("Preserved a concurrent open-issue Project status change.") | |
| print(f"Synced issue #{number} into Project {project_number} via REST; verified all five fields.") | |
| return | |
| if attempt < 2: | |
| time.sleep(attempt + 1) | |
| mismatches = [name for name, field in managed.items() | |
| if verified.get(field["id"]) != verification_expected[field["id"]]] | |
| raise RuntimeError("Project field readback did not match the intended values after bounded retries: " | |
| + ", ".join(mismatches)) | |
| try: | |
| main() | |
| except (RuntimeError, ValueError, KeyError, TypeError, OSError) as error: | |
| # Escape annotation control characters; never claim success on partial sync. | |
| message = str(error).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") | |
| print(f"::error::{message}", file=sys.stderr) | |
| sys.exit(1) |