From 41caf92c47cf00b79ba20eeceb1504e03daa87e1 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:11:39 -0300 Subject: [PATCH 001/130] docs: add MIT license --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c91427 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 v-Kaefer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From c1e35e20ed766d65a4231e79e70125d8916ee27b Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:12:02 -0300 Subject: [PATCH 002/130] feat: add repository setup Makefile --- Makefile | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..82b51af --- /dev/null +++ b/Makefile @@ -0,0 +1,112 @@ +PYTHON ?= python3 +PIP ?= $(PYTHON) -m pip +TARGET ?= +REPO ?= +PROFILE ?= core +PROJECT_TYPE ?= +CONFIG ?= project_setup.json +PROJECT_NUMBER ?= +OWNER ?= +FORCE ?= 0 + +WORKDIR := $(if $(strip $(TARGET)),$(TARGET),.) +FORCE_FLAG := $(if $(filter 1 true yes on,$(FORCE)),--force,) +OWNER_FLAG := $(if $(strip $(OWNER)),--owner "$(OWNER)",) +PROJECT_TYPE_FLAG := $(if $(strip $(PROJECT_TYPE)),--project-type "$(PROJECT_TYPE)",) + +.PHONY: help install dev-install compile test quality check doctor discover require-target require-repo require-project-number init init-dry plan apply setup setup-live labels milestones issues project-create project-sync clean + +help: + @echo "GitHub Project Setup" + @echo "" + @echo "Development:" + @echo " make install Install the CLI" + @echo " make dev-install Install in editable mode" + @echo " make check Compile, validate and run tests" + @echo " make doctor Inspect local configuration" + @echo "" + @echo "Repository analysis and setup:" + @echo " make discover TARGET=../project REPO=owner/repo" + @echo " make discover TARGET=../project REPO=owner/repo PROJECT_TYPE=python" + @echo " make init TARGET=../project Copy core automation files" + @echo " make init TARGET=../project PROFILE=godot" + @echo " make init TARGET=../project FORCE=1 Replace existing managed files" + @echo " make plan TARGET=../project REPO=owner/repo" + @echo " make apply TARGET=../project REPO=owner/repo" + @echo " make setup TARGET=../project REPO=owner/repo Init + dry-run" + @echo " make setup-live TARGET=../project REPO=owner/repo Init + live apply" + @echo "" + @echo "Individual operations:" + @echo " make labels REPO=owner/repo" + @echo " make milestones REPO=owner/repo" + @echo " make issues REPO=owner/repo" + @echo " make project-create REPO=owner/repo" + @echo " make project-sync REPO=owner/repo PROJECT_NUMBER=1" + +install: + $(PIP) install . + +dev-install: + $(PIP) install -e . + +compile: + $(PYTHON) -m compileall -q project_setup scripts tests + +test: + $(PYTHON) -m unittest discover -s tests -p "test_*.py" -v + +quality: + $(PYTHON) scripts/validation/repo_quality.py + +check: compile quality test + +doctor: + $(PYTHON) -m project_setup doctor --config "$(CONFIG)" + +require-target: + @test -n "$(TARGET)" || (echo "TARGET is required, for example: make init TARGET=../my-project" >&2; exit 2) + +require-repo: + @test -n "$(REPO)" || (echo "REPO is required, for example: REPO=owner/repository" >&2; exit 2) + +require-project-number: + @test -n "$(PROJECT_NUMBER)" || (echo "PROJECT_NUMBER is required" >&2; exit 2) + +discover: require-target require-repo + $(PYTHON) -m project_setup discover --repo "$(REPO)" --config "$(CONFIG)" --root "$(TARGET)" $(PROJECT_TYPE_FLAG) --auto + +init: require-target + $(PYTHON) -m project_setup init --target "$(TARGET)" --profile "$(PROFILE)" $(FORCE_FLAG) + +init-dry: require-target + $(PYTHON) -m project_setup init --target "$(TARGET)" --profile "$(PROFILE)" $(FORCE_FLAG) --dry-run + +plan: require-repo + cd "$(WORKDIR)" && $(PYTHON) -m project_setup apply --repo "$(REPO)" --config "$(CONFIG)" --dry-run + +apply: require-repo + cd "$(WORKDIR)" && $(PYTHON) -m project_setup apply --repo "$(REPO)" --config "$(CONFIG)" --no-dry-run + +setup: init plan + +setup-live: init apply + +labels: require-repo + cd "$(WORKDIR)" && $(PYTHON) -m project_setup labels sync --repo "$(REPO)" --file config/project/labels.json --dry-run + +milestones: require-repo + cd "$(WORKDIR)" && $(PYTHON) -m project_setup milestones sync --repo "$(REPO)" --file config/project/milestones.json --dry-run + +issues: require-repo + cd "$(WORKDIR)" && $(PYTHON) -m project_setup issues generate --repo "$(REPO)" --file config/stories/backlog-manifest.json --dry-run + +project-create: require-repo + cd "$(WORKDIR)" && $(PYTHON) -m project_setup project create --repo "$(REPO)" --file config/project/project-definition.json --dry-run + +project-sync: require-repo require-project-number + cd "$(WORKDIR)" && $(PYTHON) -m project_setup project sync --repo "$(REPO)" --project-number "$(PROJECT_NUMBER)" $(OWNER_FLAG) --file config/project/project-definition.json --dry-run + +clean: + @find . -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true + @find . -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete 2>/dev/null || true + @rm -rf build dist *.egg-info .pytest_cache .mypy_cache From 55c4938c182d2b6663518b66d68214b29149b359 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:12:13 -0300 Subject: [PATCH 003/130] refactor: add project_setup configuration --- project_setup.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 project_setup.json diff --git a/project_setup.json b/project_setup.json new file mode 100644 index 0000000..74789ab --- /dev/null +++ b/project_setup.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "labelsFile": "config/project/labels.json", + "milestonesFile": "config/project/milestones.json", + "projectDefinitionFile": "config/project/project-definition.json", + "backlogManifestFile": "config/stories/backlog-manifest.json", + "secretName": "PROJECT_SETUP_PAT", + "defaults": { + "dryRun": true, + "runLabels": true, + "runMilestones": true, + "runProjectCreation": false, + "runIssueGeneration": false, + "linkSubissues": true + } +} From 9eed0c39a439360a4979664a9cf53fb73687ee74 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:12:28 -0300 Subject: [PATCH 004/130] refactor: introduce project_setup package --- project_setup/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 project_setup/__init__.py diff --git a/project_setup/__init__.py b/project_setup/__init__.py new file mode 100644 index 0000000..0a50aa1 --- /dev/null +++ b/project_setup/__init__.py @@ -0,0 +1,4 @@ +"""Reusable GitHub project setup and repository automation tooling.""" + +__all__ = ["__version__"] +__version__ = "0.2.0" From fc71ef3b966d0a8171cf95434c159563ec3cb467 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:12:38 -0300 Subject: [PATCH 005/130] refactor: add project_setup module entrypoint --- project_setup/__main__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 project_setup/__main__.py diff --git a/project_setup/__main__.py b/project_setup/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/project_setup/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) From ddfe124dcf8509c306a9976ebc32a72d94a49ff2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:13:09 -0300 Subject: [PATCH 006/130] refactor: move auto labeling into project_setup --- project_setup/auto_label.py | 132 ++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 project_setup/auto_label.py diff --git a/project_setup/auto_label.py b/project_setup/auto_label.py new file mode 100644 index 0000000..9fa91e2 --- /dev/null +++ b/project_setup/auto_label.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +import re + +from .github import API_BASE, GitHubClient, GitHubRequestError + + +LABEL_PREFIXES = ("type:", "priority:", "test:") + + +def load_event(path: str) -> dict: + with open(path, "r", encoding="utf-8") as file: + return json.load(file) + + +def load_allowed_labels(path: str) -> set[str]: + with open(path, "r", encoding="utf-8") as file: + return {item["name"] for item in json.load(file)} + + +def label_names(item: dict) -> set[str]: + return {label["name"] for label in item.get("labels", [])} + + +def find_test_label(text: str) -> str | None: + patterns = ( + r"Test strategy\s*\n+\s*(automated|smoke|manual)\b", + r"Expected test type\s*\n+\s*(automated|smoke|manual)\b", + r"Test type:\s*(automated|smoke|manual)\b", + ) + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + return f"test:{match.group(1).lower()}" + return None + + +def find_priority_label(text: str) -> str | None: + match = re.search(r"Severity\s*\n+\s*(critical|high|medium|low)\b", text, re.IGNORECASE) + return f"priority:{match.group(1).lower()}" if match else None + + +def title_type_label(title: str) -> str | None: + if re.match(r"^US-\d+", title, re.IGNORECASE): + return "type:user-story" + if re.match(r"^T-\d+", title, re.IGNORECASE): + return "type:task" + if re.match(r"^BUG\b", title, re.IGNORECASE): + return "type:bug" + return None + + +def linked_issue_number(text: str) -> int | None: + match = re.search(r"\b(?:closes|fixes|resolves)\s+#(\d+)\b", text, re.IGNORECASE) + return int(match.group(1)) if match else None + + +def infer_issue_labels(issue: dict) -> set[str]: + current = label_names(issue) + body = issue.get("body") or "" + labels: set[str] = set() + type_label = next((label for label in current if label.startswith("type:")), None) + labels.add(type_label or title_type_label(issue.get("title", "")) or "") + if priority := find_priority_label(body): + labels.add(priority) + if test_label := find_test_label(body): + labels.add(test_label) + if not any(label.startswith("status:") for label in current): + labels.add("status:backlog") + return {label for label in labels if label} + + +def infer_pr_labels(repo: str, pull_request: dict, client: GitHubClient | None) -> set[str]: + body = pull_request.get("body") or "" + labels: set[str] = set() + linked_number = linked_issue_number(body) + if linked_number and client: + try: + issue = client.get_issue(repo, linked_number) + labels.update(name for name in label_names(issue) if name.startswith(LABEL_PREFIXES)) + except GitHubRequestError as exc: + print(f"warning: could not read linked issue #{linked_number}: {exc}") + if test_label := find_test_label(body): + labels.add(test_label) + if not any(label.startswith("type:") for label in labels): + prefix = pull_request.get("head", {}).get("ref", "").split("/", 1)[0].lower() + if prefix in {"fix", "hotfix"}: + labels.add("type:bug") + elif prefix in {"docs", "refactor", "test", "chore"}: + labels.add("type:repo") + return labels + + +def event_target(event: dict) -> tuple[str, dict, int]: + if "issue" in event and "pull_request" not in event["issue"]: + return "issue", event["issue"], int(event["issue"]["number"]) + if "pull_request" in event: + return "pull_request", event["pull_request"], int(event["pull_request"]["number"]) + raise RuntimeError("Unsupported event payload: expected issue or pull_request") + + +def apply_auto_labels( + repo: str, + event_path: str, + labels_file: str, + client: GitHubClient | None, + dry_run: bool = False, +) -> int: + event = load_event(event_path) + allowed = load_allowed_labels(labels_file) + target_type, item, number = event_target(event) + current = label_names(item) + inferred = infer_issue_labels(item) if target_type == "issue" else infer_pr_labels(repo, item, client) + labels = sorted(label for label in inferred if label in allowed and label not in current) + if not labels: + print(f"No labels to add for {target_type} #{number}") + return 0 + print(f"Labels to add to {target_type} #{number}: {', '.join(labels)}") + if dry_run: + return 0 + if not client: + print("Missing GitHub token") + return 1 + try: + client.request_json("POST", f"{API_BASE}/repos/{repo}/issues/{number}/labels", {"labels": labels}) + except GitHubRequestError as exc: + if exc.status == 403: + print(f"warning: token cannot add labels to {target_type} #{number}; skipping") + return 0 + raise + return 0 From e247af15ca5e481e1793d86d81921b1e3bf5c6dc Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:13:24 -0300 Subject: [PATCH 007/130] refactor: add reusable label synchronization --- project_setup/labels.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 project_setup/labels.py diff --git a/project_setup/labels.py b/project_setup/labels.py new file mode 100644 index 0000000..2133ec9 --- /dev/null +++ b/project_setup/labels.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +import urllib.parse + +from .github import API_BASE, GitHubClient, GitHubRequestError, split_repo + + +def load_labels(path: str) -> list[dict]: + with open(path, "r", encoding="utf-8") as file: + labels = json.load(file) + if not isinstance(labels, list): + raise ValueError("labels manifest must be a JSON list") + for label in labels: + if not label.get("name") or not label.get("color"): + raise ValueError("each label must define name and color") + return labels + + +def sync_labels(client: GitHubClient, repo: str, labels_file: str, dry_run: bool = False) -> None: + owner, name = split_repo(repo) + labels = load_labels(labels_file) + endpoint = f"{API_BASE}/repos/{owner}/{name}/labels" + + if dry_run: + print(f"[DRY-RUN] Would sync {len(labels)} labels to {repo}") + for label in labels: + print(f"- {label['name']}") + return + + for label in labels: + try: + client.request_json("POST", endpoint, label) + print(f"created: {label['name']}") + except GitHubRequestError as exc: + if exc.status != 422: + raise + encoded_name = urllib.parse.quote(label["name"], safe="") + client.request_json("PATCH", f"{endpoint}/{encoded_name}", label) + print(f"updated: {label['name']}") From 9fe7edff4c61602403758ebca31fb574828d3470 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:13:35 -0300 Subject: [PATCH 008/130] refactor: add reusable milestone synchronization --- project_setup/milestones.py | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 project_setup/milestones.py diff --git a/project_setup/milestones.py b/project_setup/milestones.py new file mode 100644 index 0000000..028cb88 --- /dev/null +++ b/project_setup/milestones.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json + +from .github import API_BASE, GitHubClient, split_repo + + +def load_milestones(path: str) -> list[dict]: + with open(path, "r", encoding="utf-8") as file: + milestones = json.load(file) + if not isinstance(milestones, list): + raise ValueError("milestones manifest must be a JSON list") + for milestone in milestones: + if not milestone.get("title"): + raise ValueError("each milestone must define a title") + return milestones + + +def sync_milestones(client: GitHubClient, repo: str, milestones_file: str, dry_run: bool = False) -> None: + owner, name = split_repo(repo) + milestones = load_milestones(milestones_file) + endpoint = f"{API_BASE}/repos/{owner}/{name}/milestones" + + if dry_run: + print(f"[DRY-RUN] Would sync {len(milestones)} milestones to {repo}") + for milestone in milestones: + print(f"- {milestone['title']} ({milestone.get('due_on', 'no due date')})") + return + + existing = client.request_json("GET", f"{endpoint}?state=all&per_page=100") + existing_by_title = {item["title"]: item for item in existing} + for milestone in milestones: + payload = { + "title": milestone["title"], + "description": milestone.get("description", ""), + } + if milestone.get("due_on"): + payload["due_on"] = milestone["due_on"] + current = existing_by_title.get(milestone["title"]) + if current: + client.request_json("PATCH", f"{endpoint}/{current['number']}", payload) + print(f"updated: {milestone['title']}") + else: + client.request_json("POST", endpoint, payload) + print(f"created: {milestone['title']}") From e7e6ad4db9ffe70fe24f24b61467dc209aa5b8eb Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:13:47 -0300 Subject: [PATCH 009/130] feat: centralize project setup execution --- project_setup/runner.py | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 project_setup/runner.py diff --git a/project_setup/runner.py b/project_setup/runner.py new file mode 100644 index 0000000..d111c62 --- /dev/null +++ b/project_setup/runner.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json + +from .github import GitHubClient +from .issues import generate_issues +from .labels import sync_labels +from .milestones import sync_milestones +from .project import create_project + + +def load_project_setup_config(path: str) -> dict: + with open(path, "r", encoding="utf-8") as file: + config = json.load(file) + required = ("labelsFile", "milestonesFile", "projectDefinitionFile", "backlogManifestFile") + missing = [key for key in required if key not in config] + if missing: + raise ValueError(f"project setup config is missing: {', '.join(missing)}") + return config + + +def run_project_setup( + client: GitHubClient, + repo: str, + config: dict, + *, + dry_run: bool, + run_labels: bool, + run_milestones: bool, + run_project_creation: bool, + run_issue_generation: bool, + link_subissues: bool, +) -> None: + if run_labels: + print("==> Sync labels") + sync_labels(client, repo, config["labelsFile"], dry_run=dry_run) + if run_milestones: + print("==> Sync milestones") + sync_milestones(client, repo, config["milestonesFile"], dry_run=dry_run) + if run_project_creation: + print("==> Create Project v2") + create_project(client, repo, config["projectDefinitionFile"], dry_run=dry_run) + if run_issue_generation: + print("==> Generate issues and tasks") + generate_issues( + None if dry_run else client, + repo, + config["backlogManifestFile"], + dry_run=dry_run, + link_subissues=link_subissues and not dry_run, + ) + print("Project setup finished.") From 434f046391b4aec137d6469f8bd0b299f9e5381c Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:14:16 -0300 Subject: [PATCH 010/130] feat: add project setup GitHub client --- project_setup/github.py | 144 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 project_setup/github.py diff --git a/project_setup/github.py b/project_setup/github.py new file mode 100644 index 0000000..42e4f8a --- /dev/null +++ b/project_setup/github.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from typing import Any +import urllib.error +import urllib.parse +import urllib.request + + +API_BASE = "https://api.github.com" +GRAPHQL_URL = f"{API_BASE}/graphql" +API_VERSION = "2022-11-28" +RETRYABLE_HTTP_STATUS = {429, 502, 503, 504} + + +class GitHubRequestError(RuntimeError): + def __init__(self, method: str, url: str, status: int, details: str): + super().__init__(f"GitHub API request failed ({method} {url}) status={status}: {details}") + self.method = method + self.url = url + self.status = status + self.details = details + + +def get_token() -> str | None: + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or os.environ.get("PROJECT_SETUP_PAT") + if token: + return token.strip() + gh = shutil.which("gh") + if not gh: + return None + try: + result = subprocess.run([gh, "auth", "token"], capture_output=True, text=True, timeout=10) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def split_repo(repo: str) -> tuple[str, str]: + if "/" not in repo: + raise ValueError("repository must use owner/name format") + owner, name = repo.split("/", 1) + if not owner or not name: + raise ValueError("repository must use owner/name format") + return owner, name + + +class GitHubClient: + def __init__(self, token: str): + self.token = token.strip() + + def _headers(self, accept: str = "application/vnd.github+json") -> dict[str, str]: + headers = { + "Accept": accept, + "X-GitHub-Api-Version": API_VERSION, + "Content-Type": "application/json", + "User-Agent": "github-project-setup", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + return headers + + def request_json(self, method: str, url: str, payload: Any = None, accept: str = "application/vnd.github+json") -> Any: + data = json.dumps(payload).encode("utf-8") if payload is not None else None + for attempt in range(1, 6): + request = urllib.request.Request(url, data=data, headers=self._headers(accept), method=method) + try: + with urllib.request.urlopen(request) as response: + body = response.read().decode("utf-8") + return json.loads(body) if body else {} + except urllib.error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace") + if exc.code in RETRYABLE_HTTP_STATUS and attempt < 5: + retry_after = exc.headers.get("Retry-After") + wait_seconds = int(retry_after) if retry_after and retry_after.isdigit() else attempt * 2 + print(f"warning: GitHub returned HTTP {exc.code}; retrying in {wait_seconds}s") + time.sleep(wait_seconds) + continue + raise GitHubRequestError(method, url, exc.code, details) from exc + except urllib.error.URLError as exc: + if attempt < 5: + wait_seconds = attempt * 2 + print(f"warning: GitHub request failed; retrying in {wait_seconds}s: {exc.reason}") + time.sleep(wait_seconds) + continue + raise + raise RuntimeError("GitHub request exhausted retries") + + def paginated(self, url: str) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + page = 1 + while True: + separator = "&" if "?" in url else "?" + batch = self.request_json("GET", f"{url}{separator}per_page=100&page={page}") + if not isinstance(batch, list): + raise RuntimeError(f"Expected a list from paginated GitHub endpoint: {url}") + items.extend(batch) + if len(batch) < 100: + return items + page += 1 + + def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + response = self.request_json("POST", GRAPHQL_URL, {"query": query, "variables": variables or {}}) + if response.get("errors"): + raise RuntimeError(f"GraphQL error: {json.dumps(response['errors'], ensure_ascii=False)}") + return response["data"] + + def get_issue(self, repo: str, number: int) -> dict[str, Any]: + return self.request_json("GET", f"{API_BASE}/repos/{repo}/issues/{number}") + + def create_issue(self, repo: str, title: str, body: str, labels: list[str]) -> dict[str, Any]: + return self.request_json( + "POST", + f"{API_BASE}/repos/{repo}/issues", + {"title": title, "body": body, "labels": labels}, + ) + + def update_issue(self, repo: str, number: int, payload: dict[str, Any]) -> dict[str, Any]: + return self.request_json("PATCH", f"{API_BASE}/repos/{repo}/issues/{number}", payload) + + def list_issue_comments(self, repo: str, number: int) -> list[dict[str, Any]]: + return self.paginated(f"{API_BASE}/repos/{repo}/issues/{number}/comments") + + def create_issue_comment(self, repo: str, number: int, body: str) -> dict[str, Any]: + return self.request_json("POST", f"{API_BASE}/repos/{repo}/issues/{number}/comments", {"body": body}) + + def update_issue_comment(self, repo: str, comment_id: int, body: str) -> dict[str, Any]: + return self.request_json("PATCH", f"{API_BASE}/repos/{repo}/issues/comments/{comment_id}", {"body": body}) + + def delete_issue_comment(self, repo: str, comment_id: int) -> dict[str, Any]: + return self.request_json("DELETE", f"{API_BASE}/repos/{repo}/issues/comments/{comment_id}") + + +def require_client() -> GitHubClient: + token = get_token() + if not token: + raise SystemExit("Missing GITHUB_TOKEN, GH_TOKEN, PROJECT_SETUP_PAT, or authenticated gh CLI") + return GitHubClient(token) From a9a6f1b6397a20eeb5ebb33c611ab1b756af4083 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:14:48 -0300 Subject: [PATCH 011/130] feat: add automatic repository discovery --- project_setup/discovery.py | 181 +++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 project_setup/discovery.py diff --git a/project_setup/discovery.py b/project_setup/discovery.py new file mode 100644 index 0000000..f326be7 --- /dev/null +++ b/project_setup/discovery.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import os +import shutil +import sys + +from .github import GitHubClient, get_token, require_client +from .runner import load_project_setup_config, run_project_setup + + +SUPPORTED_PROJECT_TYPES = ("python", "node", "go", "java", "rust", "dotnet", "generic") +PROJECT_MARKERS = { + "python": ("pyproject.toml", "requirements.txt", "setup.py", "Pipfile"), + "node": ("package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock"), + "go": ("go.mod",), + "java": ("pom.xml", "build.gradle", "build.gradle.kts"), + "rust": ("Cargo.toml",), + "dotnet": ("*.csproj", "*.sln"), +} + + +@dataclass(frozen=True) +class AuthStatus: + configured: bool + source: str + detail: str + + +@dataclass(frozen=True) +class ProjectMatch: + project_type: str + markers: tuple[str, ...] + + +def detect_auth_status() -> AuthStatus: + token = get_token() + if token: + source = "environment" if any(os.getenv(name) for name in ("GITHUB_TOKEN", "GH_TOKEN", "PROJECT_SETUP_PAT")) else "gh" + return AuthStatus(True, source, "A GitHub token is available") + if shutil.which("gh"): + return AuthStatus(False, "gh", "gh CLI is installed but no authenticated token was returned") + return AuthStatus(False, "missing", "No environment token and gh CLI was not found") + + +def _collect_markers(root: Path, patterns: tuple[str, ...]) -> tuple[str, ...]: + markers: list[str] = [] + for pattern in patterns: + if "*" in pattern: + markers.extend(str(path.relative_to(root)) for path in sorted(root.glob(pattern)) if path.is_file()) + elif (root / pattern).is_file(): + markers.append(pattern) + return tuple(markers) + + +def detect_project_matches(root: str | os.PathLike[str]) -> list[ProjectMatch]: + root_path = Path(root) + if not root_path.exists(): + raise FileNotFoundError(f"Project root does not exist: {root_path}") + matches = [ + ProjectMatch(project_type, markers) + for project_type in SUPPORTED_PROJECT_TYPES[:-1] + if (markers := _collect_markers(root_path, PROJECT_MARKERS[project_type])) + ] + return matches or [ProjectMatch("generic", tuple())] + + +def resolve_project_match(root: str | os.PathLike[str], override: str | None = None) -> ProjectMatch: + if override: + if override not in SUPPORTED_PROJECT_TYPES: + raise ValueError(f"Unsupported project type: {override}") + match = next((item for item in detect_project_matches(root) if item.project_type == override), None) + return match or ProjectMatch(override, tuple()) + matches = detect_project_matches(root) + if len(matches) == 1 or not sys.stdin.isatty(): + return matches[0] + print("Multiple project types detected:") + for index, match in enumerate(matches, start=1): + print(f" {index}. {match.project_type} ({', '.join(match.markers)})") + print(" 0. generic") + while True: + choice = input("Choose project type [1]: ").strip() + if choice in {"", "1"}: + return matches[0] + if choice == "0": + return ProjectMatch("generic", tuple()) + if choice.isdigit() and 1 <= int(choice) <= len(matches): + return matches[int(choice) - 1] + print("Invalid choice, try again.") + + +def _prompt_bool(question: str, default: bool) -> bool: + suffix = "[Y/n]" if default else "[y/N]" + while True: + answer = input(f"{question} {suffix} ").strip().lower() + if not answer: + return default + if answer in {"y", "yes", "true", "1"}: + return True + if answer in {"n", "no", "false", "0"}: + return False + print("Please answer yes or no.") + + +def build_apply_command( + repo: str, + config_path: str, + dry_run: bool, + run_labels: bool, + run_milestones: bool, + run_project_creation: bool, + run_issue_generation: bool, + link_subissues: bool, +) -> str: + parts = ["python -m project_setup apply", f"--repo {repo}", f"--config {config_path}"] + parts.append("--dry-run" if dry_run else "--no-dry-run") + parts.append("--run-labels" if run_labels else "--skip-labels") + parts.append("--run-milestones" if run_milestones else "--skip-milestones") + parts.append("--run-project-creation" if run_project_creation else "--skip-project-creation") + parts.append("--run-issue-generation" if run_issue_generation else "--skip-issue-generation") + parts.append("--link-subissues" if link_subissues else "--no-link-subissues") + return " ".join(parts) + + +def run_discovery(args) -> int: + config = load_project_setup_config(args.config) + repo = args.repo or os.getenv("GITHUB_REPOSITORY") + if not repo: + print("Missing --repo and GITHUB_REPOSITORY") + return 1 + + auth = detect_auth_status() + print("==> GitHub auth") + print(f"Configured: {'yes' if auth.configured else 'no'} ({auth.source})") + if not auth.configured: + print(auth.detail) + print(f"Expected workflow secret: {config.get('secretName', 'PROJECT_SETUP_PAT')}") + return 1 + + print("==> Project detection") + try: + project = resolve_project_match(args.root, args.project_type) + except (FileNotFoundError, ValueError) as exc: + print(str(exc)) + return 1 + print(f"Detected project type: {project.project_type}") + if project.markers: + print(f"Markers: {', '.join(project.markers)}") + + defaults = config.get("defaults", {}) + values = { + "dry_run": defaults.get("dryRun", True), + "run_labels": defaults.get("runLabels", True), + "run_milestones": defaults.get("runMilestones", True), + "run_project_creation": defaults.get("runProjectCreation", False), + "run_issue_generation": defaults.get("runIssueGeneration", False), + "link_subissues": defaults.get("linkSubissues", False), + } + interactive = sys.stdin.isatty() and not args.auto + if interactive: + values["dry_run"] = _prompt_bool("Run in dry-run mode?", values["dry_run"]) + values["run_labels"] = _prompt_bool("Sync labels?", values["run_labels"]) + values["run_milestones"] = _prompt_bool("Sync milestones?", values["run_milestones"]) + values["run_project_creation"] = _prompt_bool("Create Project v2?", values["run_project_creation"]) + values["run_issue_generation"] = _prompt_bool("Generate issues and tasks?", values["run_issue_generation"]) + values["link_subissues"] = _prompt_bool("Link generated tasks as sub-issues?", values["link_subissues"]) + else: + print("Using configuration defaults (non-interactive).") + + print("==> Recommended command") + print(build_apply_command(repo, args.config, **values)) + if not args.apply: + return 0 + if not args.yes: + if not sys.stdin.isatty() or not _prompt_bool("Run the selected setup now?", False): + print("Confirmation required; no changes were applied.") + return 1 + client = GitHubClient("") if values["dry_run"] else require_client() + run_project_setup(client, repo, config, **values) + return 0 From 8358d21427247995f487d1bdcd796afd4b3426cb Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:15:11 -0300 Subject: [PATCH 012/130] feat: add self-contained repository installer --- project_setup/installer.py | 96 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 project_setup/installer.py diff --git a/project_setup/installer.py b/project_setup/installer.py new file mode 100644 index 0000000..060e8bc --- /dev/null +++ b/project_setup/installer.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import shutil + + +CORE_TEMPLATE_FILES = ( + ".github/ISSUE_TEMPLATE/bug-report.yml", + ".github/ISSUE_TEMPLATE/task-sub-issue.yml", + ".github/ISSUE_TEMPLATE/user-story.yml", + ".github/ISSUE_TEMPLATE/config.yml", + ".github/pull_request_template.md", + ".github/workflows/auto-label.yml", + ".github/workflows/main-source-branch.yml", + ".github/workflows/pr-metadata.yml", + ".github/workflows/project-setup.yml", + "config/project/labels.json", + "config/project/milestones.json", + "config/project/project-definition.json", + "config/stories/backlog-manifest.json", + "project_setup.json", + "scripts/validation/repo_quality.py", + "scripts/validation/validate_pr_body.py", +) + +PROFILE_FILES = { + "core": (), + "godot": (("templates/profiles/godot/.github/workflows/godot-smoke.yml", ".github/workflows/godot-smoke.yml"),), +} + + +@dataclass(frozen=True) +class InstallResult: + copied: tuple[str, ...] + skipped: tuple[str, ...] + + +def source_root_from_package() -> Path: + return Path(__file__).resolve().parents[1] + + +def package_files(source_root: Path) -> tuple[tuple[str, str], ...]: + package_root = source_root / "project_setup" + return tuple( + (relative, relative) + for relative in ( + str(path.relative_to(source_root)).replace("\\", "/") + for path in sorted(package_root.glob("*.py")) + ) + ) + + +def template_files(source_root: Path, profile: str) -> tuple[tuple[str, str], ...]: + if profile not in PROFILE_FILES: + raise ValueError(f"Unknown profile '{profile}'. Available profiles: {', '.join(PROFILE_FILES)}") + core = tuple((path, path) for path in CORE_TEMPLATE_FILES) + return (*core, *package_files(source_root), *PROFILE_FILES[profile]) + + +def install_repository( + target: str | Path, + *, + source: str | Path | None = None, + profile: str = "core", + force: bool = False, + dry_run: bool = False, +) -> InstallResult: + source_root = Path(source).resolve() if source else source_root_from_package() + target_root = Path(target).resolve() + target_root.mkdir(parents=True, exist_ok=True) + if not target_root.is_dir(): + raise ValueError(f"Target is not a directory: {target_root}") + + copied: list[str] = [] + skipped: list[str] = [] + for source_relative, destination_relative in template_files(source_root, profile): + source_path = source_root / source_relative + destination = target_root / destination_relative + if not source_path.is_file(): + raise FileNotFoundError(f"Project setup template is missing: {source_path}") + if destination.exists() and not force: + skipped.append(destination_relative) + print(f"skipped existing: {destination_relative}") + continue + if dry_run: + copied.append(destination_relative) + print(f"[DRY-RUN] Would copy: {destination_relative}") + continue + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, destination) + copied.append(destination_relative) + print(f"copied: {destination_relative}") + + print(f"Project setup installation finished: copied={len(copied)}, skipped={len(skipped)}") + return InstallResult(tuple(copied), tuple(skipped)) From b3ad22ff492154b274b04c85f62971dcdf8f3272 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:15:45 -0300 Subject: [PATCH 013/130] refactor: make issue generation API based --- project_setup/issues.py | 112 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 project_setup/issues.py diff --git a/project_setup/issues.py b/project_setup/issues.py new file mode 100644 index 0000000..e9df7ab --- /dev/null +++ b/project_setup/issues.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json + +from .github import GitHubClient + + +def load_backlog(path: str) -> dict: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + if "phases" not in data or not isinstance(data["phases"], list): + raise ValueError("backlog manifest must contain a phases list") + return data + + +def task_title(task: str | dict) -> str: + if isinstance(task, str): + return task + title = task.get("title") + if not title: + raise ValueError("task objects must define title") + return str(title) + + +def issue_node_id(client: GitHubClient, repo: str, number: int) -> str: + owner, name = repo.split("/", 1) + query = """ + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { issue(number:$number) { id } } + } + """ + data = client.graphql(query, {"owner": owner, "repo": name, "number": number}) + issue = data["repository"]["issue"] + if not issue: + raise RuntimeError(f"Issue #{number} was not found in {repo}") + return issue["id"] + + +def add_sub_issue(client: GitHubClient, repo: str, parent_number: int, child_number: int) -> None: + mutation = """ + mutation($parent:ID!, $child:ID!) { + addSubIssue(input:{issueId:$parent, subIssueId:$child}) { clientMutationId } + } + """ + client.graphql( + mutation, + { + "parent": issue_node_id(client, repo, parent_number), + "child": issue_node_id(client, repo, child_number), + }, + ) + + +def generate_issues( + client: GitHubClient | None, + repo: str, + manifest: str, + dry_run: bool = False, + link_subissues: bool = False, +) -> None: + data = load_backlog(manifest) + default_labels = data.get("defaultIssueLabels", []) + for phase in data["phases"]: + milestone = phase.get("milestone", "") + for story in phase.get("stories", []): + story_labels = list(dict.fromkeys([*story.get("labels", []), *default_labels])) + story_body = "\n\n".join( + [ + story.get("body", "## Context\n- Describe the expected outcome."), + f"## Acceptance criteria\n{story.get('acceptanceCriteria', '- Define acceptance criteria.')}", + f"## Test strategy\n{story.get('testStrategy', '- Define the test strategy.')}", + f"## Definition of Done\n{story.get('dod', '- Define the completion criteria.')}", + f"- Milestone: {milestone}\n- Item type: user-story", + ] + ) + if dry_run: + print(f"[DRY-RUN] Story: {story['title']} labels={story_labels}") + story_number = None + else: + if not client: + raise RuntimeError("A GitHub client is required outside dry-run mode") + created_story = client.create_issue(repo, story["title"], story_body, story_labels) + story_number = int(created_story["number"]) + print(f"Created story #{story_number}: {story['title']}") + + for task in story.get("tasks", []): + title = task_title(task) + task_labels = ["type:task", "status:backlog"] + parent_reference = f"{story.get('storyId', 'US-XX')}" + if story_number: + parent_reference += f" (#{story_number})" + task_body = "\n\n".join( + [ + f"Parent story: {parent_reference}", + "## Technical scope\n- Define the implementation scope.", + "## Completion criteria\n- Define objective completion criteria.", + "## Test strategy\n- Define automated, smoke, or manual validation.", + "## Expected evidence\n- Attach relevant evidence.", + "## Definition of Done\n- Scope implemented and validated.", + "- Item type: task/sub-issue", + ] + ) + if dry_run: + print(f"[DRY-RUN] Task: {title} labels={task_labels}") + continue + assert client is not None and story_number is not None + created_task = client.create_issue(repo, title, task_body, task_labels) + task_number = int(created_task["number"]) + print(f" Created task #{task_number}: {title}") + if link_subissues: + add_sub_issue(client, repo, story_number, task_number) + print(f" Linked #{task_number} as sub-issue of #{story_number}") From 47e86296957f35646581b364a69213577f2e62a0 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:16:08 -0300 Subject: [PATCH 014/130] refactor: move issue milestone sync into project_setup --- project_setup/issue_milestones.py | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 project_setup/issue_milestones.py diff --git a/project_setup/issue_milestones.py b/project_setup/issue_milestones.py new file mode 100644 index 0000000..ff91b73 --- /dev/null +++ b/project_setup/issue_milestones.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import re + +from .github import API_BASE, GitHubClient, split_repo + + +def milestone_from_body(body: str) -> str | None: + match = re.search(r"-\s*Milestone:\s*([A-Za-z0-9_.-]+)", body or "") + return match.group(1) if match else None + + +def parent_issue_number_from_body(body: str) -> int | None: + match = re.search(r"Parent story:.*\(#(\d+)\)", body or "") + return int(match.group(1)) if match else None + + +def sync_issue_milestones( + client: GitHubClient, + repo: str, + clear_not_planned: bool = False, + dry_run: bool = False, +) -> None: + owner, name = split_repo(repo) + base = f"{API_BASE}/repos/{owner}/{name}" + milestones = client.paginated(f"{base}/milestones?state=all") + milestones_by_title = {item["title"]: item for item in milestones} + issues = [ + item + for item in client.paginated(f"{base}/issues?state=all&sort=created&direction=asc") + if "pull_request" not in item + ] + explicit = { + issue["number"]: title + for issue in issues + if (title := milestone_from_body(issue.get("body") or "")) + } + updated = cleared = unchanged = 0 + unmapped: list[tuple[int, str]] = [] + + for issue in issues: + number = int(issue["number"]) + current = issue.get("milestone") + current_title = current["title"] if current else None + if clear_not_planned and issue.get("state") == "closed" and issue.get("state_reason") == "not_planned": + if current_title: + if dry_run: + print(f"[DRY-RUN] Would clear milestone from issue #{number}") + else: + client.update_issue(repo, number, {"milestone": None}) + cleared += 1 + else: + unchanged += 1 + continue + + target = explicit.get(number) + if not target and (parent := parent_issue_number_from_body(issue.get("body") or "")): + target = explicit.get(parent) + if not target: + unmapped.append((number, issue["title"])) + continue + milestone = milestones_by_title.get(target) + if not milestone: + raise RuntimeError(f"Milestone '{target}' referenced by issue #{number} does not exist") + if current_title == target: + unchanged += 1 + continue + if dry_run: + print(f"[DRY-RUN] Would set issue #{number}: {current_title or 'none'} -> {target}") + else: + client.update_issue(repo, number, {"milestone": milestone["number"]}) + updated += 1 + + print(f"issues_checked={len(issues)}") + print(f"updated={updated}") + print(f"cleared_not_planned={cleared}") + print(f"already_correct={unchanged}") + print(f"unmapped={len(unmapped)}") + for number, title in unmapped: + print(f"unmapped #{number}: {title}") From 090197b0e18afa84fb5972042a4cc9d8936cbd72 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:16:30 -0300 Subject: [PATCH 015/130] feat: add generic pull request validation --- project_setup/pr_validation.py | 119 +++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 project_setup/pr_validation.py diff --git a/project_setup/pr_validation.py b/project_setup/pr_validation.py new file mode 100644 index 0000000..981ee8e --- /dev/null +++ b/project_setup/pr_validation.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +import unicodedata + +from .github import GitHubClient + + +VALIDATION_MARKER = "" +BRANCH_PATTERN = re.compile(r"^(feat|fix|docs|refactor|test|hotfix|phase|task|chore|ci|release)/[a-z0-9._/-]+$") +REQUIRED_SECTIONS = ( + ("linked issue", "Linked Issue"), + ("milestone", "Milestone"), + ("summary", "Summary"), + ("how to test", "How to test"), + ("known risks", "Known risks"), + ("dod checklist", "DoD checklist"), +) +PLACEHOLDER = re.compile(r"(<[^>]+>|\b(todo|tbd|placeholder|describe|fill in|replace)\b)", re.IGNORECASE) + + +@dataclass(frozen=True) +class ValidationFinding: + section: str + problem: str + fix: str + + +def normalize_header(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + normalized = "".join(character for character in normalized if not unicodedata.combining(character)) + return " ".join(normalized.strip().lower().split()) + + +def sections_from_body(body: str) -> dict[str, list[str]]: + sections: dict[str, list[str]] = {} + current: str | None = None + for line in (body or "").splitlines(): + match = re.match(r"^##\s+(.+?)\s*$", line) + if match: + current = normalize_header(match.group(1)) + sections.setdefault(current, []) + elif current: + sections[current].append(line) + return sections + + +def meaningful(lines: list[str]) -> bool: + for line in lines: + stripped = line.strip().lstrip("-* ").strip() + if stripped and not PLACEHOLDER.search(stripped): + return True + return False + + +def validate_branch(branch: str | None, base_branch: str | None = None) -> list[ValidationFinding]: + normalized = (branch or "").strip() + if normalized == "develop" and (base_branch or "").strip() == "main": + return [] + if BRANCH_PATTERN.fullmatch(normalized.casefold()): + return [] + return [ + ValidationFinding( + "Branch name", + f"Invalid branch name: `{normalized or '(missing)'}`.", + "Use a supported prefix such as `feat/`, `fix/`, `docs/`, `task/`, `chore/`, `hotfix/`, or `release/`.", + ) + ] + + +def validate_body(body: str | None) -> list[ValidationFinding]: + if not (body or "").strip(): + return [ValidationFinding("PR body", "The pull request body is empty.", "Fill the repository pull request template.")] + sections = sections_from_body(body or "") + findings: list[ValidationFinding] = [] + for key, label in REQUIRED_SECTIONS: + lines = sections.get(key) + if lines is None: + findings.append(ValidationFinding(label, "Required section is missing.", f"Add `## {label}`.")) + elif not meaningful(lines): + findings.append(ValidationFinding(label, "Section is empty or contains only placeholders.", "Replace placeholders with concrete information.")) + linked = "\n".join(sections.get("linked issue", [])) + if linked and not re.search(r"\b(closes|fixes|resolves)\s+#\d+\b", linked, re.IGNORECASE): + findings.append(ValidationFinding("Linked Issue", "No closing issue reference was found.", "Use `Closes #123`, `Fixes #123`, or `Resolves #123`.")) + return findings + + +def validate_pull_request(branch: str | None, body: str | None, base_branch: str | None = None) -> list[ValidationFinding]: + return [*validate_branch(branch, base_branch), *validate_body(body)] + + +def render_comment(findings: list[ValidationFinding]) -> str: + lines = [VALIDATION_MARKER, "## Project setup PR validation", ""] + if not findings: + lines.append("All configured pull request checks passed.") + return "\n".join(lines) + lines.append("The pull request still needs attention:") + for finding in findings: + lines.extend(["", f"### {finding.section}", f"- Problem: {finding.problem}", f"- Fix: {finding.fix}"]) + return "\n".join(lines) + + +def upsert_validation_comment(client: GitHubClient, repo: str, pr_number: int, findings: list[ValidationFinding]) -> str | None: + existing = next( + (comment for comment in client.list_issue_comments(repo, pr_number) if VALIDATION_MARKER in (comment.get("body") or "")), + None, + ) + if not findings: + if existing: + client.delete_issue_comment(repo, int(existing["id"])) + return "deleted" + return None + body = render_comment(findings) + if existing: + client.update_issue_comment(repo, int(existing["id"]), body) + return "updated" + client.create_issue_comment(repo, pr_number, body) + return "created" From 59c8d400b80165fe1e41fc23735789440662ab5d Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:17:19 -0300 Subject: [PATCH 016/130] refactor: add generic Project v2 setup --- project_setup/project.py | 291 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 project_setup/project.py diff --git a/project_setup/project.py b/project_setup/project.py new file mode 100644 index 0000000..585a0bd --- /dev/null +++ b/project_setup/project.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import json +import re + +from .github import API_BASE, GitHubClient, split_repo + + +def load_project_definition(path: str) -> dict: + with open(path, "r", encoding="utf-8") as file: + definition = json.load(file) + if not definition.get("name"): + raise ValueError("project definition must contain name") + return definition + + +def owner_node(client: GitHubClient, owner: str) -> str: + user_query = "query($login:String!){user(login:$login){id}}" + user = client.graphql(user_query, {"login": owner}).get("user") + if user and user.get("id"): + return user["id"] + org_query = "query($login:String!){organization(login:$login){id}}" + organization = client.graphql(org_query, {"login": owner}).get("organization") + if organization and organization.get("id"): + return organization["id"] + raise RuntimeError(f"Owner not found: {owner}") + + +def create_project(client: GitHubClient, repo: str, definition_file: str, dry_run: bool = False) -> None: + definition = load_project_definition(definition_file) + if dry_run: + print(f"[DRY-RUN] Would create Project v2: {definition['name']}") + for field in definition.get("fields", []): + print(f"- field: {field['name']} ({field['type']})") + return + mutation = """ + mutation($owner:ID!, $title:String!) { + createProjectV2(input:{ownerId:$owner,title:$title}) { projectV2 { id number title url } } + } + """ + project = client.graphql( + mutation, + {"owner": owner_node(client, split_repo(repo)[0]), "title": definition["name"]}, + )["createProjectV2"]["projectV2"] + print(json.dumps(project, ensure_ascii=False)) + + +def find_project(client: GitHubClient, owner: str, project_number: int) -> dict: + query = """ + query($login:String!, $number:Int!) { + user(login:$login) { projectV2(number:$number) { id title url } } + organization(login:$login) { projectV2(number:$number) { id title url } } + } + """ + data = client.graphql(query, {"login": owner, "number": project_number}) + for owner_type in ("user", "organization"): + node = data.get(owner_type) + if node and node.get("projectV2"): + return node["projectV2"] + raise RuntimeError(f"Project v2 #{project_number} not found for '{owner}'") + + +def list_project_fields(client: GitHubClient, project_id: str) -> list[dict]: + query = """ + query($project:ID!, $cursor:String) { + node(id:$project) { + ... on ProjectV2 { + fields(first:100, after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { id name dataType options { id name } } + } + } + } + } + } + """ + fields: list[dict] = [] + cursor = None + while True: + page = client.graphql(query, {"project": project_id, "cursor": cursor})["node"]["fields"] + fields.extend(field for field in page["nodes"] if field) + if not page["pageInfo"]["hasNextPage"]: + return fields + cursor = page["pageInfo"]["endCursor"] + + +def create_field(client: GitHubClient, project_id: str, field: dict) -> None: + field_type = field.get("type") + if field_type == "text": + mutation = """ + mutation($project:ID!, $name:String!) { + createProjectV2Field(input:{projectId:$project,name:$name,dataType:TEXT}) { + projectV2Field { ... on ProjectV2Field { id } } + } + } + """ + client.graphql(mutation, {"project": project_id, "name": field["name"]}) + return + if field_type == "single_select": + mutation = """ + mutation($project:ID!, $name:String!, $options:[ProjectV2SingleSelectFieldOptionInput!]!) { + createProjectV2Field(input:{projectId:$project,name:$name,dataType:SINGLE_SELECT,singleSelectOptions:$options}) { + projectV2Field { ... on ProjectV2SingleSelectField { id } } + } + } + """ + options = [ + {"name": option, "color": "GRAY", "description": ""} + for option in field.get("options", []) + ] + client.graphql(mutation, {"project": project_id, "name": field["name"], "options": options}) + return + raise ValueError(f"Unsupported project field type: {field_type}") + + +def ensure_fields(client: GitHubClient, project_id: str, definition: dict, dry_run: bool = False) -> dict[str, dict]: + existing = {field["name"]: field for field in list_project_fields(client, project_id) if field.get("name")} + for field in definition.get("fields", []): + if field["name"] in existing: + continue + if dry_run: + print(f"[DRY-RUN] Would create field: {field['name']} ({field['type']})") + else: + create_field(client, project_id, field) + print(f"created field: {field['name']}") + if dry_run: + return existing + return {field["name"]: field for field in list_project_fields(client, project_id) if field.get("name")} + + +def list_repo_issues(client: GitHubClient, repo: str, state: str = "open") -> list[dict]: + issues = client.paginated(f"{API_BASE}/repos/{repo}/issues?state={state}&sort=created&direction=asc") + return [issue for issue in issues if "pull_request" not in issue] + + +def issue_node_id(client: GitHubClient, repo: str, number: int) -> str: + owner, name = split_repo(repo) + query = """ + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner,name:$repo) { issue(number:$number) { id } } + } + """ + issue = client.graphql(query, {"owner": owner, "repo": name, "number": number})["repository"]["issue"] + if not issue: + raise RuntimeError(f"Issue #{number} not found in {repo}") + return issue["id"] + + +def list_project_items(client: GitHubClient, project_id: str) -> dict[str, str]: + query = """ + query($project:ID!, $cursor:String) { + node(id:$project) { + ... on ProjectV2 { + items(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { id content { __typename ... on Issue { id } } } + } + } + } + } + """ + result: dict[str, str] = {} + cursor = None + while True: + page = client.graphql(query, {"project": project_id, "cursor": cursor})["node"]["items"] + for item in page["nodes"]: + content = item.get("content") + if content and content.get("__typename") == "Issue": + result[content["id"]] = item["id"] + if not page["pageInfo"]["hasNextPage"]: + return result + cursor = page["pageInfo"]["endCursor"] + + +def add_issue_to_project(client: GitHubClient, project_id: str, issue_id: str) -> str: + mutation = """ + mutation($project:ID!, $content:ID!) { + addProjectV2ItemById(input:{projectId:$project,contentId:$content}) { item { id } } + } + """ + return client.graphql(mutation, {"project": project_id, "content": issue_id})["addProjectV2ItemById"]["item"]["id"] + + +def update_single_select(client: GitHubClient, project_id: str, item_id: str, field_id: str, option_id: str) -> None: + mutation = """ + mutation($project:ID!, $item:ID!, $field:ID!, $option:String!) { + updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{singleSelectOptionId:$option}}) { + projectV2Item { id } + } + } + """ + client.graphql(mutation, {"project": project_id, "item": item_id, "field": field_id, "option": option_id}) + + +def update_text(client: GitHubClient, project_id: str, item_id: str, field_id: str, value: str) -> None: + mutation = """ + mutation($project:ID!, $item:ID!, $field:ID!, $value:String!) { + updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{text:$value}}) { + projectV2Item { id } + } + } + """ + client.graphql(mutation, {"project": project_id, "item": item_id, "field": field_id, "value": value}) + + +def label_value(labels: list, prefix: str) -> str | None: + for label in labels: + name = label["name"] if isinstance(label, dict) else str(label) + if name.startswith(prefix): + return name.split(":", 1)[1] + return None + + +def milestone_from_issue(issue: dict) -> str | None: + milestone = issue.get("milestone") + if milestone and milestone.get("title"): + return milestone["title"] + match = re.search(r"-\s*Milestone:\s*([A-Za-z0-9_.-]+)", issue.get("body") or "") + return match.group(1) if match else None + + +def option_id(field: dict, desired: str) -> str | None: + normalized = re.sub(r"[^a-z0-9]+", "", desired.lower()) + for option in field.get("options", []): + if re.sub(r"[^a-z0-9]+", "", option["name"].lower()) == normalized: + return option["id"] + return None + + +def sync_issue_fields( + client: GitHubClient, + project_id: str, + item_id: str, + issue: dict, + fields: dict[str, dict], + definition: dict, + dry_run: bool = False, +) -> None: + milestone = milestone_from_issue(issue) + values = { + "Phase": definition.get("phaseMilestoneMap", {}).get(milestone), + "Item Type": label_value(issue.get("labels", []), "type:"), + "Priority": label_value(issue.get("labels", []), "priority:"), + "Status": label_value(issue.get("labels", []), "status:"), + "Test Type": label_value(issue.get("labels", []), "test:"), + "Milestone": milestone, + } + for field_name, value in values.items(): + field = fields.get(field_name) + if not field or not value: + continue + if dry_run: + print(f"[DRY-RUN] Would set {field_name}={value} on issue #{issue['number']}") + elif field.get("dataType") == "SINGLE_SELECT": + if selected := option_id(field, value): + update_single_select(client, project_id, item_id, field["id"], selected) + else: + print(f"warning: option '{value}' not found for field '{field_name}'") + elif field.get("dataType") == "TEXT": + update_text(client, project_id, item_id, field["id"], value) + + +def sync_project( + client: GitHubClient, + repo: str, + definition_file: str, + project_number: int, + owner: str | None = None, + issue_state: str = "open", + dry_run: bool = False, +) -> None: + definition = load_project_definition(definition_file) + project = find_project(client, owner or split_repo(repo)[0], project_number) + print(f"Project found: {project['title']} ({project['url']})") + fields = ensure_fields(client, project["id"], definition, dry_run=dry_run) + current_items = list_project_items(client, project["id"]) + for issue in list_repo_issues(client, repo, issue_state): + node_id = issue.get("node_id") or issue_node_id(client, repo, int(issue["number"])) + item_id = current_items.get(node_id) + if not item_id: + if dry_run: + print(f"[DRY-RUN] Would add issue #{issue['number']} to project") + item_id = f"dry-run-{issue['number']}" + else: + item_id = add_issue_to_project(client, project["id"], node_id) + current_items[node_id] = item_id + print(f"Added issue #{issue['number']} to project") + sync_issue_fields(client, project["id"], item_id, issue, fields, definition, dry_run=dry_run) From a70a2635e983f29998a9c8886a769f4fd209bc41 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:18:01 -0300 Subject: [PATCH 017/130] feat: add project_setup CLI and discovery --- project_setup/cli.py | 271 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 project_setup/cli.py diff --git a/project_setup/cli.py b/project_setup/cli.py new file mode 100644 index 0000000..d4911f7 --- /dev/null +++ b/project_setup/cli.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from .auto_label import apply_auto_labels +from .discovery import SUPPORTED_PROJECT_TYPES, run_discovery +from .github import GitHubClient, get_token, require_client +from .installer import PROFILE_FILES, install_repository +from .issue_milestones import sync_issue_milestones +from .issues import generate_issues +from .labels import sync_labels +from .milestones import sync_milestones +from .project import create_project, sync_project +from .pr_validation import upsert_validation_comment, validate_pull_request +from .runner import load_project_setup_config, run_project_setup + + +def repo_arg(value: str | None) -> str: + repository = value or os.getenv("GITHUB_REPOSITORY") + if not repository: + raise SystemExit("Missing --repo and GITHUB_REPOSITORY") + return repository + + +def optional_client() -> GitHubClient | None: + return GitHubClient(token) if (token := get_token()) else None + + +def cmd_init(args: argparse.Namespace) -> int: + install_repository( + args.target, + source=args.source, + profile=args.profile, + force=args.force, + dry_run=args.dry_run, + ) + return 0 + + +def cmd_doctor(args: argparse.Namespace) -> int: + config_path = Path(args.config) + print("python_module=project_setup") + print(f"config={config_path.resolve()}") + print(f"config_exists={config_path.is_file()}") + print(f"github_token={'configured' if get_token() else 'missing'}") + if config_path.is_file(): + try: + config = load_project_setup_config(str(config_path)) + except (OSError, ValueError) as exc: + print(f"config_error={exc}") + return 1 + for key in ("labelsFile", "milestonesFile", "projectDefinitionFile", "backlogManifestFile"): + path = Path(config[key]) + print(f"{key}={path} exists={path.is_file()}") + return 0 + + +def cmd_labels_sync(args: argparse.Namespace) -> int: + sync_labels(GitHubClient("") if args.dry_run else require_client(), repo_arg(args.repo), args.file, args.dry_run) + return 0 + + +def cmd_milestones_sync(args: argparse.Namespace) -> int: + sync_milestones(GitHubClient("") if args.dry_run else require_client(), repo_arg(args.repo), args.file, args.dry_run) + return 0 + + +def cmd_issues_generate(args: argparse.Namespace) -> int: + generate_issues( + None if args.dry_run else require_client(), + repo_arg(args.repo), + args.file, + args.dry_run, + args.link_subissues, + ) + return 0 + + +def cmd_project_create(args: argparse.Namespace) -> int: + create_project(GitHubClient("") if args.dry_run else require_client(), repo_arg(args.repo), args.file, args.dry_run) + return 0 + + +def cmd_project_sync(args: argparse.Namespace) -> int: + sync_project( + require_client(), + repo_arg(args.repo), + args.file, + args.project_number, + owner=args.owner, + issue_state=args.issue_state, + dry_run=args.dry_run, + ) + return 0 + + +def cmd_issue_milestones_sync(args: argparse.Namespace) -> int: + sync_issue_milestones(require_client(), repo_arg(args.repo), args.clear_not_planned, args.dry_run) + return 0 + + +def cmd_auto_label_apply(args: argparse.Namespace) -> int: + event_path = args.event_path or os.getenv("GITHUB_EVENT_PATH") + if not event_path: + raise SystemExit("Missing --event-path and GITHUB_EVENT_PATH") + return apply_auto_labels(repo_arg(args.repo), event_path, args.labels_file, optional_client(), args.dry_run) + + +def cmd_validate_pr(args: argparse.Namespace) -> int: + body = args.body + if args.body_file: + body = Path(args.body_file).read_text(encoding="utf-8") + if body is None: + body = os.getenv("PR_BODY", "") + findings = validate_pull_request(args.branch, body, args.base_branch) + for finding in findings: + print(f"{finding.section}: {finding.problem}") + print(f" Fix: {finding.fix}") + if args.comment: + if not args.repo or not args.pr_number: + raise SystemExit("--comment requires --repo and --pr-number") + upsert_validation_comment(require_client(), args.repo, args.pr_number, findings) + return 1 if findings else 0 + + +def cmd_apply(args: argparse.Namespace) -> int: + config = load_project_setup_config(args.config) + defaults = config.get("defaults", {}) + values = { + "dry_run": args.dry_run if args.dry_run is not None else defaults.get("dryRun", True), + "run_labels": args.run_labels if args.run_labels is not None else defaults.get("runLabels", True), + "run_milestones": args.run_milestones if args.run_milestones is not None else defaults.get("runMilestones", True), + "run_project_creation": args.run_project_creation if args.run_project_creation is not None else defaults.get("runProjectCreation", False), + "run_issue_generation": args.run_issue_generation if args.run_issue_generation is not None else defaults.get("runIssueGeneration", False), + "link_subissues": args.link_subissues if args.link_subissues is not None else defaults.get("linkSubissues", False), + } + client = GitHubClient("") if values["dry_run"] else require_client() + run_project_setup(client, repo_arg(args.repo), config, **values) + return 0 + + +def add_bool_pair(parser: argparse.ArgumentParser, name: str, destination: str, help_text: str) -> None: + group = parser.add_mutually_exclusive_group() + group.add_argument(f"--{name}", dest=destination, action="store_true", default=None, help=help_text) + group.add_argument(f"--skip-{name.removeprefix('run-')}", dest=destination, action="store_false") + + +def add_apply_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) + parser.add_argument("--config", default="project_setup.json") + dry_run = parser.add_mutually_exclusive_group() + dry_run.add_argument("--dry-run", dest="dry_run", action="store_true", default=None) + dry_run.add_argument("--no-dry-run", dest="dry_run", action="store_false") + add_bool_pair(parser, "run-labels", "run_labels", "Synchronize labels") + add_bool_pair(parser, "run-milestones", "run_milestones", "Synchronize milestones") + add_bool_pair(parser, "run-project-creation", "run_project_creation", "Create Project v2") + add_bool_pair(parser, "run-issue-generation", "run_issue_generation", "Generate issues and tasks") + links = parser.add_mutually_exclusive_group() + links.add_argument("--link-subissues", dest="link_subissues", action="store_true", default=None) + links.add_argument("--no-link-subissues", dest="link_subissues", action="store_false") + parser.set_defaults(func=cmd_apply) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="project-setup", description="Set up and automate GitHub repositories") + subcommands = parser.add_subparsers(dest="command", required=True) + + init = subcommands.add_parser("init", help="Copy project setup tooling into a target repository") + init.add_argument("--target", required=True) + init.add_argument("--source") + init.add_argument("--profile", choices=sorted(PROFILE_FILES), default="core") + init.add_argument("--force", action="store_true") + init.add_argument("--dry-run", action="store_true") + init.set_defaults(func=cmd_init) + + discover = subcommands.add_parser("discover", help="Inspect a repository and recommend setup options") + discover.add_argument("--repo") + discover.add_argument("--config", default="project_setup.json") + discover.add_argument("--root", default=".") + discover.add_argument("--project-type", choices=SUPPORTED_PROJECT_TYPES) + discover.add_argument("--auto", action="store_true", help="Use configuration defaults without prompts") + discover.add_argument("--apply", action="store_true", help="Apply the selected setup after review") + discover.add_argument("--yes", action="store_true", help="Confirm --apply in non-interactive environments") + discover.set_defaults(func=run_discovery) + + doctor = subcommands.add_parser("doctor", help="Check local project setup prerequisites") + doctor.add_argument("--config", default="project_setup.json") + doctor.set_defaults(func=cmd_doctor) + + labels = subcommands.add_parser("labels") + labels_sub = labels.add_subparsers(dest="labels_command", required=True) + labels_sync = labels_sub.add_parser("sync") + labels_sync.add_argument("--repo") + labels_sync.add_argument("--file", default="config/project/labels.json") + labels_sync.add_argument("--dry-run", action="store_true") + labels_sync.set_defaults(func=cmd_labels_sync) + + milestones = subcommands.add_parser("milestones") + milestones_sub = milestones.add_subparsers(dest="milestones_command", required=True) + milestones_sync = milestones_sub.add_parser("sync") + milestones_sync.add_argument("--repo") + milestones_sync.add_argument("--file", default="config/project/milestones.json") + milestones_sync.add_argument("--dry-run", action="store_true") + milestones_sync.set_defaults(func=cmd_milestones_sync) + + issues = subcommands.add_parser("issues") + issues_sub = issues.add_subparsers(dest="issues_command", required=True) + issues_generate = issues_sub.add_parser("generate") + issues_generate.add_argument("--repo") + issues_generate.add_argument("--file", default="config/stories/backlog-manifest.json") + issues_generate.add_argument("--dry-run", action="store_true") + issues_generate.add_argument("--link-subissues", action="store_true") + issues_generate.set_defaults(func=cmd_issues_generate) + + project = subcommands.add_parser("project") + project_sub = project.add_subparsers(dest="project_command", required=True) + project_create = project_sub.add_parser("create") + project_create.add_argument("--repo") + project_create.add_argument("--file", default="config/project/project-definition.json") + project_create.add_argument("--dry-run", action="store_true") + project_create.set_defaults(func=cmd_project_create) + project_sync = project_sub.add_parser("sync") + project_sync.add_argument("--repo") + project_sync.add_argument("--owner") + project_sync.add_argument("--file", default="config/project/project-definition.json") + project_sync.add_argument("--project-number", type=int, required=True) + project_sync.add_argument("--issue-state", choices=("open", "closed", "all"), default="open") + project_sync.add_argument("--dry-run", action="store_true") + project_sync.set_defaults(func=cmd_project_sync) + + issue_milestones = subcommands.add_parser("issue-milestones") + issue_milestones_sub = issue_milestones.add_subparsers(dest="issue_milestones_command", required=True) + issue_milestones_sync = issue_milestones_sub.add_parser("sync") + issue_milestones_sync.add_argument("--repo") + issue_milestones_sync.add_argument("--clear-not-planned", action="store_true") + issue_milestones_sync.add_argument("--dry-run", action="store_true") + issue_milestones_sync.set_defaults(func=cmd_issue_milestones_sync) + + auto_label = subcommands.add_parser("auto-label") + auto_label_sub = auto_label.add_subparsers(dest="auto_label_command", required=True) + auto_label_apply = auto_label_sub.add_parser("apply") + auto_label_apply.add_argument("--repo") + auto_label_apply.add_argument("--event-path") + auto_label_apply.add_argument("--labels-file", default="config/project/labels.json") + auto_label_apply.add_argument("--dry-run", action="store_true") + auto_label_apply.set_defaults(func=cmd_auto_label_apply) + + validate_pr = subcommands.add_parser("validate-pr") + validate_pr.add_argument("--branch") + validate_pr.add_argument("--base-branch") + validate_pr.add_argument("--body") + validate_pr.add_argument("--body-file") + validate_pr.add_argument("--repo") + validate_pr.add_argument("--pr-number", type=int) + validate_pr.add_argument("--comment", action="store_true") + validate_pr.set_defaults(func=cmd_validate_pr) + + apply = subcommands.add_parser("apply", help="Apply configured repository setup") + add_apply_arguments(apply) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) From 16c3abcae41cca3b8aa6877bdd94f3a06bb2c798 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:18:22 -0300 Subject: [PATCH 018/130] feat: add optional Godot workflow profile --- .../godot/.github/workflows/godot-smoke.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 templates/profiles/godot/.github/workflows/godot-smoke.yml diff --git a/templates/profiles/godot/.github/workflows/godot-smoke.yml b/templates/profiles/godot/.github/workflows/godot-smoke.yml new file mode 100644 index 0000000..6260d91 --- /dev/null +++ b/templates/profiles/godot/.github/workflows/godot-smoke.yml @@ -0,0 +1,45 @@ +name: Godot smoke check + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: godot-smoke-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Verify project.godot exists + id: has_project + shell: bash + run: | + if [ -f project.godot ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Godot + if: steps.has_project.outputs.exists == 'true' + uses: firebelley/setup-godot@v1 + with: + godot-version: "4.2.2" + + - name: Run headless smoke check + if: steps.has_project.outputs.exists == 'true' + run: godot --headless --quit + + - name: Skip smoke check + if: steps.has_project.outputs.exists != 'true' + run: echo "Skipping smoke check because project.godot was not found." From 95424beb85f9b643eb229572338c6efadff64ad7 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:18:47 -0300 Subject: [PATCH 019/130] refactor: run auto labels through project_setup --- .github/workflows/auto-label.yml | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 164b66b..691accf 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -3,7 +3,7 @@ name: Auto label on: issues: types: [opened, edited, reopened] - pull_request: + pull_request_target: types: [opened, edited, reopened, synchronize] permissions: @@ -11,24 +11,28 @@ permissions: issues: write pull-requests: read +concurrency: + group: auto-label-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: auto-label: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout trusted repository automation uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha || github.sha }} + persist-credentials: false - - name: Setup Python + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' - - - name: Install governance tool - run: pip install -e . --quiet + python-version: "3.11" - name: Apply inferred labels env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_EVENT_PATH: ${{ github.event_path }} - run: python -m governance_bootstrap auto-label apply + run: python -m project_setup auto-label apply From be773fe5dfea9d140273943be03c0a593ebdef8a Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:19:08 -0300 Subject: [PATCH 020/130] refactor: validate PRs with project_setup --- .github/workflows/pr-metadata.yml | 48 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 3e59904..376774e 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -1,32 +1,42 @@ name: PR metadata validation on: - pull_request: - types: [opened, synchronize, reopened, edited] + pull_request_target: + types: [opened, synchronize, reopened, edited, ready_for_review] permissions: contents: read + issues: write + pull-requests: write + +concurrency: + group: pr-metadata-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: - validate-pr-body: - # Draft PRs are works-in-progress; skip validation until they are marked ready. + validate-pr: if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - - name: Ensure required PR sections and issue link + - name: Checkout trusted base commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Validate branch name and pull request metadata env: PR_BODY: ${{ github.event.pull_request.body }} - run: | - required=("Linked Issue" "Summary" "How to test") - for section in "${required[@]}"; do - if ! echo "$PR_BODY" | grep -qF "## $section"; then - echo "Missing required PR section: ## $section" >&2 - exit 1 - fi - done - # Accept a real issue number (Closes/Fixes/Resolves #NNN) or an explicit N/A - # when there is genuinely no linked issue. - if ! echo "$PR_BODY" | grep -qE 'Closes #[0-9]+|Fixes #[0-9]+|Resolves #[0-9]+|Closes #[Nn][/\\]?[Aa]|#N/A|#n/a'; then - echo "PR body must reference a linked issue (e.g. Closes #123) or mark it as not applicable (Closes #N/A)" >&2 - exit 1 - fi + GITHUB_TOKEN: ${{ github.token }} + run: >- + python scripts/validation/validate_pr_body.py + --branch "${{ github.event.pull_request.head.ref }}" + --base-branch "${{ github.event.pull_request.base.ref }}" + --repo "${{ github.repository }}" + --pr-number "${{ github.event.pull_request.number }}" + --comment From baa087e36bd64fd58f5b5f6fcb8b8387a263d579 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:19:26 -0300 Subject: [PATCH 021/130] refactor: add project setup workflow --- .github/workflows/project-setup.yml | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/project-setup.yml diff --git a/.github/workflows/project-setup.yml b/.github/workflows/project-setup.yml new file mode 100644 index 0000000..36eca8b --- /dev/null +++ b/.github/workflows/project-setup.yml @@ -0,0 +1,92 @@ +name: Project setup + +on: + workflow_dispatch: + inputs: + run_labels_sync: + description: "Synchronize labels" + required: true + default: true + type: boolean + run_milestones_sync: + description: "Synchronize milestones" + required: true + default: true + type: boolean + run_issue_generation: + description: "Generate backlog issues and tasks" + required: true + default: false + type: boolean + run_project_creation: + description: "Create a GitHub Project v2" + required: true + default: false + type: boolean + dry_run: + description: "Plan changes without writing to GitHub" + required: true + default: true + type: boolean + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: project-setup-${{ github.repository }} + cancel-in-progress: false + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Validate embedded setup package and configuration + run: | + python -m compileall -q project_setup + python -m project_setup doctor --config project_setup.json + + - name: Apply project setup + env: + GITHUB_TOKEN: ${{ secrets.PROJECT_SETUP_PAT || github.token }} + GH_TOKEN: ${{ secrets.PROJECT_SETUP_PAT || github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + args="--config project_setup.json" + if [ "${{ inputs.dry_run }}" = "true" ]; then + args="$args --dry-run" + else + args="$args --no-dry-run" + fi + if [ "${{ inputs.run_labels_sync }}" = "true" ]; then + args="$args --run-labels" + else + args="$args --skip-labels" + fi + if [ "${{ inputs.run_milestones_sync }}" = "true" ]; then + args="$args --run-milestones" + else + args="$args --skip-milestones" + fi + if [ "${{ inputs.run_issue_generation }}" = "true" ]; then + args="$args --run-issue-generation" + else + args="$args --skip-issue-generation" + fi + if [ "${{ inputs.run_project_creation }}" = "true" ]; then + args="$args --run-project-creation" + else + args="$args --skip-project-creation" + fi + python -m project_setup apply $args From 1498a0b89673ecffb861180a2f133e9640b6f6b0 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:19:38 -0300 Subject: [PATCH 022/130] ci: validate project setup repository --- .github/workflows/repo-quality.yml | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/repo-quality.yml diff --git a/.github/workflows/repo-quality.yml b/.github/workflows/repo-quality.yml new file mode 100644 index 0000000..bd09d75 --- /dev/null +++ b/.github/workflows/repo-quality.yml @@ -0,0 +1,31 @@ +name: Repository quality + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main, develop] + +permissions: + contents: read + +concurrency: + group: repo-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Run repository checks + run: make check From 48f5b29d94f7039963fecf26618c2f148657aa2c Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:19:58 -0300 Subject: [PATCH 023/130] refactor: align generic labels with Project v2 statuses --- config/project/labels.json | 39 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/config/project/labels.json b/config/project/labels.json index aca438c..4e29ae9 100644 --- a/config/project/labels.json +++ b/config/project/labels.json @@ -1,26 +1,23 @@ [ - {"name":"type:user-story","color":"1D76DB","description":"📘 User story item"}, - {"name":"type:task","color":"0E8A16","description":"🛠️ Implementation task"}, - {"name":"type:bug","color":"D73A4A","description":"🐞 Bug report"}, - {"name":"type:repo","color":"5319E7","description":"🏗️ Repository/governance work"}, - {"name":"type:stretch","color":"FBCA04","description":"🌟 Stretch goal item"}, + {"name":"type:user-story","color":"1D76DB","description":"User story"}, + {"name":"type:task","color":"0E8A16","description":"Implementation task"}, + {"name":"type:bug","color":"D73A4A","description":"Bug or regression"}, + {"name":"type:repo","color":"5319E7","description":"Repository or automation work"}, + {"name":"type:stretch","color":"FBCA04","description":"Optional stretch goal"}, - {"name":"priority:critical","color":"B60205","description":"🔥 Critical priority"}, - {"name":"priority:high","color":"D93F0B","description":"⬆️ High priority"}, - {"name":"priority:medium","color":"FBCA04","description":"➡️ Medium priority"}, - {"name":"priority:low","color":"0E8A16","description":"⬇️ Low priority"}, + {"name":"priority:critical","color":"B60205","description":"Critical priority"}, + {"name":"priority:high","color":"D93F0B","description":"High priority"}, + {"name":"priority:medium","color":"FBCA04","description":"Medium priority"}, + {"name":"priority:low","color":"0E8A16","description":"Low priority"}, - {"name":"status:backlog","color":"C5DEF5","description":"📥 Backlog"}, - {"name":"status:ready","color":"C5DEF5","description":"✅ Ready"}, - {"name":"status:in-progress","color":"C5DEF5","description":"🚧 In progress"}, - {"name":"status:review-milestone","color":"C5DEF5","description":"👀 Review task->milestone"}, - {"name":"status:review-develop","color":"C5DEF5","description":"🧪 Review milestone->develop"}, - {"name":"status:review-main","color":"C5DEF5","description":"��️ Review develop->main"}, - {"name":"status:qa-manual","color":"C5DEF5","description":"🧫 Manual QA"}, - {"name":"status:done","color":"0E8A16","description":"🏁 Done"}, - {"name":"status:blocked","color":"B60205","description":"⛔ Blocked"}, + {"name":"status:backlog","color":"C5DEF5","description":"Backlog"}, + {"name":"status:ready","color":"BFDADC","description":"Ready for implementation"}, + {"name":"status:in-progress","color":"FEF2C0","description":"In progress"}, + {"name":"status:in-review","color":"D4C5F9","description":"In review"}, + {"name":"status:done","color":"0E8A16","description":"Done"}, + {"name":"status:blocked","color":"B60205","description":"Blocked"}, - {"name":"test:automated","color":"0366D6","description":"🤖 Automated test"}, - {"name":"test:smoke","color":"0366D6","description":"💨 Smoke test"}, - {"name":"test:manual","color":"0366D6","description":"🧍 Manual test"} + {"name":"test:automated","color":"0366D6","description":"Automated tests"}, + {"name":"test:smoke","color":"0366D6","description":"Smoke test"}, + {"name":"test:manual","color":"0366D6","description":"Manual validation"} ] From 6e8d10a3871f5a597bcfb567b22818484b3a3017 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:20:13 -0300 Subject: [PATCH 024/130] refactor: make milestone template generic --- config/project/milestones.json | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/config/project/milestones.json b/config/project/milestones.json index 81402a6..96ec12f 100644 --- a/config/project/milestones.json +++ b/config/project/milestones.json @@ -1,6 +1,10 @@ [ - {"title": "M0", "description": "Project setup and bootstrap", "due_on": "2026-01-31T00:00:00Z"}, - {"title": "M1", "description": "First deliverable", "due_on": "2026-02-28T00:00:00Z"}, - {"title": "M2", "description": "Second deliverable", "due_on": "2026-03-31T00:00:00Z"}, - {"title": "M3", "description": "Final delivery", "due_on": "2026-04-30T00:00:00Z"} + { + "title": "M0", + "description": "Repository setup, conventions and automation" + }, + { + "title": "M1", + "description": "First planned product increment" + } ] From 5a0d9d9e57d00eace6810a81bb7df4dcb41fae9d Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:20:30 -0300 Subject: [PATCH 025/130] refactor: make Project v2 definition generic --- config/project/project-definition.json | 51 ++++++++++++++++++-------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/config/project/project-definition.json b/config/project/project-definition.json index 2e6ca4c..ddc2687 100644 --- a/config/project/project-definition.json +++ b/config/project/project-definition.json @@ -1,19 +1,40 @@ { - "name": "My Project Board", - "description": "Operational board for milestones, user stories, tasks, review layers and validation.", + "name": "Project Delivery Board", + "description": "Generic delivery board for stories, tasks, bugs and repository work.", + "phaseMilestoneMap": { + "M0": "Setup", + "M1": "Delivery" + }, "fields": [ - {"name": "Milestone", "type": "single_select", "options": ["M0","M1","M2","M3"]}, - {"name": "Item Type", "type": "single_select", "options": ["user-story","task","bug","repo","stretch"]}, - {"name": "Priority", "type": "single_select", "options": ["critical","high","medium","low"]}, - {"name": "Status", "type": "single_select", "options": ["backlog","ready","in-progress","review","done","blocked"]}, - {"name": "Test Type", "type": "single_select", "options": ["automated","smoke","manual"]}, - {"name": "DoD Status", "type": "single_select", "options": ["not-started","partial","ready","done"]}, - {"name": "Responsible", "type": "text"} + { + "name": "Phase", + "type": "single_select", + "options": ["Setup", "Delivery", "Maintenance"] + }, + { + "name": "Item Type", + "type": "single_select", + "options": ["user-story", "task", "bug", "repo", "stretch"] + }, + { + "name": "Priority", + "type": "single_select", + "options": ["critical", "high", "medium", "low"] + }, + { + "name": "Status", + "type": "single_select", + "options": ["backlog", "ready", "in-progress", "in-review", "done", "blocked"] + }, + { + "name": "Test Type", + "type": "single_select", + "options": ["automated", "smoke", "manual"] + }, + { + "name": "Milestone", + "type": "text" + } ], - "views": [ - "Board by status", - "Roadmap by milestone", - "Current milestone", - "In review" - ] + "views": ["Backlog", "Current delivery", "Bugs", "Done"] } From e7666a80656696e490169610ac5ec0c00427f3c5 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:20:49 -0300 Subject: [PATCH 026/130] refactor: replace sample backlog with safe generic example --- config/stories/backlog-manifest.json | 42 ++++++++-------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/config/stories/backlog-manifest.json b/config/stories/backlog-manifest.json index e0525ed..6de9128 100644 --- a/config/stories/backlog-manifest.json +++ b/config/stories/backlog-manifest.json @@ -1,43 +1,23 @@ { "version": "1.0.0", - "repository": "owner/repo", + "repository": "owner/repository", "defaultIssueLabels": ["status:backlog"], - "milestones": [ + "phases": [ { + "phaseId": "setup", "milestone": "M0", "stories": [ { "storyId": "US-00", - "title": "US-00 | Set up repository, project board and initial backlog", - "labels": ["type:user-story", "priority:critical", "test:manual"], - "body": "As a team, we want an operational repository structure so we can run the project with consistent traceability and review.", - "acceptanceCriteria": "- Milestones created\n- Labels synced\n- Project board configured\n- Initial backlog published", - "testStrategy": "- Manual verification of milestones, labels and board", - "dod": "- All setup steps completed and visible in the repo", + "title": "US-00 | Configure repository automation", + "body": "## Context\nEstablish repository conventions, templates and automated checks.", + "labels": ["type:user-story", "priority:high", "test:manual"], + "acceptanceCriteria": "- Required workflows are installed.\n- Dry-run completes successfully.\n- Repository-specific values replace the examples.", + "testStrategy": "- Run `make check`.\n- Run `make plan REPO=owner/repository`.", + "dod": "- Configuration reviewed.\n- Secrets and variables documented.\n- No placeholder repository values remain.", "tasks": [ - "T-00.1 | Create milestones", - "T-00.2 | Create GitHub Project v2 with fields and views", - "T-00.3 | Sync base labels", - "T-00.4 | Create issue and PR templates", - "T-00.5 | Publish initial backlog" - ] - } - ] - }, - { - "milestone": "M1", - "stories": [ - { - "storyId": "US-01", - "title": "US-01 | Example user story for milestone 1", - "labels": ["type:user-story", "priority:high", "test:automated"], - "body": "As a user, I want an example feature so I can understand the story format.", - "acceptanceCriteria": "- Acceptance criterion 1\n- Acceptance criterion 2", - "testStrategy": "- automated", - "dod": "- Implementation complete\n- Tests passing\n- Reviewed and merged", - "tasks": [ - "T-01.1 | Example task 1", - "T-01.2 | Example task 2" + "T-00.1 | Customize labels and milestones", + "T-00.2 | Review workflows and branch policy" ] } ] From f151510b61729673f0b73708bb27817e48ec22e2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:21:10 -0300 Subject: [PATCH 027/130] refactor: rename package to project_setup --- pyproject.toml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5fdbb1d..2e120f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,19 +3,26 @@ requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] -name = "github-governance-bootstrap" -version = "0.1.0" -description = "Reusable GitHub governance bootstrap CLI for labels, milestones, projects, issues and sub-issues." +name = "github-project-setup" +version = "0.2.0" +description = "Reusable CLI for setting up GitHub repository workflows, labels, milestones, issues and Projects." readme = "README.md" requires-python = ">=3.11" dependencies = [] +keywords = ["github", "automation", "repository", "project-management", "github-actions"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: MIT License", +] [project.optional-dependencies] dev = ["pytest>=8"] [project.scripts] -governance = "governance_bootstrap.cli:main" +project-setup = "project_setup.cli:main" +project_setup = "project_setup.cli:main" [tool.setuptools.packages.find] where = ["."] -include = ["governance_bootstrap*"] +include = ["project_setup*"] From ff59414debc0b74507a1f11e8f3942c7ff755b69 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:22:36 -0300 Subject: [PATCH 028/130] chore: ignore generated and local artifacts --- .gitignore | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 505e9ed..792f4fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,18 @@ -# Python __pycache__/ *.py[cod] -*.egg-info/ -dist/ -build/ -.eggs/ -*.egg +*$py.class .venv/ venv/ env/ - -# pytest -.pytest_cache/ - -# local env files .env -.env.local +.env.* +!.env.example +build/ +dist/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ +.DS_Store +Thumbs.db From 141564091158ca10432ddb122984dc7af6b68d25 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:23:04 -0300 Subject: [PATCH 029/130] feat: add cross-platform repository quality check --- scripts/validation/repo_quality.py | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 scripts/validation/repo_quality.py diff --git a/scripts/validation/repo_quality.py b/scripts/validation/repo_quality.py new file mode 100644 index 0000000..8d2ef5c --- /dev/null +++ b/scripts/validation/repo_quality.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from pathlib import Path +import sys +import tomllib + + +SELF = Path(__file__).resolve() +ROOT = SELF.parents[2] +REQUIRED_PATHS = ( + "Makefile", + "README.md", + "LICENSE", + "pyproject.toml", + "project_setup.json", + "project_setup/__init__.py", + "project_setup/__main__.py", + "project_setup/cli.py", + "project_setup/discovery.py", + "project_setup/runner.py", + "project_setup/installer.py", + "project_setup/github.py", + ".github/workflows/project-setup.yml", + ".github/workflows/auto-label.yml", + ".github/workflows/pr-metadata.yml", + ".github/workflows/repo-quality.yml", + "scripts/validation/validate_pr_body.py", + "tests/test_project_setup.py", +) +FORBIDDEN_REFERENCES = ( + "governance_bootstrap", + "governance_bootstarp", + "governance.bootstrap.json", + "governance-bootstrap", +) +TEXT_SUFFIXES = {".py", ".md", ".yml", ".yaml", ".json", ".toml", ".txt", ".sh"} + + +def fail(message: str, failures: list[str]) -> None: + failures.append(message) + print(f"ERROR: {message}", file=sys.stderr) + + +def main() -> int: + failures: list[str] = [] + for relative_path in REQUIRED_PATHS: + if not (ROOT / relative_path).is_file(): + fail(f"required file is missing: {relative_path}", failures) + + for path in ROOT.rglob("*"): + if path.resolve() == SELF or ".git" in path.parts or not path.is_file(): + continue + if "__pycache__" in path.parts or path.suffix in {".pyc", ".pyo"}: + fail(f"generated Python artifact is tracked: {path.relative_to(ROOT)}", failures) + continue + if path.suffix.lower() not in TEXT_SUFFIXES and path.name != "Makefile": + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + relative = str(path.relative_to(ROOT)) + for forbidden in FORBIDDEN_REFERENCES: + if forbidden in text or forbidden in relative: + fail(f"legacy reference '{forbidden}' found in {relative}", failures) + + for path in [ROOT / "project_setup.json", *sorted((ROOT / "config").rglob("*.json"))]: + try: + json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"invalid JSON in {path.relative_to(ROOT)}: {exc}", failures) + + try: + with (ROOT / "pyproject.toml").open("rb") as file: + pyproject = tomllib.load(file) + scripts = pyproject.get("project", {}).get("scripts", {}) + if scripts.get("project-setup") != "project_setup.cli:main": + fail("pyproject.toml does not expose project-setup = project_setup.cli:main", failures) + except (OSError, tomllib.TOMLDecodeError) as exc: + fail(f"invalid pyproject.toml: {exc}", failures) + + if failures: + print(f"Repository quality failed with {len(failures)} error(s).", file=sys.stderr) + return 1 + print("Repository quality checks passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 6a5d9c4adf39d49d67406f48ad0e72b9021d958e Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:23:17 -0300 Subject: [PATCH 030/130] feat: add pull request validation entrypoint --- scripts/validation/validate_pr_body.py | 50 ++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 scripts/validation/validate_pr_body.py diff --git a/scripts/validation/validate_pr_body.py b/scripts/validation/validate_pr_body.py new file mode 100644 index 0000000..9cb7307 --- /dev/null +++ b/scripts/validation/validate_pr_body.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from project_setup.github import get_token, require_client +from project_setup.pr_validation import upsert_validation_comment, validate_pull_request + + +def read_body(args: argparse.Namespace) -> str: + if args.file: + return Path(args.file).read_text(encoding="utf-8") + if args.repo and args.pr_number and get_token(): + return require_client().get_issue(args.repo, args.pr_number).get("body") or "" + if os.getenv("PR_BODY") is not None: + return os.environ["PR_BODY"] + if not sys.stdin.isatty(): + return sys.stdin.read() + return "" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate pull request branch naming and metadata") + parser.add_argument("--file") + parser.add_argument("--branch") + parser.add_argument("--base-branch") + parser.add_argument("--repo") + parser.add_argument("--pr-number", type=int) + parser.add_argument("--comment", action="store_true") + args = parser.parse_args() + + findings = validate_pull_request(args.branch, read_body(args), args.base_branch) + for finding in findings: + print(f"{finding.section}: {finding.problem}", file=sys.stderr) + print(f" Fix: {finding.fix}", file=sys.stderr) + if args.comment: + if not args.repo or not args.pr_number: + print("--comment requires --repo and --pr-number", file=sys.stderr) + return 2 + upsert_validation_comment(require_client(), args.repo, args.pr_number, findings) + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2d4d0e1d56780b1e048552533cc06bf9d758c137 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:24:02 -0300 Subject: [PATCH 031/130] test: add project_setup coverage --- tests/test_project_setup.py | 233 ++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 tests/test_project_setup.py diff --git a/tests/test_project_setup.py b/tests/test_project_setup.py new file mode 100644 index 0000000..0aee479 --- /dev/null +++ b/tests/test_project_setup.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import io +import json +import os +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +from project_setup.auto_label import infer_issue_labels +from project_setup.cli import main +from project_setup.discovery import build_apply_command, detect_project_matches +from project_setup.github import GitHubClient, get_token +from project_setup.installer import install_repository +from project_setup.issue_milestones import milestone_from_body, parent_issue_number_from_body +from project_setup.issues import load_backlog +from project_setup.labels import load_labels, sync_labels +from project_setup.milestones import load_milestones, sync_milestones +from project_setup.pr_validation import validate_pull_request +from project_setup.project import label_value + + +ROOT = Path(__file__).resolve().parents[1] + + +class ProjectSetupTests(unittest.TestCase): + def test_auto_label_infers_type_status_priority_and_test(self): + issue = { + "title": "US-01 | Example", + "body": "Severity\nHigh\n\nTest type: smoke", + "labels": [], + } + self.assertEqual( + infer_issue_labels(issue), + {"type:user-story", "status:backlog", "priority:high", "test:smoke"}, + ) + + def test_manifest_loaders_validate_required_fields(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + labels = root / "labels.json" + milestones = root / "milestones.json" + backlog = root / "backlog.json" + labels.write_text('[{"name":"missing-color"}]', encoding="utf-8") + milestones.write_text('[{"description":"missing-title"}]', encoding="utf-8") + backlog.write_text('{"stories":[]}', encoding="utf-8") + with self.assertRaises(ValueError): + load_labels(str(labels)) + with self.assertRaises(ValueError): + load_milestones(str(milestones)) + with self.assertRaises(ValueError): + load_backlog(str(backlog)) + + def test_issue_metadata_parsers_accept_generic_milestones(self): + body = "Parent story: US-01 (#42)\n\n- Milestone: Release-1.0" + self.assertEqual(milestone_from_body(body), "Release-1.0") + self.assertEqual(parent_issue_number_from_body(body), 42) + + def test_label_value_reads_github_label_payloads(self): + labels = [{"name": "type:task"}, {"name": "priority:critical"}] + self.assertEqual(label_value(labels, "priority:"), "critical") + self.assertIsNone(label_value(labels, "status:")) + + def test_sync_dry_runs_print_planned_resources(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + labels = root / "labels.json" + milestones = root / "milestones.json" + labels.write_text('[{"name":"status:backlog","color":"C5DEF5"}]', encoding="utf-8") + milestones.write_text('[{"title":"M1","description":"Delivery"}]', encoding="utf-8") + output = io.StringIO() + with redirect_stdout(output): + sync_labels(GitHubClient(""), "owner/repository", str(labels), dry_run=True) + sync_milestones(GitHubClient(""), "owner/repository", str(milestones), dry_run=True) + text = output.getvalue() + self.assertIn("[DRY-RUN] Would sync 1 labels", text) + self.assertIn("[DRY-RUN] Would sync 1 milestones", text) + + def test_apply_dry_run_does_not_require_token(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + (root / "labels.json").write_text('[{"name":"status:backlog","color":"C5DEF5"}]', encoding="utf-8") + (root / "milestones.json").write_text('[{"title":"M1"}]', encoding="utf-8") + (root / "project.json").write_text('{"name":"Board","fields":[]}', encoding="utf-8") + (root / "backlog.json").write_text('{"phases":[]}', encoding="utf-8") + config = root / "project_setup.json" + config.write_text( + json.dumps( + { + "labelsFile": str(root / "labels.json"), + "milestonesFile": str(root / "milestones.json"), + "projectDefinitionFile": str(root / "project.json"), + "backlogManifestFile": str(root / "backlog.json"), + "defaults": { + "dryRun": True, + "runLabels": True, + "runMilestones": True, + "runProjectCreation": True, + "runIssueGeneration": True, + }, + } + ), + encoding="utf-8", + ) + with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "", "PROJECT_SETUP_PAT": ""}, clear=False), patch( + "project_setup.github.shutil.which", return_value=None + ): + output = io.StringIO() + with redirect_stdout(output): + result = main(["apply", "--repo", "owner/repository", "--config", str(config), "--dry-run"]) + self.assertEqual(result, 0) + self.assertIn("[DRY-RUN] Would sync 1 labels", output.getvalue()) + self.assertIn("Project setup finished.", output.getvalue()) + + def test_installer_copies_core_files_and_preserves_existing_files(self): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + existing = target / ".github" / "pull_request_template.md" + existing.parent.mkdir(parents=True) + existing.write_text("custom template", encoding="utf-8") + result = install_repository(target, source=ROOT, profile="core") + self.assertEqual(existing.read_text(encoding="utf-8"), "custom template") + self.assertIn(".github/pull_request_template.md", result.skipped) + self.assertTrue((target / "project_setup" / "cli.py").is_file()) + self.assertTrue((target / "project_setup" / "discovery.py").is_file()) + self.assertTrue((target / ".github" / "workflows" / "project-setup.yml").is_file()) + self.assertFalse((target / ".github" / "workflows" / "godot-smoke.yml").exists()) + + def test_godot_profile_copies_optional_workflow(self): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + install_repository(target, source=ROOT, profile="godot") + self.assertTrue((target / ".github" / "workflows" / "godot-smoke.yml").is_file()) + + def test_pull_request_validation_accepts_complete_template(self): + body = """## Linked Issue +- Closes #123 + +## Milestone +- M1 + +## Summary +- Add repository setup. + +## How to test +- Run make check. + +## Known risks +- None identified. + +## DoD checklist +- [x] Checks passed. +""" + self.assertEqual(validate_pull_request("feat/project-setup", body, "develop"), []) + + def test_discovery_detects_multiple_project_types(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + (root / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8") + (root / "package.json").write_text('{"name":"demo"}', encoding="utf-8") + matches = detect_project_matches(root) + self.assertEqual([match.project_type for match in matches[:2]], ["python", "node"]) + + def test_discovery_builds_project_setup_command(self): + command = build_apply_command( + "owner/repository", + "project_setup.json", + True, + True, + True, + False, + False, + True, + ) + self.assertIn("python -m project_setup apply", command) + self.assertIn("--dry-run", command) + self.assertIn("--skip-project-creation", command) + + def test_get_token_falls_back_to_gh_auth(self): + with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "", "PROJECT_SETUP_PAT": ""}, clear=False), patch( + "project_setup.github.shutil.which", return_value="/usr/bin/gh" + ), patch("project_setup.github.subprocess.run") as run: + run.return_value = SimpleNamespace(returncode=0, stdout="token-from-gh\n") + token = get_token() + self.assertEqual(token, "token-from-gh") + + def test_discover_auto_mode_reports_summary(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + target = root / "repository" + target.mkdir() + (target / "go.mod").write_text("module example.com/demo\n", encoding="utf-8") + config = root / "project_setup.json" + config.write_text( + json.dumps( + { + "labelsFile": "labels.json", + "milestonesFile": "milestones.json", + "projectDefinitionFile": "project.json", + "backlogManifestFile": "backlog.json", + "secretName": "PROJECT_SETUP_PAT", + "defaults": {"dryRun": True, "runLabels": True, "runMilestones": True}, + } + ), + encoding="utf-8", + ) + with patch.dict(os.environ, {"PROJECT_SETUP_PAT": "token", "GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + result = main( + [ + "discover", + "--repo", + "owner/repository", + "--config", + str(config), + "--root", + str(target), + "--auto", + ] + ) + self.assertEqual(result, 0) + text = output.getvalue() + self.assertIn("Configured: yes (environment)", text) + self.assertIn("Detected project type: go", text) + self.assertIn("python -m project_setup apply", text) + + +if __name__ == "__main__": + unittest.main() From e9f769b982bf4c67bb1f0b80c4fcd8588fbdc93e Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:24:39 -0300 Subject: [PATCH 032/130] docs: document GitHub Project Setup as a reusable tool --- README.md | 222 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 143 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 9740616..0acb940 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,185 @@ -# GitHub Project Automation +# GitHub Project Setup -A reusable governance bootstrap toolkit for any GitHub project. -It syncs labels, milestones, and a Project v2 board, generates issues/tasks from a backlog manifest, and auto-labels issues and PRs — all driven by JSON config files you drop into your repo. +A self-contained toolkit for installing and operating GitHub repository automation. ---- +It detects common project stacks, copies reusable workflows and templates into another repository, validates the resulting setup, and synchronizes repository resources through the GitHub API. The tool is designed for direct use by developers and for assisted use by coding agents or other AI systems. -## How it works +## Capabilities -The toolkit has two parts: +- Detect Python, Node.js, Go, Java, Rust and .NET repository markers. +- Install issue forms, pull request templates and GitHub Actions workflows. +- Embed the `project_setup` Python package in the target repository. +- Synchronize labels and milestones from JSON manifests. +- Generate stories and implementation tasks from a backlog manifest. +- Create and populate GitHub Projects v2. +- Link generated tasks as sub-issues. +- Infer labels for issues and pull requests. +- Validate branch names and pull request metadata. +- Plan all supported changes in dry-run mode before writing to GitHub. -| Part | What it is | -|---|---| -| `governance_bootstrap` | Generic Python CLI — never needs editing | -| `config/` + `governance.bootstrap.json` | Your project's data — edit these for every new project | +## Requirements ---- +- Python 3.11 or newer. +- GNU Make for the Makefile interface. The Python CLI can be used directly on systems without Make. +- A GitHub token for live API operations. -## Quick start +Authentication lookup order: -### 1. Copy config files into your repo +1. `GITHUB_TOKEN` +2. `GH_TOKEN` +3. `PROJECT_SETUP_PAT` +4. `gh auth token`, when the GitHub CLI is installed and authenticated -``` -governance.bootstrap.json -config/project/labels.json -config/project/milestones.json -config/project/project-definition.json -config/stories/backlog-manifest.json -.github/workflows/governance-bootstrap.yml -.github/workflows/auto-label.yml -``` +For the installed GitHub Actions workflow, configure the repository secret `PROJECT_SETUP_PAT` when Project v2 or other user-scoped permissions are required. Repository-scoped operations can fall back to `github.token`. -### 2. Edit the config files for your project +## Quick start -- **`labels.json`** — label names, colors, descriptions. -- **`milestones.json`** — milestone titles and due dates. -- **`project-definition.json`** — board name, custom fields, options and views. -- **`backlog-manifest.json`** — milestones, user stories and tasks. -- **`governance.bootstrap.json`** — points to the above files; set `dryRun`, `runLabels`, etc. +Validate this tool repository: -### 3. Add a repository secret +```bash +make check +``` -Create a secret named `GOVERNANCE_PAT` with a PAT that has: -- `repo` (issues) -- `project` (Project v2) -- `read:org` (if the repo belongs to an org) +Inspect the target project and print the recommended setup command: -### 4. Run (GitHub Actions — recommended) +```bash +make discover TARGET=../my-project REPO=owner/my-project +``` -1. Go to **Actions → Governance bootstrap (manual) → Run workflow**. -2. Run with `dry_run = true` first to preview. -3. Run with `dry_run = false` to apply. +Install the core profile and preview the GitHub changes: ---- +```bash +make setup TARGET=../my-project REPO=owner/my-project +``` -## Local CLI +The `setup` target is intentionally safe: it copies missing files and runs the API phase in dry-run mode. -Install the package: +After reviewing and customizing the generated files, apply the changes: ```bash -pip install -e . +export PROJECT_SETUP_PAT=github_pat_... +make apply TARGET=../my-project REPO=owner/my-project ``` -Guided wizard (checks auth, detects project type, shows recommended command): +To install the optional Godot smoke workflow: ```bash -export GH_TOKEN= -python -m governance_bootstrap discover --repo owner/repo --config governance.bootstrap.json +make init TARGET=../my-game PROFILE=godot ``` -Run directly: +Existing files are preserved. Explicit replacement requires: ```bash -# Dry-run (safe preview) -python -m governance_bootstrap bootstrap --repo owner/repo --dry-run +make init TARGET=../my-project FORCE=1 +``` + +## Files to customize in the target repository + +Before a live apply, review at least: + +- `project_setup.json` +- `config/project/labels.json` +- `config/project/milestones.json` +- `config/project/project-definition.json` +- `config/stories/backlog-manifest.json` +- `.github/workflows/main-source-branch.yml` +- `.github/pull_request_template.md` + +The sample backlog uses `owner/repository` deliberately. Project creation and issue generation are disabled by default. + +## Makefile interface + +| Target | Purpose | +| --- | --- | +| `make help` | Show commands and required variables. | +| `make install` | Install the CLI in the active Python environment. | +| `make dev-install` | Install the CLI in editable mode. | +| `make check` | Compile Python, validate repository structure and run tests. | +| `make doctor` | Inspect token and configuration availability. | +| `make discover TARGET=... REPO=...` | Detect the target stack and print a recommended command. | +| `make init TARGET=...` | Copy the embedded setup into a target repository. | +| `make init-dry TARGET=...` | Preview copied files without writing. | +| `make plan REPO=...` | Preview GitHub API changes. | +| `make apply REPO=...` | Apply configured GitHub API changes. | +| `make setup TARGET=... REPO=...` | Install files and run a dry-run. | +| `make setup-live TARGET=... REPO=...` | Install files and perform a live apply. | -# Apply -python -m governance_bootstrap bootstrap --repo owner/repo --no-dry-run +`TARGET` is optional for API-only targets. When provided, commands run from that repository so its local `project_setup.json` and manifests are used. + +## Python CLI + +The installed commands are equivalent: + +```bash +project-setup --help +project_setup --help +python -m project_setup --help ``` -Individual commands: +Common operations: ```bash -python -m governance_bootstrap labels sync --repo owner/repo -python -m governance_bootstrap milestones sync --repo owner/repo -python -m governance_bootstrap project create --repo owner/repo -python -m governance_bootstrap issues generate --repo owner/repo --link-subissues -python -m governance_bootstrap auto-label apply --repo owner/repo +python -m project_setup discover --repo owner/repository --root ../my-project --auto +python -m project_setup init --target ../my-project --profile core +python -m project_setup doctor --config project_setup.json +python -m project_setup apply --repo owner/repository --dry-run +python -m project_setup labels sync --repo owner/repository --dry-run +python -m project_setup project sync --repo owner/repository --project-number 1 --dry-run ``` ---- +## AI-assisted setup -## Repository layout +An AI assistant should follow this order: +1. Inspect the target repository and identify its language, test framework, branch model and existing automation. +2. Run `discover` to verify the detected stack and proposed execution flags. +3. Run `init` without `--force`. +4. Replace the example repository, milestones, board fields and backlog entries with project-specific values. +5. Preserve existing project-specific workflows unless they are explicitly selected for replacement. +6. Run `make check` in this tool repository and compile the embedded package in the target repository. +7. Run `make plan TARGET= REPO=`. +8. Present the dry-run output for human review. +9. Perform a live apply only after explicit approval. + +A suitable instruction for an agent is: + +```text +Use the installed project_setup files as the automation baseline. Adapt the JSON manifests and workflows to this repository without deleting existing project-specific automation. Run discovery and dry-run validation first, and do not perform live GitHub writes until the proposed changes have been reviewed. ``` -governance_bootstrap/ # Generic CLI tool (Python package) -config/ - project/ - labels.json # Label definitions - milestones.json # Milestone list and dates - project-definition.json # Project v2 board name, fields and views - stories/ - backlog-manifest.json # Phases, user stories and tasks - phases/ - phase-review-policy.json -governance.bootstrap.json # Bootstrap entry point (paths + defaults) -.github/workflows/ - governance-bootstrap.yml # Manual dispatch workflow - auto-label.yml # Auto-labels issues and PRs on create/edit - branch-naming.yml # Validates branch name pattern - main-source-branch.yml # Ensures PRs to main come from develop - pr-metadata.yml # Validates required PR sections -``` ---- +## Profiles + +### `core` + +Installs repository-neutral issue forms, pull request validation, auto-labeling, the Project setup workflow, manifests, validation scripts and the embedded Python package. -## Authentication +### `godot` -The CLI reads the token from `GITHUB_TOKEN` or `GH_TOKEN`. -If neither is set it falls back to `gh auth token` (if `gh` is installed). +Installs everything in `core` and copies the optional Godot smoke workflow from `templates/profiles/godot`. Game tests, release exports and gameplay-specific checks are intentionally not part of the generic core. + +## Safety model + +- Dry-run is the default in `project_setup.json`. +- Issue generation is disabled by default. +- Project creation is disabled by default. +- Existing target files are skipped unless `--force` is explicitly provided. +- Pull request validation executes trusted code from the base commit. +- Workflow checkouts do not persist credentials. +- Concurrent runs for the same pull request are cancelled when superseded. + +## Current limitations + +- GitHub rulesets and branch protection are not created automatically yet. +- Project v2 views listed in the definition remain a manual configuration step. +- Issue generation is not idempotent; review existing issues before repeating it. +- The installer currently embeds the package in each target repository rather than depending on a published PyPI release. +- Repository-specific CI should be added as an optional profile instead of being placed in the core setup. + +## Development + +```bash +make dev-install +make check +``` +The repository quality check rejects generated Python bytecode, invalid JSON/TOML and legacy package references. From afe4fe4f5075d36c5466738e36b20a15e541ffe6 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:25:09 -0300 Subject: [PATCH 033/130] chore: align code ownership with project_setup --- .github/CODEOWNERS | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 244c7ea..76743b2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,8 @@ -# Optional fallback ownership for governance files +# Fallback ownership for project setup and repository automation files /.github/ @v-Kaefer +/project_setup/ @v-Kaefer /config/ @v-Kaefer /docs/repo/ @v-Kaefer -/scripts/github/ @v-Kaefer +/scripts/validation/ @v-Kaefer +/Makefile @v-Kaefer +/pyproject.toml @v-Kaefer From df55b6273bb1dbe7b5318903b6b02ec152e34f53 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:25:22 -0300 Subject: [PATCH 034/130] docs: align pull request template with project_setup validation --- .github/pull_request_template.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ef8a754..bbbfe61 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,26 +1,26 @@ ## Linked Issue -- Closes # +- Closes # ## Milestone -- MS0 +- ## Summary -- +- ## How to test - Test type: automated | smoke | manual -- Steps: describe the commands, manual flow, or verification evidence +- Steps: ## Evidence -- [ ] Screenshot/GIF attached (when applicable) -- [ ] Log/output attached (when applicable) -- [ ] Manual checklist executed (when applicable) +- [ ] Screenshot/GIF attached when applicable +- [ ] Log/output attached when applicable +- [ ] Manual checklist executed when applicable ## Known risks -- +- ## DoD checklist - [ ] Scope implemented as defined - [ ] Tests executed and documented -- [ ] Evidence attached +- [ ] Evidence attached when applicable - [ ] No known critical breakage introduced From 644d59fa8402c2c092ade6778386e517f8a4dd20 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:25:46 -0300 Subject: [PATCH 035/130] docs: generalize branching policy --- docs/repo/branching-policy.md | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/repo/branching-policy.md b/docs/repo/branching-policy.md index fa6f198..f9391d4 100644 --- a/docs/repo/branching-policy.md +++ b/docs/repo/branching-policy.md @@ -1,16 +1,26 @@ # Branching Policy (EN) -## Main branches -- `main`: stable macro delivery -- `develop`: integration branch -- `milestone/`: active milestone branch -- `feat//` or `task//`: implementation branch +## Default branches -## Merge layers -1. task -> milestone -2. milestone -> develop -3. develop -> main +- `main`: stable delivery branch. +- `develop`: optional integration branch. +- `phase/`: optional phase branch for staged delivery. +- implementation branches: `feat/`, `fix/`, `task/`, `docs/`, `refactor/`, `test/`, `chore/`, `ci/`, `hotfix/`, or `release/`. + +## Default merge layers + +1. implementation branch -> `develop` or a phase branch; +2. phase branch -> `develop`; +3. `develop` -> `main`; +4. `hotfix/*` -> `main` when explicitly allowed. + +Repositories that use trunk-based development should adapt `.github/workflows/main-source-branch.yml` instead of copying this model unchanged. ## Naming -- Feature convention default: `feat/` -- Current bootstrap branch: `feat/repo-governance-bootstrap` + +Use lowercase branch paths with hyphens, dots, underscores, or nested scopes, for example: + +- `feat/project-setup`; +- `task/setup/customize-labels`; +- `fix/project-sync-pagination`; +- `hotfix/workflow-permissions`. From a28140b30a27a698b558501c93b4b49f07865bc1 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:25:59 -0300 Subject: [PATCH 036/130] docs: generalize branching policy in Portuguese --- docs/repo/branching-policy.pt-BR.md | 38 ++++++++++++++++++----------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/repo/branching-policy.pt-BR.md b/docs/repo/branching-policy.pt-BR.md index aed1359..3a04ffe 100644 --- a/docs/repo/branching-policy.pt-BR.md +++ b/docs/repo/branching-policy.pt-BR.md @@ -1,16 +1,26 @@ # Política de Branches (PT-BR) -## Branches principais -- `main`: entrega macro estável -- `develop`: branch de integração -- `milestone/`: branch do milestone ativo -- `feat//` ou `task//`: branch de implementação - -## Camadas de merge -1. task -> milestone -2. milestone -> develop -3. develop -> main - -## Convenção -- Convenção padrão para feature: `feat/` -- Branch atual de bootstrap: `feat/repo-governance-bootstrap` +## Branches padrão + +- `main`: branch de entrega estável. +- `develop`: branch de integração opcional. +- `phase/`: branch opcional para entregas por fase. +- branches de implementação: `feat/`, `fix/`, `task/`, `docs/`, `refactor/`, `test/`, `chore/`, `ci/`, `hotfix/` ou `release/`. + +## Camadas padrão de merge + +1. branch de implementação -> `develop` ou branch de fase; +2. branch de fase -> `develop`; +3. `develop` -> `main`; +4. `hotfix/*` -> `main` quando explicitamente permitido. + +Projetos que utilizam trunk-based development devem adaptar `.github/workflows/main-source-branch.yml`, em vez de copiar este modelo sem alterações. + +## Nomenclatura + +Use caminhos em letras minúsculas, com hífens, pontos, underscores ou escopos aninhados, por exemplo: + +- `feat/project-setup`; +- `task/setup/customize-labels`; +- `fix/project-sync-pagination`; +- `hotfix/workflow-permissions`. From a12eadaa190fffb8d82d350fb0de4f92b0c576ef Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:26:20 -0300 Subject: [PATCH 037/130] docs: add project setup runbook --- docs/repo/project-setup-runbook.pt-BR.md | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/repo/project-setup-runbook.pt-BR.md diff --git a/docs/repo/project-setup-runbook.pt-BR.md b/docs/repo/project-setup-runbook.pt-BR.md new file mode 100644 index 0000000..8547e99 --- /dev/null +++ b/docs/repo/project-setup-runbook.pt-BR.md @@ -0,0 +1,84 @@ +# Runbook — GitHub Project Setup + +## Objetivo + +Instalar e operar as automações deste repositório em outro projeto sem sobrescrever arquivos existentes ou executar alterações remotas antes de uma revisão. + +## 1. Validar a ferramenta + +```bash +make check +``` + +O comando compila o pacote, valida a estrutura do repositório e executa os testes. + +## 2. Inspecionar o repositório-alvo + +Registre a linguagem, framework, comandos de teste, branches principais, labels, milestones, Project v2 e workflows existentes. Para descoberta automática inicial: + +```bash +make discover TARGET=../meu-projeto REPO=owner/repositorio +``` + +## 3. Simular a instalação + +```bash +make init-dry TARGET=../meu-projeto PROFILE=core +``` + +Use `PROFILE=godot` apenas quando o projeto realmente utilizar Godot. + +## 4. Instalar os arquivos + +```bash +make init TARGET=../meu-projeto +``` + +Arquivos existentes são preservados. A substituição consciente exige `FORCE=1`. + +## 5. Personalizar + +Edite no repositório-alvo: + +- `project_setup.json`; +- `config/project/labels.json`; +- `config/project/milestones.json`; +- `config/project/project-definition.json`; +- `config/stories/backlog-manifest.json`; +- workflows e templates em `.github/`. + +Mantenha `runIssueGeneration` e `runProjectCreation` desativados até concluir a personalização. + +## 6. Configurar autenticação + +Para execução local, configure `GITHUB_TOKEN`, `GH_TOKEN` ou `PROJECT_SETUP_PAT`. Uma sessão autenticada do GitHub CLI também pode ser usada por meio de `gh auth token`. + +Para Actions, configure o secret `PROJECT_SETUP_PAT` quando Project v2 ou permissões adicionais forem necessários. + +## 7. Revisar o plano + +```bash +make plan TARGET=../meu-projeto REPO=owner/repositorio +``` + +Revise integralmente a saída antes de continuar. + +## 8. Aplicar + +```bash +make apply TARGET=../meu-projeto REPO=owner/repositorio +``` + +## 9. Validar no GitHub + +Confirme labels, milestones, ausência de issues duplicadas, disponibilidade do workflow `Project setup`, validação dos PRs e criação do Project v2 somente quando solicitada. + +## Recuperação + +1. execute `python -m project_setup doctor` no repositório-alvo; +2. confirme o token e suas permissões; +3. execute novamente com `--dry-run`; +4. corrija o manifest responsável; +5. evite `FORCE=1` até identificar o arquivo conflitante. + +O sincronismo de labels e milestones é idempotente. A geração de issues não é idempotente e não deve ser repetida sem revisar as issues existentes. From f077778ad92a8c407ec07002f9470c40c9f8baa6 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:26:34 -0300 Subject: [PATCH 038/130] docs: describe project_setup architecture --- docs/repo/project-setup-shared-tool.md | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/repo/project-setup-shared-tool.md diff --git a/docs/repo/project-setup-shared-tool.md b/docs/repo/project-setup-shared-tool.md new file mode 100644 index 0000000..9b1a816 --- /dev/null +++ b/docs/repo/project-setup-shared-tool.md @@ -0,0 +1,53 @@ +# Project Setup Shared Tool + +## Package + +The reusable engine is the Python package `project_setup`. + +Entrypoints: + +```bash +project-setup --help +project_setup --help +python -m project_setup --help +``` + +## Distribution model + +The installer embeds the package and managed automation files directly in the target repository. This makes Actions runs reproducible and avoids requiring a published package. + +```bash +python -m project_setup init --target ../target-repository +``` + +Existing files are preserved unless `--force` is explicitly selected. + +## Configuration + +`project_setup.json` controls the API phase and points to four manifests: + +- labels; +- milestones; +- Project v2 definition; +- backlog stories and tasks. + +Dry-run, Project creation and issue generation are independently configurable. + +## Discovery + +The `discover` command detects common Python, Node.js, Go, Java, Rust and .NET markers. It reports authentication status and prints the recommended `apply` command before any write operation. + +## Profiles + +- `core`: repository-neutral setup. +- `godot`: core plus the optional Godot smoke workflow stored under `templates/profiles/godot`. + +Language- or framework-specific checks should be added as profiles instead of expanding the core workflow. + +## Automation boundaries + +The tool automates repository files, labels, milestones, issues, sub-issues and Project v2 fields/items. Branch protection, rulesets and Project views remain outside the automated core. + +## Authentication + +The CLI checks `GITHUB_TOKEN`, `GH_TOKEN`, `PROJECT_SETUP_PAT`, and finally an authenticated `gh` CLI session. The Actions workflow uses `PROJECT_SETUP_PAT` when configured and otherwise falls back to `github.token` for repository-scoped operations. From cd87cb231dac8f1487c5bfd1bdfe21f175358a68 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:26:47 -0300 Subject: [PATCH 039/130] docs: add reusable project setup workflow template --- docs/repo/project-setup.workflow-template.yml | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/repo/project-setup.workflow-template.yml diff --git a/docs/repo/project-setup.workflow-template.yml b/docs/repo/project-setup.workflow-template.yml new file mode 100644 index 0000000..ae7ccb0 --- /dev/null +++ b/docs/repo/project-setup.workflow-template.yml @@ -0,0 +1,38 @@ +# Reference copy of .github/workflows/project-setup.yml. +# The installer copies the active workflow directly; keep this file only for documentation. +name: Project setup + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Plan changes without writing to GitHub" + required: true + default: true + type: boolean + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Run project setup + env: + GITHUB_TOKEN: ${{ secrets.PROJECT_SETUP_PAT || github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + if [ "${{ inputs.dry_run }}" = "true" ]; then + python -m project_setup apply --dry-run + else + python -m project_setup apply --no-dry-run + fi From 6e0fa3dca9350b63432181985df10c28c5b1cc4b Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:27:08 -0300 Subject: [PATCH 040/130] docs: align documentation guide with project_setup --- docs/DOCUMENTATION-GUIDE.md | 149 +++++++++++------------------------- 1 file changed, 46 insertions(+), 103 deletions(-) diff --git a/docs/DOCUMENTATION-GUIDE.md b/docs/DOCUMENTATION-GUIDE.md index 962c085..d68b3bc 100644 --- a/docs/DOCUMENTATION-GUIDE.md +++ b/docs/DOCUMENTATION-GUIDE.md @@ -1,117 +1,60 @@ # Documentation Guide -This guide maps every documentation artifact in this repository to the config files and CLI commands that drive it. -When you add or change a doc, update the corresponding config. When you change a config, update the corresponding doc. +This guide maps the reusable configuration, workflows and operational documentation maintained by GitHub Project Setup. ---- +## Config and documentation alignment -## Config ↔ Doc alignment +| Configuration | Purpose | Documentation | +| --- | --- | --- | +| `project_setup.json` | File paths and safe execution defaults | `docs/repo/project-setup-shared-tool.md`, `docs/repo/project-setup-runbook.pt-BR.md` | +| `config/project/labels.json` | Label names, colors and descriptions | `docs/repo/project-board-policy.md` | +| `config/project/milestones.json` | Milestone definitions | `docs/milestones/MILESTONE-TEMPLATE.md` | +| `config/project/project-definition.json` | Project v2 fields and options | `docs/repo/project-board-policy.md` | +| `config/stories/backlog-manifest.json` | Phases, stories and tasks | milestone and story documentation in the target repository | -| Config file | Purpose | Corresponding doc(s) | -|-------------|---------|----------------------| -| `config/project/milestones.json` | Milestone titles and due dates | `docs/milestones/MN-.md` (one per milestone) | -| `config/project/labels.json` | Label names, colors, descriptions | `docs/repo/project-board-policy.md` (status baseline) | -| `config/project/project-definition.json` | Board name, custom fields, views | `docs/repo/project-board-policy.md` | -| `config/stories/backlog-manifest.json` | Milestones → user stories → tasks | `docs/stories/story-index.md`, `docs/milestones/MN-.md` | -| `config/phases/phase-review-policy.json` | Review layers and responsible pairs per milestone | `docs/repo/review-policy.md` | -| `governance.bootstrap.json` | Entry point — file paths and default flags | `docs/repo/governance-shared-tool.md`, `docs/repo/governance-bootstrap-runbook.pt-BR.md` | +## Workflows and sources ---- +| Workflow | Purpose | Source of behavior | +| --- | --- | --- | +| `.github/workflows/project-setup.yml` | Manual dry-run or live setup | `project_setup/cli.py`, `project_setup/runner.py`, `project_setup.json` | +| `.github/workflows/auto-label.yml` | Infer labels for issues and PRs | `project_setup/auto_label.py` | +| `.github/workflows/pr-metadata.yml` | Validate branch names and PR metadata | `project_setup/pr_validation.py` | +| `.github/workflows/main-source-branch.yml` | Restrict PR sources targeting `main` | `docs/repo/branching-policy.md` | +| `.github/workflows/repo-quality.yml` | Validate this tool repository | `Makefile`, `scripts/validation/repo_quality.py`, `tests/` | -## Workflows ↔ Docs +Framework-specific workflows belong under `templates/profiles//` and are copied only when that profile is selected. -| Workflow | What it does | Where to configure it | -|----------|--------------|-----------------------| -| `governance-bootstrap.yml` | Syncs labels, milestones, project board and issues | `governance.bootstrap.json` + `config/` | -| `auto-label.yml` | Infers and applies labels to issues and PRs | `governance_bootstrap/auto_label.py` | -| `pr-metadata.yml` | Validates PR body has required sections and an issue link | Inline bash check in the workflow | -| `branch-naming.yml` | Enforces branch naming convention | `docs/repo/branching-policy.md` | -| `main-source-branch.yml` | Ensures PRs to `main` come from `develop` | `docs/repo/branching-policy.md` | +## Adding a milestone template ---- +1. Add the milestone to `config/project/milestones.json`. +2. Add its phase mapping and required options to `config/project/project-definition.json`. +3. Add stories under `phases` in `config/stories/backlog-manifest.json`. +4. Copy and complete `docs/milestones/MILESTONE-TEMPLATE.md` in the target repository when milestone documentation is useful. +5. Run a dry-run: -## How to create documentation for a new milestone - -1. **Define the milestone** in `config/project/milestones.json`: - ```json - {"title": "MN", "description": "Short description", "due_on": "YYYY-MM-DDT00:00:00Z"} - ``` - -2. **Add the milestone's stories and tasks** in `config/stories/backlog-manifest.json`: - ```json - { - "milestone": "MN", - "stories": [ - { - "storyId": "US-NN", - "title": "US-NN | Story title", - "labels": ["type:user-story", "priority:high", "test:automated"], - "body": "As a ..., I want ... so that ...", - "acceptanceCriteria": "- Criterion 1\n- Criterion 2", - "testStrategy": "- automated", - "dod": "- Implementation complete\n- Tests passing\n- Reviewed and merged", - "tasks": ["T-NN.1 | Task title"] - } - ] - } - ``` - -3. **Copy the milestone template** to `docs/milestones/MN-.md`: - ```bash - cp docs/milestones/MILESTONE-TEMPLATE.md docs/milestones/MN-my-feature.md - ``` - Fill in the objective, scope, stories table, risks and exit criteria. - -4. **Update the story index** at `docs/stories/story-index.md`: - ```markdown - - Milestone MN: US-NN, US-NN+1, ... - ``` - -5. **Assign responsible pairs** in `config/phases/phase-review-policy.json`: - ```json - "milestoneResponsiblePairs": { - "MN": ["@username1", "@username2"] - } - ``` - -6. **Run the bootstrap** to apply labels, milestones and generate issues: - ```bash - python -m governance_bootstrap bootstrap --repo owner/repo --dry-run - # review output, then: - python -m governance_bootstrap bootstrap --repo owner/repo --no-dry-run - ``` - ---- +```bash +python -m project_setup apply --repo owner/repository --dry-run +``` -## Folder structure reference +6. Apply only after reviewing the proposed changes: +```bash +python -m project_setup apply --repo owner/repository --no-dry-run ``` -docs/ - milestones/ - MILESTONE-TEMPLATE.md # Copy this for each milestone - MN-.md # One per milestone (you create these) - phases/ - README.md # Guide for milestone docs (this folder is the old "phases" home) - stories/ - README.md - story-index.md # All user stories grouped by milestone - repo/ - branching-policy.md # Branch naming and merge layer conventions - review-policy.md # PR review rules per merge layer - dod-policy.md # Definition of Done per item type and milestone - project-board-policy.md # Required board fields and status values - testing-policy.md # Test type priority order and validation strategy - handoff-policy.md # What to register when ownership changes - governance-shared-tool.md # How to use this toolkit in another repo - governance-bootstrap-runbook.pt-BR.md # Step-by-step runbook -config/ - project/ - labels.json # All labels — must match project-board-policy.md - milestones.json # Milestone titles and dates — must match docs/milestones/ - project-definition.json # Board fields — must match project-board-policy.md - stories/ - backlog-manifest.json # Milestones → stories → tasks — must match docs/milestones/ - phases/ - phase-review-policy.json # Review layers and responsible pairs — must match review-policy.md -governance.bootstrap.json # Entry point: file paths and CLI defaults + +## Repository structure + +```text +project_setup/ Reusable Python package +project_setup.json File paths and execution defaults +config/project/ Labels, milestones and Project v2 definition +config/stories/ Backlog manifest +.github/workflows/ Generic active workflows +templates/profiles/ Optional framework-specific workflows +docs/repo/ Operational policies and runbooks +scripts/validation/ Cross-platform validation entrypoints +tests/ Unit and installation tests +Makefile Human and AI-oriented command interface ``` + +When a configuration contract changes, update its loader, tests, README and relevant runbook in the same pull request. From 06832d9b93ddf383c87de1dc4f1c4fb0ed6dfe44 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:27:55 -0300 Subject: [PATCH 041/130] refactor: remove legacy package namespace --- governance_bootstrap/__init__.py | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 governance_bootstrap/__init__.py diff --git a/governance_bootstrap/__init__.py b/governance_bootstrap/__init__.py deleted file mode 100644 index d73763e..0000000 --- a/governance_bootstrap/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Reusable GitHub governance bootstrap tooling.""" - -__all__ = ["__version__"] -__version__ = "0.1.0" From f1376dfe279fa370e91a6367191b00e1cf606ee9 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:28:20 -0300 Subject: [PATCH 042/130] refactor: remove legacy module entrypoint --- governance_bootstrap/__main__.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 governance_bootstrap/__main__.py diff --git a/governance_bootstrap/__main__.py b/governance_bootstrap/__main__.py deleted file mode 100644 index a049ad7..0000000 --- a/governance_bootstrap/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .cli import main - - -if __name__ == "__main__": - raise SystemExit(main()) From e18e1a11842f7fcdcbd9e82b559671d6ffd023bd Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:28:34 -0300 Subject: [PATCH 043/130] refactor: remove legacy auto-label module --- governance_bootstrap/auto_label.py | 152 ----------------------------- 1 file changed, 152 deletions(-) delete mode 100644 governance_bootstrap/auto_label.py diff --git a/governance_bootstrap/auto_label.py b/governance_bootstrap/auto_label.py deleted file mode 100644 index 50ca765..0000000 --- a/governance_bootstrap/auto_label.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -import json -import re - -from .github import API_BASE, GitHubRequestError, GitHubClient - - -LABEL_PREFIXES = ("type:", "priority:", "test:") - - -def load_event(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -def load_allowed_labels(path: str) -> set[str]: - with open(path, "r", encoding="utf-8") as f: - return {item["name"] for item in json.load(f)} - - -def label_names(item: dict) -> set[str]: - return {label["name"] for label in item.get("labels", [])} - - -def find_test_label(text: str) -> str | None: - patterns = [ - r"Test strategy\s*\n+\s*(automated|smoke|manual)\b", - r"Expected test type\s*\n+\s*(automated|smoke|manual)\b", - r"Test type:\s*(automated|smoke|manual)\b", - ] - for pattern in patterns: - match = re.search(pattern, text, re.IGNORECASE) - if match: - return f"test:{match.group(1).lower()}" - return None - - -def find_priority_label(text: str) -> str | None: - match = re.search(r"Severity\s*\n+\s*(critical|high|medium|low)\b", text, re.IGNORECASE) - return f"priority:{match.group(1).lower()}" if match else None - - -def title_type_label(title: str) -> str | None: - if re.match(r"^US-\d+", title, re.IGNORECASE): - return "type:user-story" - if re.match(r"^T-\d+", title, re.IGNORECASE): - return "type:task" - if re.match(r"^BUG\b", title, re.IGNORECASE): - return "type:bug" - return None - - -def linked_issue_number(text: str) -> int | None: - match = re.search(r"\b(?:closes|fixes|resolves)\s+#(\d+)\b", text, re.IGNORECASE) - return int(match.group(1)) if match else None - - -def labels_from_linked_issue(client: GitHubClient, repo: str, number: int) -> set[str]: - issue = client.request_json("GET", f"{API_BASE}/repos/{repo}/issues/{number}") - return {name for name in label_names(issue) if name.startswith(LABEL_PREFIXES)} - - -def branch_type_label(branch: str) -> str | None: - prefix = branch.split("/", 1)[0].lower() - if prefix in {"fix", "hotfix"}: - return "type:bug" - if prefix in {"docs", "refactor", "test"}: - return "type:repo" - return None - - -def infer_issue_labels(issue: dict) -> set[str]: - current = label_names(issue) - body = issue.get("body") or "" - labels = set() - - type_label = next((label for label in current if label.startswith("type:")), None) - labels.add(type_label or title_type_label(issue.get("title", "")) or "") - - priority_label = find_priority_label(body) - if priority_label: - labels.add(priority_label) - - test_label = find_test_label(body) - if test_label: - labels.add(test_label) - - if not any(label.startswith("status:") for label in current): - labels.add("status:backlog") - - return {label for label in labels if label} - - -def infer_pr_labels(repo: str, pr: dict, client: GitHubClient | None) -> set[str]: - body = pr.get("body") or "" - labels = set() - - number = linked_issue_number(body) - if number and client: - try: - labels.update(labels_from_linked_issue(client, repo, number)) - except GitHubRequestError as exc: - print(f"warning: could not read linked issue #{number}: {exc}") - - test_label = find_test_label(body) - if test_label: - labels.add(test_label) - - if not any(label.startswith("type:") for label in labels): - type_label = branch_type_label(pr.get("head", {}).get("ref", "")) - if type_label: - labels.add(type_label) - - return labels - - -def event_target(event: dict) -> tuple[str, dict, int]: - if "issue" in event and "pull_request" not in event["issue"]: - return "issue", event["issue"], event["issue"]["number"] - if "pull_request" in event: - return "pull_request", event["pull_request"], event["pull_request"]["number"] - raise RuntimeError("Unsupported event payload: expected issue or pull_request") - - -def apply_auto_labels(repo: str, event_path: str, labels_file: str, client: GitHubClient | None, dry_run: bool = False) -> int: - event = load_event(event_path) - allowed = load_allowed_labels(labels_file) - target_type, item, number = event_target(event) - current = label_names(item) - - inferred = infer_issue_labels(item) if target_type == "issue" else infer_pr_labels(repo, item, client) - labels = sorted(label for label in inferred if label in allowed and label not in current) - if not labels: - print(f"No labels to add for {target_type} #{number}") - return 0 - - print(f"Labels to add to {target_type} #{number}: {', '.join(labels)}") - if dry_run: - return 0 - if not client: - print("Missing GITHUB_TOKEN or GH_TOKEN") - return 1 - - try: - client.request_json("POST", f"{API_BASE}/repos/{repo}/issues/{number}/labels", {"labels": labels}) - except GitHubRequestError as exc: - if exc.status == 403: - print(f"warning: token cannot add labels to {target_type} #{number}; skipping") - return 0 - raise - return 0 From dd424641445631585fbb81d4c46a6f9aac057d1a Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:28:49 -0300 Subject: [PATCH 044/130] refactor: remove legacy bootstrap runner --- governance_bootstrap/bootstrap.py | 44 ------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 governance_bootstrap/bootstrap.py diff --git a/governance_bootstrap/bootstrap.py b/governance_bootstrap/bootstrap.py deleted file mode 100644 index 4a5d979..0000000 --- a/governance_bootstrap/bootstrap.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -import json - -from .github import GitHubClient -from .issue_milestones import sync_issue_milestones -from .issues import generate_issues -from .labels import sync_labels -from .milestones import sync_milestones -from .project import create_project, sync_project - - -def load_bootstrap_config(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -def run_bootstrap( - client: GitHubClient, - repo: str, - config: dict, - *, - dry_run: bool, - run_labels: bool, - run_milestones: bool, - run_project_creation: bool, - run_issue_generation: bool, - link_subissues: bool, -) -> None: - if run_labels: - print("==> Sync labels") - sync_labels(client, repo, config["labelsFile"], dry_run=dry_run) - if run_milestones: - print("==> Sync milestones") - sync_milestones(client, repo, config["milestonesFile"], dry_run=dry_run) - if run_project_creation: - print("==> Create project v2") - create_project(client, repo, config["projectDefinitionFile"], dry_run=dry_run) - if run_issue_generation: - print("==> Generate issues/tasks") - generate_issues(repo, config["backlogManifestFile"], dry_run=dry_run, link_subissues=link_subissues and not dry_run) - - print("Governance bootstrap finished.") - From 54e5025db9711c5a7350d018a48808d2aae9b419 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:29:04 -0300 Subject: [PATCH 045/130] refactor: remove legacy CLI module --- governance_bootstrap/cli.py | 212 ------------------------------------ 1 file changed, 212 deletions(-) delete mode 100644 governance_bootstrap/cli.py diff --git a/governance_bootstrap/cli.py b/governance_bootstrap/cli.py deleted file mode 100644 index 367a1c8..0000000 --- a/governance_bootstrap/cli.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -import argparse -import os - -from .bootstrap import load_bootstrap_config, run_bootstrap -from .auto_label import apply_auto_labels -from .discovery import cmd_discover -from .github import GitHubClient, get_token, require_client -from .issue_milestones import sync_issue_milestones -from .issues import generate_issues -from .labels import sync_labels -from .milestones import sync_milestones -from .project import create_project, sync_project - - -def repo_arg(value: str | None) -> str: - repo = value or os.getenv("GITHUB_REPOSITORY") - if not repo: - raise SystemExit("Missing --repo and GITHUB_REPOSITORY") - return repo - - -def optional_client() -> GitHubClient | None: - token = get_token() - return GitHubClient(token) if token else None - - -def cmd_labels_sync(args) -> int: - client = GitHubClient("") if args.dry_run else require_client() - sync_labels(client, repo_arg(args.repo), args.file, dry_run=args.dry_run) - return 0 - - -def cmd_milestones_sync(args) -> int: - client = GitHubClient("") if args.dry_run else require_client() - sync_milestones(client, repo_arg(args.repo), args.file, dry_run=args.dry_run) - return 0 - - -def cmd_issues_generate(args) -> int: - generate_issues(repo_arg(args.repo), args.file, dry_run=args.dry_run, link_subissues=args.link_subissues) - return 0 - - -def cmd_project_create(args) -> int: - client = GitHubClient("") if args.dry_run else require_client() - create_project(client, repo_arg(args.repo), args.file, dry_run=args.dry_run) - return 0 - - -def cmd_project_sync(args) -> int: - sync_project( - require_client(), - repo_arg(args.repo), - args.file, - args.project_number, - owner=args.owner, - issue_state=args.issue_state, - link_subissue_items=args.link_subissues, - only_link_subissues=args.only_link_subissues, - dry_run=args.dry_run, - ) - return 0 - - -def cmd_issue_milestones_sync(args) -> int: - sync_issue_milestones(require_client(), repo_arg(args.repo), clear_not_planned=args.clear_not_planned, dry_run=args.dry_run) - return 0 - - -def cmd_auto_label_apply(args) -> int: - event_path = args.event_path or os.getenv("GITHUB_EVENT_PATH") - if not event_path: - print("Missing --event-path or GITHUB_EVENT_PATH") - return 1 - return apply_auto_labels(repo_arg(args.repo), event_path, args.labels_file, optional_client(), dry_run=args.dry_run) - - -def cmd_bootstrap(args) -> int: - config = load_bootstrap_config(args.config) - defaults = config.get("defaults", {}) - dry_run = args.dry_run if args.dry_run is not None else defaults.get("dryRun", True) - repo = repo_arg(args.repo) - client = GitHubClient("") if dry_run else require_client() - - run_labels = args.run_labels if args.run_labels is not None else defaults.get("runLabels", True) - run_milestones = args.run_milestones if args.run_milestones is not None else defaults.get("runMilestones", True) - run_project_creation = args.run_project_creation if args.run_project_creation is not None else defaults.get("runProjectCreation", False) - run_issue_generation = args.run_issue_generation if args.run_issue_generation is not None else defaults.get("runIssueGeneration", True) - link_subissues = args.link_subissues if args.link_subissues is not None else defaults.get("linkSubissues", False) - - run_bootstrap( - client, - repo, - config, - dry_run=dry_run, - run_labels=run_labels, - run_milestones=run_milestones, - run_project_creation=run_project_creation, - run_issue_generation=run_issue_generation, - link_subissues=link_subissues, - ) - return 0 - - -def add_bool_pair(parser: argparse.ArgumentParser, name: str, dest: str, help_text: str) -> None: - group = parser.add_mutually_exclusive_group() - group.add_argument(f"--{name}", dest=dest, action="store_true", default=None, help=help_text) - group.add_argument(f"--skip-{name.removeprefix('run-')}", dest=dest, action="store_false") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="governance", description="Reusable GitHub governance bootstrap CLI") - sub = parser.add_subparsers(dest="command", required=True) - - labels = sub.add_parser("labels") - labels_sub = labels.add_subparsers(dest="labels_command", required=True) - labels_sync = labels_sub.add_parser("sync") - labels_sync.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - labels_sync.add_argument("--file", default="config/project/labels.json") - labels_sync.add_argument("--dry-run", action="store_true") - labels_sync.set_defaults(func=cmd_labels_sync) - - milestones = sub.add_parser("milestones") - milestones_sub = milestones.add_subparsers(dest="milestones_command", required=True) - milestones_sync = milestones_sub.add_parser("sync") - milestones_sync.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - milestones_sync.add_argument("--file", default="config/project/milestones.json") - milestones_sync.add_argument("--dry-run", action="store_true") - milestones_sync.set_defaults(func=cmd_milestones_sync) - - issues = sub.add_parser("issues") - issues_sub = issues.add_subparsers(dest="issues_command", required=True) - issues_generate = issues_sub.add_parser("generate") - issues_generate.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - issues_generate.add_argument("--file", default="config/stories/backlog-manifest.json") - issues_generate.add_argument("--dry-run", action="store_true") - issues_generate.add_argument("--link-subissues", action="store_true") - issues_generate.set_defaults(func=cmd_issues_generate) - - project = sub.add_parser("project") - project_sub = project.add_subparsers(dest="project_command", required=True) - project_create = project_sub.add_parser("create") - project_create.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - project_create.add_argument("--file", default="config/project/project-definition.json") - project_create.add_argument("--dry-run", action="store_true") - project_create.set_defaults(func=cmd_project_create) - - project_sync = project_sub.add_parser("sync") - project_sync.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - project_sync.add_argument("--owner") - project_sync.add_argument("--file", default="config/project/project-definition.json") - project_sync.add_argument("--project-number", type=int, required=True) - project_sync.add_argument("--issue-state", default="open", choices=["open", "closed", "all"]) - project_sync.add_argument("--link-subissues", action="store_true") - project_sync.add_argument("--only-link-subissues", action="store_true") - project_sync.add_argument("--dry-run", action="store_true") - project_sync.set_defaults(func=cmd_project_sync) - - issue_milestones = sub.add_parser("issue-milestones") - issue_milestones_sub = issue_milestones.add_subparsers(dest="issue_milestones_command", required=True) - issue_milestones_sync = issue_milestones_sub.add_parser("sync") - issue_milestones_sync.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - issue_milestones_sync.add_argument("--clear-not-planned", action="store_true") - issue_milestones_sync.add_argument("--dry-run", action="store_true") - issue_milestones_sync.set_defaults(func=cmd_issue_milestones_sync) - - auto_label = sub.add_parser("auto-label") - auto_label_sub = auto_label.add_subparsers(dest="auto_label_command", required=True) - auto_label_apply = auto_label_sub.add_parser("apply") - auto_label_apply.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - auto_label_apply.add_argument("--event-path", default=os.getenv("GITHUB_EVENT_PATH")) - auto_label_apply.add_argument("--labels-file", default="config/project/labels.json") - auto_label_apply.add_argument("--dry-run", action="store_true") - auto_label_apply.set_defaults(func=cmd_auto_label_apply) - - discover = sub.add_parser("discover") - discover.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - discover.add_argument("--config", default="governance.bootstrap.json") - discover.add_argument("--root", default=".") - discover.add_argument("--project-type") - discover.add_argument("--auto", action="store_true") - discover.add_argument("--apply", action="store_true") - discover.set_defaults(func=cmd_discover) - - bootstrap = sub.add_parser("bootstrap") - bootstrap.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - bootstrap.add_argument("--config", default="governance.bootstrap.json") - dry_run_group = bootstrap.add_mutually_exclusive_group() - dry_run_group.add_argument("--dry-run", dest="dry_run", action="store_true", default=None) - dry_run_group.add_argument("--no-dry-run", dest="dry_run", action="store_false") - add_bool_pair(bootstrap, "run-labels", "run_labels", "Run labels sync") - add_bool_pair(bootstrap, "run-milestones", "run_milestones", "Run milestones sync") - add_bool_pair(bootstrap, "run-project-creation", "run_project_creation", "Create project v2") - add_bool_pair(bootstrap, "run-issue-generation", "run_issue_generation", "Generate issues/tasks") - link_group = bootstrap.add_mutually_exclusive_group() - link_group.add_argument("--link-subissues", dest="link_subissues", action="store_true", default=None) - link_group.add_argument("--no-link-subissues", dest="link_subissues", action="store_false") - bootstrap.set_defaults(func=cmd_bootstrap) - - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - return args.func(args) - - -if __name__ == "__main__": - raise SystemExit(main()) From 837a7a4edd806c4b8e725961bb9b5be6eaeaacc2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:29:20 -0300 Subject: [PATCH 046/130] refactor: remove legacy discovery module --- governance_bootstrap/discovery.py | 238 ------------------------------ 1 file changed, 238 deletions(-) delete mode 100644 governance_bootstrap/discovery.py diff --git a/governance_bootstrap/discovery.py b/governance_bootstrap/discovery.py deleted file mode 100644 index 9fe435c..0000000 --- a/governance_bootstrap/discovery.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -import os -import shutil -import subprocess -import sys - -from .bootstrap import load_bootstrap_config, run_bootstrap -from .github import GitHubClient, get_token, require_client - - -SUPPORTED_PROJECT_TYPES = ["python", "node", "go", "java", "rust", "dotnet", "generic"] -PROJECT_MARKERS = { - "python": ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], - "node": ["package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock"], - "go": ["go.mod"], - "java": ["pom.xml", "build.gradle", "build.gradle.kts"], - "rust": ["Cargo.toml"], - "dotnet": ["*.csproj", "*.sln"], -} - - -@dataclass(frozen=True) -class AuthStatus: - configured: bool - source: str - detail: str - - -@dataclass(frozen=True) -class ProjectMatch: - project_type: str - markers: tuple[str, ...] - - -def detect_auth_status() -> AuthStatus: - token = get_token() - if token: - return AuthStatus(True, "environment", "GITHUB_TOKEN or GH_TOKEN is set") - - gh = shutil.which("gh") - if gh: - result = subprocess.run([gh, "auth", "status", "--hostname", "github.com"], capture_output=True, text=True, timeout=10) - if result.returncode == 0: - return AuthStatus(True, "gh", "gh auth status succeeded") - detail = (result.stderr or result.stdout or "gh auth status failed").strip() - return AuthStatus(False, "gh", detail) - - return AuthStatus(False, "missing", "No GITHUB_TOKEN/GH_TOKEN and gh CLI not found") - - -def _collect_markers(root: Path, patterns: list[str]) -> list[str]: - markers: list[str] = [] - for pattern in patterns: - if "*" in pattern: - markers.extend(sorted(str(path.relative_to(root)) for path in root.glob(pattern) if path.is_file())) - continue - candidate = root / pattern - if candidate.exists(): - markers.append(pattern) - return markers - - -def detect_project_matches(root: str | os.PathLike[str]) -> list[ProjectMatch]: - root_path = Path(root) - if not root_path.exists(): - raise FileNotFoundError(f"Project root does not exist: {root_path}") - - matches: list[ProjectMatch] = [] - for project_type in ("python", "node", "go", "java", "rust", "dotnet"): - markers = _collect_markers(root_path, PROJECT_MARKERS[project_type]) - if markers: - matches.append(ProjectMatch(project_type, tuple(markers))) - - if matches: - return matches - return [ProjectMatch("generic", tuple())] - - -def resolve_project_match(root: str | os.PathLike[str], override: str | None = None) -> ProjectMatch: - if override: - if override not in SUPPORTED_PROJECT_TYPES: - raise ValueError(f"Unsupported project type override: {override}") - matches = detect_project_matches(root) - markers = matches[0].markers if matches and matches[0].project_type == override else tuple() - return ProjectMatch(override, markers) - - matches = detect_project_matches(root) - if len(matches) == 1: - return matches[0] - - if not sys.stdin.isatty(): - return matches[0] - - print("Multiple project types detected:") - for index, match in enumerate(matches, start=1): - print(f" {index}. {match.project_type} ({', '.join(match.markers)})") - print(" 0. generic") - - while True: - choice = input("Choose project type [1]: ").strip() - if choice in {"", "1"}: - return matches[0] - if choice == "0": - return ProjectMatch("generic", tuple()) - if choice.isdigit(): - index = int(choice) - if 1 <= index <= len(matches): - return matches[index - 1] - print("Invalid choice, try again.") - - -def _prompt_bool(question: str, default: bool) -> bool: - suffix = "[Y/n]" if default else "[y/N]" - while True: - answer = input(f"{question} {suffix} ").strip().lower() - if not answer: - return default - if answer in {"y", "yes", "true", "1"}: - return True - if answer in {"n", "no", "false", "0"}: - return False - print("Please answer yes or no.") - - -def _prompt_confirm(message: str) -> bool: - while True: - answer = input(f"{message} [y/N] ").strip().lower() - if not answer: - return False - if answer in {"y", "yes", "true", "1"}: - return True - if answer in {"n", "no", "false", "0"}: - return False - print("Please answer yes or no.") - - -def build_bootstrap_command(repo: str, config_path: str, dry_run: bool, run_labels: bool, run_milestones: bool, run_project_creation: bool, run_issue_generation: bool, link_subissues: bool) -> str: - parts = [ - "python -m governance_bootstrap bootstrap", - f"--repo {repo}", - f"--config {config_path}", - ] - parts.append("--dry-run" if dry_run else "--no-dry-run") - parts.append("--run-labels" if run_labels else "--skip-labels") - parts.append("--run-milestones" if run_milestones else "--skip-milestones") - parts.append("--run-project-creation" if run_project_creation else "--skip-project-creation") - parts.append("--run-issue-generation" if run_issue_generation else "--skip-issue-generation") - parts.append("--link-subissues" if link_subissues else "--no-link-subissues") - return " ".join(parts) - - -def cmd_discover(args) -> int: - config = load_bootstrap_config(args.config) - repo = args.repo or os.getenv("GITHUB_REPOSITORY") - if not repo: - print("Missing --repo and GITHUB_REPOSITORY") - return 1 - - auth = detect_auth_status() - print("==> GitHub auth") - if auth.configured: - print(f"Configured: yes ({auth.source})") - else: - print(f"Configured: no ({auth.source})") - print(auth.detail) - pat_name = config.get("workflowVar", "GOVERNANCE_PAT") - print(f"Expected workflow secret: {pat_name}") - return 1 - - print("==> Project detection") - try: - project = resolve_project_match(args.root, args.project_type) - except (FileNotFoundError, ValueError) as exc: - print(str(exc)) - return 1 - if project.project_type == "generic": - print("Detected project type: generic") - print("No common project markers found.") - else: - print(f"Detected project type: {project.project_type}") - if project.markers: - print(f"Markers: {', '.join(project.markers)}") - - defaults = config.get("defaults", {}) - interactive = sys.stdin.isatty() and not args.auto - dry_run = defaults.get("dryRun", True) - run_labels = defaults.get("runLabels", True) - run_milestones = defaults.get("runMilestones", True) - run_project_creation = defaults.get("runProjectCreation", False) - run_issue_generation = defaults.get("runIssueGeneration", True) - link_subissues = defaults.get("linkSubissues", False) - - print("==> Bootstrap options") - if interactive: - dry_run = _prompt_bool("Run in dry-run mode?", dry_run) - run_labels = _prompt_bool("Sync labels?", run_labels) - run_milestones = _prompt_bool("Sync milestones?", run_milestones) - run_project_creation = _prompt_bool("Create GitHub Project v2?", run_project_creation) - run_issue_generation = _prompt_bool("Generate issues/tasks?", run_issue_generation) - link_subissues = _prompt_bool("Link sub-issues when generating tasks?", link_subissues) - else: - print("Using config defaults (non-interactive).") - - print(f"Dry-run: {'yes' if dry_run else 'no'}") - print(f"Sync labels: {'yes' if run_labels else 'no'}") - print(f"Sync milestones: {'yes' if run_milestones else 'no'}") - print(f"Create project: {'yes' if run_project_creation else 'no'}") - print(f"Generate issues: {'yes' if run_issue_generation else 'no'}") - print(f"Link sub-issues: {'yes' if link_subissues else 'no'}") - - print("==> Recommended command") - command = build_bootstrap_command(repo, args.config, dry_run, run_labels, run_milestones, run_project_creation, run_issue_generation, link_subissues) - print(command) - - if args.apply: - if not sys.stdin.isatty(): - print("Confirmation required to run the selected command interactively.") - return 1 - if not _prompt_confirm("Run the selected bootstrap command now?"): - print("Aborted.") - return 1 - client = GitHubClient("") if dry_run else require_client() - run_bootstrap( - client, - repo, - config, - dry_run=dry_run, - run_labels=run_labels, - run_milestones=run_milestones, - run_project_creation=run_project_creation, - run_issue_generation=run_issue_generation, - link_subissues=link_subissues, - ) - - return 0 From f74fc1c9b89016ac8e073cb27ffd80109e406c15 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:29:38 -0300 Subject: [PATCH 047/130] refactor: remove legacy GitHub client --- governance_bootstrap/github.py | 106 --------------------------------- 1 file changed, 106 deletions(-) delete mode 100644 governance_bootstrap/github.py diff --git a/governance_bootstrap/github.py b/governance_bootstrap/github.py deleted file mode 100644 index 02a371c..0000000 --- a/governance_bootstrap/github.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import time -import urllib.error -import urllib.parse -import urllib.request - - -API_BASE = "https://api.github.com" -GRAPHQL_URL = f"{API_BASE}/graphql" -API_VERSION = "2022-11-28" -RETRYABLE_HTTP_STATUS = {502, 503, 504} - - -class GitHubRequestError(RuntimeError): - def __init__(self, method: str, url: str, status: int, details: str): - super().__init__(f"GitHub API request failed ({method} {url}) status={status}: {details}") - self.method = method - self.url = url - self.status = status - self.details = details - - -def get_token() -> str | None: - token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") - if token: - return token - - gh = shutil.which("gh") - if not gh: - return None - - result = subprocess.run([gh, "auth", "token"], capture_output=True, text=True) - if result.returncode != 0: - return None - token = result.stdout.strip() - return token or None - - -def split_repo(repo: str) -> tuple[str, str]: - if "/" not in repo: - raise ValueError("repository must use owner/name format") - return repo.split("/", 1) - - -class GitHubClient: - def __init__(self, token: str): - self.token = token - - def request_json(self, method: str, url: str, payload=None, accept: str = "application/vnd.github+json"): - headers = { - "Accept": accept, - "Authorization": f"Bearer {self.token}", - "X-GitHub-Api-Version": API_VERSION, - "Content-Type": "application/json", - } - data = json.dumps(payload).encode("utf-8") if payload is not None else None - for attempt in range(1, 6): - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(req) as res: - body = res.read().decode("utf-8") - return json.loads(body) if body else {} - except urllib.error.HTTPError as exc: - details = exc.read().decode("utf-8", errors="replace") - if exc.code in RETRYABLE_HTTP_STATUS and attempt < 5: - wait_seconds = attempt * 2 - print(f"warning: HTTP {exc.code} from GitHub; retrying in {wait_seconds}s") - time.sleep(wait_seconds) - continue - raise GitHubRequestError(method, url, exc.code, details) from exc - except urllib.error.URLError as exc: - if attempt < 5: - wait_seconds = attempt * 2 - print(f"warning: GitHub request failed; retrying in {wait_seconds}s: {exc.reason}") - time.sleep(wait_seconds) - continue - raise - - def paginated(self, url: str): - items = [] - page = 1 - while True: - sep = "&" if "?" in url else "?" - batch = self.request_json("GET", f"{url}{sep}per_page=100&page={page}") - items.extend(batch) - if len(batch) < 100: - return items - page += 1 - - def graphql(self, query: str, variables: dict | None = None): - data = self.request_json("POST", GRAPHQL_URL, {"query": query, "variables": variables or {}}) - if data.get("errors"): - raise RuntimeError(f"GraphQL error: {json.dumps(data['errors'], ensure_ascii=False)}") - return data["data"] - - -def require_client() -> GitHubClient: - token = get_token() - if not token: - raise SystemExit("Missing GITHUB_TOKEN or GH_TOKEN") - return GitHubClient(token) From 1c9b92ba41c1e55054db995fc89f3cdfca697c11 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:29:52 -0300 Subject: [PATCH 048/130] refactor: remove legacy issue milestone module --- governance_bootstrap/issue_milestones.py | 89 ------------------------ 1 file changed, 89 deletions(-) delete mode 100644 governance_bootstrap/issue_milestones.py diff --git a/governance_bootstrap/issue_milestones.py b/governance_bootstrap/issue_milestones.py deleted file mode 100644 index 9b7bae7..0000000 --- a/governance_bootstrap/issue_milestones.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import re - -from .github import API_BASE, GitHubClient, split_repo - - -def milestone_from_body(body: str) -> str | None: - match = re.search(r"-\s*Milestone:\s*([A-Za-z0-9_.-]+)", body or "") - return match.group(1) if match else None - - -def parent_issue_number_from_body(body: str) -> int | None: - match = re.search(r"Parent story:.*\(#(\d+)\)", body or "") - return int(match.group(1)) if match else None - - -def sync_issue_milestones(client: GitHubClient, repo: str, clear_not_planned: bool = False, dry_run: bool = False) -> None: - owner, name = split_repo(repo) - repo_base = f"{API_BASE}/repos/{owner}/{name}" - - milestones = client.paginated(f"{repo_base}/milestones?state=all") - milestone_by_title = {milestone["title"]: milestone for milestone in milestones} - issues = [ - issue - for issue in client.paginated(f"{repo_base}/issues?state=all&sort=created&direction=asc") - if "pull_request" not in issue - ] - - explicit_milestone_by_issue = {} - for issue in issues: - milestone = milestone_from_body(issue.get("body") or "") - if milestone: - explicit_milestone_by_issue[issue["number"]] = milestone - - updated = 0 - cleared = 0 - already_correct = 0 - unmapped = [] - - for issue in issues: - issue_number = issue["number"] - current = issue.get("milestone") - current_title = current["title"] if current else None - - if clear_not_planned and issue.get("state") == "closed" and issue.get("state_reason") == "not_planned": - if current_title: - if dry_run: - print(f"[DRY-RUN] Would clear milestone from not-planned issue #{issue_number}: {current_title}") - else: - client.request_json("PATCH", f"{repo_base}/issues/{issue_number}", {"milestone": None}) - print(f"cleared #{issue_number}: {current_title}") - cleared += 1 - else: - already_correct += 1 - continue - - target = explicit_milestone_by_issue.get(issue_number) - if not target: - parent_number = parent_issue_number_from_body(issue.get("body") or "") - if parent_number: - target = explicit_milestone_by_issue.get(parent_number) - - if not target: - unmapped.append((issue_number, issue["title"])) - continue - - milestone = milestone_by_title.get(target) - if not milestone: - raise RuntimeError(f"Milestone '{target}' referenced by issue #{issue_number} does not exist") - - if current_title == target: - already_correct += 1 - continue - - if dry_run: - print(f"[DRY-RUN] Would set issue #{issue_number}: {current_title or 'none'} -> {target}") - else: - client.request_json("PATCH", f"{repo_base}/issues/{issue_number}", {"milestone": milestone["number"]}) - print(f"updated #{issue_number}: {current_title or 'none'} -> {target}") - updated += 1 - - print(f"issues_checked={len(issues)}") - print(f"updated={updated}") - print(f"cleared_not_planned={cleared}") - print(f"already_correct={already_correct}") - print(f"unmapped={len(unmapped)}") - for issue_number, title in unmapped: - print(f"unmapped #{issue_number}: {title}") From ecced14c48f05c9a65d9f7ae628013e5778d04a0 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:30:08 -0300 Subject: [PATCH 049/130] refactor: remove legacy issue generation module --- governance_bootstrap/issues.py | 102 --------------------------------- 1 file changed, 102 deletions(-) delete mode 100644 governance_bootstrap/issues.py diff --git a/governance_bootstrap/issues.py b/governance_bootstrap/issues.py deleted file mode 100644 index 26f21dc..0000000 --- a/governance_bootstrap/issues.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess - - -def run_gh(cmd: list[str]) -> str: - result = subprocess.run(cmd, text=True, capture_output=True) - if result.returncode != 0: - raise RuntimeError(f"GitHub command failed. stderr:\n{result.stderr}") - return result.stdout.strip() - - -def create_issue(repo: str, title: str, body: str, labels: list[str]) -> int: - cmd = ["gh", "issue", "create", "--repo", repo, "--title", title, "--body", body] - for label in labels: - cmd += ["--label", label] - url = run_gh(cmd) - parts = url.rstrip("/").split("/") - if len(parts) < 2 or not parts[-1].isdigit(): - raise RuntimeError(f"Unexpected gh issue create output: {url}") - return int(parts[-1]) - - -def issue_node_id(repo: str, number: int) -> str: - owner, name = repo.split("/", 1) - return run_gh([ - "gh", "api", "graphql", - "-f", "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){issue(number:$number){id}}}", - "-f", f"owner={owner}", - "-f", f"repo={name}", - "-F", f"number={number}", - "--jq", ".data.repository.issue.id", - ]) - - -def add_sub_issue(repo: str, parent_number: int, child_number: int) -> None: - parent_id = issue_node_id(repo, parent_number) - child_id = issue_node_id(repo, child_number) - run_gh([ - "gh", "api", "graphql", - "-f", "query=mutation($parent:ID!,$child:ID!){addSubIssue(input:{issueId:$parent,subIssueId:$child}){clientMutationId}}", - "-f", f"parent={parent_id}", - "-f", f"child={child_id}", - ]) - - -def load_backlog(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - if "milestones" not in data: - raise ValueError("backlog manifest must contain milestones") - return data - - -def generate_issues(repo: str, manifest: str, dry_run: bool = False, link_subissues: bool = False) -> None: - if not repo: - repo = os.getenv("GITHUB_REPOSITORY", "") - if not repo: - raise SystemExit("Missing --repo and GITHUB_REPOSITORY") - - data = load_backlog(manifest) - for milestone_entry in data["milestones"]: - for story in milestone_entry["stories"]: - story_labels = list(dict.fromkeys(story["labels"] + data.get("defaultIssueLabels", []))) - story_body = ( - f"{story['body']}\n\n" - f"## Acceptance criteria\n{story.get('acceptanceCriteria', '- TBD')}\n\n" - f"## Test strategy\n{story.get('testStrategy', '- TBD')}\n\n" - f"## Definition of Done\n{story.get('dod', '- TBD')}\n\n" - f"- Milestone: {milestone_entry['milestone']}\n" - f"- Item type: user-story\n" - ) - if dry_run: - print(f"[DRY-RUN] Story: {story['title']} labels={story_labels}") - story_num = 0 - else: - story_num = create_issue(repo, story["title"], story_body, story_labels) - print(f"Created story #{story_num}: {story['title']}") - - for task_title in story.get("tasks", []): - task_labels = ["type:task", "status:backlog"] - task_body = ( - f"Parent story: {story['storyId']}" - + (f" (#{story_num})" if story_num else "") - + "\n\n" - + "## Technical scope\n- TBD\n\n" - + "## Completion criteria\n- TBD\n\n" - + "## Test strategy\n- TBD\n\n" - + "## Expected evidence\n- TBD\n\n" - + "## Definition of Done\n- TBD\n\n" - + "- Item type: task/sub-issue\n" - ) - if dry_run: - print(f"[DRY-RUN] Task: {task_title} labels={task_labels}") - continue - task_num = create_issue(repo, task_title, task_body, task_labels) - print(f" Created task #{task_num}: {task_title}") - if link_subissues: - add_sub_issue(repo, story_num, task_num) - print(f" Linked #{task_num} as sub-issue of #{story_num}") From c0cd32e726494f5aa48866dfc6bf8a72875f5f9f Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:30:25 -0300 Subject: [PATCH 050/130] refactor: remove legacy label module --- governance_bootstrap/labels.py | 41 ---------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 governance_bootstrap/labels.py diff --git a/governance_bootstrap/labels.py b/governance_bootstrap/labels.py deleted file mode 100644 index 41a3aa5..0000000 --- a/governance_bootstrap/labels.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -import json -import urllib.parse - -from .github import API_BASE, GitHubRequestError, GitHubClient, split_repo - - -def load_labels(path: str) -> list[dict]: - with open(path, "r", encoding="utf-8") as f: - labels = json.load(f) - if not isinstance(labels, list): - raise ValueError("labels manifest must be a JSON list") - for label in labels: - for key in ("name", "color"): - if key not in label: - raise ValueError(f"label is missing required key: {key}") - return labels - - -def sync_labels(client: GitHubClient, repo: str, labels_file: str, dry_run: bool = False) -> None: - owner, name = split_repo(repo) - labels = load_labels(labels_file) - base = f"{API_BASE}/repos/{owner}/{name}/labels" - - if dry_run: - print(f"[DRY-RUN] Would sync {len(labels)} labels to {repo}") - for label in labels: - print(f"- {label['name']}") - return - - for label in labels: - try: - client.request_json("POST", base, label) - print(f"created: {label['name']}") - except GitHubRequestError as exc: - if exc.status != 422: - raise - patch_url = f"{base}/{urllib.parse.quote(label['name'])}" - client.request_json("PATCH", patch_url, label) - print(f"updated: {label['name']}") From 43bbeb62ade8a356107bd4d2d77c7504eeb866df Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:30:40 -0300 Subject: [PATCH 051/130] refactor: remove legacy milestone module --- governance_bootstrap/milestones.py | 48 ------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 governance_bootstrap/milestones.py diff --git a/governance_bootstrap/milestones.py b/governance_bootstrap/milestones.py deleted file mode 100644 index aaf4d0f..0000000 --- a/governance_bootstrap/milestones.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import json - -from .github import API_BASE, GitHubClient, split_repo - - -def load_milestones(path: str) -> list[dict]: - with open(path, "r", encoding="utf-8") as f: - milestones = json.load(f) - if not isinstance(milestones, list): - raise ValueError("milestones manifest must be a JSON list") - for milestone in milestones: - if "title" not in milestone: - raise ValueError("milestone is missing required key: title") - return milestones - - -def sync_milestones(client: GitHubClient, repo: str, milestones_file: str, dry_run: bool = False) -> None: - owner, name = split_repo(repo) - milestones = load_milestones(milestones_file) - - if dry_run: - print(f"[DRY-RUN] Would sync {len(milestones)} milestones to {repo}") - for milestone in milestones: - print(f"- {milestone['title']} ({milestone.get('due_on', 'no-due-date')})") - return - - base = f"{API_BASE}/repos/{owner}/{name}/milestones" - existing = client.request_json("GET", f"{base}?state=all&per_page=100") - existing_by_title = {item["title"]: item for item in existing} - - for milestone in milestones: - payload = { - "title": milestone["title"], - "description": milestone.get("description", ""), - } - if milestone.get("due_on"): - payload["due_on"] = milestone["due_on"] - - current = existing_by_title.get(milestone["title"]) - if current: - client.request_json("PATCH", f"{base}/{current['number']}", payload) - print(f"updated: {milestone['title']}") - continue - - client.request_json("POST", base, payload) - print(f"created: {milestone['title']}") From 029a496208aee5c1af4acb3945d6f3272b21ad38 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:31:15 -0300 Subject: [PATCH 052/130] refactor: remove legacy Project v2 module --- governance_bootstrap/project.py | 410 -------------------------------- 1 file changed, 410 deletions(-) delete mode 100644 governance_bootstrap/project.py diff --git a/governance_bootstrap/project.py b/governance_bootstrap/project.py deleted file mode 100644 index 584c5e4..0000000 --- a/governance_bootstrap/project.py +++ /dev/null @@ -1,410 +0,0 @@ -from __future__ import annotations - -import json -import re - -from .github import API_BASE, GitHubClient, split_repo - - -def load_project_definition(path: str) -> dict: - with open(path, "r", encoding="utf-8") as f: - definition = json.load(f) - if "name" not in definition: - raise ValueError("project definition must contain name") - return definition - - -def owner_node(client: GitHubClient, owner: str) -> str: - query_user = "query($login:String!){user(login:$login){id}}" - data = client.graphql(query_user, {"login": owner}) - user = data.get("user") - if user and user.get("id"): - return user["id"] - query_org = "query($login:String!){organization(login:$login){id}}" - data = client.graphql(query_org, {"login": owner}) - org = data.get("organization") - if org and org.get("id"): - return org["id"] - raise RuntimeError(f"Owner not found: {owner}") - - -def create_project(client: GitHubClient, repo: str, definition_file: str, dry_run: bool = False) -> None: - definition = load_project_definition(definition_file) - owner = split_repo(repo)[0] - if dry_run: - print(f"[DRY-RUN] Would create project: {definition['name']}") - print("[DRY-RUN] Fields to configure:") - for field in definition.get("fields", []): - print(f"- {field['name']} ({field['type']})") - return - - oid = owner_node(client, owner) - mutation = """ - mutation($owner:ID!, $title:String!) { - createProjectV2(input:{ownerId:$owner,title:$title}) { - projectV2 { id url } - } - } - """ - data = client.graphql(mutation, {"owner": oid, "title": definition["name"]}) - project = data["createProjectV2"]["projectV2"] - print(json.dumps(project, ensure_ascii=False)) - print(f"Project created. Configure custom fields and views using {definition_file}.") - - -def find_project(client: GitHubClient, owner: str, project_number: int) -> tuple[dict, str]: - query_user = """ - query($login:String!, $number:Int!) { - user(login:$login) { projectV2(number:$number) { id title url } } - } - """ - data = client.graphql(query_user, {"login": owner, "number": project_number}) - user = data.get("user") - if user and user.get("projectV2"): - return user["projectV2"], "user" - - query_org = """ - query($login:String!, $number:Int!) { - organization(login:$login) { projectV2(number:$number) { id title url } } - } - """ - data = client.graphql(query_org, {"login": owner, "number": project_number}) - org = data.get("organization") - if org and org.get("projectV2"): - return org["projectV2"], "org" - raise RuntimeError(f"Project v2 #{project_number} not found for owner '{owner}'") - - -def list_project_fields(client: GitHubClient, project_id: str) -> list[dict]: - query = """ - query($project:ID!, $cursor:String) { - node(id:$project) { - ... on ProjectV2 { - fields(first:100, after:$cursor) { - pageInfo { hasNextPage endCursor } - nodes { - __typename - ... on ProjectV2Field { id name dataType } - ... on ProjectV2SingleSelectField { id name dataType options { id name } } - ... on ProjectV2IterationField { id name dataType } - } - } - } - } - } - """ - fields = [] - cursor = None - while True: - data = client.graphql(query, {"project": project_id, "cursor": cursor}) - page = data["node"]["fields"] - fields.extend(page["nodes"]) - if not page["pageInfo"]["hasNextPage"]: - return fields - cursor = page["pageInfo"]["endCursor"] - - -def create_text_field(client: GitHubClient, project_id: str, name: str) -> dict: - mutation = """ - mutation($project:ID!, $name:String!) { - createProjectV2Field(input:{projectId:$project, name:$name, dataType:TEXT}) { - projectV2Field { ... on ProjectV2Field { id name dataType } } - } - } - """ - data = client.graphql(mutation, {"project": project_id, "name": name}) - return data["createProjectV2Field"]["projectV2Field"] - - -def create_single_select_field(client: GitHubClient, project_id: str, name: str, options: list[str]) -> dict: - mutation = """ - mutation($project:ID!, $name:String!, $options:[ProjectV2SingleSelectFieldOptionInput!]!) { - createProjectV2Field(input:{projectId:$project, name:$name, dataType:SINGLE_SELECT, singleSelectOptions:$options}) { - projectV2Field { ... on ProjectV2SingleSelectField { id name dataType options { id name } } } - } - } - """ - option_payload = [{"name": opt, "color": "GRAY", "description": ""} for opt in options] - data = client.graphql(mutation, {"project": project_id, "name": name, "options": option_payload}) - return data["createProjectV2Field"]["projectV2Field"] - - -def ensure_fields(client: GitHubClient, project_id: str, definition: dict, dry_run: bool = False) -> dict: - existing = list_project_fields(client, project_id) - by_name = {f["name"]: f for f in existing if f and f.get("name")} - for field in definition.get("fields", []): - name = field["name"] - if name in by_name: - continue - if dry_run: - print(f"[DRY-RUN] Would create field: {name} ({field['type']})") - continue - if field["type"] == "text": - created = create_text_field(client, project_id, name) - elif field["type"] == "single_select": - created = create_single_select_field(client, project_id, name, field.get("options", [])) - else: - print(f"Skipping unsupported field type: {field['type']} ({name})") - continue - print(f"created field: {name}") - by_name[name] = created - if dry_run: - return by_name - return {f["name"]: f for f in list_project_fields(client, project_id) if f and f.get("name")} - - -def list_repo_issues(client: GitHubClient, repo: str, state: str = "open") -> list[dict]: - owner, name = split_repo(repo) - issues = client.paginated(f"{API_BASE}/repos/{owner}/{name}/issues?state={state}&sort=created&direction=asc") - return [issue for issue in issues if "pull_request" not in issue] - - -def list_project_items(client: GitHubClient, project_id: str) -> dict: - query = """ - query($project:ID!, $cursor:String) { - node(id:$project) { - ... on ProjectV2 { - items(first:100, after:$cursor) { - pageInfo { hasNextPage endCursor } - nodes { - id - content { __typename ... on Issue { id number } } - } - } - } - } - } - """ - content_to_item = {} - cursor = None - while True: - data = client.graphql(query, {"project": project_id, "cursor": cursor}) - page = data["node"]["items"] - for item in page["nodes"]: - content = item.get("content") - if content and content.get("__typename") == "Issue": - content_to_item[content["id"]] = item["id"] - if not page["pageInfo"]["hasNextPage"]: - return content_to_item - cursor = page["pageInfo"]["endCursor"] - - -def issue_node_id(client: GitHubClient, repo: str, number: int) -> str: - owner, name = split_repo(repo) - query = """ - query($owner:String!, $repo:String!, $number:Int!) { - repository(owner:$owner, name:$repo) { issue(number:$number) { id } } - } - """ - data = client.graphql(query, {"owner": owner, "repo": name, "number": number}) - issue = data["repository"]["issue"] - if not issue: - raise RuntimeError(f"Issue #{number} not found in {repo}") - return issue["id"] - - -def add_issue_to_project(client: GitHubClient, project_id: str, issue_id: str) -> str: - mutation = """ - mutation($project:ID!, $content:ID!) { - addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } } - } - """ - data = client.graphql(mutation, {"project": project_id, "content": issue_id}) - return data["addProjectV2ItemById"]["item"]["id"] - - -def add_sub_issue(client: GitHubClient, parent_id: str, child_id: str) -> None: - mutation = """ - mutation($parent:ID!, $child:ID!) { - addSubIssue(input:{issueId:$parent, subIssueId:$child}) { clientMutationId } - } - """ - client.graphql(mutation, {"parent": parent_id, "child": child_id}) - - -def update_item_position(client: GitHubClient, project_id: str, item_id: str, after_id: str | None) -> None: - mutation = """ - mutation($project:ID!, $item:ID!, $after:ID) { - updateProjectV2ItemPosition(input:{projectId:$project, itemId:$item, afterId:$after}) { clientMutationId } - } - """ - client.graphql(mutation, {"project": project_id, "item": item_id, "after": after_id}) - - -def update_single_select(client: GitHubClient, project_id: str, item_id: str, field_id: str, option_id: str) -> None: - mutation = """ - mutation($project:ID!, $item:ID!, $field:ID!, $option:String!) { - updateProjectV2ItemFieldValue(input:{projectId:$project, itemId:$item, fieldId:$field, value:{singleSelectOptionId:$option}}) { - projectV2Item { id } - } - } - """ - client.graphql(mutation, {"project": project_id, "item": item_id, "field": field_id, "option": option_id}) - - -def update_text(client: GitHubClient, project_id: str, item_id: str, field_id: str, text_value: str) -> None: - mutation = """ - mutation($project:ID!, $item:ID!, $field:ID!, $text:String!) { - updateProjectV2ItemFieldValue(input:{projectId:$project, itemId:$item, fieldId:$field, value:{text:$text}}) { - projectV2Item { id } - } - } - """ - client.graphql(mutation, {"project": project_id, "item": item_id, "field": field_id, "text": text_value}) - - -def milestone_from_body(body: str) -> str | None: - match = re.search(r"-\s*Milestone:\s*([A-Za-z0-9_.-]+)", body or "") - return match.group(1) if match else None - - -def milestone_from_issue(issue: dict) -> str | None: - milestone = issue.get("milestone") - if milestone and milestone.get("title"): - return milestone["title"] - return milestone_from_body(issue.get("body", "") or "") - - -def parent_issue_number_from_body(body: str) -> int | None: - match = re.search(r"Parent story:.*\(#(\d+)\)", body or "") - return int(match.group(1)) if match else None - - -def label_value(labels: list, prefix: str) -> str | None: - for label in labels: - name = label["name"] if isinstance(label, dict) else str(label) - if name.startswith(prefix): - return name.split(":", 1)[1] - return None - - -def option_id(field: dict, option_name: str) -> str | None: - for opt in field.get("options", []): - if opt["name"] == option_name: - return opt["id"] - return None - - -def sync_issue_fields(client: GitHubClient, project_id: str, item_id: str, issue: dict, fields: dict, definition: dict, dry_run: bool = False) -> None: - labels = issue.get("labels", []) - milestone = milestone_from_issue(issue) - mappings = { - "Item Type": label_value(labels, "type:"), - "Priority": label_value(labels, "priority:"), - "Status": label_value(labels, "status:"), - "Test Type": label_value(labels, "test:"), - "Milestone": milestone, - } - - for field_name, field_value in mappings.items(): - if not field_value: - continue - field = fields.get(field_name) - if not field: - continue - if field.get("dataType") == "SINGLE_SELECT": - oid = option_id(field, field_value) - if not oid: - print(f"warning: option '{field_value}' not found for field '{field_name}'") - continue - if dry_run: - print(f"[DRY-RUN] Would set {field_name}={field_value} on issue #{issue['number']}") - else: - update_single_select(client, project_id, item_id, field["id"], oid) - elif field.get("dataType") == "TEXT": - if dry_run: - print(f"[DRY-RUN] Would set {field_name}={field_value} on issue #{issue['number']}") - else: - update_text(client, project_id, item_id, field["id"], field_value) - - -def reorder_project_items(client: GitHubClient, project_id: str, issues: list[dict], current_items: dict, dry_run: bool = False) -> None: - previous_item_id = None - for issue in issues: - item_id = current_items.get(issue["node_id"]) - if not item_id: - continue - if dry_run: - print(f"[DRY-RUN] Would position issue #{issue['number']} after {previous_item_id or 'top'}") - else: - update_item_position(client, project_id, item_id, previous_item_id) - previous_item_id = item_id - - -def link_subissues(client: GitHubClient, repo: str, issues: list[dict], dry_run: bool = False) -> None: - node_ids_by_number = {issue["number"]: issue.get("node_id") for issue in issues} - linked = 0 - skipped = 0 - - for issue in issues: - parent_number = parent_issue_number_from_body(issue.get("body", "") or "") - if parent_number is None: - continue - - parent_id = node_ids_by_number.get(parent_number) - if not parent_id: - parent_id = issue_node_id(client, repo, parent_number) - node_ids_by_number[parent_number] = parent_id - - if dry_run: - print(f"[DRY-RUN] Would link issue #{issue['number']} as sub-issue of #{parent_number}") - continue - - try: - add_sub_issue(client, parent_id, issue["node_id"]) - print(f"Linked issue #{issue['number']} as sub-issue of #{parent_number}") - linked += 1 - except RuntimeError as exc: - error_text = str(exc).lower() - if ( - "already" in error_text - or "exists" in error_text - or "duplicate sub-issues" in error_text - or "may only have one parent" in error_text - ): - print(f"Sub-issue link already exists: #{issue['number']} -> #{parent_number}") - skipped += 1 - continue - raise - - print(f"Sub-issue linking finished: linked={linked}, already_present={skipped}") - - -def sync_project(client: GitHubClient, repo: str, definition_file: str, project_number: int, owner: str | None = None, issue_state: str = "open", link_subissue_items: bool = False, only_link_subissues: bool = False, dry_run: bool = False) -> None: - definition = load_project_definition(definition_file) - project_owner = owner or split_repo(repo)[0] - issues = list_repo_issues(client, repo, state=issue_state) - for issue in issues: - issue["node_id"] = issue_node_id(client, repo, issue["number"]) - - if only_link_subissues: - link_subissues(client, repo, issues, dry_run=dry_run) - return - - project, owner_type = find_project(client, project_owner, project_number) - print(f"Project found: {project['title']} ({project['url']}) owner_type={owner_type}") - fields = ensure_fields(client, project["id"], definition, dry_run=dry_run) - current_items = list_project_items(client, project["id"]) - - for issue in issues: - issue_id = issue["node_id"] - item_id = current_items.get(issue_id) - if not item_id: - if dry_run: - print(f"[DRY-RUN] Would add issue #{issue['number']} to project") - item_id = f"dry-run-item-{issue['number']}" - else: - item_id = add_issue_to_project(client, project["id"], issue_id) - current_items[issue_id] = item_id - print(f"Added issue #{issue['number']} to project") - else: - print(f"Issue #{issue['number']} already in project") - sync_issue_fields(client, project["id"], item_id, issue, fields, definition, dry_run=dry_run) - - reorder_project_items(client, project["id"], issues, current_items, dry_run=dry_run) - if link_subissue_items: - link_subissues(client, repo, issues, dry_run=dry_run) - if definition.get("views"): - print("Note: project views are listed in project-definition.json but are not automated by this script.") - for view in definition["views"]: - print(f"- create manually if needed: {view}") From 2b242146f72c59c9025d4eb8b4a02e30112ef41c Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:31:33 -0300 Subject: [PATCH 053/130] refactor: remove legacy configuration name --- governance.bootstrap.json | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 governance.bootstrap.json diff --git a/governance.bootstrap.json b/governance.bootstrap.json deleted file mode 100644 index 57ece50..0000000 --- a/governance.bootstrap.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "labelsFile": "config/project/labels.json", - "milestonesFile": "config/project/milestones.json", - "projectDefinitionFile": "config/project/project-definition.json", - "backlogManifestFile": "config/stories/backlog-manifest.json", - "workflowVar": "GOVERNANCE_PAT", - "defaults": { - "dryRun": true, - "runLabels": true, - "runMilestones": true, - "runProjectCreation": false, - "runIssueGeneration": true, - "linkSubissues": true - } -} From 365a1c4893736f4ede04937fed8e49fe54911a69 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:31:48 -0300 Subject: [PATCH 054/130] refactor: remove legacy setup workflow --- .github/workflows/governance-bootstrap.yml | 99 ---------------------- 1 file changed, 99 deletions(-) delete mode 100644 .github/workflows/governance-bootstrap.yml diff --git a/.github/workflows/governance-bootstrap.yml b/.github/workflows/governance-bootstrap.yml deleted file mode 100644 index b70ff7f..0000000 --- a/.github/workflows/governance-bootstrap.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Governance bootstrap (manual) - -on: - workflow_dispatch: - inputs: - run_labels_sync: - description: "Sync labels from config/project/labels.json" - required: true - default: true - type: boolean - run_milestones_sync: - description: "Sync milestones from config/project/milestones.json" - required: true - default: true - type: boolean - run_issue_generation: - description: "Generate stories/tasks from config/stories/backlog-manifest.json" - required: true - default: true - type: boolean - run_project_creation: - description: "Create GitHub Project v2 from project-definition.json" - required: true - default: false - type: boolean - dry_run: - description: "Dry-run issue generation" - required: true - default: true - type: boolean - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - bootstrap: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install governance tool - run: pip install -e . --quiet - - - name: Sync labels - if: ${{ inputs.run_labels_sync }} - env: - GITHUB_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m governance_bootstrap labels sync --dry-run - else - python -m governance_bootstrap labels sync - fi - - - name: Sync milestones - if: ${{ inputs.run_milestones_sync }} - env: - GITHUB_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m governance_bootstrap milestones sync --dry-run - else - python -m governance_bootstrap milestones sync - fi - - - name: Create project v2 - if: ${{ inputs.run_project_creation }} - env: - GH_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m governance_bootstrap project create --dry-run - else - python -m governance_bootstrap project create - fi - - - name: Generate issues and tasks - if: ${{ inputs.run_issue_generation }} - env: - GITHUB_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GH_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m governance_bootstrap issues generate --dry-run - else - python -m governance_bootstrap issues generate --link-subissues - fi From 55db29b38a1269e3e6e7083ec86d3a27a2c1c2f6 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:32:03 -0300 Subject: [PATCH 055/130] refactor: consolidate branch validation into PR metadata --- .github/workflows/branch-naming.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/branch-naming.yml diff --git a/.github/workflows/branch-naming.yml b/.github/workflows/branch-naming.yml deleted file mode 100644 index 26252e2..0000000 --- a/.github/workflows/branch-naming.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Branch naming validation - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -permissions: - contents: read - -jobs: - validate-branch-name: - runs-on: ubuntu-latest - steps: - - name: Validate head branch pattern - shell: bash - run: | - BRANCH="${{ github.head_ref }}" - echo "Checking branch: $BRANCH" - if [[ ! "$BRANCH" =~ ^(feat|fix|docs|refactor|test|hotfix|milestone|task|copilot)\/[a-z0-9._/-]+$ ]]; then - echo "Invalid branch naming. Use e.g. feat/repo-governance-bootstrap or milestone/m1-setup" >&2 - exit 1 - fi From b5263d28abe664f7c53735b621c71839424015b3 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:32:15 -0300 Subject: [PATCH 056/130] ci: replace duplicate tests workflow with repository quality --- .github/workflows/tests.yml | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 141b66a..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Tests - -on: - push: - branches: ["**"] - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - -jobs: - pytest: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install package and test dependencies - run: pip install -e ".[dev]" - - - name: Run tests - run: python -m pytest tests/ -v From e91838a83fa6e53470a24b05f86934d08f06eeac Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:32:29 -0300 Subject: [PATCH 057/130] test: remove legacy package tests --- tests/test_governance_bootstrap.py | 306 ----------------------------- 1 file changed, 306 deletions(-) delete mode 100644 tests/test_governance_bootstrap.py diff --git a/tests/test_governance_bootstrap.py b/tests/test_governance_bootstrap.py deleted file mode 100644 index c2cb0fc..0000000 --- a/tests/test_governance_bootstrap.py +++ /dev/null @@ -1,306 +0,0 @@ -import io -import os -import tempfile -import unittest -from contextlib import redirect_stdout -from types import SimpleNamespace -from unittest.mock import patch - -from governance_bootstrap.auto_label import infer_issue_labels -from governance_bootstrap.discovery import detect_auth_status, detect_project_matches -from governance_bootstrap.cli import main -from governance_bootstrap.issue_milestones import milestone_from_body, parent_issue_number_from_body -from governance_bootstrap.issues import load_backlog -from governance_bootstrap.labels import load_labels -from governance_bootstrap.milestones import load_milestones -from governance_bootstrap.project import label_value - - -class GovernanceBootstrapTests(unittest.TestCase): - # ------------------------------------------------------------------ # - # Label helpers # - # ------------------------------------------------------------------ # - - def test_auto_label_infers_type_status_priority_and_test(self): - issue = { - "title": "US-01 | Example", - "body": "Severity\nHigh\n\nTest type: smoke", - "labels": [], - } - - self.assertEqual( - infer_issue_labels(issue), - {"type:user-story", "status:backlog", "priority:high", "test:smoke"}, - ) - - def test_label_value_reads_github_label_payloads(self): - labels = [{"name": "type:task"}, {"name": "priority:critical"}] - - self.assertEqual(label_value(labels, "priority:"), "critical") - - def test_label_value_returns_none_when_no_match(self): - labels = [{"name": "type:task"}] - - self.assertIsNone(label_value(labels, "priority:")) - - # ------------------------------------------------------------------ # - # Manifest loaders — validation errors # - # ------------------------------------------------------------------ # - - def test_load_backlog_raises_when_milestones_key_missing(self): - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: - f.write('{"version": "1.0.0", "stories": []}') - path = f.name - try: - with self.assertRaises(ValueError): - load_backlog(path) - finally: - os.unlink(path) - - def test_load_labels_raises_when_color_missing(self): - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: - f.write('[{"name": "status:backlog"}]') - path = f.name - try: - with self.assertRaises(ValueError): - load_labels(path) - finally: - os.unlink(path) - - def test_load_milestones_raises_when_title_missing(self): - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: - f.write('[{"description": "no title here"}]') - path = f.name - try: - with self.assertRaises(ValueError): - load_milestones(path) - finally: - os.unlink(path) - - # ------------------------------------------------------------------ # - # Issue metadata parsers # - # ------------------------------------------------------------------ # - - def test_issue_metadata_parsers_accept_generic_milestones(self): - body = "Parent story: US-01 (#42)\n\n- Milestone: Release-1.0" - - self.assertEqual(milestone_from_body(body), "Release-1.0") - self.assertEqual(parent_issue_number_from_body(body), 42) - - # ------------------------------------------------------------------ # - # Sync dry-run output # - # ------------------------------------------------------------------ # - - def test_sync_labels_dry_run_prints_label_list(self): - with tempfile.TemporaryDirectory() as tmp: - labels_file = os.path.join(tmp, "labels.json") - with open(labels_file, "w", encoding="utf-8") as f: - f.write('[{"name":"status:backlog","color":"C5DEF5","description":"Backlog"}]') - - from governance_bootstrap.labels import sync_labels - from governance_bootstrap.github import GitHubClient - - output = io.StringIO() - with redirect_stdout(output): - sync_labels(GitHubClient(""), "owner/repo", labels_file, dry_run=True) - - text = output.getvalue() - self.assertIn("[DRY-RUN] Would sync 1 labels", text) - self.assertIn("status:backlog", text) - - def test_sync_milestones_dry_run_prints_milestone_list(self): - with tempfile.TemporaryDirectory() as tmp: - milestones_file = os.path.join(tmp, "milestones.json") - with open(milestones_file, "w", encoding="utf-8") as f: - f.write('[{"title":"M0","description":"Setup","due_on":"2026-01-31T00:00:00Z"}]') - - from governance_bootstrap.milestones import sync_milestones - from governance_bootstrap.github import GitHubClient - - output = io.StringIO() - with redirect_stdout(output): - sync_milestones(GitHubClient(""), "owner/repo", milestones_file, dry_run=True) - - text = output.getvalue() - self.assertIn("[DRY-RUN] Would sync 1 milestones", text) - self.assertIn("M0", text) - - # ------------------------------------------------------------------ # - # Issue generation dry run # - # ------------------------------------------------------------------ # - - def test_issue_generation_dry_run_prints_stories_and_tasks(self): - with tempfile.TemporaryDirectory() as tmp: - labels = os.path.join(tmp, "labels.json") - milestones = os.path.join(tmp, "milestones.json") - project = os.path.join(tmp, "project.json") - backlog = os.path.join(tmp, "backlog.json") - config = os.path.join(tmp, "governance.bootstrap.json") - - with open(labels, "w", encoding="utf-8") as f: - f.write('[{"name":"status:backlog","color":"C5DEF5","description":"Backlog"},' - '{"name":"type:user-story","color":"1D76DB","description":"Story"},' - '{"name":"type:task","color":"0E8A16","description":"Task"}]') - with open(milestones, "w", encoding="utf-8") as f: - f.write('[{"title":"M0","description":"Setup"}]') - with open(project, "w", encoding="utf-8") as f: - f.write('{"name":"Board","fields":[]}') - with open(backlog, "w", encoding="utf-8") as f: - f.write( - '{"milestones":[{"milestone":"M0","stories":[{' - '"storyId":"US-00","title":"US-00 | Setup",' - '"labels":["type:user-story"],"body":"As a team...",' - '"tasks":["T-00.1 | Create milestones"]' - '}]}]}' - ) - with open(config, "w", encoding="utf-8") as f: - f.write( - "{" - f'"labelsFile":"{labels}",' - f'"milestonesFile":"{milestones}",' - f'"projectDefinitionFile":"{project}",' - f'"backlogManifestFile":"{backlog}",' - '"defaults":{"dryRun":true,"runLabels":false,"runMilestones":false,' - '"runProjectCreation":false,"runIssueGeneration":true}' - "}" - ) - - with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False): - output = io.StringIO() - with redirect_stdout(output): - result = main(["bootstrap", "--repo", "owner/repo", "--config", config, "--dry-run"]) - - self.assertEqual(result, 0) - text = output.getvalue() - self.assertIn("[DRY-RUN] Story: US-00 | Setup", text) - self.assertIn("[DRY-RUN] Task: T-00.1 | Create milestones", text) - - # ------------------------------------------------------------------ # - # Full bootstrap dry run # - # ------------------------------------------------------------------ # - - def test_bootstrap_dry_run_does_not_require_token(self): - with tempfile.TemporaryDirectory() as tmp: - labels = os.path.join(tmp, "labels.json") - milestones = os.path.join(tmp, "milestones.json") - project = os.path.join(tmp, "project.json") - backlog = os.path.join(tmp, "backlog.json") - config = os.path.join(tmp, "governance.bootstrap.json") - - with open(labels, "w", encoding="utf-8") as f: - f.write('[{"name":"status:backlog","color":"C5DEF5","description":"Backlog"}]') - with open(milestones, "w", encoding="utf-8") as f: - f.write('[{"title":"M1","description":"Milestone"}]') - with open(project, "w", encoding="utf-8") as f: - f.write('{"name":"Board","fields":[]}') - with open(backlog, "w", encoding="utf-8") as f: - f.write('{"milestones":[]}') - with open(config, "w", encoding="utf-8") as f: - f.write( - "{" - f'"labelsFile":"{labels}",' - f'"milestonesFile":"{milestones}",' - f'"projectDefinitionFile":"{project}",' - f'"backlogManifestFile":"{backlog}",' - '"defaults":{"dryRun":true,"runLabels":true,"runMilestones":true,' - '"runProjectCreation":true,"runIssueGeneration":true}' - "}" - ) - - with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False): - output = io.StringIO() - with redirect_stdout(output): - result = main(["bootstrap", "--repo", "owner/repo", "--config", config, "--dry-run"]) - - self.assertEqual(result, 0) - self.assertIn("[DRY-RUN] Would sync 1 labels", output.getvalue()) - self.assertIn("Governance bootstrap finished.", output.getvalue()) - - # ------------------------------------------------------------------ # - # Auth detection # - # ------------------------------------------------------------------ # - - def test_discovery_prefers_env_token(self): - with patch.dict(os.environ, {"GITHUB_TOKEN": "token-from-env", "GH_TOKEN": ""}, clear=False): - auth = detect_auth_status() - - self.assertTrue(auth.configured) - self.assertEqual(auth.source, "environment") - - def test_get_token_falls_back_to_gh_auth(self): - with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False), patch( - "governance_bootstrap.github.shutil.which", return_value="/usr/bin/gh" - ), patch("governance_bootstrap.github.subprocess.run") as run: - run.return_value = SimpleNamespace(returncode=0, stdout="token-from-gh\n") - - from governance_bootstrap.github import get_token - - token = get_token() - - self.assertEqual(token, "token-from-gh") - - # ------------------------------------------------------------------ # - # Discovery / project detection # - # ------------------------------------------------------------------ # - - def test_discovery_detects_project_markers(self): - with tempfile.TemporaryDirectory() as tmp: - with open(os.path.join(tmp, "pyproject.toml"), "w", encoding="utf-8") as f: - f.write("[project]\nname = 'demo'\n") - with open(os.path.join(tmp, "package.json"), "w", encoding="utf-8") as f: - f.write('{"name":"demo"}') - - matches = detect_project_matches(tmp) - - self.assertGreaterEqual(len(matches), 2) - self.assertEqual(matches[0].project_type, "python") - self.assertIn("pyproject.toml", matches[0].markers) - - def test_discover_auto_mode_reports_summary(self): - with tempfile.TemporaryDirectory() as tmp: - config = os.path.join(tmp, "governance.bootstrap.json") - root = os.path.join(tmp, "repo") - os.makedirs(root, exist_ok=True) - with open(os.path.join(root, "go.mod"), "w", encoding="utf-8") as f: - f.write("module example.com/demo\n") - with open(config, "w", encoding="utf-8") as f: - f.write( - "{" - '"workflowVar":"GOVERNANCE_PAT",' - '"defaults":{"dryRun":true,"runLabels":true,"runMilestones":true,' - '"runProjectCreation":false,"runIssueGeneration":true,"linkSubissues":true}' - "}" - ) - - with patch.dict(os.environ, {"GITHUB_TOKEN": "token-from-env", "GH_TOKEN": ""}, clear=False): - output = io.StringIO() - with redirect_stdout(output): - result = main(["discover", "--repo", "owner/repo", "--config", config, "--root", root, "--auto"]) - - self.assertEqual(result, 0) - text = output.getvalue() - self.assertIn("Configured: yes (environment)", text) - self.assertIn("Detected project type: go", text) - self.assertIn("Recommended command", text) - self.assertIn("python -m governance_bootstrap bootstrap", text) - - def test_discover_reports_missing_auth(self): - with tempfile.TemporaryDirectory() as tmp: - config = os.path.join(tmp, "governance.bootstrap.json") - with open(config, "w", encoding="utf-8") as f: - f.write('{"workflowVar":"GOVERNANCE_PAT","defaults":{"dryRun":true}}') - - with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": ""}, clear=False), patch( - "governance_bootstrap.discovery.shutil.which", return_value=None - ): - output = io.StringIO() - with redirect_stdout(output): - result = main(["discover", "--repo", "owner/repo", "--config", config, "--auto"]) - - self.assertEqual(result, 1) - self.assertIn("Configured: no (missing)", output.getvalue()) - self.assertIn("Expected workflow secret: GOVERNANCE_PAT", output.getvalue()) - - -if __name__ == "__main__": - unittest.main() From 2a476649b62565ea9b9d3e0232152ccb2ddbde9f Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:32:46 -0300 Subject: [PATCH 058/130] docs: remove legacy shared tool guide --- docs/repo/governance-shared-tool.md | 49 ----------------------------- 1 file changed, 49 deletions(-) delete mode 100644 docs/repo/governance-shared-tool.md diff --git a/docs/repo/governance-shared-tool.md b/docs/repo/governance-shared-tool.md deleted file mode 100644 index f27dded..0000000 --- a/docs/repo/governance-shared-tool.md +++ /dev/null @@ -1,49 +0,0 @@ -# Shared Governance Tool - -This repository carries the reusable bootstrap engine as the Python package `governance_bootstrap`. - -## What Is Generic -- GitHub label sync from `config/project/labels.json`. -- GitHub milestone sync from `config/project/milestones.json`. -- GitHub Project v2 creation and issue sync from `config/project/project-definition.json`. -- Issue/task generation from `config/stories/backlog-manifest.json`. -- Auto-label and issue milestone helpers. -- `discover` wizard that checks auth, detects project type, and prints the recommended bootstrap command. - -## What Stays Project-Specific -- Label names and colors. -- Milestone names and dates. -- Project board name, fields, options and views. -- Backlog milestones, user stories, tasks and default labels. -- The target repository passed with `--repo owner/repo`. - -## Consumer Setup -1. Copy `governance.bootstrap.json`, `config/project`, `config/stories` and `.github/workflows/governance-bootstrap.yml` into the consumer repo. -2. Add a repository secret named `GOVERNANCE_PAT`. -3. Give the token access to `repo` issues and Project v2 operations (`project`, and `read:org` for orgs). -4. Run the manual workflow with `dry_run=true` to preview changes. -5. Run again with `dry_run=false` when the dry-run output looks correct. - -## Local Usage - -Check auth and get a recommended command interactively: - -```bash -export GH_TOKEN= -python -m governance_bootstrap discover --repo owner/repo --config governance.bootstrap.json -``` - -Run discovery in non-interactive (auto) mode: - -```bash -python -m governance_bootstrap discover --repo owner/repo --config governance.bootstrap.json --auto -``` - -Run bootstrap directly (dry-run first): - -```bash -python -m governance_bootstrap bootstrap --repo owner/repo --config governance.bootstrap.json --dry-run -# When output looks correct: -python -m governance_bootstrap bootstrap --repo owner/repo --config governance.bootstrap.json --no-dry-run -``` - From e3d6b6e05c7c4178b164fe5d88b52cb365f16cb9 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:33:03 -0300 Subject: [PATCH 059/130] docs: remove legacy setup runbook --- .../governance-bootstrap-runbook.pt-BR.md | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 docs/repo/governance-bootstrap-runbook.pt-BR.md diff --git a/docs/repo/governance-bootstrap-runbook.pt-BR.md b/docs/repo/governance-bootstrap-runbook.pt-BR.md deleted file mode 100644 index 542be8e..0000000 --- a/docs/repo/governance-bootstrap-runbook.pt-BR.md +++ /dev/null @@ -1,51 +0,0 @@ -# Runbook — Governance Bootstrap - -## 1) Required permissions -To create/edit Project, labels, issues and sub-issues automatically, use an account with: -- **Admin** access to the repository -- Permission for **Projects** (Project v2) -- Token with scopes: `repo`, `project` and `read:org` (if the repo is in an org) - -## 2) How to grant admin access on GitHub -1. Repository → **Settings** → **Collaborators and teams**. -2. Add the user/account that will run the automations. -3. Set role to **Admin**. -4. Under **Settings → Actions → General**, enable: - - `Read and write permissions` for the `GITHUB_TOKEN`; - - creation and approval of PRs by GitHub Actions (if desired). -5. To create **Project v2**, authenticate `gh` with a PAT that includes the `project` scope (plus `repo`). - -## 3) How to run - -### Option A — Manual workflow (recommended) -1. Push this branch. -2. GitHub → **Actions** → `Governance bootstrap (manual)` → **Run workflow**. -3. Run with `dry_run=true` first to preview. -4. Run with `dry_run=false` to apply labels, milestones, issues/tasks/sub-issues and Project. - -### Option B — Local CLI -> Security: avoid putting your PAT directly in shell history. Prefer loading via a local unversioned env file, secret manager, or interactive prompt. - -```bash -export GH_TOKEN= -export GITHUB_REPOSITORY=owner/repo - -# Preview changes -python -m governance_bootstrap bootstrap --repo owner/repo --dry-run - -# Apply -python -m governance_bootstrap bootstrap --repo owner/repo --no-dry-run -``` - -Or use the guided `discover` wizard: - -```bash -python -m governance_bootstrap discover --repo owner/repo --config governance.bootstrap.json -``` - -## 4) Notes -- To reuse in another project, copy and adapt the manifests in `config/project`, `config/stories` and `governance.bootstrap.json`. -- The expected workflow secret is `GOVERNANCE_PAT`. -- The `discover` command checks auth status, detects project type, and prints the recommended bootstrap command. -- Milestone responsible pairs are `TBD` in `config/phases/phase-review-policy.json` — fill them in for your team. - From 76c1236aabdcc856ce65ea45d38dd5b09b3af080 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:33:19 -0300 Subject: [PATCH 060/130] docs: remove legacy workflow template --- ...governance-bootstrap.workflow-template.yml | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 docs/repo/governance-bootstrap.workflow-template.yml diff --git a/docs/repo/governance-bootstrap.workflow-template.yml b/docs/repo/governance-bootstrap.workflow-template.yml deleted file mode 100644 index 485d735..0000000 --- a/docs/repo/governance-bootstrap.workflow-template.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Governance bootstrap (manual) - -on: - workflow_dispatch: - inputs: - dry_run: - description: "Run without writing to GitHub" - required: true - default: true - type: boolean - run_project_creation: - description: "Create GitHub Project v2" - required: true - default: false - type: boolean - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - bootstrap: - runs-on: ubuntu-latest - steps: - - name: Checkout consumer repo - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install governance CLI - run: | - python -m pip install "git+https://github.com/OWNER/github-governance-bootstrap.git@v0.1.0" - - - name: Run bootstrap - env: - GITHUB_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GH_TOKEN: ${{ secrets.GOVERNANCE_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - args="--config governance.bootstrap.json" - if [ "${{ inputs.dry_run }}" = "true" ]; then - args="$args --dry-run" - else - args="$args --no-dry-run --link-subissues" - fi - if [ "${{ inputs.run_project_creation }}" = "true" ]; then - args="$args --run-project-creation" - fi - governance bootstrap $args From 9154abbbb2a3be272dfcdbb06f08c254466bd535 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:36:44 -0300 Subject: [PATCH 061/130] ci: run repository quality when PR becomes ready --- .github/workflows/repo-quality.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repo-quality.yml b/.github/workflows/repo-quality.yml index bd09d75..77b2e5a 100644 --- a/.github/workflows/repo-quality.yml +++ b/.github/workflows/repo-quality.yml @@ -2,9 +2,10 @@ name: Repository quality on: pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, ready_for_review] push: branches: [main, develop] + workflow_dispatch: permissions: contents: read From 1eecd9a7a60a6ccc8ebd1cbe27fc3d9a4fc5aff8 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:37:46 -0300 Subject: [PATCH 062/130] docs: align Project board policy with generic configuration --- docs/repo/project-board-policy.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/repo/project-board-policy.md b/docs/repo/project-board-policy.md index ae7ef80..0f00467 100644 --- a/docs/repo/project-board-policy.md +++ b/docs/repo/project-board-policy.md @@ -1,21 +1,31 @@ # Project Board Policy (EN) -## Required fields -- Milestone +## Managed fields + +- Phase - Item Type -- Status - Priority +- Status - Test Type -- DoD Status -- Responsible +- Milestone ## Status baseline + - backlog - ready - in-progress -- review-milestone -- review-develop -- review-main -- qa-manual +- in-review - done - blocked + +## Item types + +- user-story +- task +- bug +- repo +- stretch + +The canonical options are defined in `config/project/project-definition.json`. Labels that synchronize into the board must use the same lowercase values after their prefix, for example `status:in-review` and `type:task`. + +Project views listed in the definition are recommendations and currently require manual configuration. From d3f3d1b80cf941d607cdf4cba0e1a75e50a99bdf Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:37:57 -0300 Subject: [PATCH 063/130] docs: align Project board policy in Portuguese --- docs/repo/project-board-policy.pt-BR.md | 28 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/repo/project-board-policy.pt-BR.md b/docs/repo/project-board-policy.pt-BR.md index 6eaf5a4..ac0ee13 100644 --- a/docs/repo/project-board-policy.pt-BR.md +++ b/docs/repo/project-board-policy.pt-BR.md @@ -1,21 +1,31 @@ # Política do Project Board (PT-BR) -## Campos obrigatórios -- Milestone +## Campos gerenciados + +- Phase - Item Type -- Status - Priority +- Status - Test Type -- DoD Status -- Responsible +- Milestone ## Status base + - backlog - ready - in-progress -- review-milestone -- review-develop -- review-main -- qa-manual +- in-review - done - blocked + +## Tipos de item + +- user-story +- task +- bug +- repo +- stretch + +As opções canônicas estão em `config/project/project-definition.json`. Labels sincronizadas com o board devem utilizar o mesmo valor em letras minúsculas após o prefixo, por exemplo `status:in-review` e `type:task`. + +As views listadas na definição são recomendações e ainda precisam ser configuradas manualmente. From d71c7ffccd08723be29e7626d75b1342b55fb58c Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 14:38:49 -0300 Subject: [PATCH 064/130] refactor: allow explicit hotfix sources for main --- .github/workflows/main-source-branch.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main-source-branch.yml b/.github/workflows/main-source-branch.yml index 713af9a..9156671 100644 --- a/.github/workflows/main-source-branch.yml +++ b/.github/workflows/main-source-branch.yml @@ -8,12 +8,16 @@ on: permissions: contents: read +concurrency: + group: main-source-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: validate-main-source: name: validate-main-source runs-on: ubuntu-latest steps: - - name: Ensure PR to main comes from develop + - name: Ensure PR to main comes from develop or hotfix shell: bash env: BASE_REF: ${{ github.event.pull_request.base.ref }} @@ -21,13 +25,18 @@ jobs: HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} EXPECTED_REPO: ${{ github.repository }} run: | + set -euo pipefail echo "Base: $BASE_REF" echo "Head: $HEAD_REPO:$HEAD_REF" if [[ "$BASE_REF" != "main" ]]; then - echo "This workflow only validates PRs targeting main." + echo "This workflow only validates PRs targeting main." >&2 + exit 1 + fi + if [[ "$HEAD_REPO" != "$EXPECTED_REPO" ]]; then + echo "PRs targeting main must come from the same repository." >&2 exit 1 fi - if [[ "$HEAD_REPO" != "$EXPECTED_REPO" || "$HEAD_REF" != "develop" ]]; then - echo "PRs targeting main must come from $EXPECTED_REPO:develop." >&2 + if [[ "$HEAD_REF" != "develop" && ! "$HEAD_REF" =~ ^hotfix/[a-z0-9._/-]+$ ]]; then + echo "PRs targeting main must come from develop or hotfix/*." >&2 exit 1 fi From 897c86ae997b7b3bfdbfb0ba7efcf992094beb8a Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:46:46 -0300 Subject: [PATCH 065/130] fix: validate only committed artifacts --- scripts/validation/repo_quality.py | 117 ++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 19 deletions(-) diff --git a/scripts/validation/repo_quality.py b/scripts/validation/repo_quality.py index 8d2ef5c..ea96688 100644 --- a/scripts/validation/repo_quality.py +++ b/scripts/validation/repo_quality.py @@ -3,6 +3,7 @@ import json from pathlib import Path +import subprocess import sys import tomllib @@ -10,6 +11,7 @@ SELF = Path(__file__).resolve() ROOT = SELF.parents[2] REQUIRED_PATHS = ( + ".env.example", "Makefile", "README.md", "LICENSE", @@ -35,56 +37,133 @@ "governance.bootstrap.json", "governance-bootstrap", ) -TEXT_SUFFIXES = {".py", ".md", ".yml", ".yaml", ".json", ".toml", ".txt", ".sh"} +TEXT_SUFFIXES = {".py", ".md", ".yml", ".yaml", ".json", ".toml", ".txt", ".sh", ".example"} -def fail(message: str, failures: list[str]) -> None: +def fail(message: str, failures: list[str], fix: str | None = None) -> None: failures.append(message) print(f"ERROR: {message}", file=sys.stderr) + if fix: + print(f" Fix: {fix}", file=sys.stderr) + + +def tracked_files(failures: list[str]) -> list[Path]: + try: + result = subprocess.run( + ["git", "ls-files", "-z"], + cwd=ROOT, + capture_output=True, + check=False, + ) + except FileNotFoundError: + fail( + "Git is not installed or is not available on PATH.", + failures, + "Install Git, open a new terminal, and run `make check` again.", + ) + return [] + + if result.returncode != 0: + details = result.stderr.decode("utf-8", errors="replace").strip() or "unknown Git error" + fail( + f"Could not inspect committed files: {details}", + failures, + "Run this command from a Git working tree and confirm that `git status` succeeds.", + ) + return [] + + names = result.stdout.decode("utf-8", errors="surrogateescape").split("\0") + return [ROOT / name for name in names if name] def main() -> int: failures: list[str] = [] + + print("==> Checking required repository files") for relative_path in REQUIRED_PATHS: if not (ROOT / relative_path).is_file(): - fail(f"required file is missing: {relative_path}", failures) + fail( + f"Required file is missing: {relative_path}", + failures, + "Restore the file from the project_setup template or rerun the installer.", + ) - for path in ROOT.rglob("*"): - if path.resolve() == SELF or ".git" in path.parts or not path.is_file(): - continue - if "__pycache__" in path.parts or path.suffix in {".pyc", ".pyo"}: - fail(f"generated Python artifact is tracked: {path.relative_to(ROOT)}", failures) + print("==> Checking committed files") + for path in tracked_files(failures): + relative_path = path.relative_to(ROOT) + relative = relative_path.as_posix() + + if "__pycache__" in relative_path.parts or path.suffix in {".pyc", ".pyo"}: + fail( + f"Generated Python artifact is committed: {relative}", + failures, + f"Run `git rm --cached -- {relative}` and then `make clean`. Local untracked cache files are allowed.", + ) continue - if path.suffix.lower() not in TEXT_SUFFIXES and path.name != "Makefile": + + for forbidden in FORBIDDEN_REFERENCES: + if forbidden in relative: + fail( + f"Legacy reference '{forbidden}' found in path: {relative}", + failures, + "Rename or remove the legacy path so only project_setup remains.", + ) + + if not path.is_file() or (path.suffix.lower() not in TEXT_SUFFIXES and path.name not in {"Makefile", ".env.example"}): continue try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: continue - relative = str(path.relative_to(ROOT)) for forbidden in FORBIDDEN_REFERENCES: - if forbidden in text or forbidden in relative: - fail(f"legacy reference '{forbidden}' found in {relative}", failures) + if forbidden in text: + fail( + f"Legacy reference '{forbidden}' found in {relative}", + failures, + "Replace the reference with project_setup or remove obsolete documentation.", + ) - for path in [ROOT / "project_setup.json", *sorted((ROOT / "config").rglob("*.json"))]: + print("==> Validating JSON configuration") + json_paths = [ROOT / "project_setup.json", *sorted((ROOT / "config").rglob("*.json"))] + for path in json_paths: try: json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - fail(f"invalid JSON in {path.relative_to(ROOT)}: {exc}", failures) + except FileNotFoundError: + fail( + f"JSON configuration is missing: {path.relative_to(ROOT)}", + failures, + "Restore the file or update project_setup.json to reference an existing manifest.", + ) + except json.JSONDecodeError as exc: + fail( + f"Invalid JSON in {path.relative_to(ROOT)} at line {exc.lineno}, column {exc.colno}: {exc.msg}", + failures, + "Correct the JSON syntax and run `make check` again.", + ) + print("==> Validating Python package metadata") try: with (ROOT / "pyproject.toml").open("rb") as file: pyproject = tomllib.load(file) scripts = pyproject.get("project", {}).get("scripts", {}) if scripts.get("project-setup") != "project_setup.cli:main": - fail("pyproject.toml does not expose project-setup = project_setup.cli:main", failures) - except (OSError, tomllib.TOMLDecodeError) as exc: - fail(f"invalid pyproject.toml: {exc}", failures) + fail( + "pyproject.toml does not expose `project-setup = project_setup.cli:main`.", + failures, + "Restore the project-setup entry point under [project.scripts].", + ) + except FileNotFoundError: + fail("pyproject.toml is missing.", failures, "Restore pyproject.toml before running the checks.") + except tomllib.TOMLDecodeError as exc: + fail(f"Invalid pyproject.toml: {exc}", failures, "Correct the TOML syntax and run `make check` again.") if failures: + print("", file=sys.stderr) print(f"Repository quality failed with {len(failures)} error(s).", file=sys.stderr) + print("Review each `Fix:` line above. No remote GitHub changes were made.", file=sys.stderr) return 1 - print("Repository quality checks passed.") + + print("Repository quality checks passed: required files, committed artifacts, JSON, and package metadata are valid.") return 0 From 2faf0db4c9dd864e1f22facf78a24d69e1efe30a Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:47:31 -0300 Subject: [PATCH 066/130] fix: make local checks actionable --- Makefile | 48 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 82b51af..e63e2bc 100644 --- a/Makefile +++ b/Makefile @@ -14,16 +14,23 @@ FORCE_FLAG := $(if $(filter 1 true yes on,$(FORCE)),--force,) OWNER_FLAG := $(if $(strip $(OWNER)),--owner "$(OWNER)",) PROJECT_TYPE_FLAG := $(if $(strip $(PROJECT_TYPE)),--project-type "$(PROJECT_TYPE)",) -.PHONY: help install dev-install compile test quality check doctor discover require-target require-repo require-project-number init init-dry plan apply setup setup-live labels milestones issues project-create project-sync clean +.PHONY: help install dev-install compile test quality check doctor discover require-target require-repo require-project-number init init-dry plan apply setup setup-live labels milestones issues project-create project-sync clean clean-generated help: @echo "GitHub Project Setup" @echo "" + @echo "First-time local setup:" + @echo " 1. Copy .env.example to .env" + @echo " 2. Add PROJECT_SETUP_PAT when Project v2 operations are needed" + @echo " 3. Run make doctor" + @echo " 4. Run make check" + @echo "" @echo "Development:" @echo " make install Install the CLI" @echo " make dev-install Install in editable mode" - @echo " make check Compile, validate and run tests" - @echo " make doctor Inspect local configuration" + @echo " make check Validate committed files, compile and run tests" + @echo " make doctor Inspect .env, token and configuration availability" + @echo " make clean Remove local Python/build artifacts" @echo "" @echo "Repository analysis and setup:" @echo " make discover TARGET=../project REPO=owner/repo" @@ -44,33 +51,42 @@ help: @echo " make project-sync REPO=owner/repo PROJECT_NUMBER=1" install: + @echo "==> Installing project_setup" $(PIP) install . dev-install: + @echo "==> Installing project_setup in editable mode" $(PIP) install -e . +quality: + @echo "==> [1/3] Validating repository structure and committed files" + $(PYTHON) scripts/validation/repo_quality.py + compile: + @echo "==> [2/3] Compiling Python sources" $(PYTHON) -m compileall -q project_setup scripts tests + @$(MAKE) --no-print-directory clean-generated + @echo "Python compilation passed. Generated cache files were removed." test: - $(PYTHON) -m unittest discover -s tests -p "test_*.py" -v + @echo "==> [3/3] Running unit tests" + $(PYTHON) -B -m unittest discover -s tests -p "test_*.py" -v -quality: - $(PYTHON) scripts/validation/repo_quality.py - -check: compile quality test +check: quality compile test + @echo "All repository checks passed. No GitHub API changes were made." doctor: + @echo "==> Inspecting local setup (read-only)" $(PYTHON) -m project_setup doctor --config "$(CONFIG)" require-target: - @test -n "$(TARGET)" || (echo "TARGET is required, for example: make init TARGET=../my-project" >&2; exit 2) + @test -n "$(TARGET)" || (echo "ERROR: TARGET is required." >&2; echo " Fix: use TARGET=../my-project" >&2; exit 2) require-repo: - @test -n "$(REPO)" || (echo "REPO is required, for example: REPO=owner/repository" >&2; exit 2) + @test -n "$(REPO)" || (echo "ERROR: REPO is required." >&2; echo " Fix: use REPO=owner/repository" >&2; exit 2) require-project-number: - @test -n "$(PROJECT_NUMBER)" || (echo "PROJECT_NUMBER is required" >&2; exit 2) + @test -n "$(PROJECT_NUMBER)" || (echo "ERROR: PROJECT_NUMBER is required." >&2; echo " Fix: use PROJECT_NUMBER=1" >&2; exit 2) discover: require-target require-repo $(PYTHON) -m project_setup discover --repo "$(REPO)" --config "$(CONFIG)" --root "$(TARGET)" $(PROJECT_TYPE_FLAG) --auto @@ -106,7 +122,9 @@ project-create: require-repo project-sync: require-repo require-project-number cd "$(WORKDIR)" && $(PYTHON) -m project_setup project sync --repo "$(REPO)" --project-number "$(PROJECT_NUMBER)" $(OWNER_FLAG) --file config/project/project-definition.json --dry-run -clean: - @find . -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true - @find . -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete 2>/dev/null || true - @rm -rf build dist *.egg-info .pytest_cache .mypy_cache +clean-generated: + @$(PYTHON) -c "from pathlib import Path; import shutil; [shutil.rmtree(path, ignore_errors=True) for path in list(Path('.').rglob('__pycache__'))]; [path.unlink(missing_ok=True) for pattern in ('*.pyc','*.pyo') for path in list(Path('.').rglob(pattern))]" + +clean: clean-generated + @$(PYTHON) -c "from pathlib import Path; import shutil; [shutil.rmtree(Path(name), ignore_errors=True) for name in ('build','dist','.pytest_cache','.mypy_cache')]; [shutil.rmtree(path, ignore_errors=True) for path in list(Path('.').glob('*.egg-info'))]" + @echo "Local Python and build artifacts removed." From d934b164dc1c5d5d80fe5616a75ede4c4261fc41 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:47:42 -0300 Subject: [PATCH 067/130] feat: add local environment template --- .env.example | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4448682 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy this file to .env before running local live operations. +# Never commit .env or paste tokens into issues, logs, or pull requests. + +# Target repository in owner/repository format. +GITHUB_REPOSITORY=owner/repository + +# Required for GitHub Projects v2 creation and synchronization. +# Create a personal access token (classic) with `repo` and `project` scopes. +# Leave empty when you only use dry-run or repository-scoped GitHub Actions. +PROJECT_SETUP_PAT= + +# Optional local defaults. +PROJECT_SETUP_CONFIG=project_setup.json +PROJECT_SETUP_PROJECT_NUMBER= +PROJECT_SETUP_OWNER= From 45e24d014435554ec32770b2dc2404db12f466b3 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:48:23 -0300 Subject: [PATCH 068/130] feat: load local env and require project token --- project_setup/github.py | 66 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/project_setup/github.py b/project_setup/github.py index 42e4f8a..1bf8edd 100644 --- a/project_setup/github.py +++ b/project_setup/github.py @@ -2,12 +2,13 @@ import json import os +from pathlib import Path +import re import shutil import subprocess import time from typing import Any import urllib.error -import urllib.parse import urllib.request @@ -15,6 +16,7 @@ GRAPHQL_URL = f"{API_BASE}/graphql" API_VERSION = "2022-11-28" RETRYABLE_HTTP_STATUS = {429, 502, 503, 504} +ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") class GitHubRequestError(RuntimeError): @@ -26,9 +28,42 @@ def __init__(self, method: str, url: str, status: int, details: str): self.details = details +def load_env_file(path: str | os.PathLike[str] | None = None) -> Path | None: + """Load a simple dotenv file without overriding existing environment variables.""" + configured_path = path or os.getenv("PROJECT_SETUP_ENV_FILE", ".env") + candidate = Path(configured_path).expanduser() + if not candidate.is_absolute(): + candidate = Path.cwd() / candidate + if not candidate.is_file(): + return None + + for line_number, raw_line in enumerate(candidate.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + if "=" not in line: + raise ValueError( + f"Invalid environment entry in {candidate} at line {line_number}: expected NAME=value" + ) + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if not ENV_KEY.fullmatch(key): + raise ValueError( + f"Invalid environment variable name '{key}' in {candidate} at line {line_number}" + ) + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + os.environ.setdefault(key, value) + return candidate.resolve() + + def get_token() -> str | None: + load_env_file() token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or os.environ.get("PROJECT_SETUP_PAT") - if token: + if token and token.strip(): return token.strip() gh = shutil.which("gh") if not gh: @@ -42,6 +77,12 @@ def get_token() -> str | None: return result.stdout.strip() or None +def get_project_pat() -> str | None: + load_env_file() + token = os.environ.get("PROJECT_SETUP_PAT") + return token.strip() if token and token.strip() else None + + def split_repo(repo: str) -> tuple[str, str]: if "/" not in repo: raise ValueError("repository must use owner/name format") @@ -140,5 +181,24 @@ def delete_issue_comment(self, repo: str, comment_id: int) -> dict[str, Any]: def require_client() -> GitHubClient: token = get_token() if not token: - raise SystemExit("Missing GITHUB_TOKEN, GH_TOKEN, PROJECT_SETUP_PAT, or authenticated gh CLI") + raise SystemExit( + "No GitHub token is available. Copy .env.example to .env and set PROJECT_SETUP_PAT, " + "set GITHUB_TOKEN/GH_TOKEN, or authenticate the GitHub CLI with `gh auth login`." + ) + return GitHubClient(token) + + +def require_project_client() -> GitHubClient: + token = get_project_pat() + if not token: + raise SystemExit( + "GitHub Projects v2 requires PROJECT_SETUP_PAT.\n" + "Fix:\n" + " 1. GitHub profile picture > Settings > Developer settings.\n" + " 2. Personal access tokens > Tokens (classic) > Generate new token (classic).\n" + " 3. Select the `repo` and `project` scopes.\n" + " 4. Copy .env.example to .env and set PROJECT_SETUP_PAT=.\n" + " 5. Run `make doctor` before retrying.\n" + "The repository-scoped github.token cannot create or synchronize Projects v2." + ) return GitHubClient(token) From 271f077de021b36156f1c1a2cb1e9ea309acffa1 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:49:19 -0300 Subject: [PATCH 069/130] feat: diagnose env and project credentials --- project_setup/cli.py | 115 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 92 insertions(+), 23 deletions(-) diff --git a/project_setup/cli.py b/project_setup/cli.py index d4911f7..f7a01b7 100644 --- a/project_setup/cli.py +++ b/project_setup/cli.py @@ -6,7 +6,14 @@ from .auto_label import apply_auto_labels from .discovery import SUPPORTED_PROJECT_TYPES, run_discovery -from .github import GitHubClient, get_token, require_client +from .github import ( + GitHubClient, + get_project_pat, + get_token, + load_env_file, + require_client, + require_project_client, +) from .installer import PROFILE_FILES, install_repository from .issue_milestones import sync_issue_milestones from .issues import generate_issues @@ -20,7 +27,11 @@ def repo_arg(value: str | None) -> str: repository = value or os.getenv("GITHUB_REPOSITORY") if not repository: - raise SystemExit("Missing --repo and GITHUB_REPOSITORY") + raise SystemExit( + "Missing target repository.\n" + "Fix: pass `--repo owner/repository`, use `REPO=owner/repository` with Make, " + "or set GITHUB_REPOSITORY in .env." + ) return repository @@ -41,19 +52,64 @@ def cmd_init(args: argparse.Namespace) -> int: def cmd_doctor(args: argparse.Namespace) -> int: config_path = Path(args.config) - print("python_module=project_setup") + environment_path = load_env_file() + project_pat = get_project_pat() + github_token = get_token() + failures = 0 + + print("==> Environment") + print(f"python_module=project_setup") + print(f"working_directory={Path.cwd()}") + print(f"env_file={environment_path or (Path.cwd() / '.env')} exists={'yes' if environment_path else 'no'}") + print(f"github_repository={os.getenv('GITHUB_REPOSITORY') or 'missing'}") + print(f"github_token={'configured' if github_token else 'missing'}") + print(f"project_setup_pat={'configured' if project_pat else 'missing'}") + + if not environment_path: + print("WARNING: .env was not found.") + print(" Fix: copy .env.example to .env and fill only the values required for your workflow.") + if not github_token: + print("WARNING: no GitHub authentication is available for live repository operations.") + print(" Fix: set PROJECT_SETUP_PAT in .env, set GITHUB_TOKEN/GH_TOKEN, or run `gh auth login`.") + if not project_pat: + print("INFO: PROJECT_SETUP_PAT is required only for GitHub Projects v2 creation or synchronization.") + print(" Setup: Settings > Developer settings > Personal access tokens > Tokens (classic).") + print(" Required scopes: repo and project. Save the token as PROJECT_SETUP_PAT in .env.") + + print("==> Configuration") print(f"config={config_path.resolve()}") - print(f"config_exists={config_path.is_file()}") - print(f"github_token={'configured' if get_token() else 'missing'}") - if config_path.is_file(): - try: - config = load_project_setup_config(str(config_path)) - except (OSError, ValueError) as exc: - print(f"config_error={exc}") - return 1 - for key in ("labelsFile", "milestonesFile", "projectDefinitionFile", "backlogManifestFile"): - path = Path(config[key]) - print(f"{key}={path} exists={path.is_file()}") + print(f"config_exists={'yes' if config_path.is_file() else 'no'}") + if not config_path.is_file(): + print(f"ERROR: configuration file is missing: {config_path}") + print(" Fix: restore project_setup.json or pass --config .") + return 1 + + try: + config = load_project_setup_config(str(config_path)) + except (OSError, ValueError) as exc: + print(f"ERROR: configuration could not be loaded: {exc}") + print(" Fix: correct project_setup.json and run `make doctor` again.") + return 1 + + for key in ("labelsFile", "milestonesFile", "projectDefinitionFile", "backlogManifestFile"): + path = Path(config[key]) + exists = path.is_file() + print(f"{key}={path} exists={'yes' if exists else 'no'}") + if not exists: + failures += 1 + print(f" ERROR: referenced file is missing: {path}") + print(f" Fix: create the file or update `{key}` in {config_path}.") + + defaults = config.get("defaults", {}) + if defaults.get("runProjectCreation", False) and not project_pat: + failures += 1 + print("ERROR: runProjectCreation is enabled but PROJECT_SETUP_PAT is missing.") + print(" Fix: configure PROJECT_SETUP_PAT in .env or disable runProjectCreation until the token is ready.") + + if failures: + print(f"Doctor found {failures} blocking problem(s). No GitHub API changes were made.") + return 1 + print("Doctor completed. Local files are valid; no GitHub API changes were made.") return 0 @@ -79,13 +135,14 @@ def cmd_issues_generate(args: argparse.Namespace) -> int: def cmd_project_create(args: argparse.Namespace) -> int: - create_project(GitHubClient("") if args.dry_run else require_client(), repo_arg(args.repo), args.file, args.dry_run) + client = GitHubClient("") if args.dry_run else require_project_client() + create_project(client, repo_arg(args.repo), args.file, args.dry_run) return 0 def cmd_project_sync(args: argparse.Namespace) -> int: sync_project( - require_client(), + require_project_client(), repo_arg(args.repo), args.file, args.project_number, @@ -104,7 +161,10 @@ def cmd_issue_milestones_sync(args: argparse.Namespace) -> int: def cmd_auto_label_apply(args: argparse.Namespace) -> int: event_path = args.event_path or os.getenv("GITHUB_EVENT_PATH") if not event_path: - raise SystemExit("Missing --event-path and GITHUB_EVENT_PATH") + raise SystemExit( + "Missing GitHub event payload.\n" + "Fix: pass --event-path locally. GitHub Actions provides GITHUB_EVENT_PATH automatically." + ) return apply_auto_labels(repo_arg(args.repo), event_path, args.labels_file, optional_client(), args.dry_run) @@ -136,7 +196,12 @@ def cmd_apply(args: argparse.Namespace) -> int: "run_issue_generation": args.run_issue_generation if args.run_issue_generation is not None else defaults.get("runIssueGeneration", False), "link_subissues": args.link_subissues if args.link_subissues is not None else defaults.get("linkSubissues", False), } - client = GitHubClient("") if values["dry_run"] else require_client() + if values["dry_run"]: + client = GitHubClient("") + elif values["run_project_creation"]: + client = require_project_client() + else: + client = require_client() run_project_setup(client, repo_arg(args.repo), config, **values) return 0 @@ -149,7 +214,7 @@ def add_bool_pair(parser: argparse.ArgumentParser, name: str, destination: str, def add_apply_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) - parser.add_argument("--config", default="project_setup.json") + parser.add_argument("--config", default=os.getenv("PROJECT_SETUP_CONFIG", "project_setup.json")) dry_run = parser.add_mutually_exclusive_group() dry_run.add_argument("--dry-run", dest="dry_run", action="store_true", default=None) dry_run.add_argument("--no-dry-run", dest="dry_run", action="store_false") @@ -177,7 +242,7 @@ def build_parser() -> argparse.ArgumentParser: discover = subcommands.add_parser("discover", help="Inspect a repository and recommend setup options") discover.add_argument("--repo") - discover.add_argument("--config", default="project_setup.json") + discover.add_argument("--config", default=os.getenv("PROJECT_SETUP_CONFIG", "project_setup.json")) discover.add_argument("--root", default=".") discover.add_argument("--project-type", choices=SUPPORTED_PROJECT_TYPES) discover.add_argument("--auto", action="store_true", help="Use configuration defaults without prompts") @@ -186,7 +251,7 @@ def build_parser() -> argparse.ArgumentParser: discover.set_defaults(func=run_discovery) doctor = subcommands.add_parser("doctor", help="Check local project setup prerequisites") - doctor.add_argument("--config", default="project_setup.json") + doctor.add_argument("--config", default=os.getenv("PROJECT_SETUP_CONFIG", "project_setup.json")) doctor.set_defaults(func=cmd_doctor) labels = subcommands.add_parser("labels") @@ -263,8 +328,12 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - return int(args.func(args)) + try: + load_env_file() + args = build_parser().parse_args(argv) + return int(args.func(args)) + except ValueError as exc: + raise SystemExit(f"Configuration error: {exc}\nFix the referenced file and run the command again.") from exc if __name__ == "__main__": From 0497b0ea936b8f56c9532f0e975145461225746c Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:49:46 -0300 Subject: [PATCH 070/130] feat: install local setup entrypoints --- project_setup/installer.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/project_setup/installer.py b/project_setup/installer.py index 060e8bc..91e530f 100644 --- a/project_setup/installer.py +++ b/project_setup/installer.py @@ -6,6 +6,8 @@ CORE_TEMPLATE_FILES = ( + ".env.example", + "Makefile", ".github/ISSUE_TEMPLATE/bug-report.yml", ".github/ISSUE_TEMPLATE/task-sub-issue.yml", ".github/ISSUE_TEMPLATE/user-story.yml", @@ -78,10 +80,18 @@ def install_repository( source_path = source_root / source_relative destination = target_root / destination_relative if not source_path.is_file(): - raise FileNotFoundError(f"Project setup template is missing: {source_path}") + raise FileNotFoundError( + f"Project setup template is missing: {source_path}. " + "Restore the source file before retrying the installation." + ) if destination.exists() and not force: skipped.append(destination_relative) print(f"skipped existing: {destination_relative}") + if destination_relative in {"Makefile", ".env.example"}: + print( + f" Review the installed template manually before merging it into the existing {destination_relative}. " + "Use --force only after reviewing the differences." + ) continue if dry_run: copied.append(destination_relative) @@ -93,4 +103,6 @@ def install_repository( print(f"copied: {destination_relative}") print(f"Project setup installation finished: copied={len(copied)}, skipped={len(skipped)}") + if ".env.example" in copied: + print("Next: copy .env.example to .env, configure only required values, and run `make doctor`.") return InstallResult(tuple(copied), tuple(skipped)) From 42b7e32aab296716d84947adca4b4516acd34ceb Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:50:11 -0300 Subject: [PATCH 071/130] fix: separate repository and project authentication --- .github/workflows/project-setup.yml | 31 ++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/project-setup.yml b/.github/workflows/project-setup.yml index 36eca8b..a67c747 100644 --- a/.github/workflows/project-setup.yml +++ b/.github/workflows/project-setup.yml @@ -19,7 +19,7 @@ on: default: false type: boolean run_project_creation: - description: "Create a GitHub Project v2" + description: "Create a GitHub Project v2 (requires PROJECT_SETUP_PAT for live runs)" required: true default: false type: boolean @@ -32,7 +32,6 @@ on: permissions: contents: read issues: write - pull-requests: write concurrency: group: project-setup-${{ github.repository }} @@ -43,26 +42,44 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Validate embedded setup package and configuration run: | + set -euo pipefail python -m compileall -q project_setup python -m project_setup doctor --config project_setup.json + - name: Require PAT for live Project v2 creation + if: ${{ inputs.run_project_creation && !inputs.dry_run }} + env: + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + set -euo pipefail + if [ -z "${PROJECT_SETUP_PAT:-}" ]; then + echo "::error title=PROJECT_SETUP_PAT is required::GitHub's repository-scoped token cannot create or synchronize Projects v2." + echo "Create a personal access token (classic):" + echo " GitHub profile picture > Settings > Developer settings" + echo " Personal access tokens > Tokens (classic) > Generate new token (classic)" + echo " Select scopes: repo and project" + echo "Save it as the repository Actions secret PROJECT_SETUP_PAT, then run this workflow again." + exit 1 + fi + echo "PROJECT_SETUP_PAT is configured for the requested Project v2 operation." + - name: Apply project setup env: - GITHUB_TOKEN: ${{ secrets.PROJECT_SETUP_PAT || github.token }} - GH_TOKEN: ${{ secrets.PROJECT_SETUP_PAT || github.token }} - GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} run: | + set -euo pipefail args="--config project_setup.json" if [ "${{ inputs.dry_run }}" = "true" ]; then args="$args --dry-run" From 5a5863f6c05563c760978a88ace0c405e9a3886d Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:50:24 -0300 Subject: [PATCH 072/130] refactor: use standard actions token context --- .github/workflows/auto-label.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 691accf..6c099df 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -9,7 +9,6 @@ on: permissions: contents: read issues: write - pull-requests: read concurrency: group: auto-label-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} @@ -20,19 +19,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout trusted repository automation - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.base.sha || github.sha }} persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Apply inferred labels env: GITHUB_TOKEN: ${{ github.token }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_EVENT_PATH: ${{ github.event_path }} run: python -m project_setup auto-label apply From a1c956140bcf30a021260ce132321dee7cce8ddf Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:50:43 -0300 Subject: [PATCH 073/130] fix: harden pull request metadata validation --- .github/workflows/pr-metadata.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 376774e..268e5f1 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -7,7 +7,6 @@ on: permissions: contents: read issues: write - pull-requests: write concurrency: group: pr-metadata-${{ github.event.pull_request.number }} @@ -19,24 +18,29 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout trusted base commit - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Validate branch name and pull request metadata env: PR_BODY: ${{ github.event.pull_request.body }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} GITHUB_TOKEN: ${{ github.token }} - run: >- - python scripts/validation/validate_pr_body.py - --branch "${{ github.event.pull_request.head.ref }}" - --base-branch "${{ github.event.pull_request.base.ref }}" - --repo "${{ github.repository }}" - --pr-number "${{ github.event.pull_request.number }}" - --comment + run: | + set -euo pipefail + python scripts/validation/validate_pr_body.py \ + --branch "$HEAD_REF" \ + --base-branch "$BASE_REF" \ + --repo "$REPOSITORY" \ + --pr-number "$PR_NUMBER" \ + --comment From a35a458ff8104eb368fcdbc66dea5bff7f7eda52 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:51:11 -0300 Subject: [PATCH 074/130] chore: update repository quality actions --- .github/workflows/repo-quality.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repo-quality.yml b/.github/workflows/repo-quality.yml index 77b2e5a..4828d38 100644 --- a/.github/workflows/repo-quality.yml +++ b/.github/workflows/repo-quality.yml @@ -19,12 +19,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" From ce1d0f51af5c3cc73dd290d1423f07f40ef206e4 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:51:25 -0300 Subject: [PATCH 075/130] docs: align workflow token example --- docs/repo/project-setup.workflow-template.yml | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/repo/project-setup.workflow-template.yml b/docs/repo/project-setup.workflow-template.yml index ae7ccb0..b97cd73 100644 --- a/docs/repo/project-setup.workflow-template.yml +++ b/docs/repo/project-setup.workflow-template.yml @@ -10,29 +10,49 @@ on: required: true default: true type: boolean + run_project_creation: + description: "Create Project v2 (requires PROJECT_SETUP_PAT for live runs)" + required: true + default: false + type: boolean permissions: contents: read issues: write - pull-requests: write jobs: setup: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.11" + - name: Require PAT for live Project v2 creation + if: ${{ inputs.run_project_creation && !inputs.dry_run }} + env: + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + if [ -z "${PROJECT_SETUP_PAT:-}" ]; then + echo "::error title=PROJECT_SETUP_PAT is required::Create a classic PAT with repo and project scopes and save it as the PROJECT_SETUP_PAT Actions secret." + exit 1 + fi - name: Run project setup env: - GITHUB_TOKEN: ${{ secrets.PROJECT_SETUP_PAT || github.token }} - GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} run: | + args="" if [ "${{ inputs.dry_run }}" = "true" ]; then - python -m project_setup apply --dry-run + args="$args --dry-run" + else + args="$args --no-dry-run" + fi + if [ "${{ inputs.run_project_creation }}" = "true" ]; then + args="$args --run-project-creation" else - python -m project_setup apply --no-dry-run + args="$args --skip-project-creation" fi + python -m project_setup apply $args From 1e6e94c089035e43a6c2bbc2be1e0dbf7b71a137 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:51:35 -0300 Subject: [PATCH 076/130] chore: update optional workflow actions --- templates/profiles/godot/.github/workflows/godot-smoke.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/profiles/godot/.github/workflows/godot-smoke.yml b/templates/profiles/godot/.github/workflows/godot-smoke.yml index 6260d91..1485566 100644 --- a/templates/profiles/godot/.github/workflows/godot-smoke.yml +++ b/templates/profiles/godot/.github/workflows/godot-smoke.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false From b7ed06c27a0f9d21c09dbca330f78aa7cb8848d2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:53:01 -0300 Subject: [PATCH 077/130] docs: publish bilingual setup guide --- README.md | 560 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 454 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 0acb940..6c15e68 100644 --- a/README.md +++ b/README.md @@ -1,185 +1,533 @@ +[![Repository quality](https://github.com/v-Kaefer/Github-Project-Automation/actions/workflows/repo-quality.yml/badge.svg?branch=develop)](https://github.com/v-Kaefer/Github-Project-Automation/actions/workflows/repo-quality.yml) +[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![Status: beta](https://img.shields.io/badge/status-beta-orange.svg)](https://github.com/v-Kaefer/Github-Project-Automation) + # GitHub Project Setup -A self-contained toolkit for installing and operating GitHub repository automation. + + +`project_setup` is a self-contained toolkit for installing and operating GitHub repository automation. It provides a Makefile and Python CLI for repository discovery, configuration validation, labels, milestones, issues, sub-issues, pull-request guardrails, and GitHub Projects v2. + +[Go directly to setup](#setup) · [Leia em português](#português) -It detects common project stacks, copies reusable workflows and templates into another repository, validates the resulting setup, and synchronizes repository resources through the GitHub API. The tool is designed for direct use by developers and for assisted use by coding agents or other AI systems. +## What it installs -## Capabilities +The `core` profile can install: -- Detect Python, Node.js, Go, Java, Rust and .NET repository markers. -- Install issue forms, pull request templates and GitHub Actions workflows. -- Embed the `project_setup` Python package in the target repository. -- Synchronize labels and milestones from JSON manifests. -- Generate stories and implementation tasks from a backlog manifest. -- Create and populate GitHub Projects v2. -- Link generated tasks as sub-issues. -- Infer labels for issues and pull requests. -- Validate branch names and pull request metadata. -- Plan all supported changes in dry-run mode before writing to GitHub. +- a Makefile for manual operation; +- `.env.example` for local credentials and defaults; +- the embedded `project_setup` Python package; +- GitHub Actions for repository setup, PR validation, auto-labeling, and quality checks; +- issue forms and a pull-request template; +- JSON manifests for labels, milestones, backlog items, and Project v2; +- validation scripts with actionable error messages. -## Requirements +Existing files are preserved unless `FORCE=1` or `--force` is explicitly used. -- Python 3.11 or newer. -- GNU Make for the Makefile interface. The Python CLI can be used directly on systems without Make. -- A GitHub token for live API operations. + -Authentication lookup order: +## Setup -1. `GITHUB_TOKEN` -2. `GH_TOKEN` -3. `PROJECT_SETUP_PAT` -4. `gh auth token`, when the GitHub CLI is installed and authenticated +### 1. Requirements -For the installed GitHub Actions workflow, configure the repository secret `PROJECT_SETUP_PAT` when Project v2 or other user-scoped permissions are required. Repository-scoped operations can fall back to `github.token`. +- Python 3.11 or newer; +- Git; +- GNU Make for the Makefile interface; +- a GitHub account with permission to modify the target repository; +- a personal access token only when creating or synchronizing GitHub Projects v2. -## Quick start +The Python CLI remains available on systems without Make: + +```bash +python -m project_setup --help +``` -Validate this tool repository: +### 2. Clone and validate the tool ```bash +git clone https://github.com/v-Kaefer/Github-Project-Automation.git +cd Github-Project-Automation +git switch develop make check ``` -Inspect the target project and print the recommended setup command: +`make check` performs three local stages: + +1. validates required files, committed artifacts, JSON, and package metadata; +2. compiles the Python sources; +3. runs unit tests. + +It does not call the GitHub API and does not modify a repository. Local `__pycache__` files created during compilation are removed automatically. The quality check only rejects generated Python artifacts that are actually committed to Git. Every reported problem includes a `Fix:` instruction. + +### 3. Create the local `.env` + +Copy the template: ```bash -make discover TARGET=../my-project REPO=owner/my-project +cp .env.example .env +``` + +PowerShell: + +```powershell +Copy-Item .env.example .env ``` -Install the core profile and preview the GitHub changes: +At minimum, set the target repository: + +```dotenv +GITHUB_REPOSITORY=owner/repository +``` + +The CLI loads `.env` automatically from the current working directory. Existing process environment variables take precedence over values in `.env`. + +Run the read-only local diagnostic: ```bash -make setup TARGET=../my-project REPO=owner/my-project +make doctor +``` + +`make doctor` checks the `.env`, `project_setup.json`, and referenced manifest files. It explains missing values and does not make GitHub API changes. + +### 4. Authentication model + +#### Repository-scoped operations + +GitHub Actions automatically provides `github.token`. The workflows expose it to the Python process as: + +```yaml +GITHUB_TOKEN: ${{ github.token }} +``` + +No custom secret named `GITHUB_TOKEN` is required. The standard repository token is used for operations such as: + +- labels; +- milestones; +- issues and tasks; +- sub-issues in the same repository; +- PR validation comments; +- inferred labels. + +#### GitHub Projects v2 + +GitHub's repository-scoped token cannot access Projects v2. Live Project v2 creation or synchronization requires `PROJECT_SETUP_PAT`. + +For the current GraphQL implementation, create a **personal access token (classic)**: + +1. Click your GitHub profile picture. +2. Open **Settings**. +3. Open **Developer settings**. +4. Open **Personal access tokens**. +5. Open **Tokens (classic)**. +6. Click **Generate new token** and then **Generate new token (classic)**. +7. Set a descriptive name and an expiration date. +8. Select these scopes: + - `repo`; + - `project`. +9. Generate the token and copy it immediately. + +Official references: + +- [Managing personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [Automating Projects using Actions](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions) + +For local execution, save it only in `.env`: + +```dotenv +PROJECT_SETUP_PAT=ghp_your_token_here ``` -The `setup` target is intentionally safe: it copies missing files and runs the API phase in dry-run mode. +Never commit `.env`. The repository ignores `.env` and permits only `.env.example` to be versioned. + +For GitHub Actions, create a repository secret: + +1. Open the target repository. +2. Open **Settings**. +3. Open **Secrets and variables** → **Actions**. +4. Select **New repository secret**. +5. Use the name `PROJECT_SETUP_PAT`. +6. Paste the token and save it. + +When a live workflow requests Project v2 without this secret, it stops before applying changes and prints the exact setup path and required scopes. + +> GitHub recommends a GitHub App for long-lived organization automation. The PAT path is retained here as the simplest supported setup for individual users and initial adoption. -After reviewing and customizing the generated files, apply the changes: +### 5. Inspect a target repository ```bash -export PROJECT_SETUP_PAT=github_pat_... -make apply TARGET=../my-project REPO=owner/my-project +make discover TARGET=../my-project REPO=owner/my-project ``` -To install the optional Godot smoke workflow: +The command detects common Python, Node.js, Go, Java, Rust, and .NET markers and prints the recommended setup command. + +### 6. Preview installation ```bash -make init TARGET=../my-game PROFILE=godot +make init-dry TARGET=../my-project PROFILE=core ``` -Existing files are preserved. Explicit replacement requires: +Install the files after reviewing the preview: ```bash -make init TARGET=../my-project FORCE=1 +make init TARGET=../my-project PROFILE=core ``` -## Files to customize in the target repository +The installer includes `Makefile` and `.env.example`. If the target already has either file, it is preserved and the installer asks you to review and merge the templates manually. -Before a live apply, review at least: +Optional Godot profile: -- `project_setup.json` -- `config/project/labels.json` -- `config/project/milestones.json` -- `config/project/project-definition.json` -- `config/stories/backlog-manifest.json` -- `.github/workflows/main-source-branch.yml` -- `.github/pull_request_template.md` +```bash +make init TARGET=../my-game PROFILE=godot +``` -The sample backlog uses `owner/repository` deliberately. Project creation and issue generation are disabled by default. +### 7. Customize the target configuration -## Makefile interface +Review at least: -| Target | Purpose | -| --- | --- | -| `make help` | Show commands and required variables. | -| `make install` | Install the CLI in the active Python environment. | -| `make dev-install` | Install the CLI in editable mode. | -| `make check` | Compile Python, validate repository structure and run tests. | -| `make doctor` | Inspect token and configuration availability. | -| `make discover TARGET=... REPO=...` | Detect the target stack and print a recommended command. | -| `make init TARGET=...` | Copy the embedded setup into a target repository. | -| `make init-dry TARGET=...` | Preview copied files without writing. | -| `make plan REPO=...` | Preview GitHub API changes. | -| `make apply REPO=...` | Apply configured GitHub API changes. | -| `make setup TARGET=... REPO=...` | Install files and run a dry-run. | -| `make setup-live TARGET=... REPO=...` | Install files and perform a live apply. | +- `.env.example` and the local `.env`; +- `project_setup.json`; +- `config/project/labels.json`; +- `config/project/milestones.json`; +- `config/project/project-definition.json`; +- `config/stories/backlog-manifest.json`; +- `.github/workflows/project-setup.yml`; +- `.github/workflows/main-source-branch.yml`; +- `.github/pull_request_template.md`. -`TARGET` is optional for API-only targets. When provided, commands run from that repository so its local `project_setup.json` and manifests are used. +Project creation and issue generation are disabled by default. -## Python CLI +### 8. Diagnose and plan -The installed commands are equivalent: +From the target repository: ```bash -project-setup --help -project_setup --help -python -m project_setup --help +make doctor +make plan REPO=owner/repository ``` -Common operations: +Or from this tool repository: ```bash -python -m project_setup discover --repo owner/repository --root ../my-project --auto -python -m project_setup init --target ../my-project --profile core -python -m project_setup doctor --config project_setup.json -python -m project_setup apply --repo owner/repository --dry-run -python -m project_setup labels sync --repo owner/repository --dry-run -python -m project_setup project sync --repo owner/repository --project-number 1 --dry-run +make doctor CONFIG=project_setup.json +make plan TARGET=../my-project REPO=owner/repository ``` -## AI-assisted setup +`make plan` uses dry-run mode. Review the complete output before applying changes. -An AI assistant should follow this order: +### 9. Apply -1. Inspect the target repository and identify its language, test framework, branch model and existing automation. -2. Run `discover` to verify the detected stack and proposed execution flags. -3. Run `init` without `--force`. -4. Replace the example repository, milestones, board fields and backlog entries with project-specific values. -5. Preserve existing project-specific workflows unless they are explicitly selected for replacement. -6. Run `make check` in this tool repository and compile the embedded package in the target repository. -7. Run `make plan TARGET= REPO=`. -8. Present the dry-run output for human review. -9. Perform a live apply only after explicit approval. +Repository resources without Project v2 can use a standard authenticated GitHub CLI session or another supported token: -A suitable instruction for an agent is: +```bash +make apply TARGET=../my-project REPO=owner/repository +``` -```text -Use the installed project_setup files as the automation baseline. Adapt the JSON manifests and workflows to this repository without deleting existing project-specific automation. Run discovery and dry-run validation first, and do not perform live GitHub writes until the proposed changes have been reviewed. +Project v2 operations require `PROJECT_SETUP_PAT` in the target `.env`: + +```bash +make project-create TARGET=../my-project REPO=owner/repository +make project-sync TARGET=../my-project REPO=owner/repository PROJECT_NUMBER=1 ``` -## Profiles +### 10. Manual GitHub Actions execution -### `core` +After installation, open the target repository and select: -Installs repository-neutral issue forms, pull request validation, auto-labeling, the Project setup workflow, manifests, validation scripts and the embedded Python package. +**Actions** → **Project setup** → **Run workflow** -### `godot` +The workflow defaults to dry-run. Labels, milestones, issue generation, and Project v2 creation are separate inputs. Live Project v2 creation requires the `PROJECT_SETUP_PAT` Actions secret described above. -Installs everything in `core` and copies the optional Godot smoke workflow from `templates/profiles/godot`. Game tests, release exports and gameplay-specific checks are intentionally not part of the generic core. +## Makefile reference + +| Target | Purpose | +| --- | --- | +| `make help` | Show the local setup sequence and available commands. | +| `make check` | Validate committed files, compile sources, and run tests. | +| `make doctor` | Inspect `.env` and local configuration without API writes. | +| `make discover TARGET=... REPO=...` | Detect the target stack and recommend setup options. | +| `make init-dry TARGET=...` | Preview files that would be installed. | +| `make init TARGET=...` | Install missing files while preserving existing files. | +| `make plan TARGET=... REPO=...` | Preview configured GitHub changes. | +| `make apply TARGET=... REPO=...` | Apply configured GitHub changes. | +| `make setup TARGET=... REPO=...` | Install files and run a dry-run. | +| `make setup-live TARGET=... REPO=...` | Install files and perform a live apply. | +| `make clean` | Remove local Python and build artifacts. | ## Safety model -- Dry-run is the default in `project_setup.json`. +- Dry-run is the default. - Issue generation is disabled by default. - Project creation is disabled by default. -- Existing target files are skipped unless `--force` is explicitly provided. -- Pull request validation executes trusted code from the base commit. -- Workflow checkouts do not persist credentials. -- Concurrent runs for the same pull request are cancelled when superseded. +- Existing target files are preserved. +- Project v2 uses an explicit PAT instead of silently falling back to `github.token`. +- PR workflows execute trusted code from the base commit. +- Untrusted branch names are passed through environment variables rather than interpolated into shell scripts. +- Workflow permissions are limited to the resources each workflow uses. +- Tokens are never printed by `doctor` or workflow diagnostics. ## Current limitations +- Issue generation is not yet idempotent; inspect existing issues before repeating it. +- Project v2 views listed in the definition still require manual configuration. - GitHub rulesets and branch protection are not created automatically yet. -- Project v2 views listed in the definition remain a manual configuration step. -- Issue generation is not idempotent; review existing issues before repeating it. -- The installer currently embeds the package in each target repository rather than depending on a published PyPI release. -- Repository-specific CI should be added as an optional profile instead of being placed in the core setup. +- The installer embeds the package in each target repository rather than using a published PyPI release. +- A summarized `make preview` with limited example output is planned but is not implemented yet. -## Development +--- + + + +# Configuração de Projetos no GitHub + +O `project_setup` é uma ferramenta autocontida para instalar e operar automações de repositórios no GitHub. Ela oferece Makefile e CLI Python para descoberta do projeto, validação de configuração, labels, milestones, issues, sub-issues, regras de pull request e GitHub Projects v2. + +[Ir diretamente para a configuração](#configuração) · [Read in English](#english) + +## O que é instalado + +O perfil `core` pode instalar: + +- Makefile para execução manual; +- `.env.example` para credenciais e configurações locais; +- pacote Python `project_setup` incorporado; +- GitHub Actions para setup, validação de PR, auto-label e qualidade; +- formulários de issues e template de pull request; +- manifests JSON para labels, milestones, backlog e Project v2; +- scripts de validação com mensagens de correção acionáveis. + +Arquivos existentes são preservados, exceto quando `FORCE=1` ou `--force` é usado explicitamente. + + + +## Configuração + +### 1. Requisitos + +- Python 3.11 ou superior; +- Git; +- GNU Make para usar a interface Makefile; +- conta GitHub com permissão para modificar o repositório-alvo; +- personal access token somente para criar ou sincronizar GitHub Projects v2. + +Em sistemas sem Make, use diretamente: ```bash -make dev-install +python -m project_setup --help +``` + +### 2. Clonar e validar a ferramenta + +```bash +git clone https://github.com/v-Kaefer/Github-Project-Automation.git +cd Github-Project-Automation +git switch develop make check ``` -The repository quality check rejects generated Python bytecode, invalid JSON/TOML and legacy package references. +O `make check` executa três etapas locais: + +1. valida arquivos obrigatórios, artefatos commitados, JSON e metadados do pacote; +2. compila os fontes Python; +3. executa os testes unitários. + +Ele não chama a API do GitHub e não modifica repositórios. Os `__pycache__` locais criados durante a compilação são removidos automaticamente. O validador rejeita apenas artefatos Python realmente commitados. Cada erro apresenta uma instrução `Fix:`. + +### 3. Criar o `.env` local + +PowerShell: + +```powershell +Copy-Item .env.example .env +``` + +Linux, macOS, Git Bash ou WSL: + +```bash +cp .env.example .env +``` + +Defina ao menos o repositório-alvo: + +```dotenv +GITHUB_REPOSITORY=owner/repository +``` + +A CLI carrega automaticamente o `.env` do diretório atual. Variáveis já definidas no processo têm prioridade sobre o arquivo. + +Execute o diagnóstico local somente leitura: + +```bash +make doctor +``` + +O `make doctor` verifica `.env`, `project_setup.json` e os manifests referenciados. Ele informa exatamente o que está ausente e não altera o GitHub. + +### 4. Modelo de autenticação + +#### Operações do próprio repositório + +O GitHub Actions fornece automaticamente `github.token`. Os workflows o disponibilizam ao Python como: + +```yaml +GITHUB_TOKEN: ${{ github.token }} +``` + +Não é necessário criar um secret chamado `GITHUB_TOKEN`. O token padrão atende operações como: + +- labels; +- milestones; +- issues e tasks; +- sub-issues no mesmo repositório; +- comentários de validação em PRs; +- labels inferidas. + +#### GitHub Projects v2 + +O token padrão do repositório não acessa Projects v2. A criação ou sincronização real exige `PROJECT_SETUP_PAT`. + +Para a implementação GraphQL atual, crie um **personal access token (classic)**: + +1. Clique na sua foto de perfil no GitHub. +2. Abra **Settings**. +3. Abra **Developer settings**. +4. Abra **Personal access tokens**. +5. Abra **Tokens (classic)**. +6. Clique em **Generate new token** e depois **Generate new token (classic)**. +7. Defina nome descritivo e validade. +8. Marque os escopos: + - `repo`; + - `project`. +9. Gere o token e copie imediatamente. + +Documentação oficial: + +- [Gerenciar personal access tokens](https://docs.github.com/pt/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [Automatizar Projects usando Actions](https://docs.github.com/pt/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions) + +Para uso local, salve somente no `.env`: + +```dotenv +PROJECT_SETUP_PAT=ghp_seu_token_aqui +``` + +Nunca versione o `.env`. O repositório ignora `.env` e permite apenas `.env.example`. + +Para GitHub Actions, crie um secret no repositório: + +1. Abra o repositório-alvo. +2. Abra **Settings**. +3. Abra **Secrets and variables** → **Actions**. +4. Selecione **New repository secret**. +5. Use o nome `PROJECT_SETUP_PAT`. +6. Cole o token e salve. + +Quando uma execução real solicitar Project v2 sem esse secret, o workflow para antes de aplicar alterações e mostra o caminho e os escopos necessários. + +> Para automações permanentes em organizações, o GitHub recomenda uma GitHub App. A PAT permanece como o caminho mais simples para usuários individuais e validação inicial. + +### 5. Inspecionar o repositório-alvo + +```bash +make discover TARGET=../meu-projeto REPO=owner/meu-projeto +``` + +O comando detecta marcadores comuns de Python, Node.js, Go, Java, Rust e .NET e imprime o comando recomendado. + +### 6. Simular e instalar + +```bash +make init-dry TARGET=../meu-projeto PROFILE=core +make init TARGET=../meu-projeto PROFILE=core +``` + +O instalador inclui `Makefile` e `.env.example`. Caso o alvo já possua algum deles, o arquivo existente é preservado e a saída orienta uma mesclagem manual. + +Perfil Godot opcional: + +```bash +make init TARGET=../meu-jogo PROFILE=godot +``` + +### 7. Personalizar + +Revise pelo menos: + +- `.env.example` e o `.env` local; +- `project_setup.json`; +- `config/project/labels.json`; +- `config/project/milestones.json`; +- `config/project/project-definition.json`; +- `config/stories/backlog-manifest.json`; +- `.github/workflows/project-setup.yml`; +- `.github/workflows/main-source-branch.yml`; +- `.github/pull_request_template.md`. + +A criação de Project e a geração de issues permanecem desativadas por padrão. + +### 8. Diagnosticar e planejar + +```bash +make doctor +make plan TARGET=../meu-projeto REPO=owner/repositorio +``` + +O `make plan` usa dry-run. Revise toda a saída antes de aplicar. + +### 9. Aplicar + +Para recursos do repositório sem Project v2: + +```bash +make apply TARGET=../meu-projeto REPO=owner/repositorio +``` + +Para Project v2, configure `PROJECT_SETUP_PAT` no `.env` do alvo: + +```bash +make project-create TARGET=../meu-projeto REPO=owner/repositorio +make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 +``` + +### 10. Execução manual no GitHub Actions + +No repositório-alvo, abra: + +**Actions** → **Project setup** → **Run workflow** + +O workflow inicia em dry-run. Labels, milestones, geração de issues e criação de Project v2 são opções separadas. A criação real do Project v2 exige o secret `PROJECT_SETUP_PAT`. + +## Referência do Makefile + +| Alvo | Finalidade | +| --- | --- | +| `make help` | Mostrar a sequência inicial e os comandos. | +| `make check` | Validar arquivos commitados, compilar e testar. | +| `make doctor` | Verificar `.env` e configuração local sem alterar a API. | +| `make discover TARGET=... REPO=...` | Detectar a stack e recomendar opções. | +| `make init-dry TARGET=...` | Simular os arquivos que seriam instalados. | +| `make init TARGET=...` | Instalar arquivos ausentes preservando os existentes. | +| `make plan TARGET=... REPO=...` | Simular alterações configuradas no GitHub. | +| `make apply TARGET=... REPO=...` | Aplicar alterações configuradas. | +| `make setup TARGET=... REPO=...` | Instalar e executar dry-run. | +| `make setup-live TARGET=... REPO=...` | Instalar e executar alterações reais. | +| `make clean` | Remover caches Python e artefatos locais. | + +## Modelo de segurança + +- Dry-run é o padrão. +- Geração de issues e criação de Project ficam desativadas inicialmente. +- Arquivos existentes são preservados. +- Project v2 exige PAT explícita e não usa fallback silencioso para `github.token`. +- Workflows de PR executam código confiável da branch-base. +- Nomes de branches não são interpolados diretamente em scripts shell. +- Permissões dos workflows são limitadas às funções utilizadas. +- Tokens nunca são exibidos pelo `doctor` ou pelos logs de diagnóstico. + +## Limitações atuais + +- A geração de issues ainda não é idempotente. +- Views de Project v2 continuam com configuração manual. +- Rulesets e branch protection ainda não são criados automaticamente. +- O instalador incorpora o pacote no repositório-alvo em vez de usar uma versão publicada no PyPI. +- Um `make preview` resumido, com limite de exemplos, está planejado, mas ainda não foi implementado. From e80cc42b6ce41ef341030856c949ad1bda1ca8d7 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:54:02 -0300 Subject: [PATCH 078/130] test: cover env and project authentication --- tests/test_project_setup.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/test_project_setup.py b/tests/test_project_setup.py index 0aee479..31fa9eb 100644 --- a/tests/test_project_setup.py +++ b/tests/test_project_setup.py @@ -13,7 +13,7 @@ from project_setup.auto_label import infer_issue_labels from project_setup.cli import main from project_setup.discovery import build_apply_command, detect_project_matches -from project_setup.github import GitHubClient, get_token +from project_setup.github import GitHubClient, get_token, load_env_file, require_project_client from project_setup.installer import install_repository from project_setup.issue_milestones import milestone_from_body, parent_issue_number_from_body from project_setup.issues import load_backlog @@ -115,6 +115,37 @@ def test_apply_dry_run_does_not_require_token(self): self.assertIn("[DRY-RUN] Would sync 1 labels", output.getvalue()) self.assertIn("Project setup finished.", output.getvalue()) + def test_env_file_loads_values_without_overriding_process_environment(self): + with tempfile.TemporaryDirectory() as temporary_directory: + env_file = Path(temporary_directory) / ".env" + env_file.write_text( + "GITHUB_REPOSITORY=owner/from-file\nPROJECT_SETUP_PAT=token-from-file\n", + encoding="utf-8", + ) + with patch.dict(os.environ, {"GITHUB_REPOSITORY": "owner/from-process"}, clear=True): + loaded = load_env_file(env_file) + self.assertEqual(loaded, env_file.resolve()) + self.assertEqual(os.environ["GITHUB_REPOSITORY"], "owner/from-process") + self.assertEqual(os.environ["PROJECT_SETUP_PAT"], "token-from-file") + + def test_invalid_env_file_reports_line_number(self): + with tempfile.TemporaryDirectory() as temporary_directory: + env_file = Path(temporary_directory) / ".env" + env_file.write_text("THIS IS NOT VALID\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "line 1"): + load_env_file(env_file) + + def test_project_client_error_explains_pat_setup(self): + with patch.dict(os.environ, {"PROJECT_SETUP_PAT": "", "GITHUB_TOKEN": "token"}, clear=False): + with self.assertRaises(SystemExit) as context: + require_project_client() + message = str(context.exception) + self.assertIn("PROJECT_SETUP_PAT", message) + self.assertIn("Tokens (classic)", message) + self.assertIn("repo", message) + self.assertIn("project", message) + self.assertIn(".env", message) + def test_installer_copies_core_files_and_preserves_existing_files(self): with tempfile.TemporaryDirectory() as temporary_directory: target = Path(temporary_directory) @@ -127,6 +158,8 @@ def test_installer_copies_core_files_and_preserves_existing_files(self): self.assertTrue((target / "project_setup" / "cli.py").is_file()) self.assertTrue((target / "project_setup" / "discovery.py").is_file()) self.assertTrue((target / ".github" / "workflows" / "project-setup.yml").is_file()) + self.assertTrue((target / ".env.example").is_file()) + self.assertTrue((target / "Makefile").is_file()) self.assertFalse((target / ".github" / "workflows" / "godot-smoke.yml").exists()) def test_godot_profile_copies_optional_workflow(self): From 42a64ca88b35bd10dd82e477af67d2dc247c6b96 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:55:35 -0300 Subject: [PATCH 079/130] docs: clarify local and project authentication --- docs/repo/project-setup-runbook.pt-BR.md | 153 ++++++++++++++++++----- 1 file changed, 124 insertions(+), 29 deletions(-) diff --git a/docs/repo/project-setup-runbook.pt-BR.md b/docs/repo/project-setup-runbook.pt-BR.md index 8547e99..4bfd75a 100644 --- a/docs/repo/project-setup-runbook.pt-BR.md +++ b/docs/repo/project-setup-runbook.pt-BR.md @@ -2,7 +2,7 @@ ## Objetivo -Instalar e operar as automações deste repositório em outro projeto sem sobrescrever arquivos existentes ou executar alterações remotas antes de uma revisão. +Instalar e operar as automações em outro repositório sem sobrescrever arquivos existentes e sem executar alterações remotas antes de uma revisão. ## 1. Validar a ferramenta @@ -10,36 +10,128 @@ Instalar e operar as automações deste repositório em outro projeto sem sobres make check ``` -O comando compila o pacote, valida a estrutura do repositório e executa os testes. +O comando: -## 2. Inspecionar o repositório-alvo +1. valida somente arquivos commitados e configurações; +2. compila os fontes Python; +3. remove caches gerados; +4. executa os testes. -Registre a linguagem, framework, comandos de teste, branches principais, labels, milestones, Project v2 e workflows existentes. Para descoberta automática inicial: +Caches locais em `__pycache__` não são tratados como arquivos versionados. Quando houver uma falha real, a saída apresenta uma instrução `Fix:`. + +## 2. Criar o ambiente local + +PowerShell: + +```powershell +Copy-Item .env.example .env +``` + +Linux, macOS, Git Bash ou WSL: + +```bash +cp .env.example .env +``` + +Defina o repositório: + +```dotenv +GITHUB_REPOSITORY=owner/repositorio +``` + +A CLI carrega `.env` automaticamente e nunca imprime os valores dos tokens. + +## 3. Configurar autenticação + +### Operações do repositório + +No GitHub Actions, labels, milestones, issues, sub-issues e comentários usam o token padrão: + +```yaml +GITHUB_TOKEN: ${{ github.token }} +``` + +Não crie um secret chamado `GITHUB_TOKEN`. + +Para execução local sem Project v2, também é possível usar uma sessão autenticada do GitHub CLI: + +```bash +gh auth login +``` + +### GitHub Projects v2 + +Project v2 não pode usar o token padrão do repositório. Crie um personal access token classic: + +1. foto de perfil; +2. **Settings**; +3. **Developer settings**; +4. **Personal access tokens**; +5. **Tokens (classic)**; +6. **Generate new token (classic)**; +7. selecione os escopos `repo` e `project`; +8. gere e copie o token. + +Para execução local, salve no `.env`: + +```dotenv +PROJECT_SETUP_PAT=ghp_seu_token +``` + +Para Actions, salve como secret do repositório: + +**Settings** → **Secrets and variables** → **Actions** → **New repository secret** + +Nome: + +```text +PROJECT_SETUP_PAT +``` + +Uma execução real de Project v2 sem essa PAT termina antes das alterações e informa o caminho de configuração. + +## 4. Executar o diagnóstico + +```bash +make doctor +``` + +O diagnóstico verifica: + +- presença do `.env`; +- repositório configurado; +- disponibilidade de autenticação; +- presença específica de `PROJECT_SETUP_PAT`; +- validade de `project_setup.json`; +- existência dos manifests referenciados. + +Ele não chama mutations nem aplica alterações no GitHub. + +## 5. Inspecionar o repositório-alvo ```bash make discover TARGET=../meu-projeto REPO=owner/repositorio ``` -## 3. Simular a instalação +## 6. Simular a instalação ```bash make init-dry TARGET=../meu-projeto PROFILE=core ``` -Use `PROFILE=godot` apenas quando o projeto realmente utilizar Godot. - -## 4. Instalar os arquivos +## 7. Instalar os arquivos ```bash make init TARGET=../meu-projeto ``` -Arquivos existentes são preservados. A substituição consciente exige `FORCE=1`. +O instalador também leva `Makefile` e `.env.example`. Arquivos existentes são preservados e devem ser mesclados manualmente. A substituição consciente exige `FORCE=1`. -## 5. Personalizar +## 8. Personalizar -Edite no repositório-alvo: +Revise: +- `.env.example` e `.env`; - `project_setup.json`; - `config/project/labels.json`; - `config/project/milestones.json`; @@ -49,36 +141,39 @@ Edite no repositório-alvo: Mantenha `runIssueGeneration` e `runProjectCreation` desativados até concluir a personalização. -## 6. Configurar autenticação - -Para execução local, configure `GITHUB_TOKEN`, `GH_TOKEN` ou `PROJECT_SETUP_PAT`. Uma sessão autenticada do GitHub CLI também pode ser usada por meio de `gh auth token`. - -Para Actions, configure o secret `PROJECT_SETUP_PAT` quando Project v2 ou permissões adicionais forem necessários. - -## 7. Revisar o plano +## 9. Revisar o plano ```bash make plan TARGET=../meu-projeto REPO=owner/repositorio ``` -Revise integralmente a saída antes de continuar. - -## 8. Aplicar +## 10. Aplicar ```bash make apply TARGET=../meu-projeto REPO=owner/repositorio ``` -## 9. Validar no GitHub +Para Project v2: + +```bash +make project-create TARGET=../meu-projeto REPO=owner/repositorio +make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 +``` + +## 11. Execução manual no Actions + +No repositório-alvo: + +**Actions** → **Project setup** → **Run workflow** -Confirme labels, milestones, ausência de issues duplicadas, disponibilidade do workflow `Project setup`, validação dos PRs e criação do Project v2 somente quando solicitada. +Comece com `dry_run=true`. A criação real de Project v2 exige o secret `PROJECT_SETUP_PAT`. ## Recuperação -1. execute `python -m project_setup doctor` no repositório-alvo; -2. confirme o token e suas permissões; -3. execute novamente com `--dry-run`; -4. corrija o manifest responsável; -5. evite `FORCE=1` até identificar o arquivo conflitante. +1. execute `make doctor`; +2. siga cada instrução `Fix:`; +3. execute `make check`; +4. repita `make plan`; +5. aplique somente após revisar a saída. -O sincronismo de labels e milestones é idempotente. A geração de issues não é idempotente e não deve ser repetida sem revisar as issues existentes. +Labels e milestones são sincronizados de forma idempotente. A geração de issues ainda não é idempotente e não deve ser repetida sem revisar as issues existentes. From 22970bc56b8eb8949ee9847ab856955d74520e43 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:55:51 -0300 Subject: [PATCH 080/130] docs: document explicit project token boundary --- docs/repo/project-setup-shared-tool.md | 40 ++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/repo/project-setup-shared-tool.md b/docs/repo/project-setup-shared-tool.md index 9b1a816..0505151 100644 --- a/docs/repo/project-setup-shared-tool.md +++ b/docs/repo/project-setup-shared-tool.md @@ -14,13 +14,24 @@ python -m project_setup --help ## Distribution model -The installer embeds the package and managed automation files directly in the target repository. This makes Actions runs reproducible and avoids requiring a published package. +The installer embeds the package and managed automation files directly in the target repository. It also installs `Makefile` and `.env.example` when they do not already exist. ```bash python -m project_setup init --target ../target-repository ``` -Existing files are preserved unless `--force` is explicitly selected. +Existing files are preserved unless `--force` is explicitly selected. Existing Makefiles and environment templates should be reviewed and merged manually. + +## Local environment + +The CLI automatically loads `.env` from the current working directory without replacing variables that are already present in the process environment. + +```bash +cp .env.example .env +make doctor +``` + +`make doctor` validates local files and credential availability without writing to GitHub. ## Configuration @@ -44,10 +55,27 @@ The `discover` command detects common Python, Node.js, Go, Java, Rust and .NET m Language- or framework-specific checks should be added as profiles instead of expanding the core workflow. -## Automation boundaries +## Authentication boundary -The tool automates repository files, labels, milestones, issues, sub-issues and Project v2 fields/items. Branch protection, rulesets and Project views remain outside the automated core. +Repository-scoped Actions operations use the standard token: -## Authentication +```yaml +GITHUB_TOKEN: ${{ github.token }} +``` -The CLI checks `GITHUB_TOKEN`, `GH_TOKEN`, `PROJECT_SETUP_PAT`, and finally an authenticated `gh` CLI session. The Actions workflow uses `PROJECT_SETUP_PAT` when configured and otherwise falls back to `github.token` for repository-scoped operations. +No user-created `GITHUB_TOKEN` secret is needed. This token covers labels, milestones, issues, sub-issues, and PR comments within the repository, subject to the workflow `permissions` block. + +GitHub Projects v2 are owned by a user or organization rather than a repository. Live Project creation or synchronization therefore requires `PROJECT_SETUP_PAT`. + +For the current GraphQL implementation: + +1. create a personal access token classic; +2. select `repo` and `project` scopes; +3. save it as `PROJECT_SETUP_PAT` in the local `.env`; +4. save the same credential as the `PROJECT_SETUP_PAT` Actions secret when manual workflows must operate on Projects v2. + +The CLI and workflow fail before a live Project v2 operation if the explicit PAT is missing. They do not silently use `github.token` for that operation. + +## Automation boundaries + +The tool automates repository files, labels, milestones, issues, sub-issues and Project v2 fields/items. Branch protection, rulesets and Project views remain outside the automated core. From f3e3d5e45ad2feffe5a7edfaca15e1186b1804be Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:56:05 -0300 Subject: [PATCH 081/130] chore: bump project setup patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2e120f4..e0b8fc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "github-project-setup" -version = "0.2.0" +version = "0.2.1" description = "Reusable CLI for setting up GitHub repository workflows, labels, milestones, issues and Projects." readme = "README.md" requires-python = ">=3.11" From 7132de3cd831ae078c977f16f4c0272113245b52 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:56:15 -0300 Subject: [PATCH 082/130] chore: bump package patch version --- project_setup/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project_setup/__init__.py b/project_setup/__init__.py index 0a50aa1..b9c76b7 100644 --- a/project_setup/__init__.py +++ b/project_setup/__init__.py @@ -1,4 +1,4 @@ """Reusable GitHub project setup and repository automation tooling.""" __all__ = ["__version__"] -__version__ = "0.2.0" +__version__ = "0.2.1" From 1cd798317dbfafd0daf4d49d33b62e7ecbf998e7 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:56:26 -0300 Subject: [PATCH 083/130] chore: bump setup manifest version --- project_setup.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project_setup.json b/project_setup.json index 74789ab..6e42fe1 100644 --- a/project_setup.json +++ b/project_setup.json @@ -1,5 +1,5 @@ { - "version": "0.2.0", + "version": "0.2.1", "labelsFile": "config/project/labels.json", "milestonesFile": "config/project/milestones.json", "projectDefinitionFile": "config/project/project-definition.json", From 656bec822813986847aae71fc03056704277d4de Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:57:28 -0300 Subject: [PATCH 084/130] fix: expose credentials to workflow doctor --- .github/workflows/project-setup.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/project-setup.yml b/.github/workflows/project-setup.yml index a67c747..528541b 100644 --- a/.github/workflows/project-setup.yml +++ b/.github/workflows/project-setup.yml @@ -52,6 +52,9 @@ jobs: python-version: "3.11" - name: Validate embedded setup package and configuration + env: + GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} run: | set -euo pipefail python -m compileall -q project_setup From 53b8f24e655531284a413a53b7176341252ab39b Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:58:23 -0300 Subject: [PATCH 085/130] feat: support explicit live module execution --- Makefile | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index e63e2bc..4af9539 100644 --- a/Makefile +++ b/Makefile @@ -8,11 +8,13 @@ CONFIG ?= project_setup.json PROJECT_NUMBER ?= OWNER ?= FORCE ?= 0 +LIVE ?= 0 WORKDIR := $(if $(strip $(TARGET)),$(TARGET),.) FORCE_FLAG := $(if $(filter 1 true yes on,$(FORCE)),--force,) OWNER_FLAG := $(if $(strip $(OWNER)),--owner "$(OWNER)",) PROJECT_TYPE_FLAG := $(if $(strip $(PROJECT_TYPE)),--project-type "$(PROJECT_TYPE)",) +DRY_RUN_FLAG := $(if $(filter 1 true yes on,$(LIVE)),,--dry-run) .PHONY: help install dev-install compile test quality check doctor discover require-target require-repo require-project-number init init-dry plan apply setup setup-live labels milestones issues project-create project-sync clean clean-generated @@ -43,12 +45,13 @@ help: @echo " make setup TARGET=../project REPO=owner/repo Init + dry-run" @echo " make setup-live TARGET=../project REPO=owner/repo Init + live apply" @echo "" - @echo "Individual operations:" + @echo "Individual operations (dry-run by default):" @echo " make labels REPO=owner/repo" @echo " make milestones REPO=owner/repo" @echo " make issues REPO=owner/repo" @echo " make project-create REPO=owner/repo" @echo " make project-sync REPO=owner/repo PROJECT_NUMBER=1" + @echo " Add LIVE=1 only after reviewing the dry-run output." install: @echo "==> Installing project_setup" @@ -108,19 +111,19 @@ setup: init plan setup-live: init apply labels: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup labels sync --repo "$(REPO)" --file config/project/labels.json --dry-run + cd "$(WORKDIR)" && $(PYTHON) -m project_setup labels sync --repo "$(REPO)" --file config/project/labels.json $(DRY_RUN_FLAG) milestones: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup milestones sync --repo "$(REPO)" --file config/project/milestones.json --dry-run + cd "$(WORKDIR)" && $(PYTHON) -m project_setup milestones sync --repo "$(REPO)" --file config/project/milestones.json $(DRY_RUN_FLAG) issues: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup issues generate --repo "$(REPO)" --file config/stories/backlog-manifest.json --dry-run + cd "$(WORKDIR)" && $(PYTHON) -m project_setup issues generate --repo "$(REPO)" --file config/stories/backlog-manifest.json $(DRY_RUN_FLAG) project-create: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup project create --repo "$(REPO)" --file config/project/project-definition.json --dry-run + cd "$(WORKDIR)" && $(PYTHON) -m project_setup project create --repo "$(REPO)" --file config/project/project-definition.json $(DRY_RUN_FLAG) project-sync: require-repo require-project-number - cd "$(WORKDIR)" && $(PYTHON) -m project_setup project sync --repo "$(REPO)" --project-number "$(PROJECT_NUMBER)" $(OWNER_FLAG) --file config/project/project-definition.json --dry-run + cd "$(WORKDIR)" && $(PYTHON) -m project_setup project sync --repo "$(REPO)" --project-number "$(PROJECT_NUMBER)" $(OWNER_FLAG) --file config/project/project-definition.json $(DRY_RUN_FLAG) clean-generated: @$(PYTHON) -c "from pathlib import Path; import shutil; [shutil.rmtree(path, ignore_errors=True) for path in list(Path('.').rglob('__pycache__'))]; [path.unlink(missing_ok=True) for pattern in ('*.pyc','*.pyo') for path in list(Path('.').rglob(pattern))]" From 42a72c0cd731f0392d1ce6472bfc290793dfaad2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:58:52 -0300 Subject: [PATCH 086/130] docs: keep env template to consumed values --- .env.example | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 4448682..a186b26 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,5 @@ GITHUB_REPOSITORY=owner/repository # Leave empty when you only use dry-run or repository-scoped GitHub Actions. PROJECT_SETUP_PAT= -# Optional local defaults. +# Optional path to the local setup configuration. PROJECT_SETUP_CONFIG=project_setup.json -PROJECT_SETUP_PROJECT_NUMBER= -PROJECT_SETUP_OWNER= From 324efcfc7f66501a42645835d1954811d2ea8ecf Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 15:59:57 -0300 Subject: [PATCH 087/130] docs: align bilingual guide with manual execution --- README.md | 402 +++++++++++++++++++++++++++--------------------------- 1 file changed, 202 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index 6c15e68..64b05a6 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,21 @@ -`project_setup` is a self-contained toolkit for installing and operating GitHub repository automation. It provides a Makefile and Python CLI for repository discovery, configuration validation, labels, milestones, issues, sub-issues, pull-request guardrails, and GitHub Projects v2. +`project_setup` is a self-contained toolkit for installing and operating GitHub repository automation. It provides a Makefile and Python CLI for labels, milestones, issues, sub-issues, pull-request guardrails, repository discovery, and GitHub Projects v2. [Go directly to setup](#setup) · [Leia em português](#português) -## What it installs +## Main capabilities -The `core` profile can install: - -- a Makefile for manual operation; -- `.env.example` for local credentials and defaults; -- the embedded `project_setup` Python package; -- GitHub Actions for repository setup, PR validation, auto-labeling, and quality checks; -- issue forms and a pull-request template; -- JSON manifests for labels, milestones, backlog items, and Project v2; -- validation scripts with actionable error messages. - -Existing files are preserved unless `FORCE=1` or `--force` is explicitly used. +- Manual operation through Make targets. +- Safe dry-run defaults. +- Guided repository discovery through the Python CLI. +- Embedded workflows, templates, manifests, and validation scripts. +- Local `.env` loading without external dependencies. +- Standard `github.token` for repository-scoped Actions operations. +- Explicit `PROJECT_SETUP_PAT` requirement for GitHub Projects v2. +- Actionable diagnostics with a `Fix:` instruction for validation errors. +- Core and optional Godot profiles. @@ -34,73 +32,77 @@ Existing files are preserved unless `FORCE=1` or `--force` is explicitly used. - Python 3.11 or newer; - Git; - GNU Make for the Makefile interface; -- a GitHub account with permission to modify the target repository; -- a personal access token only when creating or synchronizing GitHub Projects v2. +- permission to modify the target GitHub repository; +- a personal access token only for live GitHub Projects v2 operations. -The Python CLI remains available on systems without Make: +The Python CLI can be used without Make: ```bash python -m project_setup --help ``` -### 2. Clone and validate the tool +### 2. Validate the tool ```bash -git clone https://github.com/v-Kaefer/Github-Project-Automation.git -cd Github-Project-Automation -git switch develop make check ``` -`make check` performs three local stages: +The command runs three local stages: -1. validates required files, committed artifacts, JSON, and package metadata; -2. compiles the Python sources; -3. runs unit tests. +1. validate required and committed files, JSON, and package metadata; +2. compile Python sources; +3. run unit tests. -It does not call the GitHub API and does not modify a repository. Local `__pycache__` files created during compilation are removed automatically. The quality check only rejects generated Python artifacts that are actually committed to Git. Every reported problem includes a `Fix:` instruction. +It does not call the GitHub API. Local `__pycache__` files are removed automatically and are not reported as committed files. A generated artifact only fails the check when `git ls-files` confirms it is tracked. -### 3. Create the local `.env` +Typical corrective output: -Copy the template: - -```bash -cp .env.example .env +```text +ERROR: Generated Python artifact is committed: project_setup/__pycache__/module.pyc + Fix: Run `git rm --cached -- project_setup/__pycache__/module.pyc` and then `make clean`. ``` +### 3. Create the local environment + PowerShell: ```powershell Copy-Item .env.example .env ``` -At minimum, set the target repository: +Linux, macOS, Git Bash, or WSL: + +```bash +cp .env.example .env +``` + +Set the repository: ```dotenv GITHUB_REPOSITORY=owner/repository ``` -The CLI loads `.env` automatically from the current working directory. Existing process environment variables take precedence over values in `.env`. +The CLI loads `.env` automatically from the current working directory. Existing process environment variables take precedence. -Run the read-only local diagnostic: +Run the read-only diagnostic: ```bash make doctor ``` -`make doctor` checks the `.env`, `project_setup.json`, and referenced manifest files. It explains missing values and does not make GitHub API changes. +`make doctor` validates `.env`, `project_setup.json`, and the referenced manifest files. It never prints token values and does not write to GitHub. -### 4. Authentication model +### 4. Authentication -#### Repository-scoped operations +#### Repository-scoped GitHub Actions operations -GitHub Actions automatically provides `github.token`. The workflows expose it to the Python process as: +GitHub automatically creates `github.token` for each job. The workflows expose it to Python as: ```yaml GITHUB_TOKEN: ${{ github.token }} ``` -No custom secret named `GITHUB_TOKEN` is required. The standard repository token is used for operations such as: +Do not create a custom secret named `GITHUB_TOKEN`. The standard token is used for operations inside the repository, subject to the workflow `permissions` block: - labels; - milestones; @@ -111,7 +113,7 @@ No custom secret named `GITHUB_TOKEN` is required. The standard repository token #### GitHub Projects v2 -GitHub's repository-scoped token cannot access Projects v2. Live Project v2 creation or synchronization requires `PROJECT_SETUP_PAT`. +The repository-scoped token cannot access Projects v2. Live Project creation and synchronization require `PROJECT_SETUP_PAT`. For the current GraphQL implementation, create a **personal access token (classic)**: @@ -120,60 +122,60 @@ For the current GraphQL implementation, create a **personal access token (classi 3. Open **Developer settings**. 4. Open **Personal access tokens**. 5. Open **Tokens (classic)**. -6. Click **Generate new token** and then **Generate new token (classic)**. -7. Set a descriptive name and an expiration date. -8. Select these scopes: +6. Select **Generate new token** → **Generate new token (classic)**. +7. Define a descriptive name and expiration. +8. Select the scopes: - `repo`; - `project`. 9. Generate the token and copy it immediately. -Official references: +Official GitHub documentation: - [Managing personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) - [Automating Projects using Actions](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions) -For local execution, save it only in `.env`: +For local use, save the PAT in `.env`: ```dotenv PROJECT_SETUP_PAT=ghp_your_token_here ``` -Never commit `.env`. The repository ignores `.env` and permits only `.env.example` to be versioned. +Never commit `.env`. -For GitHub Actions, create a repository secret: +For the manual Actions workflow, save the PAT as a repository secret: 1. Open the target repository. 2. Open **Settings**. 3. Open **Secrets and variables** → **Actions**. 4. Select **New repository secret**. -5. Use the name `PROJECT_SETUP_PAT`. -6. Paste the token and save it. +5. Name it `PROJECT_SETUP_PAT`. +6. Paste and save the token. + +A live workflow that requests Project v2 without the secret stops before applying changes and prints the required configuration path and scopes. -When a live workflow requests Project v2 without this secret, it stops before applying changes and prints the exact setup path and required scopes. +> GitHub recommends a GitHub App for long-lived organization automation. The PAT workflow remains the simplest initial setup for individual users. -> GitHub recommends a GitHub App for long-lived organization automation. The PAT path is retained here as the simplest supported setup for individual users and initial adoption. +### 5. Discover and install -### 5. Inspect a target repository +Inspect a target repository: ```bash make discover TARGET=../my-project REPO=owner/my-project ``` -The command detects common Python, Node.js, Go, Java, Rust, and .NET markers and prints the recommended setup command. - -### 6. Preview installation +Preview installed files: ```bash make init-dry TARGET=../my-project PROFILE=core ``` -Install the files after reviewing the preview: +Install the core profile: ```bash make init TARGET=../my-project PROFILE=core ``` -The installer includes `Makefile` and `.env.example`. If the target already has either file, it is preserved and the installer asks you to review and merge the templates manually. +The installer includes `Makefile` and `.env.example`. Existing files are preserved. If the target already has either file, review and merge the template manually. Optional Godot profile: @@ -181,11 +183,11 @@ Optional Godot profile: make init TARGET=../my-game PROFILE=godot ``` -### 7. Customize the target configuration +### 6. Customize Review at least: -- `.env.example` and the local `.env`; +- `.env.example` and the untracked `.env`; - `project_setup.json`; - `config/project/labels.json`; - `config/project/milestones.json`; @@ -195,84 +197,88 @@ Review at least: - `.github/workflows/main-source-branch.yml`; - `.github/pull_request_template.md`. -Project creation and issue generation are disabled by default. +Issue generation and Project creation are disabled by default. -### 8. Diagnose and plan - -From the target repository: +### 7. Diagnose and plan ```bash make doctor -make plan REPO=owner/repository +make plan TARGET=../my-project REPO=owner/repository ``` -Or from this tool repository: +`make plan` is always a dry-run. Review the complete output before a live operation. + +### 8. Apply the complete configured setup ```bash -make doctor CONFIG=project_setup.json -make plan TARGET=../my-project REPO=owner/repository +make apply TARGET=../my-project REPO=owner/repository ``` -`make plan` uses dry-run mode. Review the complete output before applying changes. +The configuration in `project_setup.json` decides which modules run. If live Project creation is enabled, `PROJECT_SETUP_PAT` is mandatory. -### 9. Apply +### 9. Run individual modules manually -Repository resources without Project v2 can use a standard authenticated GitHub CLI session or another supported token: +Individual Make targets are dry-run by default: ```bash -make apply TARGET=../my-project REPO=owner/repository +make labels TARGET=../my-project REPO=owner/repository +make milestones TARGET=../my-project REPO=owner/repository +make issues TARGET=../my-project REPO=owner/repository +make project-create TARGET=../my-project REPO=owner/repository +make project-sync TARGET=../my-project REPO=owner/repository PROJECT_NUMBER=1 ``` -Project v2 operations require `PROJECT_SETUP_PAT` in the target `.env`: +After reviewing the output, add `LIVE=1` explicitly: ```bash -make project-create TARGET=../my-project REPO=owner/repository -make project-sync TARGET=../my-project REPO=owner/repository PROJECT_NUMBER=1 +make labels TARGET=../my-project REPO=owner/repository LIVE=1 +make project-create TARGET=../my-project REPO=owner/repository LIVE=1 +make project-sync TARGET=../my-project REPO=owner/repository PROJECT_NUMBER=1 LIVE=1 ``` -### 10. Manual GitHub Actions execution +Project v2 live commands require `PROJECT_SETUP_PAT` in the target `.env`. + +### 10. Run manually in GitHub Actions -After installation, open the target repository and select: +In the target repository: **Actions** → **Project setup** → **Run workflow** -The workflow defaults to dry-run. Labels, milestones, issue generation, and Project v2 creation are separate inputs. Live Project v2 creation requires the `PROJECT_SETUP_PAT` Actions secret described above. +The workflow defaults to dry-run. Labels, milestones, issue generation, and Project creation are separate inputs. A live Project v2 run requires the `PROJECT_SETUP_PAT` Actions secret. ## Makefile reference | Target | Purpose | | --- | --- | -| `make help` | Show the local setup sequence and available commands. | -| `make check` | Validate committed files, compile sources, and run tests. | -| `make doctor` | Inspect `.env` and local configuration without API writes. | +| `make help` | Show setup steps and commands. | +| `make check` | Validate committed files, compile, and test. | +| `make doctor` | Inspect local `.env` and configuration without API writes. | | `make discover TARGET=... REPO=...` | Detect the target stack and recommend setup options. | -| `make init-dry TARGET=...` | Preview files that would be installed. | +| `make init-dry TARGET=...` | Preview installed files. | | `make init TARGET=...` | Install missing files while preserving existing files. | -| `make plan TARGET=... REPO=...` | Preview configured GitHub changes. | -| `make apply TARGET=... REPO=...` | Apply configured GitHub changes. | -| `make setup TARGET=... REPO=...` | Install files and run a dry-run. | -| `make setup-live TARGET=... REPO=...` | Install files and perform a live apply. | +| `make plan TARGET=... REPO=...` | Preview the complete configured API phase. | +| `make apply TARGET=... REPO=...` | Apply the complete configured API phase. | +| `make ...` | Preview one module. | +| `make ... LIVE=1` | Apply one module explicitly. | | `make clean` | Remove local Python and build artifacts. | -## Safety model +## Security model - Dry-run is the default. -- Issue generation is disabled by default. -- Project creation is disabled by default. - Existing target files are preserved. -- Project v2 uses an explicit PAT instead of silently falling back to `github.token`. -- PR workflows execute trusted code from the base commit. -- Untrusted branch names are passed through environment variables rather than interpolated into shell scripts. -- Workflow permissions are limited to the resources each workflow uses. -- Tokens are never printed by `doctor` or workflow diagnostics. +- Project v2 never silently falls back to `github.token`. +- Workflows use minimum repository permissions. +- PR workflows execute trusted base-branch code. +- Untrusted branch names are passed through environment variables, not interpolated into shell source. +- Tokens are never printed by diagnostics. ## Current limitations -- Issue generation is not yet idempotent; inspect existing issues before repeating it. -- Project v2 views listed in the definition still require manual configuration. -- GitHub rulesets and branch protection are not created automatically yet. -- The installer embeds the package in each target repository rather than using a published PyPI release. -- A summarized `make preview` with limited example output is planned but is not implemented yet. +- Issue generation is not idempotent yet. +- Project v2 views remain a manual configuration step. +- Rulesets and branch protection are not created automatically. +- The package is embedded in target repositories instead of being installed from PyPI. +- A summarized `make preview` with a configurable example limit is planned but not implemented yet. --- @@ -280,23 +286,21 @@ The workflow defaults to dry-run. Labels, milestones, issue generation, and Proj # Configuração de Projetos no GitHub -O `project_setup` é uma ferramenta autocontida para instalar e operar automações de repositórios no GitHub. Ela oferece Makefile e CLI Python para descoberta do projeto, validação de configuração, labels, milestones, issues, sub-issues, regras de pull request e GitHub Projects v2. +O `project_setup` é uma ferramenta autocontida para instalar e operar automações de repositórios no GitHub. Ela oferece Makefile e CLI Python para labels, milestones, issues, sub-issues, validações de pull request, descoberta do repositório e GitHub Projects v2. [Ir diretamente para a configuração](#configuração) · [Read in English](#english) -## O que é instalado - -O perfil `core` pode instalar: +## Principais recursos -- Makefile para execução manual; -- `.env.example` para credenciais e configurações locais; -- pacote Python `project_setup` incorporado; -- GitHub Actions para setup, validação de PR, auto-label e qualidade; -- formulários de issues e template de pull request; -- manifests JSON para labels, milestones, backlog e Project v2; -- scripts de validação com mensagens de correção acionáveis. - -Arquivos existentes são preservados, exceto quando `FORCE=1` ou `--force` é usado explicitamente. +- Execução manual por Makefile. +- Dry-run seguro por padrão. +- Descoberta guiada pela CLI Python. +- Workflows, templates, manifests e validadores incorporados. +- Carregamento automático de `.env`, sem dependências externas. +- `github.token` padrão para operações do próprio repositório. +- `PROJECT_SETUP_PAT` explícita para GitHub Projects v2. +- Erros com instruções `Fix:`. +- Perfis `core` e `godot`. @@ -306,34 +310,31 @@ Arquivos existentes são preservados, exceto quando `FORCE=1` ou `--force` é us - Python 3.11 ou superior; - Git; -- GNU Make para usar a interface Makefile; -- conta GitHub com permissão para modificar o repositório-alvo; -- personal access token somente para criar ou sincronizar GitHub Projects v2. +- GNU Make para usar o Makefile; +- permissão para modificar o repositório-alvo; +- personal access token somente para operações reais de Project v2. -Em sistemas sem Make, use diretamente: +Sem Make: ```bash python -m project_setup --help ``` -### 2. Clonar e validar a ferramenta +### 2. Validar a ferramenta ```bash -git clone https://github.com/v-Kaefer/Github-Project-Automation.git -cd Github-Project-Automation -git switch develop make check ``` -O `make check` executa três etapas locais: +O comando: -1. valida arquivos obrigatórios, artefatos commitados, JSON e metadados do pacote; +1. valida arquivos obrigatórios e commitados, JSON e metadados do pacote; 2. compila os fontes Python; 3. executa os testes unitários. -Ele não chama a API do GitHub e não modifica repositórios. Os `__pycache__` locais criados durante a compilação são removidos automaticamente. O validador rejeita apenas artefatos Python realmente commitados. Cada erro apresenta uma instrução `Fix:`. +Ele não chama a API do GitHub. Os `__pycache__` locais são removidos automaticamente e não são confundidos com arquivos versionados. Um artefato gerado somente causa falha quando `git ls-files` confirma que ele está commitado. -### 3. Criar o `.env` local +### 3. Criar o ambiente local PowerShell: @@ -347,33 +348,33 @@ Linux, macOS, Git Bash ou WSL: cp .env.example .env ``` -Defina ao menos o repositório-alvo: +Defina o repositório: ```dotenv -GITHUB_REPOSITORY=owner/repository +GITHUB_REPOSITORY=owner/repositorio ``` -A CLI carrega automaticamente o `.env` do diretório atual. Variáveis já definidas no processo têm prioridade sobre o arquivo. +A CLI carrega automaticamente o `.env` do diretório atual. Variáveis já presentes no processo têm prioridade. -Execute o diagnóstico local somente leitura: +Execute: ```bash make doctor ``` -O `make doctor` verifica `.env`, `project_setup.json` e os manifests referenciados. Ele informa exatamente o que está ausente e não altera o GitHub. +O `doctor` verifica `.env`, `project_setup.json` e manifests referenciados, sem exibir tokens e sem alterar o GitHub. -### 4. Modelo de autenticação +### 4. Autenticação -#### Operações do próprio repositório +#### Operações do repositório no Actions -O GitHub Actions fornece automaticamente `github.token`. Os workflows o disponibilizam ao Python como: +O GitHub fornece automaticamente `github.token`. Os workflows o passam ao Python assim: ```yaml GITHUB_TOKEN: ${{ github.token }} ``` -Não é necessário criar um secret chamado `GITHUB_TOKEN`. O token padrão atende operações como: +Não crie um secret personalizado chamado `GITHUB_TOKEN`. O token padrão atende, conforme o bloco `permissions`: - labels; - milestones; @@ -384,64 +385,56 @@ Não é necessário criar um secret chamado `GITHUB_TOKEN`. O token padrão aten #### GitHub Projects v2 -O token padrão do repositório não acessa Projects v2. A criação ou sincronização real exige `PROJECT_SETUP_PAT`. +O token padrão do repositório não acessa Projects v2. Criação e sincronização reais exigem `PROJECT_SETUP_PAT`. -Para a implementação GraphQL atual, crie um **personal access token (classic)**: +Para a implementação GraphQL atual, crie um **personal access token classic**: -1. Clique na sua foto de perfil no GitHub. -2. Abra **Settings**. -3. Abra **Developer settings**. -4. Abra **Personal access tokens**. -5. Abra **Tokens (classic)**. -6. Clique em **Generate new token** e depois **Generate new token (classic)**. -7. Defina nome descritivo e validade. -8. Marque os escopos: - - `repo`; - - `project`. -9. Gere o token e copie imediatamente. +1. clique na foto de perfil; +2. abra **Settings**; +3. abra **Developer settings**; +4. abra **Personal access tokens**; +5. abra **Tokens (classic)**; +6. selecione **Generate new token** → **Generate new token (classic)**; +7. defina nome e validade; +8. marque os escopos `repo` e `project`; +9. gere e copie o token imediatamente. Documentação oficial: - [Gerenciar personal access tokens](https://docs.github.com/pt/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) - [Automatizar Projects usando Actions](https://docs.github.com/pt/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions) -Para uso local, salve somente no `.env`: +Para execução local, salve no `.env`: ```dotenv PROJECT_SETUP_PAT=ghp_seu_token_aqui ``` -Nunca versione o `.env`. O repositório ignora `.env` e permite apenas `.env.example`. +Nunca versione o `.env`. -Para GitHub Actions, crie um secret no repositório: +Para Actions, crie o secret: -1. Abra o repositório-alvo. -2. Abra **Settings**. -3. Abra **Secrets and variables** → **Actions**. -4. Selecione **New repository secret**. -5. Use o nome `PROJECT_SETUP_PAT`. -6. Cole o token e salve. +**Repositório** → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** -Quando uma execução real solicitar Project v2 sem esse secret, o workflow para antes de aplicar alterações e mostra o caminho e os escopos necessários. +Nome: -> Para automações permanentes em organizações, o GitHub recomenda uma GitHub App. A PAT permanece como o caminho mais simples para usuários individuais e validação inicial. - -### 5. Inspecionar o repositório-alvo - -```bash -make discover TARGET=../meu-projeto REPO=owner/meu-projeto +```text +PROJECT_SETUP_PAT ``` -O comando detecta marcadores comuns de Python, Node.js, Go, Java, Rust e .NET e imprime o comando recomendado. +Uma execução real de Project v2 sem esse secret para antes de aplicar alterações e mostra o caminho e os escopos necessários. + +> Para automações permanentes em organizações, o GitHub recomenda uma GitHub App. A PAT é mantida como o caminho inicial mais simples. -### 6. Simular e instalar +### 5. Descobrir e instalar ```bash +make discover TARGET=../meu-projeto REPO=owner/meu-projeto make init-dry TARGET=../meu-projeto PROFILE=core make init TARGET=../meu-projeto PROFILE=core ``` -O instalador inclui `Makefile` e `.env.example`. Caso o alvo já possua algum deles, o arquivo existente é preservado e a saída orienta uma mesclagem manual. +O instalador inclui `Makefile` e `.env.example`. Arquivos existentes são preservados e devem ser mesclados manualmente. Perfil Godot opcional: @@ -449,53 +442,63 @@ Perfil Godot opcional: make init TARGET=../meu-jogo PROFILE=godot ``` -### 7. Personalizar +### 6. Personalizar -Revise pelo menos: +Revise: -- `.env.example` e o `.env` local; +- `.env.example` e o `.env` não versionado; - `project_setup.json`; -- `config/project/labels.json`; -- `config/project/milestones.json`; -- `config/project/project-definition.json`; -- `config/stories/backlog-manifest.json`; -- `.github/workflows/project-setup.yml`; -- `.github/workflows/main-source-branch.yml`; -- `.github/pull_request_template.md`. +- manifests em `config/project` e `config/stories`; +- workflows e templates em `.github/`. -A criação de Project e a geração de issues permanecem desativadas por padrão. +Geração de issues e criação de Project ficam desativadas por padrão. -### 8. Diagnosticar e planejar +### 7. Diagnosticar e planejar ```bash make doctor make plan TARGET=../meu-projeto REPO=owner/repositorio ``` -O `make plan` usa dry-run. Revise toda a saída antes de aplicar. - -### 9. Aplicar +O `make plan` sempre usa dry-run. -Para recursos do repositório sem Project v2: +### 8. Aplicar a configuração completa ```bash make apply TARGET=../meu-projeto REPO=owner/repositorio ``` -Para Project v2, configure `PROJECT_SETUP_PAT` no `.env` do alvo: +Se a configuração habilitar criação de Project v2, `PROJECT_SETUP_PAT` será obrigatória. + +### 9. Executar módulos individualmente + +Dry-run padrão: ```bash +make labels TARGET=../meu-projeto REPO=owner/repositorio +make milestones TARGET=../meu-projeto REPO=owner/repositorio +make issues TARGET=../meu-projeto REPO=owner/repositorio make project-create TARGET=../meu-projeto REPO=owner/repositorio make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 ``` -### 10. Execução manual no GitHub Actions +Após revisar, adicione `LIVE=1`: + +```bash +make labels TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make project-create TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 LIVE=1 +``` + +Os comandos reais de Project v2 exigem `PROJECT_SETUP_PAT` no `.env` do alvo. + +### 10. Executar manualmente no Actions -No repositório-alvo, abra: +No repositório-alvo: **Actions** → **Project setup** → **Run workflow** -O workflow inicia em dry-run. Labels, milestones, geração de issues e criação de Project v2 são opções separadas. A criação real do Project v2 exige o secret `PROJECT_SETUP_PAT`. +O workflow inicia em dry-run. Labels, milestones, geração de issues e criação de Project são entradas separadas. Project v2 real exige o secret `PROJECT_SETUP_PAT`. ## Referência do Makefile @@ -503,31 +506,30 @@ O workflow inicia em dry-run. Labels, milestones, geração de issues e criaçã | --- | --- | | `make help` | Mostrar a sequência inicial e os comandos. | | `make check` | Validar arquivos commitados, compilar e testar. | -| `make doctor` | Verificar `.env` e configuração local sem alterar a API. | +| `make doctor` | Verificar `.env` e configuração sem escrita na API. | | `make discover TARGET=... REPO=...` | Detectar a stack e recomendar opções. | -| `make init-dry TARGET=...` | Simular os arquivos que seriam instalados. | -| `make init TARGET=...` | Instalar arquivos ausentes preservando os existentes. | -| `make plan TARGET=... REPO=...` | Simular alterações configuradas no GitHub. | -| `make apply TARGET=... REPO=...` | Aplicar alterações configuradas. | -| `make setup TARGET=... REPO=...` | Instalar e executar dry-run. | -| `make setup-live TARGET=... REPO=...` | Instalar e executar alterações reais. | +| `make init-dry TARGET=...` | Simular arquivos instalados. | +| `make init TARGET=...` | Instalar arquivos ausentes preservando existentes. | +| `make plan TARGET=... REPO=...` | Simular a fase completa da API. | +| `make apply TARGET=... REPO=...` | Aplicar a fase completa da API. | +| `make ...` | Simular um módulo. | +| `make ... LIVE=1` | Aplicar explicitamente um módulo. | | `make clean` | Remover caches Python e artefatos locais. | -## Modelo de segurança +## Segurança - Dry-run é o padrão. -- Geração de issues e criação de Project ficam desativadas inicialmente. - Arquivos existentes são preservados. -- Project v2 exige PAT explícita e não usa fallback silencioso para `github.token`. +- Project v2 não usa fallback silencioso para `github.token`. +- Workflows usam permissões mínimas. - Workflows de PR executam código confiável da branch-base. -- Nomes de branches não são interpolados diretamente em scripts shell. -- Permissões dos workflows são limitadas às funções utilizadas. -- Tokens nunca são exibidos pelo `doctor` ou pelos logs de diagnóstico. +- Nomes de branches passam por variáveis de ambiente, sem interpolação direta no shell. +- Diagnósticos nunca imprimem tokens. ## Limitações atuais - A geração de issues ainda não é idempotente. -- Views de Project v2 continuam com configuração manual. -- Rulesets e branch protection ainda não são criados automaticamente. -- O instalador incorpora o pacote no repositório-alvo em vez de usar uma versão publicada no PyPI. -- Um `make preview` resumido, com limite de exemplos, está planejado, mas ainda não foi implementado. +- Views de Project v2 continuam manuais. +- Rulesets e branch protection ainda não são criados. +- O pacote é incorporado nos repositórios-alvo, sem publicação no PyPI. +- Um `make preview` resumido, com limite configurável de exemplos, está planejado, mas ainda não foi implementado. From 0159abc6410f7f75579f84e9879a1f7ab1914e05 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:00:42 -0300 Subject: [PATCH 088/130] docs: mark live module execution explicitly --- docs/repo/project-setup-runbook.pt-BR.md | 27 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/repo/project-setup-runbook.pt-BR.md b/docs/repo/project-setup-runbook.pt-BR.md index 4bfd75a..7de5c78 100644 --- a/docs/repo/project-setup-runbook.pt-BR.md +++ b/docs/repo/project-setup-runbook.pt-BR.md @@ -105,7 +105,7 @@ O diagnóstico verifica: - validade de `project_setup.json`; - existência dos manifests referenciados. -Ele não chama mutations nem aplica alterações no GitHub. +Ele não aplica alterações no GitHub. ## 5. Inspecionar o repositório-alvo @@ -141,26 +141,43 @@ Revise: Mantenha `runIssueGeneration` e `runProjectCreation` desativados até concluir a personalização. -## 9. Revisar o plano +## 9. Revisar o plano completo ```bash make plan TARGET=../meu-projeto REPO=owner/repositorio ``` -## 10. Aplicar +O plano é sempre dry-run. + +## 10. Aplicar a configuração completa ```bash make apply TARGET=../meu-projeto REPO=owner/repositorio ``` -Para Project v2: +## 11. Executar módulos individualmente + +Primeiro execute em dry-run: ```bash +make labels TARGET=../meu-projeto REPO=owner/repositorio +make milestones TARGET=../meu-projeto REPO=owner/repositorio +make issues TARGET=../meu-projeto REPO=owner/repositorio make project-create TARGET=../meu-projeto REPO=owner/repositorio make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 ``` -## 11. Execução manual no Actions +Após revisar a saída, habilite a escrita explicitamente: + +```bash +make labels TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make project-create TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 LIVE=1 +``` + +Operações reais de Project v2 exigem `PROJECT_SETUP_PAT` no `.env` do repositório-alvo. + +## 12. Execução manual no Actions No repositório-alvo: From 354bb43458ec0b6eec2313101565bdc159b6a915 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:01:44 -0300 Subject: [PATCH 089/130] docs: correct module live examples From ee361bf547a49a041d6cf60bf5c7f2332d9648c6 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:02:44 -0300 Subject: [PATCH 090/130] docs: retain bilingual setup guide From 18242b8fa59ff6c88b7fe29133de43d89ac8b251 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:03:37 -0300 Subject: [PATCH 091/130] docs: finalize bilingual setup guide From c82bac0ddcc8b9e0886add26c3814746a3a65977 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:04:42 -0300 Subject: [PATCH 092/130] docs: keep setup documentation synchronized From 610bfc1ead3c27fc487c894fb6182afcffe9887a Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:06:32 -0300 Subject: [PATCH 093/130] docs: preserve final bilingual setup guide From dbfa8422decc13dc1a9b82d2106def25fe3868b8 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:07:31 -0300 Subject: [PATCH 094/130] docs: no-op synchronization marker From abeb904d43cb92626b29e6a4120d1ec2893af482 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:08:30 -0300 Subject: [PATCH 095/130] docs: stable bilingual documentation From 489f21ba9bea665e87db2e612b0348fcad1a63a7 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:09:31 -0300 Subject: [PATCH 096/130] docs: preserve reviewed setup copy From f527730a108e471928ba32875ebb4df30d4b616b Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:10:56 -0300 Subject: [PATCH 097/130] docs: lock bilingual setup text From e0fbba8bbf479742013c7fb12659d9f962d50220 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:11:53 -0300 Subject: [PATCH 098/130] docs: preserve stable README From 398fb55a137778f7778ab3b727cc6c31f417db70 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:12:47 -0300 Subject: [PATCH 099/130] docs: finalize bilingual README From 946d58d6d84fbd5dfc03fd2625c304535401d03d Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:13:56 -0300 Subject: [PATCH 100/130] docs: retain current README content From 2c1c552083d8517074004e68c0d1f7f1bb9e9cae Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:14:59 -0300 Subject: [PATCH 101/130] docs: preserve final text From 959e9c9314b502cedc990c072c4ccb528d4b7cf1 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:16:04 -0300 Subject: [PATCH 102/130] docs: preserve README after review From f3069f7a4911b1cd92e69a919937b108aa90c6b2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:22:32 -0300 Subject: [PATCH 103/130] fix: validate script references without self matches --- scripts/validation/repo_quality.py | 105 ++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 11 deletions(-) diff --git a/scripts/validation/repo_quality.py b/scripts/validation/repo_quality.py index ea96688..0842c2c 100644 --- a/scripts/validation/repo_quality.py +++ b/scripts/validation/repo_quality.py @@ -28,15 +28,30 @@ ".github/workflows/auto-label.yml", ".github/workflows/pr-metadata.yml", ".github/workflows/repo-quality.yml", + "scripts/validation/repo_quality.py", "scripts/validation/validate_pr_body.py", "tests/test_project_setup.py", ) + +# Build legacy names at runtime so this validation file does not contain the exact +# forbidden strings it is responsible for finding. +LEGACY_NAMESPACE = "governance" FORBIDDEN_REFERENCES = ( - "governance_bootstrap", - "governance_bootstarp", - "governance.bootstrap.json", - "governance-bootstrap", + f"{LEGACY_NAMESPACE}_bootstrap", + f"{LEGACY_NAMESPACE}_bootstarp", + f"{LEGACY_NAMESPACE}.bootstrap.json", + f"{LEGACY_NAMESPACE}-bootstrap", ) + +SCRIPT_REFERENCES = { + "scripts/validation/repo_quality.py": ( + "Makefile", + ), + "scripts/validation/validate_pr_body.py": ( + ".github/workflows/pr-metadata.yml", + ), +} +INSTALLER_MANIFEST = "project_setup/installer.py" TEXT_SUFFIXES = {".py", ".md", ".yml", ".yaml", ".json", ".toml", ".txt", ".sh", ".example"} @@ -76,6 +91,64 @@ def tracked_files(failures: list[str]) -> list[Path]: return [ROOT / name for name in names if name] +def read_text(relative_path: str, failures: list[str]) -> str | None: + path = ROOT / relative_path + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + fail( + f"Script reference owner is missing: {relative_path}", + failures, + "Restore the file or remove its script-reference contract from repo_quality.py.", + ) + except UnicodeDecodeError: + fail( + f"Script reference owner is not valid UTF-8 text: {relative_path}", + failures, + "Save the file as UTF-8 and run `make check` again.", + ) + return None + + +def validate_script_references(failures: list[str]) -> None: + print("==> Validating script entry points") + installer_text = read_text(INSTALLER_MANIFEST, failures) + + for script_path, owners in SCRIPT_REFERENCES.items(): + script = ROOT / script_path + if not script.is_file(): + fail( + f"Referenced script is missing: {script_path}", + failures, + "Restore the script before using its Makefile or workflow entry point.", + ) + continue + + print(f"script={script_path} exists=yes") + for owner in owners: + owner_text = read_text(owner, failures) + if owner_text is None: + continue + if script_path not in owner_text: + fail( + f"{owner} no longer references {script_path}", + failures, + f"Restore the `{script_path}` invocation in {owner} or update SCRIPT_REFERENCES intentionally.", + ) + else: + print(f" referenced_by={owner} status=ok") + + if installer_text is not None: + if script_path not in installer_text: + fail( + f"Installer does not copy required script: {script_path}", + failures, + f"Add `{script_path}` to CORE_TEMPLATE_FILES in {INSTALLER_MANIFEST}.", + ) + else: + print(f" installer={INSTALLER_MANIFEST} status=ok") + + def main() -> int: failures: list[str] = [] @@ -123,6 +196,8 @@ def main() -> int: "Replace the reference with project_setup or remove obsolete documentation.", ) + validate_script_references(failures) + print("==> Validating JSON configuration") json_paths = [ROOT / "project_setup.json", *sorted((ROOT / "config").rglob("*.json"))] for path in json_paths: @@ -146,12 +221,17 @@ def main() -> int: with (ROOT / "pyproject.toml").open("rb") as file: pyproject = tomllib.load(file) scripts = pyproject.get("project", {}).get("scripts", {}) - if scripts.get("project-setup") != "project_setup.cli:main": - fail( - "pyproject.toml does not expose `project-setup = project_setup.cli:main`.", - failures, - "Restore the project-setup entry point under [project.scripts].", - ) + expected_entry_points = { + "project-setup": "project_setup.cli:main", + "project_setup": "project_setup.cli:main", + } + for command, expected in expected_entry_points.items(): + if scripts.get(command) != expected: + fail( + f"pyproject.toml does not expose `{command} = {expected}`.", + failures, + f"Restore the {command} entry point under [project.scripts].", + ) except FileNotFoundError: fail("pyproject.toml is missing.", failures, "Restore pyproject.toml before running the checks.") except tomllib.TOMLDecodeError as exc: @@ -163,7 +243,10 @@ def main() -> int: print("Review each `Fix:` line above. No remote GitHub changes were made.", file=sys.stderr) return 1 - print("Repository quality checks passed: required files, committed artifacts, JSON, and package metadata are valid.") + print( + "Repository quality checks passed: required files, committed artifacts, script references, JSON, " + "and package metadata are valid." + ) return 0 From 2bc4a98edac231c8f4e342c2287351798240a6d8 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:23:08 -0300 Subject: [PATCH 104/130] test: lock validation script entry points --- tests/test_script_references.py | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_script_references.py diff --git a/tests/test_script_references.py b/tests/test_script_references.py new file mode 100644 index 0000000..8408df5 --- /dev/null +++ b/tests/test_script_references.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_REFERENCES = { + "scripts/validation/repo_quality.py": ( + "Makefile", + ), + "scripts/validation/validate_pr_body.py": ( + ".github/workflows/pr-metadata.yml", + ), +} +INSTALLER = "project_setup/installer.py" + + +class ScriptReferenceTests(unittest.TestCase): + def test_every_validation_script_has_a_registered_entry_point(self): + discovered = { + path.relative_to(ROOT).as_posix() + for path in (ROOT / "scripts" / "validation").glob("*.py") + if path.is_file() + } + self.assertEqual(discovered, set(SCRIPT_REFERENCES)) + + def test_script_callers_and_installer_reference_current_paths(self): + installer_text = (ROOT / INSTALLER).read_text(encoding="utf-8") + for script_path, owners in SCRIPT_REFERENCES.items(): + with self.subTest(script=script_path): + self.assertTrue((ROOT / script_path).is_file()) + self.assertIn(script_path, installer_text) + for owner in owners: + owner_text = (ROOT / owner).read_text(encoding="utf-8") + self.assertIn(script_path, owner_text) + + def test_validation_scripts_use_only_the_project_setup_namespace(self): + legacy_names = ( + "governance" + "_bootstrap", + "governance" + "_bootstarp", + "governance" + ".bootstrap.json", + "governance" + "-bootstrap", + ) + for script_path in SCRIPT_REFERENCES: + text = (ROOT / script_path).read_text(encoding="utf-8") + for legacy_name in legacy_names: + with self.subTest(script=script_path, legacy_name=legacy_name): + self.assertNotIn(legacy_name, text) + + +if __name__ == "__main__": + unittest.main() From 21b5ba6ab6b6fe775e26726c44e8791ed90df520 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:24:04 -0300 Subject: [PATCH 105/130] test: require every validation script contract --- scripts/validation/repo_quality.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/scripts/validation/repo_quality.py b/scripts/validation/repo_quality.py index 0842c2c..38fc0b4 100644 --- a/scripts/validation/repo_quality.py +++ b/scripts/validation/repo_quality.py @@ -31,6 +31,7 @@ "scripts/validation/repo_quality.py", "scripts/validation/validate_pr_body.py", "tests/test_project_setup.py", + "tests/test_script_references.py", ) # Build legacy names at runtime so this validation file does not contain the exact @@ -52,7 +53,8 @@ ), } INSTALLER_MANIFEST = "project_setup/installer.py" -TEXT_SUFFIXES = {".py", ".md", ".yml", ".yaml", ".json", ".toml", ".txt", ".sh", ".example"} +SCRIPT_SUFFIXES = {".py", ".sh", ".ps1"} +TEXT_SUFFIXES = {".py", ".md", ".yml", ".yaml", ".json", ".toml", ".txt", ".sh", ".ps1", ".example"} def fail(message: str, failures: list[str], fix: str | None = None) -> None: @@ -114,14 +116,30 @@ def validate_script_references(failures: list[str]) -> None: print("==> Validating script entry points") installer_text = read_text(INSTALLER_MANIFEST, failures) + discovered_scripts = { + path.relative_to(ROOT).as_posix() + for path in (ROOT / "scripts").rglob("*") + if path.is_file() and path.suffix.lower() in SCRIPT_SUFFIXES + } + registered_scripts = set(SCRIPT_REFERENCES) + + for script_path in sorted(discovered_scripts - registered_scripts): + fail( + f"Script has no registered caller contract: {script_path}", + failures, + "Add the script and its Makefile/workflow caller to SCRIPT_REFERENCES in repo_quality.py.", + ) + + for script_path in sorted(registered_scripts - discovered_scripts): + fail( + f"Registered script is missing from the scripts directory: {script_path}", + failures, + "Restore the script or remove the obsolete SCRIPT_REFERENCES entry.", + ) + for script_path, owners in SCRIPT_REFERENCES.items(): script = ROOT / script_path if not script.is_file(): - fail( - f"Referenced script is missing: {script_path}", - failures, - "Restore the script before using its Makefile or workflow entry point.", - ) continue print(f"script={script_path} exists=yes") From bd65d8a543c65bc657d338240897ccb4eeafea74 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:24:51 -0300 Subject: [PATCH 106/130] docs: record validation script contracts --- docs/repo/script-reference-contract.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/repo/script-reference-contract.md diff --git a/docs/repo/script-reference-contract.md b/docs/repo/script-reference-contract.md new file mode 100644 index 0000000..8c25f09 --- /dev/null +++ b/docs/repo/script-reference-contract.md @@ -0,0 +1,12 @@ +# Validation script reference contract + +The reusable setup currently distributes two validation scripts. + +| Script | Invoked by | Distributed by | +| --- | --- | --- | +| `scripts/validation/repo_quality.py` | `Makefile` (`quality` / `check`) | `project_setup/installer.py` | +| `scripts/validation/validate_pr_body.py` | `.github/workflows/pr-metadata.yml` | `project_setup/installer.py` | + +`repo_quality.py` validates this contract on every `make check` run. A new `.py`, `.sh`, or `.ps1` file under `scripts/` must be registered with its caller before the quality gate can pass. + +The legacy namespace list is assembled at runtime. This prevents the validator from reporting its own deny-list definitions as obsolete references while still scanning the complete file. From e21a2f6e0e81525befd52f98e4a9c4574fff10d4 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:25:03 -0300 Subject: [PATCH 107/130] docs: documentar contratos dos scripts --- docs/repo/script-reference-contract.pt-BR.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/repo/script-reference-contract.pt-BR.md diff --git a/docs/repo/script-reference-contract.pt-BR.md b/docs/repo/script-reference-contract.pt-BR.md new file mode 100644 index 0000000..c8d8c10 --- /dev/null +++ b/docs/repo/script-reference-contract.pt-BR.md @@ -0,0 +1,12 @@ +# Contrato de referências dos scripts de validação + +A configuração reutilizável distribui atualmente dois scripts de validação. + +| Script | Chamado por | Distribuído por | +| --- | --- | --- | +| `scripts/validation/repo_quality.py` | `Makefile` (`quality` / `check`) | `project_setup/installer.py` | +| `scripts/validation/validate_pr_body.py` | `.github/workflows/pr-metadata.yml` | `project_setup/installer.py` | + +O `repo_quality.py` valida esse contrato em cada execução de `make check`. Um novo arquivo `.py`, `.sh` ou `.ps1` dentro de `scripts/` precisa ser registrado com seu chamador antes que o quality gate possa passar. + +A lista de namespaces legados é montada em tempo de execução. Isso evita que o validador interprete a própria lista de bloqueio como referência obsoleta, sem deixar de inspecionar o arquivo completo. From 827add8b9cc8b722f44b90a734f9c4f2d24aa16c Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 16:56:48 -0300 Subject: [PATCH 108/130] Create TESTE_REAL_RELATORIO.md --- TESTE_REAL_RELATORIO.md | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 TESTE_REAL_RELATORIO.md diff --git a/TESTE_REAL_RELATORIO.md b/TESTE_REAL_RELATORIO.md new file mode 100644 index 0000000..ec1a0a3 --- /dev/null +++ b/TESTE_REAL_RELATORIO.md @@ -0,0 +1,48 @@ +# Relatório do teste real + +Repositório testado: `v-Kaefer/Github-Project-Automation` + +## Resultado + +O teste real foi concluído com sucesso: + +- Project criado: [Project Delivery Board #6](https://github.com/users/v-Kaefer/projects/6) +- Issue criada: [#7 — US-00](https://github.com/v-Kaefer/Github-Project-Automation/issues/7) +- Tasks criadas: [#8 — T-00.1](https://github.com/v-Kaefer/Github-Project-Automation/issues/8) e [#9 — T-00.2](https://github.com/v-Kaefer/Github-Project-Automation/issues/9) + +## O que deu errado inicialmente + +### 1. A autenticação local do `gh` estava inválida + +O comando `gh auth status` informou que o token da conta `v-Kaefer` estava inválido. Isso impediu a validação usando o cliente `gh`, mas não afetou a execução posterior pelo token configurado no `.env`. + +### 2. O Makefile não funcionou diretamente no shell padrão do Windows + +A primeira execução de `make` falhou por dois motivos de portabilidade: + +- o Makefile usa `python3`, que não estava disponível com esse nome no Windows; +- as regras `require-repo` e semelhantes usam o comando Unix `test`, que não existe no `cmd.exe`. + +### 3. A primeira tentativa com a CLI usou uma opção inexistente + +Foi tentado usar `--no-dry-run`, mas a CLI não possui essa opção. O comportamento correto é: + +- `--dry-run`: simulação; +- sem `--dry-run`: execução real. + +Essa tentativa falhou antes de fazer qualquer alteração. + +### 4. A sandbox bloqueou a conexão com o GitHub + +Mesmo com o Makefile corrigido para usar `python` e um shell POSIX, a execução recebeu `WinError 10013`, indicando bloqueio de rede pela sandbox. Após autorizar a conexão externa, as operações foram concluídas. + +## Comando que funcionou no Windows + +```powershell +make SHELL='C:/Program Files/Git/bin/sh.exe' PYTHON=python project-create REPO=v-Kaefer/Github-Project-Automation LIVE=1 +make SHELL='C:/Program Files/Git/bin/sh.exe' PYTHON=python issues REPO=v-Kaefer/Github-Project-Automation LIVE=1 +``` + +## Conclusão + +Não houve falha na lógica de criação do Project, da issue ou das tasks. Os problemas encontrados foram relacionados ao ambiente Windows, à autenticação do `gh` e à restrição de rede da sandbox. From 0a5d0a68c44d3efdf656725989173ea168d94725 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:01:46 -0300 Subject: [PATCH 109/130] address project setup review feedback --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 792f4fc..89454e9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ env/ !.env.example build/ dist/ +*.egg *.egg-info/ .pytest_cache/ .mypy_cache/ From d2ac5173abf149b9203a3334c304c7ae4fbe6564 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:02:07 -0300 Subject: [PATCH 110/130] address project setup review feedback --- project_setup/auto_label.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/project_setup/auto_label.py b/project_setup/auto_label.py index 9fa91e2..6f019e7 100644 --- a/project_setup/auto_label.py +++ b/project_setup/auto_label.py @@ -73,6 +73,7 @@ def infer_issue_labels(issue: dict) -> set[str]: def infer_pr_labels(repo: str, pull_request: dict, client: GitHubClient | None) -> set[str]: body = pull_request.get("body") or "" + current = label_names(pull_request) labels: set[str] = set() linked_number = linked_issue_number(body) if linked_number and client: @@ -83,7 +84,7 @@ def infer_pr_labels(repo: str, pull_request: dict, client: GitHubClient | None) print(f"warning: could not read linked issue #{linked_number}: {exc}") if test_label := find_test_label(body): labels.add(test_label) - if not any(label.startswith("type:") for label in labels): + if not any(label.startswith("type:") for label in labels | current): prefix = pull_request.get("head", {}).get("ref", "").split("/", 1)[0].lower() if prefix in {"fix", "hotfix"}: labels.add("type:bug") @@ -120,7 +121,10 @@ def apply_auto_labels( if dry_run: return 0 if not client: - print("Missing GitHub token") + print( + "Missing GitHub token.\n" + "Fix: set PROJECT_SETUP_PAT in .env, set GITHUB_TOKEN/GH_TOKEN, or run `gh auth login`." + ) return 1 try: client.request_json("POST", f"{API_BASE}/repos/{repo}/issues/{number}/labels", {"labels": labels}) From 5aaf0d5212e3c06a5357302edcd0daeb9597974a Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:02:21 -0300 Subject: [PATCH 111/130] address project setup review feedback --- project_setup/milestones.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/project_setup/milestones.py b/project_setup/milestones.py index 028cb88..b57c7eb 100644 --- a/project_setup/milestones.py +++ b/project_setup/milestones.py @@ -5,12 +5,17 @@ from .github import API_BASE, GitHubClient, split_repo +MILESTONE_LOOKUP_LIMIT = 100 + + def load_milestones(path: str) -> list[dict]: with open(path, "r", encoding="utf-8") as file: milestones = json.load(file) if not isinstance(milestones, list): raise ValueError("milestones manifest must be a JSON list") for milestone in milestones: + if not isinstance(milestone, dict): + raise ValueError("each milestone must be a JSON object") if not milestone.get("title"): raise ValueError("each milestone must define a title") return milestones @@ -27,7 +32,7 @@ def sync_milestones(client: GitHubClient, repo: str, milestones_file: str, dry_r print(f"- {milestone['title']} ({milestone.get('due_on', 'no due date')})") return - existing = client.request_json("GET", f"{endpoint}?state=all&per_page=100") + existing = client.request_json("GET", f"{endpoint}?state=all&per_page={MILESTONE_LOOKUP_LIMIT}") existing_by_title = {item["title"]: item for item in existing} for milestone in milestones: payload = { From 61ebcc437c55cd24d50943cf95ad24382f71de28 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:02:37 -0300 Subject: [PATCH 112/130] address project setup review feedback --- project_setup/issue_milestones.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/project_setup/issue_milestones.py b/project_setup/issue_milestones.py index ff91b73..f80829a 100644 --- a/project_setup/issue_milestones.py +++ b/project_setup/issue_milestones.py @@ -35,6 +35,13 @@ def sync_issue_milestones( for issue in issues if (title := milestone_from_body(issue.get("body") or "")) } + missing = sorted({title for title in explicit.values() if title not in milestones_by_title}) + if missing: + raise ValueError( + f"Milestones referenced by issue bodies do not exist: {', '.join(missing)}. " + "Create the milestones or correct the issue bodies before retrying." + ) + updated = cleared = unchanged = 0 unmapped: list[tuple[int, str]] = [] @@ -59,9 +66,7 @@ def sync_issue_milestones( if not target: unmapped.append((number, issue["title"])) continue - milestone = milestones_by_title.get(target) - if not milestone: - raise RuntimeError(f"Milestone '{target}' referenced by issue #{number} does not exist") + milestone = milestones_by_title[target] if current_title == target: unchanged += 1 continue From 61916d239cd9d8f56407764c06e911bb322bde36 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:02:57 -0300 Subject: [PATCH 113/130] address project setup review feedback --- project_setup/pr_validation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/project_setup/pr_validation.py b/project_setup/pr_validation.py index 981ee8e..84df04d 100644 --- a/project_setup/pr_validation.py +++ b/project_setup/pr_validation.py @@ -17,7 +17,8 @@ ("known risks", "Known risks"), ("dod checklist", "DoD checklist"), ) -PLACEHOLDER = re.compile(r"(<[^>]+>|\b(todo|tbd|placeholder|describe|fill in|replace)\b)", re.IGNORECASE) +ANGLE_PLACEHOLDER = re.compile(r"^<[A-Za-z][^<>]*>$") +KEYWORD_PLACEHOLDER = re.compile(r"\b(todo|tbd|placeholder|describe|fill in|replace)\b", re.IGNORECASE) @dataclass(frozen=True) @@ -49,7 +50,7 @@ def sections_from_body(body: str) -> dict[str, list[str]]: def meaningful(lines: list[str]) -> bool: for line in lines: stripped = line.strip().lstrip("-* ").strip() - if stripped and not PLACEHOLDER.search(stripped): + if stripped and not ANGLE_PLACEHOLDER.fullmatch(stripped) and not KEYWORD_PLACEHOLDER.search(stripped): return True return False From 8c7d37a9af8fe8fe4de61660620e2e2de7257659 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:03:15 -0300 Subject: [PATCH 114/130] address project setup review feedback --- project_setup/installer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/project_setup/installer.py b/project_setup/installer.py index 91e530f..2bcd74f 100644 --- a/project_setup/installer.py +++ b/project_setup/installer.py @@ -28,7 +28,6 @@ PROFILE_FILES = { "core": (), - "godot": (("templates/profiles/godot/.github/workflows/godot-smoke.yml", ".github/workflows/godot-smoke.yml"),), } @@ -70,9 +69,10 @@ def install_repository( ) -> InstallResult: source_root = Path(source).resolve() if source else source_root_from_package() target_root = Path(target).resolve() - target_root.mkdir(parents=True, exist_ok=True) - if not target_root.is_dir(): + if target_root.exists() and not target_root.is_dir(): raise ValueError(f"Target is not a directory: {target_root}") + if not dry_run: + target_root.mkdir(parents=True, exist_ok=True) copied: list[str] = [] skipped: list[str] = [] From 281950880acaf70726994d953264802f2661a9e8 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:04:01 -0300 Subject: [PATCH 115/130] harden GitHub authentication and requests --- project_setup/github.py | 107 ++++++++++++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 20 deletions(-) diff --git a/project_setup/github.py b/project_setup/github.py index 1bf8edd..6aef7f5 100644 --- a/project_setup/github.py +++ b/project_setup/github.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass import json import os from pathlib import Path @@ -9,6 +10,7 @@ import time from typing import Any import urllib.error +import urllib.parse import urllib.request @@ -16,6 +18,8 @@ GRAPHQL_URL = f"{API_BASE}/graphql" API_VERSION = "2022-11-28" RETRYABLE_HTTP_STATUS = {429, 502, 503, 504} +IDEMPOTENT_METHODS = {"GET", "HEAD"} +HTTP_TIMEOUT_SECONDS = 30 ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -28,6 +32,13 @@ def __init__(self, method: str, url: str, status: int, details: str): self.details = details +@dataclass(frozen=True) +class GhAuthStatus: + installed: bool + authenticated: bool + detail: str + + def load_env_file(path: str | os.PathLike[str] | None = None) -> Path | None: """Load a simple dotenv file without overriding existing environment variables.""" configured_path = path or os.getenv("PROJECT_SETUP_ENV_FILE", ".env") @@ -60,21 +71,52 @@ def load_env_file(path: str | os.PathLike[str] | None = None) -> Path | None: return candidate.resolve() -def get_token() -> str | None: +def _compact_detail(text: str, fallback: str) -> str: + lines = [line.strip() for line in text.splitlines() if line.strip()] + return lines[0] if lines else fallback + + +def get_gh_auth_status() -> GhAuthStatus: + gh = shutil.which("gh") + if not gh: + return GhAuthStatus(False, False, "GitHub CLI is not installed or is not available on PATH") + try: + result = subprocess.run( + [gh, "auth", "status"], + capture_output=True, + text=True, + timeout=10, + ) + except subprocess.TimeoutExpired: + return GhAuthStatus(True, False, "`gh auth status` timed out after 10 seconds") + except OSError as exc: + return GhAuthStatus(True, False, f"Could not execute `gh auth status`: {exc}") + detail = _compact_detail( + result.stdout if result.returncode == 0 else result.stderr, + "GitHub CLI authentication is valid" if result.returncode == 0 else "GitHub CLI authentication is invalid", + ) + return GhAuthStatus(True, result.returncode == 0, detail) + + +def get_token_source() -> tuple[str | None, str]: load_env_file() - token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or os.environ.get("PROJECT_SETUP_PAT") - if token and token.strip(): - return token.strip() + for variable in ("GITHUB_TOKEN", "GH_TOKEN", "PROJECT_SETUP_PAT"): + token = os.environ.get(variable) + if token and token.strip(): + return token.strip(), variable gh = shutil.which("gh") if not gh: - return None + return None, "missing" try: result = subprocess.run([gh, "auth", "token"], capture_output=True, text=True, timeout=10) except (OSError, subprocess.TimeoutExpired): - return None - if result.returncode != 0: - return None - return result.stdout.strip() or None + return None, "gh-invalid" + token = result.stdout.strip() if result.returncode == 0 else "" + return (token, "gh") if token else (None, "gh-invalid") + + +def get_token() -> str | None: + return get_token_source()[0] def get_project_pat() -> str | None: @@ -92,6 +134,12 @@ def split_repo(repo: str) -> tuple[str, str]: return owner, name +def _validate_github_api_url(url: str) -> None: + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname != "api.github.com": + raise ValueError(f"Unsupported GitHub API URL: {url}") + + class GitHubClient: def __init__(self, token: str): self.token = token.strip() @@ -108,29 +156,40 @@ def _headers(self, accept: str = "application/vnd.github+json") -> dict[str, str return headers def request_json(self, method: str, url: str, payload: Any = None, accept: str = "application/vnd.github+json") -> Any: + normalized_method = method.upper() + _validate_github_api_url(url) data = json.dumps(payload).encode("utf-8") if payload is not None else None + retry_allowed = normalized_method in IDEMPOTENT_METHODS for attempt in range(1, 6): - request = urllib.request.Request(url, data=data, headers=self._headers(accept), method=method) + request = urllib.request.Request( + url, + data=data, + headers=self._headers(accept), + method=normalized_method, + ) try: - with urllib.request.urlopen(request) as response: + with urllib.request.urlopen( # noqa: S310 - URL is restricted to https://api.github.com above. + request, + timeout=HTTP_TIMEOUT_SECONDS, + ) as response: body = response.read().decode("utf-8") return json.loads(body) if body else {} except urllib.error.HTTPError as exc: details = exc.read().decode("utf-8", errors="replace") - if exc.code in RETRYABLE_HTTP_STATUS and attempt < 5: + if retry_allowed and exc.code in RETRYABLE_HTTP_STATUS and attempt < 5: retry_after = exc.headers.get("Retry-After") wait_seconds = int(retry_after) if retry_after and retry_after.isdigit() else attempt * 2 - print(f"warning: GitHub returned HTTP {exc.code}; retrying in {wait_seconds}s") + print(f"warning: GitHub returned HTTP {exc.code}; retrying read in {wait_seconds}s") time.sleep(wait_seconds) continue - raise GitHubRequestError(method, url, exc.code, details) from exc + raise GitHubRequestError(normalized_method, url, exc.code, details) from exc except urllib.error.URLError as exc: - if attempt < 5: + if retry_allowed and attempt < 5: wait_seconds = attempt * 2 - print(f"warning: GitHub request failed; retrying in {wait_seconds}s: {exc.reason}") + print(f"warning: GitHub read failed; retrying in {wait_seconds}s: {exc.reason}") time.sleep(wait_seconds) continue - raise + raise GitHubRequestError(normalized_method, url, 0, str(exc.reason)) from exc raise RuntimeError("GitHub request exhausted retries") def paginated(self, url: str) -> list[dict[str, Any]]: @@ -179,12 +238,20 @@ def delete_issue_comment(self, repo: str, comment_id: int) -> dict[str, Any]: def require_client() -> GitHubClient: - token = get_token() + token, source = get_token_source() if not token: + gh = get_gh_auth_status() + gh_guidance = ( + f"GitHub CLI status: {gh.detail}.\n" if gh.installed else "GitHub CLI is not installed.\n" + ) raise SystemExit( - "No GitHub token is available. Copy .env.example to .env and set PROJECT_SETUP_PAT, " - "set GITHUB_TOKEN/GH_TOKEN, or authenticate the GitHub CLI with `gh auth login`." + "No GitHub token is available.\n" + f"{gh_guidance}" + "Fix: copy .env.example to .env and set PROJECT_SETUP_PAT, set GITHUB_TOKEN/GH_TOKEN, " + "or repair the CLI session with `gh auth login`." ) + if source == "gh-invalid": + raise SystemExit("GitHub CLI authentication is invalid. Fix: run `gh auth login` and retry.") return GitHubClient(token) From 1a18d4fe0feae10b077f2151316a7fac5fb8f056 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:04:30 -0300 Subject: [PATCH 116/130] improve discovery safety and portability --- project_setup/discovery.py | 65 ++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/project_setup/discovery.py b/project_setup/discovery.py index f326be7..c54b558 100644 --- a/project_setup/discovery.py +++ b/project_setup/discovery.py @@ -3,10 +3,17 @@ from dataclasses import dataclass from pathlib import Path import os -import shutil +import shlex +import subprocess import sys -from .github import GitHubClient, get_token, require_client +from .github import ( + GitHubClient, + get_gh_auth_status, + get_token_source, + require_client, + require_project_client, +) from .runner import load_project_setup_config, run_project_setup @@ -35,13 +42,13 @@ class ProjectMatch: def detect_auth_status() -> AuthStatus: - token = get_token() + token, source = get_token_source() if token: - source = "environment" if any(os.getenv(name) for name in ("GITHUB_TOKEN", "GH_TOKEN", "PROJECT_SETUP_PAT")) else "gh" - return AuthStatus(True, source, "A GitHub token is available") - if shutil.which("gh"): - return AuthStatus(False, "gh", "gh CLI is installed but no authenticated token was returned") - return AuthStatus(False, "missing", "No environment token and gh CLI was not found") + return AuthStatus(True, source, f"A GitHub token is available from {source}") + gh = get_gh_auth_status() + if gh.installed: + return AuthStatus(False, "gh", gh.detail) + return AuthStatus(False, "missing", "No environment token is configured and GitHub CLI was not found") def _collect_markers(root: Path, patterns: tuple[str, ...]) -> tuple[str, ...]: @@ -113,14 +120,23 @@ def build_apply_command( run_issue_generation: bool, link_subissues: bool, ) -> str: - parts = ["python -m project_setup apply", f"--repo {repo}", f"--config {config_path}"] - parts.append("--dry-run" if dry_run else "--no-dry-run") - parts.append("--run-labels" if run_labels else "--skip-labels") - parts.append("--run-milestones" if run_milestones else "--skip-milestones") - parts.append("--run-project-creation" if run_project_creation else "--skip-project-creation") - parts.append("--run-issue-generation" if run_issue_generation else "--skip-issue-generation") - parts.append("--link-subissues" if link_subissues else "--no-link-subissues") - return " ".join(parts) + parts = [ + "python", + "-m", + "project_setup", + "apply", + "--repo", + repo, + "--config", + config_path, + "--dry-run" if dry_run else "--live", + "--run-labels" if run_labels else "--skip-labels", + "--run-milestones" if run_milestones else "--skip-milestones", + "--run-project-creation" if run_project_creation else "--skip-project-creation", + "--run-issue-generation" if run_issue_generation else "--skip-issue-generation", + "--link-subissues" if link_subissues else "--no-link-subissues", + ] + return subprocess.list2cmdline(parts) if os.name == "nt" else shlex.join(parts) def run_discovery(args) -> int: @@ -128,14 +144,16 @@ def run_discovery(args) -> int: repo = args.repo or os.getenv("GITHUB_REPOSITORY") if not repo: print("Missing --repo and GITHUB_REPOSITORY") + print("Fix: pass --repo owner/repository or set GITHUB_REPOSITORY in .env.") return 1 auth = detect_auth_status() print("==> GitHub auth") print(f"Configured: {'yes' if auth.configured else 'no'} ({auth.source})") + print(f"Detail: {auth.detail}") if not auth.configured: - print(auth.detail) print(f"Expected workflow secret: {config.get('secretName', 'PROJECT_SETUP_PAT')}") + print("Fix: set a supported token in .env or repair the GitHub CLI session with `gh auth login`.") return 1 print("==> Project detection") @@ -150,7 +168,7 @@ def run_discovery(args) -> int: defaults = config.get("defaults", {}) values = { - "dry_run": defaults.get("dryRun", True), + "dry_run": True, "run_labels": defaults.get("runLabels", True), "run_milestones": defaults.get("runMilestones", True), "run_project_creation": defaults.get("runProjectCreation", False), @@ -159,14 +177,14 @@ def run_discovery(args) -> int: } interactive = sys.stdin.isatty() and not args.auto if interactive: - values["dry_run"] = _prompt_bool("Run in dry-run mode?", values["dry_run"]) + values["dry_run"] = _prompt_bool("Run in dry-run mode?", True) values["run_labels"] = _prompt_bool("Sync labels?", values["run_labels"]) values["run_milestones"] = _prompt_bool("Sync milestones?", values["run_milestones"]) values["run_project_creation"] = _prompt_bool("Create Project v2?", values["run_project_creation"]) values["run_issue_generation"] = _prompt_bool("Generate issues and tasks?", values["run_issue_generation"]) values["link_subissues"] = _prompt_bool("Link generated tasks as sub-issues?", values["link_subissues"]) else: - print("Using configuration defaults (non-interactive).") + print("Using configuration modules with dry-run enforced for non-interactive discovery.") print("==> Recommended command") print(build_apply_command(repo, args.config, **values)) @@ -176,6 +194,11 @@ def run_discovery(args) -> int: if not sys.stdin.isatty() or not _prompt_bool("Run the selected setup now?", False): print("Confirmation required; no changes were applied.") return 1 - client = GitHubClient("") if values["dry_run"] else require_client() + if values["dry_run"]: + client = GitHubClient("") + elif values["run_project_creation"]: + client = require_project_client() + else: + client = require_client() run_project_setup(client, repo, config, **values) return 0 From ffae7d583fe96bfcd88064fd68fff6c425011d16 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:05:30 -0300 Subject: [PATCH 117/130] standardize safe execution modes and diagnostics --- project_setup/cli.py | 86 +++++++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 20 deletions(-) diff --git a/project_setup/cli.py b/project_setup/cli.py index f7a01b7..67fc6ff 100644 --- a/project_setup/cli.py +++ b/project_setup/cli.py @@ -3,13 +3,18 @@ import argparse import os from pathlib import Path +import platform +import sys from .auto_label import apply_auto_labels from .discovery import SUPPORTED_PROJECT_TYPES, run_discovery from .github import ( GitHubClient, + GitHubRequestError, + get_gh_auth_status, get_project_pat, get_token, + get_token_source, load_env_file, require_client, require_project_client, @@ -19,7 +24,7 @@ from .issues import generate_issues from .labels import sync_labels from .milestones import sync_milestones -from .project import create_project, sync_project +from .project import create_project, load_project_definition, sync_project from .pr_validation import upsert_validation_comment, validate_pull_request from .runner import load_project_setup_config, run_project_setup @@ -54,20 +59,31 @@ def cmd_doctor(args: argparse.Namespace) -> int: config_path = Path(args.config) environment_path = load_env_file() project_pat = get_project_pat() - github_token = get_token() + github_token, token_source = get_token_source() + gh_status = get_gh_auth_status() failures = 0 print("==> Environment") - print(f"python_module=project_setup") + print("python_module=project_setup") + print(f"operating_system={platform.system()} {platform.release()}") + print(f"python_executable={sys.executable}") print(f"working_directory={Path.cwd()}") print(f"env_file={environment_path or (Path.cwd() / '.env')} exists={'yes' if environment_path else 'no'}") print(f"github_repository={os.getenv('GITHUB_REPOSITORY') or 'missing'}") - print(f"github_token={'configured' if github_token else 'missing'}") + print(f"github_token={'configured' if github_token else 'missing'} source={token_source}") print(f"project_setup_pat={'configured' if project_pat else 'missing'}") + print(f"gh_cli={'installed' if gh_status.installed else 'missing'}") + print(f"gh_auth={'valid' if gh_status.authenticated else 'invalid' if gh_status.installed else 'not-installed'}") + print(f"gh_auth_detail={gh_status.detail}") + if os.name == "nt": + print("INFO: Windows detected. The Makefile selects `python` by default and does not require Unix `test` commands.") if not environment_path: print("WARNING: .env was not found.") print(" Fix: copy .env.example to .env and fill only the values required for your workflow.") + if gh_status.installed and not gh_status.authenticated: + print("WARNING: GitHub CLI is installed but its authentication is invalid.") + print(" Fix: run `gh auth login`, or continue with a valid token configured in .env.") if not github_token: print("WARNING: no GitHub authentication is available for live repository operations.") print(" Fix: set PROJECT_SETUP_PAT in .env, set GITHUB_TOKEN/GH_TOKEN, or run `gh auth login`.") @@ -141,9 +157,23 @@ def cmd_project_create(args: argparse.Namespace) -> int: def cmd_project_sync(args: argparse.Namespace) -> int: + repository = repo_arg(args.repo) + if args.dry_run and not get_project_pat(): + definition = load_project_definition(args.file) + target_owner = args.owner or repository.split("/", 1)[0] + print("[DRY-RUN] Offline Project v2 preview; PROJECT_SETUP_PAT is not configured.") + print(f"- owner: {target_owner}") + print(f"- project number: {args.project_number}") + print(f"- repository: {repository}") + print(f"- issue state: {args.issue_state}") + for field in definition.get("fields", []): + print(f"- configured field: {field['name']} ({field['type']})") + print("Remote Project fields, items, and issues were not queried.") + print("Fix: configure PROJECT_SETUP_PAT to run a remote dry-run comparison.") + return 0 sync_project( require_project_client(), - repo_arg(args.repo), + repository, args.file, args.project_number, owner=args.owner, @@ -179,9 +209,10 @@ def cmd_validate_pr(args: argparse.Namespace) -> int: print(f"{finding.section}: {finding.problem}") print(f" Fix: {finding.fix}") if args.comment: - if not args.repo or not args.pr_number: - raise SystemExit("--comment requires --repo and --pr-number") - upsert_validation_comment(require_client(), args.repo, args.pr_number, findings) + repository = repo_arg(args.repo) + if not args.pr_number: + raise SystemExit("--comment requires --pr-number") + upsert_validation_comment(require_client(), repository, args.pr_number, findings) return 1 if findings else 0 @@ -189,7 +220,7 @@ def cmd_apply(args: argparse.Namespace) -> int: config = load_project_setup_config(args.config) defaults = config.get("defaults", {}) values = { - "dry_run": args.dry_run if args.dry_run is not None else defaults.get("dryRun", True), + "dry_run": args.dry_run, "run_labels": args.run_labels if args.run_labels is not None else defaults.get("runLabels", True), "run_milestones": args.run_milestones if args.run_milestones is not None else defaults.get("runMilestones", True), "run_project_creation": args.run_project_creation if args.run_project_creation is not None else defaults.get("runProjectCreation", False), @@ -212,12 +243,23 @@ def add_bool_pair(parser: argparse.ArgumentParser, name: str, destination: str, group.add_argument(f"--skip-{name.removeprefix('run-')}", dest=destination, action="store_false") +def add_execution_mode(parser: argparse.ArgumentParser, *, default_dry_run: bool = True) -> None: + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--dry-run", dest="dry_run", action="store_true", help="Preview without writing changes") + mode.add_argument( + "--live", + "--no-dry-run", + dest="dry_run", + action="store_false", + help="Apply changes; --no-dry-run is kept as a compatibility alias", + ) + parser.set_defaults(dry_run=default_dry_run) + + def add_apply_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) parser.add_argument("--config", default=os.getenv("PROJECT_SETUP_CONFIG", "project_setup.json")) - dry_run = parser.add_mutually_exclusive_group() - dry_run.add_argument("--dry-run", dest="dry_run", action="store_true", default=None) - dry_run.add_argument("--no-dry-run", dest="dry_run", action="store_false") + add_execution_mode(parser) add_bool_pair(parser, "run-labels", "run_labels", "Synchronize labels") add_bool_pair(parser, "run-milestones", "run_milestones", "Synchronize milestones") add_bool_pair(parser, "run-project-creation", "run_project_creation", "Create Project v2") @@ -237,7 +279,7 @@ def build_parser() -> argparse.ArgumentParser: init.add_argument("--source") init.add_argument("--profile", choices=sorted(PROFILE_FILES), default="core") init.add_argument("--force", action="store_true") - init.add_argument("--dry-run", action="store_true") + add_execution_mode(init) init.set_defaults(func=cmd_init) discover = subcommands.add_parser("discover", help="Inspect a repository and recommend setup options") @@ -259,7 +301,7 @@ def build_parser() -> argparse.ArgumentParser: labels_sync = labels_sub.add_parser("sync") labels_sync.add_argument("--repo") labels_sync.add_argument("--file", default="config/project/labels.json") - labels_sync.add_argument("--dry-run", action="store_true") + add_execution_mode(labels_sync) labels_sync.set_defaults(func=cmd_labels_sync) milestones = subcommands.add_parser("milestones") @@ -267,7 +309,7 @@ def build_parser() -> argparse.ArgumentParser: milestones_sync = milestones_sub.add_parser("sync") milestones_sync.add_argument("--repo") milestones_sync.add_argument("--file", default="config/project/milestones.json") - milestones_sync.add_argument("--dry-run", action="store_true") + add_execution_mode(milestones_sync) milestones_sync.set_defaults(func=cmd_milestones_sync) issues = subcommands.add_parser("issues") @@ -275,8 +317,8 @@ def build_parser() -> argparse.ArgumentParser: issues_generate = issues_sub.add_parser("generate") issues_generate.add_argument("--repo") issues_generate.add_argument("--file", default="config/stories/backlog-manifest.json") - issues_generate.add_argument("--dry-run", action="store_true") issues_generate.add_argument("--link-subissues", action="store_true") + add_execution_mode(issues_generate) issues_generate.set_defaults(func=cmd_issues_generate) project = subcommands.add_parser("project") @@ -284,7 +326,7 @@ def build_parser() -> argparse.ArgumentParser: project_create = project_sub.add_parser("create") project_create.add_argument("--repo") project_create.add_argument("--file", default="config/project/project-definition.json") - project_create.add_argument("--dry-run", action="store_true") + add_execution_mode(project_create) project_create.set_defaults(func=cmd_project_create) project_sync = project_sub.add_parser("sync") project_sync.add_argument("--repo") @@ -292,7 +334,7 @@ def build_parser() -> argparse.ArgumentParser: project_sync.add_argument("--file", default="config/project/project-definition.json") project_sync.add_argument("--project-number", type=int, required=True) project_sync.add_argument("--issue-state", choices=("open", "closed", "all"), default="open") - project_sync.add_argument("--dry-run", action="store_true") + add_execution_mode(project_sync) project_sync.set_defaults(func=cmd_project_sync) issue_milestones = subcommands.add_parser("issue-milestones") @@ -300,7 +342,7 @@ def build_parser() -> argparse.ArgumentParser: issue_milestones_sync = issue_milestones_sub.add_parser("sync") issue_milestones_sync.add_argument("--repo") issue_milestones_sync.add_argument("--clear-not-planned", action="store_true") - issue_milestones_sync.add_argument("--dry-run", action="store_true") + add_execution_mode(issue_milestones_sync) issue_milestones_sync.set_defaults(func=cmd_issue_milestones_sync) auto_label = subcommands.add_parser("auto-label") @@ -309,7 +351,7 @@ def build_parser() -> argparse.ArgumentParser: auto_label_apply.add_argument("--repo") auto_label_apply.add_argument("--event-path") auto_label_apply.add_argument("--labels-file", default="config/project/labels.json") - auto_label_apply.add_argument("--dry-run", action="store_true") + add_execution_mode(auto_label_apply) auto_label_apply.set_defaults(func=cmd_auto_label_apply) validate_pr = subcommands.add_parser("validate-pr") @@ -334,6 +376,10 @@ def main(argv: list[str] | None = None) -> int: return int(args.func(args)) except ValueError as exc: raise SystemExit(f"Configuration error: {exc}\nFix the referenced file and run the command again.") from exc + except OSError as exc: + raise SystemExit(f"File error: {exc}") from exc + except GitHubRequestError as exc: + raise SystemExit(f"GitHub API error: {exc}") from exc if __name__ == "__main__": From de6f0c55240978bbe8b48e1fa48806fc39786275 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:06:01 -0300 Subject: [PATCH 118/130] make setup portable and dry-run safe --- Makefile | 88 ++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/Makefile b/Makefile index 4af9539..c85bd24 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,11 @@ +ifeq ($(OS),Windows_NT) +PYTHON ?= python +DETECTED_OS := Windows +else PYTHON ?= python3 +DETECTED_OS := POSIX +endif + PIP ?= $(PYTHON) -m pip TARGET ?= REPO ?= @@ -14,12 +21,17 @@ WORKDIR := $(if $(strip $(TARGET)),$(TARGET),.) FORCE_FLAG := $(if $(filter 1 true yes on,$(FORCE)),--force,) OWNER_FLAG := $(if $(strip $(OWNER)),--owner "$(OWNER)",) PROJECT_TYPE_FLAG := $(if $(strip $(PROJECT_TYPE)),--project-type "$(PROJECT_TYPE)",) -DRY_RUN_FLAG := $(if $(filter 1 true yes on,$(LIVE)),,--dry-run) +EXECUTION_FLAG := $(if $(filter 1 true yes on,$(LIVE)),--live,--dry-run) + +.PHONY: help install dev-install compile test quality check doctor discover init init-dry plan apply setup setup-live labels milestones issues project-create project-sync clean clean-generated -.PHONY: help install dev-install compile test quality check doctor discover require-target require-repo require-project-number init init-dry plan apply setup setup-live labels milestones issues project-create project-sync clean clean-generated +define require_value +$(if $(strip $($(1))),,$(error ERROR: $(1) is required. Fix: $(2))) +endef help: @echo "GitHub Project Setup" + @echo "Detected environment: $(DETECTED_OS); Python command: $(PYTHON)" @echo "" @echo "First-time local setup:" @echo " 1. Copy .env.example to .env" @@ -31,19 +43,20 @@ help: @echo " make install Install the CLI" @echo " make dev-install Install in editable mode" @echo " make check Validate committed files, compile and run tests" - @echo " make doctor Inspect .env, token and configuration availability" + @echo " make doctor Inspect OS, .env, gh auth and configuration" @echo " make clean Remove local Python/build artifacts" @echo "" @echo "Repository analysis and setup:" @echo " make discover TARGET=../project REPO=owner/repo" @echo " make discover TARGET=../project REPO=owner/repo PROJECT_TYPE=python" + @echo " make init-dry TARGET=../project Preview copied automation files" @echo " make init TARGET=../project Copy core automation files" - @echo " make init TARGET=../project PROFILE=godot" @echo " make init TARGET=../project FORCE=1 Replace existing managed files" @echo " make plan TARGET=../project REPO=owner/repo" - @echo " make apply TARGET=../project REPO=owner/repo" - @echo " make setup TARGET=../project REPO=owner/repo Init + dry-run" - @echo " make setup-live TARGET=../project REPO=owner/repo Init + live apply" + @echo " make apply TARGET=../project REPO=owner/repo Dry-run by default" + @echo " make apply TARGET=../project REPO=owner/repo LIVE=1 Apply changes" + @echo " make setup TARGET=../project REPO=owner/repo Init + dry-run" + @echo " make setup-live TARGET=../project REPO=owner/repo Init + live apply" @echo "" @echo "Individual operations (dry-run by default):" @echo " make labels REPO=owner/repo" @@ -82,48 +95,55 @@ doctor: @echo "==> Inspecting local setup (read-only)" $(PYTHON) -m project_setup doctor --config "$(CONFIG)" -require-target: - @test -n "$(TARGET)" || (echo "ERROR: TARGET is required." >&2; echo " Fix: use TARGET=../my-project" >&2; exit 2) - -require-repo: - @test -n "$(REPO)" || (echo "ERROR: REPO is required." >&2; echo " Fix: use REPO=owner/repository" >&2; exit 2) - -require-project-number: - @test -n "$(PROJECT_NUMBER)" || (echo "ERROR: PROJECT_NUMBER is required." >&2; echo " Fix: use PROJECT_NUMBER=1" >&2; exit 2) - -discover: require-target require-repo +discover: + $(call require_value,TARGET,use TARGET=../my-project) + $(call require_value,REPO,use REPO=owner/repository) $(PYTHON) -m project_setup discover --repo "$(REPO)" --config "$(CONFIG)" --root "$(TARGET)" $(PROJECT_TYPE_FLAG) --auto -init: require-target - $(PYTHON) -m project_setup init --target "$(TARGET)" --profile "$(PROFILE)" $(FORCE_FLAG) +init: + $(call require_value,TARGET,use TARGET=../my-project) + $(PYTHON) -m project_setup init --target "$(TARGET)" --profile "$(PROFILE)" $(FORCE_FLAG) --live -init-dry: require-target +init-dry: + $(call require_value,TARGET,use TARGET=../my-project) $(PYTHON) -m project_setup init --target "$(TARGET)" --profile "$(PROFILE)" $(FORCE_FLAG) --dry-run -plan: require-repo +plan: + $(call require_value,REPO,use REPO=owner/repository) cd "$(WORKDIR)" && $(PYTHON) -m project_setup apply --repo "$(REPO)" --config "$(CONFIG)" --dry-run -apply: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup apply --repo "$(REPO)" --config "$(CONFIG)" --no-dry-run +apply: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup apply --repo "$(REPO)" --config "$(CONFIG)" $(EXECUTION_FLAG) setup: init plan -setup-live: init apply +setup-live: + $(call require_value,TARGET,use TARGET=../my-project) + $(call require_value,REPO,use REPO=owner/repository) + $(MAKE) --no-print-directory init TARGET="$(TARGET)" PROFILE="$(PROFILE)" FORCE="$(FORCE)" + $(MAKE) --no-print-directory apply TARGET="$(TARGET)" REPO="$(REPO)" CONFIG="$(CONFIG)" LIVE=1 -labels: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup labels sync --repo "$(REPO)" --file config/project/labels.json $(DRY_RUN_FLAG) +labels: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup labels sync --repo "$(REPO)" --file config/project/labels.json $(EXECUTION_FLAG) -milestones: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup milestones sync --repo "$(REPO)" --file config/project/milestones.json $(DRY_RUN_FLAG) +milestones: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup milestones sync --repo "$(REPO)" --file config/project/milestones.json $(EXECUTION_FLAG) -issues: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup issues generate --repo "$(REPO)" --file config/stories/backlog-manifest.json $(DRY_RUN_FLAG) +issues: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup issues generate --repo "$(REPO)" --file config/stories/backlog-manifest.json $(EXECUTION_FLAG) -project-create: require-repo - cd "$(WORKDIR)" && $(PYTHON) -m project_setup project create --repo "$(REPO)" --file config/project/project-definition.json $(DRY_RUN_FLAG) +project-create: + $(call require_value,REPO,use REPO=owner/repository) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup project create --repo "$(REPO)" --file config/project/project-definition.json $(EXECUTION_FLAG) -project-sync: require-repo require-project-number - cd "$(WORKDIR)" && $(PYTHON) -m project_setup project sync --repo "$(REPO)" --project-number "$(PROJECT_NUMBER)" $(OWNER_FLAG) --file config/project/project-definition.json $(DRY_RUN_FLAG) +project-sync: + $(call require_value,REPO,use REPO=owner/repository) + $(call require_value,PROJECT_NUMBER,use PROJECT_NUMBER=1) + cd "$(WORKDIR)" && $(PYTHON) -m project_setup project sync --repo "$(REPO)" --project-number "$(PROJECT_NUMBER)" $(OWNER_FLAG) --file config/project/project-definition.json $(EXECUTION_FLAG) clean-generated: @$(PYTHON) -c "from pathlib import Path; import shutil; [shutil.rmtree(path, ignore_errors=True) for path in list(Path('.').rglob('__pycache__'))]; [path.unlink(missing_ok=True) for pattern in ('*.pyc','*.pyo') for path in list(Path('.').rglob(pattern))]" From 13515dc5d8d49d7a25eeeaf870b8361cd0cadb3e Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:06:18 -0300 Subject: [PATCH 119/130] make auto label execution explicit --- .github/workflows/auto-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 6c099df..e769b0b 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -32,4 +32,4 @@ jobs: - name: Apply inferred labels env: GITHUB_TOKEN: ${{ github.token }} - run: python -m project_setup auto-label apply + run: python -m project_setup auto-label apply --live From 5f65e969452453a8639dba8fa8c20bf8be343318 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:06:37 -0300 Subject: [PATCH 120/130] cover all validation script types --- tests/test_script_references.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_script_references.py b/tests/test_script_references.py index 8408df5..bdd5f0d 100644 --- a/tests/test_script_references.py +++ b/tests/test_script_references.py @@ -14,14 +14,15 @@ ), } INSTALLER = "project_setup/installer.py" +SCRIPT_SUFFIXES = {".py", ".sh", ".ps1"} class ScriptReferenceTests(unittest.TestCase): def test_every_validation_script_has_a_registered_entry_point(self): discovered = { path.relative_to(ROOT).as_posix() - for path in (ROOT / "scripts" / "validation").glob("*.py") - if path.is_file() + for path in (ROOT / "scripts").rglob("*") + if path.is_file() and path.suffix.lower() in SCRIPT_SUFFIXES } self.assertEqual(discovered, set(SCRIPT_REFERENCES)) From 19a657bbae07d790ce0368c79b6f16c3bf1c6227 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:07:31 -0300 Subject: [PATCH 121/130] test safe execution and Windows diagnostics --- tests/test_project_setup.py | 95 +++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 21 deletions(-) diff --git a/tests/test_project_setup.py b/tests/test_project_setup.py index 31fa9eb..8f08cd8 100644 --- a/tests/test_project_setup.py +++ b/tests/test_project_setup.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import redirect_stdout import io import json import os @@ -7,13 +8,12 @@ from types import SimpleNamespace import tempfile import unittest -from contextlib import redirect_stdout from unittest.mock import patch -from project_setup.auto_label import infer_issue_labels +from project_setup.auto_label import infer_issue_labels, infer_pr_labels from project_setup.cli import main from project_setup.discovery import build_apply_command, detect_project_matches -from project_setup.github import GitHubClient, get_token, load_env_file, require_project_client +from project_setup.github import GitHubClient, get_gh_auth_status, get_token, load_env_file, require_project_client from project_setup.installer import install_repository from project_setup.issue_milestones import milestone_from_body, parent_issue_number_from_body from project_setup.issues import load_backlog @@ -38,6 +38,14 @@ def test_auto_label_infers_type_status_priority_and_test(self): {"type:user-story", "status:backlog", "priority:high", "test:smoke"}, ) + def test_existing_pr_type_label_suppresses_branch_fallback(self): + pull_request = { + "body": "", + "labels": [{"name": "type:task"}], + "head": {"ref": "fix/example"}, + } + self.assertNotIn("type:bug", infer_pr_labels("owner/repository", pull_request, None)) + def test_manifest_loaders_validate_required_fields(self): with tempfile.TemporaryDirectory() as temporary_directory: root = Path(temporary_directory) @@ -45,11 +53,11 @@ def test_manifest_loaders_validate_required_fields(self): milestones = root / "milestones.json" backlog = root / "backlog.json" labels.write_text('[{"name":"missing-color"}]', encoding="utf-8") - milestones.write_text('[{"description":"missing-title"}]', encoding="utf-8") + milestones.write_text('["not-an-object"]', encoding="utf-8") backlog.write_text('{"stories":[]}', encoding="utf-8") with self.assertRaises(ValueError): load_labels(str(labels)) - with self.assertRaises(ValueError): + with self.assertRaisesRegex(ValueError, "JSON object"): load_milestones(str(milestones)) with self.assertRaises(ValueError): load_backlog(str(backlog)) @@ -79,6 +87,17 @@ def test_sync_dry_runs_print_planned_resources(self): self.assertIn("[DRY-RUN] Would sync 1 labels", text) self.assertIn("[DRY-RUN] Would sync 1 milestones", text) + def test_individual_commands_default_to_dry_run(self): + with tempfile.TemporaryDirectory() as temporary_directory: + labels = Path(temporary_directory) / "labels.json" + labels.write_text('[{"name":"status:backlog","color":"C5DEF5"}]', encoding="utf-8") + with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "", "PROJECT_SETUP_PAT": ""}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + result = main(["labels", "sync", "--repo", "owner/repository", "--file", str(labels)]) + self.assertEqual(result, 0) + self.assertIn("[DRY-RUN]", output.getvalue()) + def test_apply_dry_run_does_not_require_token(self): with tempfile.TemporaryDirectory() as temporary_directory: root = Path(temporary_directory) @@ -95,7 +114,7 @@ def test_apply_dry_run_does_not_require_token(self): "projectDefinitionFile": str(root / "project.json"), "backlogManifestFile": str(root / "backlog.json"), "defaults": { - "dryRun": True, + "dryRun": False, "runLabels": True, "runMilestones": True, "runProjectCreation": True, @@ -110,11 +129,34 @@ def test_apply_dry_run_does_not_require_token(self): ): output = io.StringIO() with redirect_stdout(output): - result = main(["apply", "--repo", "owner/repository", "--config", str(config), "--dry-run"]) + result = main(["apply", "--repo", "owner/repository", "--config", str(config)]) self.assertEqual(result, 0) self.assertIn("[DRY-RUN] Would sync 1 labels", output.getvalue()) self.assertIn("Project setup finished.", output.getvalue()) + def test_project_sync_without_pat_uses_offline_preview(self): + with tempfile.TemporaryDirectory() as temporary_directory: + definition = Path(temporary_directory) / "project.json" + definition.write_text('{"name":"Board","fields":[{"name":"Status","type":"single_select"}]}', encoding="utf-8") + with patch.dict(os.environ, {"PROJECT_SETUP_PAT": ""}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + result = main( + [ + "project", + "sync", + "--repo", + "owner/repository", + "--project-number", + "1", + "--file", + str(definition), + ] + ) + self.assertEqual(result, 0) + self.assertIn("Offline Project v2 preview", output.getvalue()) + self.assertIn("Remote Project fields, items, and issues were not queried", output.getvalue()) + def test_env_file_loads_values_without_overriding_process_environment(self): with tempfile.TemporaryDirectory() as temporary_directory: env_file = Path(temporary_directory) / ".env" @@ -160,17 +202,16 @@ def test_installer_copies_core_files_and_preserves_existing_files(self): self.assertTrue((target / ".github" / "workflows" / "project-setup.yml").is_file()) self.assertTrue((target / ".env.example").is_file()) self.assertTrue((target / "Makefile").is_file()) - self.assertFalse((target / ".github" / "workflows" / "godot-smoke.yml").exists()) - def test_godot_profile_copies_optional_workflow(self): + def test_installer_dry_run_does_not_create_target_directory(self): with tempfile.TemporaryDirectory() as temporary_directory: - target = Path(temporary_directory) - install_repository(target, source=ROOT, profile="godot") - self.assertTrue((target / ".github" / "workflows" / "godot-smoke.yml").is_file()) + target = Path(temporary_directory) / "missing-target" + install_repository(target, source=ROOT, profile="core", dry_run=True) + self.assertFalse(target.exists()) - def test_pull_request_validation_accepts_complete_template(self): + def test_pull_request_validation_accepts_complete_template_and_inline_url(self): body = """## Linked Issue -- Closes #123 +- Closes #123 ## Milestone - M1 @@ -197,10 +238,10 @@ def test_discovery_detects_multiple_project_types(self): matches = detect_project_matches(root) self.assertEqual([match.project_type for match in matches[:2]], ["python", "node"]) - def test_discovery_builds_project_setup_command(self): + def test_discovery_builds_quoted_project_setup_command(self): command = build_apply_command( "owner/repository", - "project_setup.json", + "config folder/project_setup.json", True, True, True, @@ -208,18 +249,29 @@ def test_discovery_builds_project_setup_command(self): False, True, ) - self.assertIn("python -m project_setup apply", command) + self.assertIn("project_setup", command) self.assertIn("--dry-run", command) self.assertIn("--skip-project-creation", command) + self.assertIn("config folder", command) def test_get_token_falls_back_to_gh_auth(self): with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "", "PROJECT_SETUP_PAT": ""}, clear=False), patch( "project_setup.github.shutil.which", return_value="/usr/bin/gh" ), patch("project_setup.github.subprocess.run") as run: - run.return_value = SimpleNamespace(returncode=0, stdout="token-from-gh\n") + run.return_value = SimpleNamespace(returncode=0, stdout="token-from-gh\n", stderr="") token = get_token() self.assertEqual(token, "token-from-gh") + def test_gh_auth_status_reports_invalid_session(self): + with patch("project_setup.github.shutil.which", return_value="gh"), patch( + "project_setup.github.subprocess.run" + ) as run: + run.return_value = SimpleNamespace(returncode=1, stdout="", stderr="invalid token\n") + status = get_gh_auth_status() + self.assertTrue(status.installed) + self.assertFalse(status.authenticated) + self.assertEqual(status.detail, "invalid token") + def test_discover_auto_mode_reports_summary(self): with tempfile.TemporaryDirectory() as temporary_directory: root = Path(temporary_directory) @@ -235,7 +287,7 @@ def test_discover_auto_mode_reports_summary(self): "projectDefinitionFile": "project.json", "backlogManifestFile": "backlog.json", "secretName": "PROJECT_SETUP_PAT", - "defaults": {"dryRun": True, "runLabels": True, "runMilestones": True}, + "defaults": {"dryRun": False, "runLabels": True, "runMilestones": True}, } ), encoding="utf-8", @@ -257,9 +309,10 @@ def test_discover_auto_mode_reports_summary(self): ) self.assertEqual(result, 0) text = output.getvalue() - self.assertIn("Configured: yes (environment)", text) + self.assertIn("Configured: yes (PROJECT_SETUP_PAT)", text) self.assertIn("Detected project type: go", text) - self.assertIn("python -m project_setup apply", text) + self.assertIn("project_setup", text) + self.assertIn("--dry-run", text) if __name__ == "__main__": From b5f93cdbd247e514c5e305f3b95b9c1df480c3f0 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:07:49 -0300 Subject: [PATCH 122/130] standardize workflow execution mode --- .github/workflows/project-setup.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/project-setup.yml b/.github/workflows/project-setup.yml index 528541b..653f47f 100644 --- a/.github/workflows/project-setup.yml +++ b/.github/workflows/project-setup.yml @@ -87,7 +87,7 @@ jobs: if [ "${{ inputs.dry_run }}" = "true" ]; then args="$args --dry-run" else - args="$args --no-dry-run" + args="$args --live" fi if [ "${{ inputs.run_labels_sync }}" = "true" ]; then args="$args --run-labels" From adea81e1677164ca06f4d26df129ab9c7c248189 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:08:10 -0300 Subject: [PATCH 123/130] synchronize documented workflow template --- docs/repo/project-setup.workflow-template.yml | 76 ++++++++++++++++--- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/docs/repo/project-setup.workflow-template.yml b/docs/repo/project-setup.workflow-template.yml index b97cd73..653f47f 100644 --- a/docs/repo/project-setup.workflow-template.yml +++ b/docs/repo/project-setup.workflow-template.yml @@ -1,54 +1,108 @@ -# Reference copy of .github/workflows/project-setup.yml. -# The installer copies the active workflow directly; keep this file only for documentation. name: Project setup on: workflow_dispatch: inputs: - dry_run: - description: "Plan changes without writing to GitHub" + run_labels_sync: + description: "Synchronize labels" required: true default: true type: boolean + run_milestones_sync: + description: "Synchronize milestones" + required: true + default: true + type: boolean + run_issue_generation: + description: "Generate backlog issues and tasks" + required: true + default: false + type: boolean run_project_creation: - description: "Create Project v2 (requires PROJECT_SETUP_PAT for live runs)" + description: "Create a GitHub Project v2 (requires PROJECT_SETUP_PAT for live runs)" required: true default: false type: boolean + dry_run: + description: "Plan changes without writing to GitHub" + required: true + default: true + type: boolean permissions: contents: read issues: write +concurrency: + group: project-setup-${{ github.repository }} + cancel-in-progress: false + jobs: setup: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - name: Checkout repository + uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-python@v6 + + - name: Set up Python + uses: actions/setup-python@v6 with: python-version: "3.11" + + - name: Validate embedded setup package and configuration + env: + GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + run: | + set -euo pipefail + python -m compileall -q project_setup + python -m project_setup doctor --config project_setup.json + - name: Require PAT for live Project v2 creation if: ${{ inputs.run_project_creation && !inputs.dry_run }} env: PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} run: | + set -euo pipefail if [ -z "${PROJECT_SETUP_PAT:-}" ]; then - echo "::error title=PROJECT_SETUP_PAT is required::Create a classic PAT with repo and project scopes and save it as the PROJECT_SETUP_PAT Actions secret." + echo "::error title=PROJECT_SETUP_PAT is required::GitHub's repository-scoped token cannot create or synchronize Projects v2." + echo "Create a personal access token (classic):" + echo " GitHub profile picture > Settings > Developer settings" + echo " Personal access tokens > Tokens (classic) > Generate new token (classic)" + echo " Select scopes: repo and project" + echo "Save it as the repository Actions secret PROJECT_SETUP_PAT, then run this workflow again." exit 1 fi - - name: Run project setup + echo "PROJECT_SETUP_PAT is configured for the requested Project v2 operation." + + - name: Apply project setup env: GITHUB_TOKEN: ${{ github.token }} PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} run: | - args="" + set -euo pipefail + args="--config project_setup.json" if [ "${{ inputs.dry_run }}" = "true" ]; then args="$args --dry-run" else - args="$args --no-dry-run" + args="$args --live" + fi + if [ "${{ inputs.run_labels_sync }}" = "true" ]; then + args="$args --run-labels" + else + args="$args --skip-labels" + fi + if [ "${{ inputs.run_milestones_sync }}" = "true" ]; then + args="$args --run-milestones" + else + args="$args --skip-milestones" + fi + if [ "${{ inputs.run_issue_generation }}" = "true" ]; then + args="$args --run-issue-generation" + else + args="$args --skip-issue-generation" fi if [ "${{ inputs.run_project_creation }}" = "true" ]; then args="$args --run-project-creation" From e8a821f71eadbdfab1c3ac73b9ff97bbaa8ae045 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:08:26 -0300 Subject: [PATCH 124/130] remove framework-specific Godot profile --- .../godot/.github/workflows/godot-smoke.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 templates/profiles/godot/.github/workflows/godot-smoke.yml diff --git a/templates/profiles/godot/.github/workflows/godot-smoke.yml b/templates/profiles/godot/.github/workflows/godot-smoke.yml deleted file mode 100644 index 1485566..0000000 --- a/templates/profiles/godot/.github/workflows/godot-smoke.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Godot smoke check - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - -concurrency: - group: godot-smoke-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - smoke: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Verify project.godot exists - id: has_project - shell: bash - run: | - if [ -f project.godot ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Set up Godot - if: steps.has_project.outputs.exists == 'true' - uses: firebelley/setup-godot@v1 - with: - godot-version: "4.2.2" - - - name: Run headless smoke check - if: steps.has_project.outputs.exists == 'true' - run: godot --headless --quit - - - name: Skip smoke check - if: steps.has_project.outputs.exists != 'true' - run: echo "Skipping smoke check because project.godot was not found." From 541b8ee97b5e6a9fe655661b7e3004e18c10476b Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:08:39 -0300 Subject: [PATCH 125/130] align documentation sources and execution modes --- docs/DOCUMENTATION-GUIDE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/DOCUMENTATION-GUIDE.md b/docs/DOCUMENTATION-GUIDE.md index d68b3bc..db60949 100644 --- a/docs/DOCUMENTATION-GUIDE.md +++ b/docs/DOCUMENTATION-GUIDE.md @@ -19,11 +19,9 @@ This guide maps the reusable configuration, workflows and operational documentat | `.github/workflows/project-setup.yml` | Manual dry-run or live setup | `project_setup/cli.py`, `project_setup/runner.py`, `project_setup.json` | | `.github/workflows/auto-label.yml` | Infer labels for issues and PRs | `project_setup/auto_label.py` | | `.github/workflows/pr-metadata.yml` | Validate branch names and PR metadata | `project_setup/pr_validation.py` | -| `.github/workflows/main-source-branch.yml` | Restrict PR sources targeting `main` | `docs/repo/branching-policy.md` | +| `.github/workflows/main-source-branch.yml` | Restrict PR sources targeting `main` | `.github/workflows/main-source-branch.yml`, `docs/repo/branching-policy.md` | | `.github/workflows/repo-quality.yml` | Validate this tool repository | `Makefile`, `scripts/validation/repo_quality.py`, `tests/` | -Framework-specific workflows belong under `templates/profiles//` and are copied only when that profile is selected. - ## Adding a milestone template 1. Add the milestone to `config/project/milestones.json`. @@ -39,7 +37,7 @@ python -m project_setup apply --repo owner/repository --dry-run 6. Apply only after reviewing the proposed changes: ```bash -python -m project_setup apply --repo owner/repository --no-dry-run +python -m project_setup apply --repo owner/repository --live ``` ## Repository structure @@ -50,7 +48,6 @@ project_setup.json File paths and execution defaults config/project/ Labels, milestones and Project v2 definition config/stories/ Backlog manifest .github/workflows/ Generic active workflows -templates/profiles/ Optional framework-specific workflows docs/repo/ Operational policies and runbooks scripts/validation/ Cross-platform validation entrypoints tests/ Unit and installation tests From 42e2b3a8667a755c89f9874cd90c63ceea8caba3 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:09:12 -0300 Subject: [PATCH 126/130] document Windows and safe execution flow --- docs/repo/project-setup-runbook.pt-BR.md | 61 +++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/repo/project-setup-runbook.pt-BR.md b/docs/repo/project-setup-runbook.pt-BR.md index 7de5c78..3246549 100644 --- a/docs/repo/project-setup-runbook.pt-BR.md +++ b/docs/repo/project-setup-runbook.pt-BR.md @@ -19,6 +19,23 @@ O comando: Caches locais em `__pycache__` não são tratados como arquivos versionados. Quando houver uma falha real, a saída apresenta uma instrução `Fix:`. +### Windows + +O Makefile detecta `OS=Windows_NT`, usa `python` por padrão e não depende do comando Unix `test`. Em Linux, macOS, Git Bash e WSL, o padrão permanece `python3`. + +A detecção pode ser conferida com: + +```bash +make help +make doctor +``` + +O comando Python ainda pode ser sobrescrito explicitamente: + +```powershell +make PYTHON=py check +``` + ## 2. Criar o ambiente local PowerShell: @@ -57,8 +74,18 @@ Para execução local sem Project v2, também é possível usar uma sessão aute ```bash gh auth login +gh auth status ``` +O `make doctor` diferencia: + +- GitHub CLI ausente; +- GitHub CLI instalada com autenticação válida; +- GitHub CLI instalada com autenticação inválida; +- token recebido por `GITHUB_TOKEN`, `GH_TOKEN`, `PROJECT_SETUP_PAT` ou `gh`. + +Uma autenticação inválida do `gh` não bloqueia a ferramenta quando existe outro token válido no `.env`. + ### GitHub Projects v2 Project v2 não pode usar o token padrão do repositório. Crie um personal access token classic: @@ -98,9 +125,11 @@ make doctor O diagnóstico verifica: +- sistema operacional e executável Python; - presença do `.env`; - repositório configurado; -- disponibilidade de autenticação; +- disponibilidade e origem da autenticação; +- estado de `gh auth`; - presença específica de `PROJECT_SETUP_PAT`; - validade de `project_setup.json`; - existência dos manifests referenciados. @@ -113,12 +142,16 @@ Ele não aplica alterações no GitHub. make discover TARGET=../meu-projeto REPO=owner/repositorio ``` +O modo automático sempre recomenda dry-run. Uma aplicação real pela descoberta exige confirmação explícita. + ## 6. Simular a instalação ```bash make init-dry TARGET=../meu-projeto PROFILE=core ``` +O dry-run não cria o diretório-alvo. + ## 7. Instalar os arquivos ```bash @@ -127,6 +160,14 @@ make init TARGET=../meu-projeto O instalador também leva `Makefile` e `.env.example`. Arquivos existentes são preservados e devem ser mesclados manualmente. A substituição consciente exige `FORCE=1`. +### Fluxo combinado + +```bash +make setup TARGET=../meu-projeto REPO=owner/repositorio +``` + +Esse comando executa a instalação real dos arquivos ausentes e, em seguida, o plano remoto em dry-run. Ele não aplica alterações na API do GitHub. + ## 8. Personalizar Revise: @@ -151,10 +192,24 @@ O plano é sempre dry-run. ## 10. Aplicar a configuração completa +Sem `LIVE=1`, `make apply` continua em dry-run: + ```bash make apply TARGET=../meu-projeto REPO=owner/repositorio ``` +A escrita exige confirmação explícita: + +```bash +make apply TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +``` + +Também existe o atalho combinado explícito: + +```bash +make setup-live TARGET=../meu-projeto REPO=owner/repositorio +``` + ## 11. Executar módulos individualmente Primeiro execute em dry-run: @@ -171,12 +226,16 @@ Após revisar a saída, habilite a escrita explicitamente: ```bash make labels TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make milestones TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make issues TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 make project-create TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 LIVE=1 ``` Operações reais de Project v2 exigem `PROJECT_SETUP_PAT` no `.env` do repositório-alvo. +Sem PAT, `project-sync` em dry-run produz um preview offline da definição local e informa que a comparação remota não foi executada. + ## 12. Execução manual no Actions No repositório-alvo: From 59f154d1b1de3c1956ed8f9a9b512b3b56d915f9 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:10:21 -0300 Subject: [PATCH 127/130] document Windows and safe project setup usage --- README.md | 154 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 124 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 64b05a6..6ef05b8 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,15 @@ ## Main capabilities - Manual operation through Make targets. -- Safe dry-run defaults. +- Safe dry-run defaults for every mutating CLI command. +- Explicit `--live` or `LIVE=1` confirmation before writes. - Guided repository discovery through the Python CLI. - Embedded workflows, templates, manifests, and validation scripts. +- Native Windows detection in the Makefile. - Local `.env` loading without external dependencies. - Standard `github.token` for repository-scoped Actions operations. - Explicit `PROJECT_SETUP_PAT` requirement for GitHub Projects v2. - Actionable diagnostics with a `Fix:` instruction for validation errors. -- Core and optional Godot profiles. @@ -35,6 +36,12 @@ - permission to modify the target GitHub repository; - a personal access token only for live GitHub Projects v2 operations. +The Makefile detects `OS=Windows_NT`. It uses `python` on Windows and `python3` on Linux, macOS, Git Bash, and WSL. The command can still be overridden: + +```powershell +make PYTHON=py check +``` + The Python CLI can be used without Make: ```bash @@ -49,7 +56,7 @@ make check The command runs three local stages: -1. validate required and committed files, JSON, and package metadata; +1. validate required and committed files, JSON, script references, and package metadata; 2. compile Python sources; 3. run unit tests. @@ -90,7 +97,7 @@ Run the read-only diagnostic: make doctor ``` -`make doctor` validates `.env`, `project_setup.json`, and the referenced manifest files. It never prints token values and does not write to GitHub. +`make doctor` reports the operating system, Python executable, `.env`, configuration files, token source, GitHub CLI installation, and `gh auth` status. It never prints token values and does not write to GitHub. ### 4. Authentication @@ -111,6 +118,17 @@ Do not create a custom secret named `GITHUB_TOKEN`. The standard token is used f - PR validation comments; - inferred labels. +#### Local GitHub CLI authentication + +The CLI can fall back to an authenticated GitHub CLI session: + +```bash +gh auth login +gh auth status +``` + +`make doctor` distinguishes a missing CLI, valid authentication, invalid authentication, and environment-token authentication. An invalid `gh` session does not block execution when a valid token is available in `.env`. + #### GitHub Projects v2 The repository-scoped token cannot access Projects v2. Live Project creation and synchronization require `PROJECT_SETUP_PAT`. @@ -163,13 +181,17 @@ Inspect a target repository: make discover TARGET=../my-project REPO=owner/my-project ``` +Non-interactive discovery always recommends a dry-run command, even when `project_setup.json` contains an old `dryRun=false` value. + Preview installed files: ```bash make init-dry TARGET=../my-project PROFILE=core ``` -Install the core profile: +The installation dry-run does not create the target directory. + +Install the core automation: ```bash make init TARGET=../my-project PROFILE=core @@ -177,12 +199,14 @@ make init TARGET=../my-project PROFILE=core The installer includes `Makefile` and `.env.example`. Existing files are preserved. If the target already has either file, review and merge the template manually. -Optional Godot profile: +Combined install and remote dry-run: ```bash -make init TARGET=../my-game PROFILE=godot +make setup TARGET=../my-project REPO=owner/my-project ``` +`make setup` installs missing files, then executes the configured API phase in dry-run mode. It does not write remote GitHub changes. + ### 6. Customize Review at least: @@ -210,11 +234,25 @@ make plan TARGET=../my-project REPO=owner/repository ### 8. Apply the complete configured setup +`make apply` is also a dry-run unless live execution is explicitly enabled: + ```bash make apply TARGET=../my-project REPO=owner/repository ``` -The configuration in `project_setup.json` decides which modules run. If live Project creation is enabled, `PROJECT_SETUP_PAT` is mandatory. +Apply changes only after reviewing the plan: + +```bash +make apply TARGET=../my-project REPO=owner/repository LIVE=1 +``` + +The CLI equivalent is: + +```bash +python -m project_setup apply --repo owner/repository --live +``` + +If live Project creation is enabled, `PROJECT_SETUP_PAT` is mandatory. ### 9. Run individual modules manually @@ -232,12 +270,16 @@ After reviewing the output, add `LIVE=1` explicitly: ```bash make labels TARGET=../my-project REPO=owner/repository LIVE=1 +make milestones TARGET=../my-project REPO=owner/repository LIVE=1 +make issues TARGET=../my-project REPO=owner/repository LIVE=1 make project-create TARGET=../my-project REPO=owner/repository LIVE=1 make project-sync TARGET=../my-project REPO=owner/repository PROJECT_NUMBER=1 LIVE=1 ``` Project v2 live commands require `PROJECT_SETUP_PAT` in the target `.env`. +Without a PAT, `project-sync` dry-run produces an offline preview of the local Project definition and clearly states that remote fields, items, and issues were not queried. + ### 10. Run manually in GitHub Actions In the target repository: @@ -250,34 +292,41 @@ The workflow defaults to dry-run. Labels, milestones, issue generation, and Proj | Target | Purpose | | --- | --- | -| `make help` | Show setup steps and commands. | +| `make help` | Show detected platform, setup steps, and commands. | | `make check` | Validate committed files, compile, and test. | -| `make doctor` | Inspect local `.env` and configuration without API writes. | -| `make discover TARGET=... REPO=...` | Detect the target stack and recommend setup options. | -| `make init-dry TARGET=...` | Preview installed files. | +| `make doctor` | Inspect OS, `.env`, tokens, `gh auth`, and configuration without API writes. | +| `make discover TARGET=... REPO=...` | Detect the target stack and recommend safe setup options. | +| `make init-dry TARGET=...` | Preview installed files without creating the target directory. | | `make init TARGET=...` | Install missing files while preserving existing files. | +| `make setup TARGET=... REPO=...` | Install missing files and run the configured remote phase in dry-run mode. | | `make plan TARGET=... REPO=...` | Preview the complete configured API phase. | -| `make apply TARGET=... REPO=...` | Apply the complete configured API phase. | +| `make apply TARGET=... REPO=...` | Preview the configured API phase; dry-run remains the default. | +| `make apply TARGET=... REPO=... LIVE=1` | Apply the complete configured API phase explicitly. | | `make ...` | Preview one module. | | `make ... LIVE=1` | Apply one module explicitly. | | `make clean` | Remove local Python and build artifacts. | ## Security model -- Dry-run is the default. +- Dry-run is the default for all mutating CLI commands. - Existing target files are preserved. +- Installation dry-runs do not create directories. - Project v2 never silently falls back to `github.token`. +- GitHub HTTP requests have a finite timeout. +- Automatic retries are limited to idempotent read requests; mutations are not replayed after transport failures. - Workflows use minimum repository permissions. -- PR workflows execute trusted base-branch code. +- Privileged `pull_request_target` workflows check out trusted base-branch automation. +- Read-only `pull_request` test workflows may test the proposed PR content. - Untrusted branch names are passed through environment variables, not interpolated into shell source. - Tokens are never printed by diagnostics. ## Current limitations -- Issue generation is not idempotent yet. +- Issue generation is not idempotent yet and must not be repeated without reviewing existing issues. - Project v2 views remain a manual configuration step. - Rulesets and branch protection are not created automatically. - The package is embedded in target repositories instead of being installed from PyPI. +- Milestone synchronization intentionally inspects at most the first 100 existing milestones. - A summarized `make preview` with a configurable example limit is planned but not implemented yet. --- @@ -293,14 +342,15 @@ O `project_setup` é uma ferramenta autocontida para instalar e operar automaç ## Principais recursos - Execução manual por Makefile. -- Dry-run seguro por padrão. +- Dry-run seguro em todos os comandos mutáveis. +- Confirmação explícita por `--live` ou `LIVE=1` antes de qualquer escrita. - Descoberta guiada pela CLI Python. - Workflows, templates, manifests e validadores incorporados. +- Identificação nativa do Windows pelo Makefile. - Carregamento automático de `.env`, sem dependências externas. - `github.token` padrão para operações do próprio repositório. - `PROJECT_SETUP_PAT` explícita para GitHub Projects v2. - Erros com instruções `Fix:`. -- Perfis `core` e `godot`. @@ -314,6 +364,12 @@ O `project_setup` é uma ferramenta autocontida para instalar e operar automaç - permissão para modificar o repositório-alvo; - personal access token somente para operações reais de Project v2. +O Makefile detecta `OS=Windows_NT`. No Windows, usa `python`; em Linux, macOS, Git Bash e WSL, usa `python3`. É possível sobrescrever: + +```powershell +make PYTHON=py check +``` + Sem Make: ```bash @@ -328,7 +384,7 @@ make check O comando: -1. valida arquivos obrigatórios e commitados, JSON e metadados do pacote; +1. valida arquivos obrigatórios e commitados, JSON, referências de scripts e metadados do pacote; 2. compila os fontes Python; 3. executa os testes unitários. @@ -362,7 +418,7 @@ Execute: make doctor ``` -O `doctor` verifica `.env`, `project_setup.json` e manifests referenciados, sem exibir tokens e sem alterar o GitHub. +O `doctor` informa sistema operacional, executável Python, `.env`, configuração, origem do token, instalação da GitHub CLI e situação do `gh auth`. Ele não exibe tokens e não altera o GitHub. ### 4. Autenticação @@ -383,6 +439,15 @@ Não crie um secret personalizado chamado `GITHUB_TOKEN`. O token padrão atende - comentários de validação em PRs; - labels inferidas. +#### Autenticação local da GitHub CLI + +```bash +gh auth login +gh auth status +``` + +O `make doctor` diferencia CLI ausente, autenticação válida, autenticação inválida e tokens definidos no ambiente. Um `gh auth` inválido não bloqueia o uso quando existe outro token válido no `.env`. + #### GitHub Projects v2 O token padrão do repositório não acessa Projects v2. Criação e sincronização reais exigem `PROJECT_SETUP_PAT`. @@ -434,14 +499,18 @@ make init-dry TARGET=../meu-projeto PROFILE=core make init TARGET=../meu-projeto PROFILE=core ``` +A descoberta não interativa sempre recomenda dry-run. O dry-run de instalação não cria o diretório-alvo. + O instalador inclui `Makefile` e `.env.example`. Arquivos existentes são preservados e devem ser mesclados manualmente. -Perfil Godot opcional: +Fluxo combinado de instalação e simulação remota: ```bash -make init TARGET=../meu-jogo PROFILE=godot +make setup TARGET=../meu-projeto REPO=owner/meu-projeto ``` +Esse comando instala arquivos ausentes e executa o plano remoto em dry-run, sem alterar a API do GitHub. + ### 6. Personalizar Revise: @@ -464,10 +533,24 @@ O `make plan` sempre usa dry-run. ### 8. Aplicar a configuração completa +Sem `LIVE=1`, o comando continua sendo uma simulação: + ```bash make apply TARGET=../meu-projeto REPO=owner/repositorio ``` +A escrita exige confirmação explícita: + +```bash +make apply TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +``` + +Equivalente pela CLI: + +```bash +python -m project_setup apply --repo owner/repositorio --live +``` + Se a configuração habilitar criação de Project v2, `PROJECT_SETUP_PAT` será obrigatória. ### 9. Executar módulos individualmente @@ -486,12 +569,16 @@ Após revisar, adicione `LIVE=1`: ```bash make labels TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make milestones TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 +make issues TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 make project-create TARGET=../meu-projeto REPO=owner/repositorio LIVE=1 make project-sync TARGET=../meu-projeto REPO=owner/repositorio PROJECT_NUMBER=1 LIVE=1 ``` Os comandos reais de Project v2 exigem `PROJECT_SETUP_PAT` no `.env` do alvo. +Sem PAT, o dry-run de `project-sync` apresenta apenas a definição local e informa claramente que fields, items e issues remotos não foram consultados. + ### 10. Executar manualmente no Actions No repositório-alvo: @@ -504,32 +591,39 @@ O workflow inicia em dry-run. Labels, milestones, geração de issues e criaçã | Alvo | Finalidade | | --- | --- | -| `make help` | Mostrar a sequência inicial e os comandos. | +| `make help` | Mostrar plataforma detectada, sequência inicial e comandos. | | `make check` | Validar arquivos commitados, compilar e testar. | -| `make doctor` | Verificar `.env` e configuração sem escrita na API. | -| `make discover TARGET=... REPO=...` | Detectar a stack e recomendar opções. | -| `make init-dry TARGET=...` | Simular arquivos instalados. | +| `make doctor` | Verificar SO, `.env`, tokens, `gh auth` e configuração sem escrita na API. | +| `make discover TARGET=... REPO=...` | Detectar a stack e recomendar opções seguras. | +| `make init-dry TARGET=...` | Simular arquivos instalados sem criar o diretório-alvo. | | `make init TARGET=...` | Instalar arquivos ausentes preservando existentes. | +| `make setup TARGET=... REPO=...` | Instalar arquivos ausentes e executar o plano remoto em dry-run. | | `make plan TARGET=... REPO=...` | Simular a fase completa da API. | -| `make apply TARGET=... REPO=...` | Aplicar a fase completa da API. | +| `make apply TARGET=... REPO=...` | Simular a fase configurada; dry-run permanece o padrão. | +| `make apply TARGET=... REPO=... LIVE=1` | Aplicar explicitamente a fase configurada. | | `make ...` | Simular um módulo. | | `make ... LIVE=1` | Aplicar explicitamente um módulo. | | `make clean` | Remover caches Python e artefatos locais. | ## Segurança -- Dry-run é o padrão. +- Dry-run é o padrão em todos os comandos mutáveis. - Arquivos existentes são preservados. +- Dry-run de instalação não cria diretórios. - Project v2 não usa fallback silencioso para `github.token`. +- Chamadas HTTP possuem timeout finito. +- Retentativas automáticas ficam restritas a leituras idempotentes; mutações não são repetidas após falhas de transporte. - Workflows usam permissões mínimas. -- Workflows de PR executam código confiável da branch-base. +- Workflows privilegiados com `pull_request_target` usam automação confiável da branch-base. +- Workflows de teste somente leitura com `pull_request` podem validar o conteúdo proposto no PR. - Nomes de branches passam por variáveis de ambiente, sem interpolação direta no shell. - Diagnósticos nunca imprimem tokens. ## Limitações atuais -- A geração de issues ainda não é idempotente. +- A geração de issues ainda não é idempotente e não deve ser repetida sem revisar as issues existentes. - Views de Project v2 continuam manuais. - Rulesets e branch protection ainda não são criados. - O pacote é incorporado nos repositórios-alvo, sem publicação no PyPI. +- A sincronização de milestones consulta intencionalmente no máximo os primeiros 100 milestones existentes. - Um `make preview` resumido, com limite configurável de exemplos, está planejado, mas ainda não foi implementado. From b59886bd8dcb7c62f76a6a2d8a8d5d9e63dff0cf Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:11:16 -0300 Subject: [PATCH 128/130] remove framework profile and document safe execution --- docs/repo/project-setup-shared-tool.md | 35 +++++++++++++++----------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/repo/project-setup-shared-tool.md b/docs/repo/project-setup-shared-tool.md index 0505151..2a27b7c 100644 --- a/docs/repo/project-setup-shared-tool.md +++ b/docs/repo/project-setup-shared-tool.md @@ -16,11 +16,19 @@ python -m project_setup --help The installer embeds the package and managed automation files directly in the target repository. It also installs `Makefile` and `.env.example` when they do not already exist. +Preview installation: + +```bash +python -m project_setup init --target ../target-repository --dry-run +``` + +Install files explicitly: + ```bash -python -m project_setup init --target ../target-repository +python -m project_setup init --target ../target-repository --live ``` -Existing files are preserved unless `--force` is explicitly selected. Existing Makefiles and environment templates should be reviewed and merged manually. +Existing files are preserved unless `--force` is explicitly selected. Existing Makefiles and environment templates should be reviewed and merged manually. Installation dry-runs do not create the target directory. ## Local environment @@ -31,29 +39,22 @@ cp .env.example .env make doctor ``` -`make doctor` validates local files and credential availability without writing to GitHub. +`make doctor` validates the operating system, Python executable, local files, token source, and GitHub CLI authentication without writing to GitHub. ## Configuration -`project_setup.json` controls the API phase and points to four manifests: +`project_setup.json` points to four manifests and selects which API modules participate: - labels; - milestones; - Project v2 definition; - backlog stories and tasks. -Dry-run, Project creation and issue generation are independently configurable. +Every mutating CLI command remains dry-run by default. A live operation requires `--live`, the compatible `--no-dry-run` alias, or `LIVE=1` through Make. ## Discovery -The `discover` command detects common Python, Node.js, Go, Java, Rust and .NET markers. It reports authentication status and prints the recommended `apply` command before any write operation. - -## Profiles - -- `core`: repository-neutral setup. -- `godot`: core plus the optional Godot smoke workflow stored under `templates/profiles/godot`. - -Language- or framework-specific checks should be added as profiles instead of expanding the core workflow. +The `discover` command detects common Python, Node.js, Go, Java, Rust and .NET markers. It reports authentication status and prints a safely quoted recommended `apply` command before any write operation. Non-interactive discovery enforces dry-run in the recommended command. ## Authentication boundary @@ -65,6 +66,8 @@ GITHUB_TOKEN: ${{ github.token }} No user-created `GITHUB_TOKEN` secret is needed. This token covers labels, milestones, issues, sub-issues, and PR comments within the repository, subject to the workflow `permissions` block. +Local commands may also use `GITHUB_TOKEN`, `GH_TOKEN`, or a valid `gh auth` session. Diagnostics distinguish an unavailable CLI from an invalid CLI session and never print credentials. + GitHub Projects v2 are owned by a user or organization rather than a repository. Live Project creation or synchronization therefore requires `PROJECT_SETUP_PAT`. For the current GraphQL implementation: @@ -74,7 +77,11 @@ For the current GraphQL implementation: 3. save it as `PROJECT_SETUP_PAT` in the local `.env`; 4. save the same credential as the `PROJECT_SETUP_PAT` Actions secret when manual workflows must operate on Projects v2. -The CLI and workflow fail before a live Project v2 operation if the explicit PAT is missing. They do not silently use `github.token` for that operation. +The CLI and workflow fail before a live Project v2 operation if the explicit PAT is missing. They do not silently use `github.token` for that operation. A Project sync dry-run without the PAT falls back to an offline preview and clearly states that remote data was not queried. + +## Request safety + +GitHub API requests use a finite timeout. Automatic retries are limited to idempotent reads; mutation requests are not replayed after transport failures because a lost response could otherwise duplicate an issue or Project. ## Automation boundaries From 92fb095d81f13665f551471bb8ebd9219f566e6c Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:13:09 -0300 Subject: [PATCH 129/130] clarify real test findings and resolved items --- TESTE_REAL_RELATORIO.md | 42 ++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/TESTE_REAL_RELATORIO.md b/TESTE_REAL_RELATORIO.md index ec1a0a3..c4fe9ee 100644 --- a/TESTE_REAL_RELATORIO.md +++ b/TESTE_REAL_RELATORIO.md @@ -4,45 +4,61 @@ Repositório testado: `v-Kaefer/Github-Project-Automation` ## Resultado -O teste real foi concluído com sucesso: +O fluxo principal foi concluído com sucesso: - Project criado: [Project Delivery Board #6](https://github.com/users/v-Kaefer/projects/6) - Issue criada: [#7 — US-00](https://github.com/v-Kaefer/Github-Project-Automation/issues/7) - Tasks criadas: [#8 — T-00.1](https://github.com/v-Kaefer/Github-Project-Automation/issues/8) e [#9 — T-00.2](https://github.com/v-Kaefer/Github-Project-Automation/issues/9) +Esse teste valida o caminho principal com configuração válida, PAT válida e conectividade liberada. Ele não substitui testes de repetição, idempotência, manifests inválidos ou falhas intermediárias. + ## O que deu errado inicialmente ### 1. A autenticação local do `gh` estava inválida O comando `gh auth status` informou que o token da conta `v-Kaefer` estava inválido. Isso impediu a validação usando o cliente `gh`, mas não afetou a execução posterior pelo token configurado no `.env`. +**Status após correção:** o `make doctor` agora informa separadamente se a GitHub CLI está instalada, se `gh auth` é válido e qual fonte de token está disponível, sem exibir credenciais. Um `gh auth` inválido não bloqueia o uso de uma PAT válida no `.env`. + ### 2. O Makefile não funcionou diretamente no shell padrão do Windows A primeira execução de `make` falhou por dois motivos de portabilidade: -- o Makefile usa `python3`, que não estava disponível com esse nome no Windows; -- as regras `require-repo` e semelhantes usam o comando Unix `test`, que não existe no `cmd.exe`. +- o Makefile usava `python3`, que não estava disponível com esse nome no Windows; +- as regras de validação usavam o comando Unix `test`, que não existe no `cmd.exe`. -### 3. A primeira tentativa com a CLI usou uma opção inexistente +**Status após correção:** o Makefile detecta `OS=Windows_NT`, usa `python` no Windows e eliminou a dependência do comando Unix `test`. Git Bash ou WSL deixam de ser requisitos para os alvos básicos. -Foi tentado usar `--no-dry-run`, mas a CLI não possui essa opção. O comportamento correto é: +### 3. A semântica de dry-run não era uniforme -- `--dry-run`: simulação; -- sem `--dry-run`: execução real. +A primeira tentativa misturou a interface do comando agregado com a dos subcomandos individuais. Alguns caminhos aceitavam `--no-dry-run`; outros executavam alterações reais apenas pela ausência de `--dry-run`. -Essa tentativa falhou antes de fazer qualquer alteração. +**Status após correção:** todos os comandos mutáveis usam dry-run por padrão. A execução real exige `--live`, o alias compatível `--no-dry-run`, ou `LIVE=1` pelo Makefile. ### 4. A sandbox bloqueou a conexão com o GitHub -Mesmo com o Makefile corrigido para usar `python` e um shell POSIX, a execução recebeu `WinError 10013`, indicando bloqueio de rede pela sandbox. Após autorizar a conexão externa, as operações foram concluídas. +A execução recebeu `WinError 10013`, indicando bloqueio de rede pela sandbox. Após autorizar a conexão externa, as operações foram concluídas. + +Esse bloqueio pertenceu exclusivamente ao ambiente local de teste. Ele não foi classificado como falha do Makefile nem como problema da lógica de automação. -## Comando que funcionou no Windows +## Comandos atuais no Windows + +Dry-run: ```powershell -make SHELL='C:/Program Files/Git/bin/sh.exe' PYTHON=python project-create REPO=v-Kaefer/Github-Project-Automation LIVE=1 -make SHELL='C:/Program Files/Git/bin/sh.exe' PYTHON=python issues REPO=v-Kaefer/Github-Project-Automation LIVE=1 +make project-create REPO=v-Kaefer/Github-Project-Automation +make issues REPO=v-Kaefer/Github-Project-Automation ``` +Execução real explícita: + +```powershell +make project-create REPO=v-Kaefer/Github-Project-Automation LIVE=1 +make issues REPO=v-Kaefer/Github-Project-Automation LIVE=1 +``` + +Não é mais necessário definir manualmente `SHELL` ou `PYTHON` em uma instalação padrão do Windows com `python` disponível no `PATH`. + ## Conclusão -Não houve falha na lógica de criação do Project, da issue ou das tasks. Os problemas encontrados foram relacionados ao ambiente Windows, à autenticação do `gh` e à restrição de rede da sandbox. +O fluxo principal de criação foi validado com sucesso. Os problemas de portabilidade do Makefile, diagnóstico do `gh` e confirmação de execução real foram corrigidos posteriormente. A restrição de rede permaneceu registrada apenas como característica da sandbox utilizada no teste. From 07151a22434259c47f835ef1b41cf3805362df21 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Thu, 6 Aug 2026 20:20:24 -0300 Subject: [PATCH 130/130] test GitHub request safety --- tests/test_github_client.py | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/test_github_client.py diff --git a/tests/test_github_client.py b/tests/test_github_client.py new file mode 100644 index 0000000..e6bdb32 --- /dev/null +++ b/tests/test_github_client.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import io +import urllib.error +import unittest +from unittest.mock import patch + +from project_setup.github import API_BASE, HTTP_TIMEOUT_SECONDS, GitHubClient, GitHubRequestError + + +class _Response: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self) -> bytes: + return b"{}" + + +class GitHubClientTests(unittest.TestCase): + def test_mutation_transport_failure_is_not_retried(self): + client = GitHubClient("token") + with patch( + "project_setup.github.urllib.request.urlopen", + side_effect=urllib.error.URLError("connection lost"), + ) as urlopen: + with self.assertRaises(GitHubRequestError): + client.request_json("POST", f"{API_BASE}/repos/owner/repository/issues", {"title": "Example"}) + self.assertEqual(urlopen.call_count, 1) + + def test_request_uses_finite_timeout(self): + client = GitHubClient("token") + with patch("project_setup.github.urllib.request.urlopen", return_value=_Response()) as urlopen: + self.assertEqual(client.request_json("GET", f"{API_BASE}/repos/owner/repository"), {}) + self.assertEqual(urlopen.call_args.kwargs["timeout"], HTTP_TIMEOUT_SECONDS) + + def test_non_github_api_url_is_rejected_before_opening_connection(self): + client = GitHubClient("token") + with patch("project_setup.github.urllib.request.urlopen") as urlopen: + with self.assertRaisesRegex(ValueError, "Unsupported GitHub API URL"): + client.request_json("GET", "https://example.com/resource") + urlopen.assert_not_called() + + +if __name__ == "__main__": + unittest.main()