Skip to content

bug: define and enforce attributes for caller-owned output variables #592

bug: define and enforce attributes for caller-owned output variables

bug: define and enforce attributes for caller-owned output variables #592

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)