diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..d168a22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,62 @@ +--- +name: Bug report +about: Report something that is broken or behaving incorrectly +title: "" +labels: bug +assignees: "" +--- + +## What + + + +## Why + + + +## How + + + +1. +2. +3. + +**Expected behavior:** + +**Actual behavior:** + +**Environment:** (backend/frontend/CDK, browser, OS, commit/branch) + +## Files Affected + + + +## Constraints + + + +## LLM tier + + + +## Value + + + +## Success looks like + +- [ ] + +## Failure looks like + +- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..64eb98d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: true +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c312eeb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,55 @@ +--- +name: Feature request +about: Propose a new feature or enhancement +title: "" +labels: enhancement +assignees: "" +--- + +## What + + + +## Why + + + +## How + + + +## Files Affected + + + +## Constraints + + + +## LLM tier + + + +## Value + + + +## Success looks like + +- [ ] + +## Failure looks like + +- diff --git a/.github/ISSUE_TEMPLATE/task_chore.md b/.github/ISSUE_TEMPLATE/task_chore.md new file mode 100644 index 0000000..4e4916f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task_chore.md @@ -0,0 +1,55 @@ +--- +name: Task/Chore +about: Non-feature, non-bug maintenance work (refactoring, dependency updates, CI improvements) +title: "" +labels: chore +assignees: "" +--- + +## What + + + +## Why + + + +## How + + + +## Files Affected + + + +## Constraints + + + +## LLM tier + + + +## Value + + + +## Success looks like + +- [ ] + +## Failure looks like + +- diff --git a/.github/cicaid-pins.env b/.github/cicaid-pins.env new file mode 100644 index 0000000..a516a35 --- /dev/null +++ b/.github/cicaid-pins.env @@ -0,0 +1,15 @@ +# Versions of cicaid-devtools / cicaid-devtools-pro that this repo's AI +# PR review workflow installs. +# +# This lives outside .github/workflows/ on purpose: GitHub hard-blocks a +# workflow's default GITHUB_TOKEN from pushing any change under that +# directory, whatever permissions the job is granted, so a pin written +# inline in a workflow file could only ever be bumped by a PAT carrying +# the `workflow` scope. Here, the scheduled update workflow bumps it +# unattended (update-cicaid.yml; update-dependencies.yml in cicaid-pro). +# +# Managed by `cicaid setup-review-actions` and by that update workflow. +# Only KEY= lines are honoured -- both the install step and the +# updater reject anything else rather than run it. +CICAID_REF=v0.5.0 +CICAID_PRO_REF=v0.14.1 diff --git a/.github/cicaid-scaffold.json b/.github/cicaid-scaffold.json new file mode 100644 index 0000000..7ac300d --- /dev/null +++ b/.github/cicaid-scaffold.json @@ -0,0 +1,19 @@ +{ + "files": { + ".github/ISSUE_TEMPLATE/bug_report.md": "2fa6568cac6693bdea18698841b99c5c473dae7df31dd249ebe588fff7bbb237", + ".github/ISSUE_TEMPLATE/config.yml": "e71bea835ed1158881306294d96968f125b0b0c5eff66cd94df559336bd0b210", + ".github/ISSUE_TEMPLATE/feature_request.md": "bb7068f2d817a657c81fbbb7875fd5bfc28b922c70467ea0277bd574dea1b7f3", + ".github/ISSUE_TEMPLATE/task_chore.md": "f2feb8aa218868b9dfba30350b85dd4b44bf4c54a1c976b13290c1b12a551f08", + ".github/cicaid-pins.env": "86d6783d9a82a750c28072ea39ec83e39c3c8952a4623aeae76b670d14ccd134", + ".github/scripts/pip_install_cicaid_pro.sh": "f1f96ec858e887ecebde64220b6ed88bc52100aa58fb4bf2513cbadbe4548b1d", + ".github/scripts/update_cicaid_pins.py": "18aa919e79912988962c80e4880a204f785daf2ec3731ed0c9b4205bc62970f4", + ".github/workflows/_ai-pr-review.yml": "8ad950bed5510c1c778721cac752afc10da2b6b5c3c966f3fb3cd4bfd495c110", + ".github/workflows/codeql.yml": "82a8ea8a436aab174794580f4bd320f31c588f2e33a670193858e1b3a76ae677", + ".github/workflows/deepseek-pr-review.yml": "0968093290f53e966d3b2b5675f3625ec68e80fcaba27f44a85706779e051913", + ".github/workflows/dependency-review.yml": "f1ea2e8a7c6c7ba6f855fd57bcd6ca94998ac9278c52dad7ffcf8ec0bb30dcba", + ".github/workflows/gpt-pr-review.yml": "01ea72c61fcda1592f1ab44bf16e207a00ef6d6554ca095c3cf808a2ca4f1d46", + ".github/workflows/pr-lint.yml": "a0e12bf2c14acbf0a4ce1a86b79a358528212b59bc0a56cec3ccbfd4f1d66d2b", + ".github/workflows/update-cicaid.yml": "07c6345ab47761e8d2edda2a6ffd4c873a3c00d47fb7c15eec510c9a5759ecb6", + ".github/workflows/workflow-lint.yml": "640d1968ff710b475f913ec4681dfe1370b86a63fe7674f0f01ca4c885fb030b" + } +} diff --git a/.github/scripts/pip_install_cicaid_pro.sh b/.github/scripts/pip_install_cicaid_pro.sh new file mode 100644 index 0000000..042382f --- /dev/null +++ b/.github/scripts/pip_install_cicaid_pro.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Runs a command (typically a pip install) with git credentials configured so it +# can clone the private leonarduk/cicaid-pro repo. The AI-review modules the +# generated workflows import (review_diff, review_comment, deepseek_review, +# gpt_review, etc.) only exist in cicaid-pro, not the public leonarduk/cicaid +# "free shell" package, so a plain pip install of the public wheel/repo 404s +# or is missing these modules. +# +# Fails fast with an actionable message if CICAID_PRO_TOKEN is unset or empty, +# instead of letting the wrapped command fail later with a confusing git auth +# error. The credential rewrite is scoped to exactly this invocation through +# Git's GIT_CONFIG_* environment variables; the token is never written to a +# config file. +# +# Uses `url..insteadOf` with the token embedded as URL userinfo, rather +# than a `credential..helper` shell snippet that reads the token from the +# env var at request time. The helper form looks more secure on paper (the raw +# token never touches disk), but empirically it is NOT reliable here: any +# pre-existing generic `credential.helper` (e.g. Git Credential Manager, or a +# local `gh auth login`) is consulted first and can supply -- or fail to yield +# to -- a different credential before a URL-scoped helper ever runs, since +# helpers accumulate across config scopes rather than "most specific wins". +# The `insteadOf` rewrite has no such ambiguity: the token is embedded +# directly in the URL, which git's transport layer uses unconditionally +# without consulting the credential-helper chain at all. GitHub's own PAT +# formats (ghp_/github_pat_/gho_/ghs_/ghr_) are always alphanumeric and never +# contain the URL-reserved characters (@, :, /) that would make this rewrite +# unsafe, so that's not a practical concern for the token this script expects. +# +# Usage: pip_install_cicaid_pro.sh +# Required env: CICAID_PRO_TOKEN +set -euo pipefail + +if [ -z "${CICAID_PRO_TOKEN:-}" ]; then + echo "::error::CICAID_PRO_TOKEN is empty or unset. Add a fine-grained PAT (Contents: Read-only, scoped to leonarduk/cicaid-pro) as the CICAID_PRO_TOKEN repository secret (Settings > Secrets and variables > Actions) before this workflow can install cicaid-devtools-pro." >&2 + exit 1 +fi + +export GIT_CONFIG_COUNT=1 +export GIT_CONFIG_KEY_0="url.https://x-access-token:${CICAID_PRO_TOKEN}@github.com/leonarduk/cicaid-pro.insteadOf" +export GIT_CONFIG_VALUE_0="https://github.com/leonarduk/cicaid-pro" + +exec "$@" diff --git a/.github/scripts/update_cicaid_pins.py b/.github/scripts/update_cicaid_pins.py new file mode 100644 index 0000000..af0e218 --- /dev/null +++ b/.github/scripts/update_cicaid_pins.py @@ -0,0 +1,216 @@ +"""Check and update the cicaid pins in the repo's cicaid-pins.env. + +Scaffolded by `cicaid setup-review-actions` and driven by +.github/workflows/update-cicaid.yml on a schedule. Re-running +setup-review-actions overwrites this file, so edit the generator +(cicaid-pro's setup_review_actions.py) rather than this copy. + +Stdlib-only by design: this must run even when the pin it is fixing is +stale or malformed, so it cannot import cicaid_devtools -- the package that +very pin installs. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +PINS_PATH = ".github/cicaid-pins.env" + +# The only pin shapes accepted, matching the validation the workflow's own +# install step applies: a released tag, or the literal "main" the scaffolder +# falls back to when the releases lookup fails at setup time. A "main" pin +# never compares equal to a release, so the first scheduled run after such a +# fallback pins a real version -- the repo self-heals. +_PIN_VALUE = r"(?:main|v[0-9][A-Za-z0-9.+-]*)" + +# dep -> (pins-file key, latest-release API, needs an auth token to read it). +# leonarduk/cicaid is public; leonarduk/cicaid-pro is private, so its lookup +# 404s unauthenticated and reuses the CICAID_PRO_TOKEN secret this repo +# already needs to install it (a fine-grained PAT with Contents: Read-only, +# which covers the releases API). +_DEPS = { + "cicaid-devtools": ( + "CICAID_REF", + "https://api.github.com/repos/leonarduk/cicaid/releases/latest", + False, + ), + "cicaid-devtools-pro": ( + "CICAID_PRO_REF", + "https://api.github.com/repos/leonarduk/cicaid-pro/releases/latest", + True, + ), +} + +_USER_AGENT = "cicaid-update-cicaid-pins" + + +class PinError(Exception): + """A pin is missing, malformed, or could not be checked.""" + + +def _pin_re(key: str) -> re.Pattern[str]: + """Match ``KEY=`` on its own line, with the pin as group 2. + + The optional carriage return tolerates a CRLF pins file: the scaffolder + writes it with the setup machine's line endings, so a setup run on + Windows commits CRLF, and a bare end-of-line anchor would then never + match on the Linux runner. + """ + pattern = r"^(" + re.escape(key) + r"=)(" + _PIN_VALUE + r")\r?$" + return re.compile(pattern, re.MULTILINE) + + +def _read_text(path: Path) -> str: + # No "r": it is the default, and ruff's UP015 fails a consumer repo whose + # own lint covers .github/scripts/ (a real leonarduk/doodlebug run). + with open(path, encoding="utf-8", newline="") as fh: + return fh.read() + + +def _write_text(path: Path, text: str) -> None: + # newline="" so rewriting one pin does not also rewrite every line + # ending in the file, which would bury the change in the diff. + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(text) + + +def _http_json(url: str, token: str | None = None) -> dict: + request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) + if token: + request.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except (OSError, ValueError) as exc: + raise PinError(f"failed to fetch {url}: {exc}") from exc + + +def latest_version(dep: str, token: str | None = None) -> str: + """Latest released version of ``dep``, never v-prefixed.""" + _key, releases_api, needs_token = _DEPS[dep] + if needs_token and not token: + raise PinError( + f"{dep} lives in a private repo; set GITHUB_TOKEN to a token that " + "can read its releases (the CICAID_PRO_TOKEN secret)" + ) + data = _http_json(releases_api, token=token if needs_token else None) + tag = data.get("tag_name", "") + if not tag.startswith("v"): + msg = f"unexpected {dep} release tag {tag!r}; expected a v-prefixed tag" + raise PinError(msg) + return tag[1:] + + +_VERSION_RE = re.compile(r"^(\d+(?:\.\d+)*)(.*)$") + + +def _parse_version(version: str) -> tuple[tuple[int, ...], str] | None: + """Split into (release components, suffix), or None if not numeric-led. + + Trailing zero components are dropped so 1.0 and 1.0.0 parse identically. + """ + match = _VERSION_RE.match(version.strip()) + if match is None: + return None + release = [int(part) for part in match.group(1).split(".")] + while release and release[-1] == 0: + release.pop() + return tuple(release), match.group(2).strip().lower() + + +def _versions_equal(left: str, right: str) -> bool: + """True when two version strings name the same release. + + String equality alone would call 1.0 and 1.0.0 different and open a + pointless update PR every single day. + """ + parsed_left = _parse_version(left) + parsed_right = _parse_version(right) + if parsed_left is None or parsed_right is None: + return left == right + return parsed_left == parsed_right + + +def current_pin(dep: str, root: Path | None = None) -> str: + """Currently pinned version of ``dep`` (v-prefix stripped), or "main".""" + root = ROOT if root is None else root + key = _DEPS[dep][0] + match = _pin_re(key).search(_read_text(root / PINS_PATH)) + if match is None: + raise PinError(f"no valid {key}= line in {PINS_PATH}") + value = match.group(2) + return value if value == "main" else value[1:] + + +def apply_update( + dep: str, new_version: str, root: Path | None = None, dry_run: bool = False +) -> bool: + """Rewrite ``dep``'s pin to ``new_version``. True if the file changed.""" + root = ROOT if root is None else root + key = _DEPS[dep][0] + path = root / PINS_PATH + text = _read_text(path) + match = _pin_re(key).search(text) + if match is None: + raise PinError(f"no valid {key}= line in {PINS_PATH}") + start, end = match.span(2) + new_text = text[:start] + "v" + new_version + text[end:] + if new_text == text: + return False + if not dry_run: + _write_text(path, new_text) + return True + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Check/update the cicaid-devtools and cicaid-devtools-pro pins." + ) + parser.add_argument("dependency", choices=sorted(_DEPS)) + parser.add_argument( + "--check", + action="store_true", + help="only report whether an update exists; never writes files", + ) + parser.add_argument( + "--dry-run", action="store_true", help="print what would change without writing" + ) + args = parser.parse_args(argv) + + token = os.environ.get("GITHUB_TOKEN") + try: + new_version = latest_version(args.dependency, token=token) + current = current_pin(args.dependency) + except PinError as exc: + print(f"update_cicaid_pins: {exc}", file=sys.stderr) + return 1 + + if _versions_equal(new_version, current): + print(f"UP-TO-DATE {args.dependency} {current}") + return 0 + + if args.check: + print(f"UPDATE {args.dependency} {current} -> {new_version}") + return 0 + + try: + changed = apply_update(args.dependency, new_version, dry_run=args.dry_run) + except PinError as exc: + print(f"update_cicaid_pins: {exc}", file=sys.stderr) + return 1 + + verb = "WOULD UPDATE" if args.dry_run else "UPDATED" + detail = PINS_PATH if changed else "no files (already up to date)" + print(f"{verb} {args.dependency} {current} -> {new_version} ({detail})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/_ai-pr-review.yml b/.github/workflows/_ai-pr-review.yml new file mode 100644 index 0000000..9ea908d --- /dev/null +++ b/.github/workflows/_ai-pr-review.yml @@ -0,0 +1,336 @@ +name: Reusable AI PR Review + +on: + workflow_call: + inputs: + provider_name: + description: "Display name of the reviewer (e.g. DeepSeek, GPT)" + required: true + type: string + provider_id: + description: "Lowercase identifier used for file/output naming (e.g. deepseek, gpt)" + required: true + type: string + review_module: + description: "Dotted module name of the provider's review integration (e.g. cicaid_devtools.lib.deepseek_review)" + required: true + type: string + workflow_file: + description: "Filename of the calling workflow, used in the posted comment link" + required: true + type: string + deepseek_model: + description: "DEEPSEEK_MODEL override passed to the review script, if any" + required: false + type: string + default: "" + deepseek_max_tokens: + description: "DEEPSEEK_MAX_TOKENS override passed to the review script, if any" + required: false + type: string + default: "" + discussion_max_chars: + description: "Max characters of PR discussion to include in the review prompt, if any (default 20000)" + required: false + type: string + default: "" + secrets: + openai_api_key: + description: "OpenAI API key, used for GPT reviews" + required: false + deepseek_api_key: + description: "DeepSeek API key, used for DeepSeek reviews" + required: false + gh_token: + description: "Token used for gh CLI calls (PR/issue read and write)" + required: true + cicaid_pro_token: + description: "Fine-grained PAT (Contents: Read-only) scoped to leonarduk/cicaid-pro, used to install cicaid-devtools-pro since the review modules (review_diff, review_comment, deepseek_review, gpt_review, etc.) only exist in that private package" + required: true + +# github.workflow resolves to the *calling* workflow's name (DeepSeek/GPT PR +# Review), so this groups per-provider without cross-cancelling other +# providers' runs for the same PR. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + ai-review: + name: ${{ inputs.provider_name }} AI code review + runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' }} + permissions: + pull-requests: write + contents: read + issues: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + # The AI-review modules this workflow imports below (review_diff, + # review_comment, deepseek_review, gpt_review, etc.) only exist in the + # private leonarduk/cicaid-pro package, not this public "free shell" + # repo -- installing from here either 404s or is missing these + # modules. pip_install_cicaid_pro.sh validates the token up front and + # exposes the credential only to the pip process through GIT_CONFIG_* + # variables. + # + # The versions come from .github/cicaid-pins.env rather than being + # written inline here, so update-cicaid.yml can bump them without + # pushing to .github/workflows/ (see CICAID_PINS_PATH's comment in + # setup_review_actions.py). + - name: Install cicaid-devtools + cicaid-devtools-pro (PR review helpers) + env: + CICAID_PRO_TOKEN: ${{ secrets.cicaid_pro_token }} + run: | + set -euo pipefail + PINS=".github/cicaid-pins.env" + if [ ! -f "$PINS" ]; then + echo "::error::$PINS is missing. Run 'cicaid setup-review-actions' in this repo to restore it." >&2 + exit 1 + fi + CICAID_REF=$(grep -oP '^CICAID_REF=\K(?:main|v[0-9][A-Za-z0-9.+-]*)(?=\r?$)' "$PINS" || true) + if [ -z "${CICAID_REF}" ]; then + echo "::error::no valid CICAID_REF= line in $PINS." >&2 + exit 1 + fi + CICAID_PRO_REF=$(grep -oP '^CICAID_PRO_REF=\K(?:main|v[0-9][A-Za-z0-9.+-]*)(?=\r?$)' "$PINS" || true) + if [ -z "${CICAID_PRO_REF}" ]; then + echo "::error::no valid CICAID_PRO_REF= line in $PINS." >&2 + exit 1 + fi + bash .github/scripts/pip_install_cicaid_pro.sh pip install --retries 10 \ + "cicaid-devtools @ git+https://github.com/leonarduk/cicaid.git@${CICAID_REF}" \ + "cicaid-devtools-pro @ git+https://github.com/leonarduk/cicaid-pro.git@${CICAID_PRO_REF}" + + - name: Get linked issue body + id: issue + env: + GH_TOKEN: ${{ secrets.gh_token }} + run: | + # Only Closes/Fixes count as the closing reference; a contextual + # 'Refs #N' must not be picked up as the linked issue (issue #409). + ISSUE_NUM=$(gh pr view ${{ github.event.pull_request.number }} --json body,title \ + | jq -r '[.title, .body] | join(" ")' \ + | python3 -m cicaid_devtools.lib.linked_issue || true) + if [ -n "$ISSUE_NUM" ]; then + ISSUE_BODY=$(gh issue view "$ISSUE_NUM" --json body -q .body 2>/dev/null || echo "Issue not found") + else + ISSUE_BODY="No linked issue found. Review code on its own merits." + fi + DELIM="EOF_$(uuidgen | tr -d '-')" + { + echo "body<<$DELIM" + echo "$ISSUE_BODY" + echo "$DELIM" + } >> "$GITHUB_OUTPUT" + + - name: Get PR diff + id: diff + env: + BASE_REF: ${{ github.base_ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + ISSUE_BODY: ${{ steps.issue.outputs.body }} + run: | + git fetch origin "$BASE_REF" + DIFF=$(python3 -m cicaid_devtools.lib.review_diff \ + --base-ref "$BASE_REF" \ + --pr-title "$PR_TITLE" \ + --issue-body "$ISSUE_BODY") + DELIM="EOF_$(uuidgen | tr -d '-')" + { + echo "diff<<$DELIM" + echo "$DIFF" + echo "$DELIM" + } >> "$GITHUB_OUTPUT" + + - name: Get PR discussion since last review + id: discussion + env: + GH_TOKEN: ${{ secrets.gh_token }} + DISCUSSION_MAX_CHARS: ${{ inputs.discussion_max_chars }} + run: | + MAX_CHARS_ARGS=() + if [ -n "$DISCUSSION_MAX_CHARS" ]; then + MAX_CHARS_ARGS=(--max-chars "$DISCUSSION_MAX_CHARS") + fi + DISCUSSION=$(python3 -m cicaid_devtools.lib.review_discussion \ + --repo "${{ github.repository }}" \ + --pr-number "${{ github.event.pull_request.number }}" \ + --provider-name "${{ inputs.provider_name }}" \ + "${MAX_CHARS_ARGS[@]}") + DELIM="EOF_$(uuidgen | tr -d '-')" + { + echo "text<<$DELIM" + echo "$DISCUSSION" + echo "$DELIM" + } >> "$GITHUB_OUTPUT" + + - name: Extract verified symbols from diff + id: verified + env: + DIFF_OUTPUT: ${{ steps.diff.outputs.diff }} + run: | + VERIFIED=$(python3 -m cicaid_devtools.lib.verified_symbols \ + --diff "$DIFF_OUTPUT") + DELIM="EOF_$(uuidgen | tr -d '-')" + { + echo "facts<<$DELIM" + echo "$VERIFIED" + echo "$DELIM" + } >> "$GITHUB_OUTPUT" + + - name: Call ${{ inputs.provider_name }} API + env: + OPENAI_API_KEY: ${{ secrets.openai_api_key }} + DEEPSEEK_API_KEY: ${{ secrets.deepseek_api_key }} + DEEPSEEK_MODEL: ${{ inputs.deepseek_model }} + DEEPSEEK_MAX_TOKENS: ${{ inputs.deepseek_max_tokens }} + PR_TITLE: ${{ github.event.pull_request.title }} + DIFF: ${{ steps.diff.outputs.diff }} + ISSUE_BODY: ${{ steps.issue.outputs.body }} + DISCUSSION: ${{ steps.discussion.outputs.text }} + VERIFIED_FACTS: ${{ steps.verified.outputs.facts }} + run: | + python3 -m ${{ inputs.review_module }} > /tmp/${{ inputs.provider_id }}_review_body.md + + # Guard: `-s` returns true only if the file exists AND has size > 0. + # This distinguishes a successful API response (review body written to stdout) + # from a failure (empty output, missing file, or API error). + if [ ! -s /tmp/${{ inputs.provider_id }}_review_body.md ]; then + echo "ERROR: ${{ inputs.provider_name }} review output was empty" >&2 + exit 1 + fi + + - name: Check ${{ inputs.provider_name }} approval + id: check_approval + continue-on-error: true # REQUEST CHANGES exits 1; allow later steps (post comment, create issues) to still run + run: | + set +e + python3 -m cicaid_devtools.lib.verdict /tmp/${{ inputs.provider_id }}_review_body.md "${{ inputs.provider_name }}" + verdict_exit=$? + set -e + + # Exit codes from extract_verdict.py: 0 = APPROVE, 2 = provider outage + # (soft-fail, doesn't block merge), anything else = REQUEST CHANGES / + # missing verdict (hard fail). + if [ "$verdict_exit" -eq 0 ]; then + echo "approved=true" >> "$GITHUB_OUTPUT" + elif [ "$verdict_exit" -eq 2 ]; then + echo "approved=skipped" >> "$GITHUB_OUTPUT" + else + echo "approved=false" >> "$GITHUB_OUTPUT" + exit 1 + fi + + - name: Post ${{ inputs.provider_name }} review comment + if: (success() || failure()) && !cancelled() + continue-on-error: true # If posting fails (e.g. API rate limit), don't fail job; review is also in Actions summary + env: + GH_TOKEN: ${{ secrets.gh_token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + python3 -m cicaid_devtools.lib.review_comment /tmp/${{ inputs.provider_id }}_review_body.md \ + "${{ inputs.provider_name }}" "${{ inputs.workflow_file }}" "$RUN_URL" > /tmp/${{ inputs.provider_id }}_comment_body.md + + # Write to Actions job summary so the review is always visible in the UI, + # even if the gh pr comment call below fails. + cat /tmp/${{ inputs.provider_id }}_comment_body.md >> "$GITHUB_STEP_SUMMARY" + + # Retry with backoff: a transient rate limit shouldn't silently drop + # the review comment (the step summary above is a fallback, not a + # substitute -- reviewers watching the PR don't see the summary). + posted=false + for delay in 0 5 10 20 40; do + if [ "$delay" -gt 0 ]; then + echo "gh pr comment failed, retrying in ${delay}s..." >&2 + sleep "$delay" + fi + if gh pr comment "$PR_NUMBER" --body-file /tmp/${{ inputs.provider_id }}_comment_body.md; then + posted=true + break + fi + done + + if [ "$posted" != true ]; then + echo "::warning title=${{ inputs.provider_name }} review comment::Failed to post the ${{ inputs.provider_name }} review comment to PR #$PR_NUMBER after retries — check the Actions log above for the gh error (rate limit, token scope, or network)." + echo "WARNING: failed to post review comment after retries — check the Actions log" >&2 + exit 0 + fi + + - name: Create follow-up issues + if: steps.check_approval.outputs.approved == 'true' + env: + GH_TOKEN: ${{ secrets.gh_token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + OPENAI_API_KEY: ${{ secrets.openai_api_key }} + DEEPSEEK_API_KEY: ${{ secrets.deepseek_api_key }} + FOLLOWUP_LLM_PROVIDER: ${{ vars.FOLLOWUP_LLM_PROVIDER }} + REPO_NAME: ${{ github.event.repository.name }} + run: | + # Fetch existing label names once and only create the ones missing, + # instead of firing a gh label create call (and eating a 422) for + # every label on every run. + existing_labels="$(gh label list --json name -q '.[].name')" + + create_label_if_missing() { + local name="$1" color="$2" description="$3" + if ! grep -qxF "$name" <<< "$existing_labels"; then + gh label create "$name" --color "$color" --description "$description" 2>/dev/null || true + fi + } + + create_label_if_missing "ai-suggested" "0075ca" "Suggested by AI code review" + create_label_if_missing "haiku" "C5DEF5" "Suitable for Claude Haiku (simple/mechanical task)" + create_label_if_missing "sonnet" "BFD4F2" "Suitable for Claude Sonnet (moderate reasoning)" + create_label_if_missing "opus" "0052CC" "Suitable for Claude Opus (complex design/architecture)" + + # Extract titles to a file, then create issues via Python subprocess (no shell injection) + python3 -m cicaid_devtools.lib.followups /tmp/${{ inputs.provider_id }}_review_body.md \ + > /tmp/followups.json + python3 -m cicaid_devtools.lib.followup_issues \ + /tmp/followups.json "${PR_NUMBER}" /tmp/${{ inputs.provider_id }}_review_body.md + + - name: Add 'Changes Requested' label if review failed + if: steps.check_approval.outputs.approved == 'false' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.gh_token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Create the label only if it doesn't already exist + if ! gh label list --json name -q '.[].name' | grep -qxF "Changes Requested"; then + gh label create "Changes Requested" --color "d73a49" --description "PR requested changes from code review" \ + 2>/dev/null || true + fi + + # Add the label to the PR + gh pr edit "$PR_NUMBER" --add-label "Changes Requested" + + - name: Remove 'Changes Requested' label if review approved + if: steps.check_approval.outputs.approved == 'true' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.gh_token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # gh pr edit --remove-label errors when the label is not on the PR + # (same caveat as github_issues.remove_issue_label), so only remove + # it when it is currently applied -- keeps the step idempotent. + if gh pr view "$PR_NUMBER" --json labels -q '.labels[].name' | grep -qxF "Changes Requested"; then + gh pr edit "$PR_NUMBER" --remove-label "Changes Requested" + fi + + - name: Fail if ${{ inputs.provider_name }} did not approve + if: always() && contains(fromJson('["failure","skipped"]'), steps.check_approval.outcome) + run: | + echo "::error::${{ inputs.provider_name }} review: CHANGES REQUESTED — address the review findings before merging." + exit 1 diff --git a/.github/workflows/deepseek-pr-review.yml b/.github/workflows/deepseek-pr-review.yml new file mode 100644 index 0000000..a7efb1b --- /dev/null +++ b/.github/workflows/deepseek-pr-review.yml @@ -0,0 +1,24 @@ +name: DeepSeek PR Review + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + ai-review: + if: ${{ github.actor != 'dependabot[bot]' && vars.ENABLE_DEEPSEEK_REVIEW != 'false' }} + permissions: + pull-requests: write + contents: read + issues: write + uses: ./.github/workflows/_ai-pr-review.yml + with: + provider_name: DeepSeek + provider_id: deepseek + review_module: cicaid_devtools.lib.deepseek_review + workflow_file: deepseek-pr-review.yml + secrets: + openai_api_key: ${{ secrets.OPENAI_API_KEY }} + deepseek_api_key: ${{ secrets.DEEPSEEK_API_KEY }} + gh_token: ${{ secrets.GITHUB_TOKEN }} + cicaid_pro_token: ${{ secrets.CICAID_PRO_TOKEN }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..33005e0 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,16 @@ +name: Dependency Review + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/dependency-review-action@v5.0.0 + with: + fail-on-severity: high diff --git a/.github/workflows/gpt-pr-review.yml b/.github/workflows/gpt-pr-review.yml new file mode 100644 index 0000000..8d2398f --- /dev/null +++ b/.github/workflows/gpt-pr-review.yml @@ -0,0 +1,24 @@ +name: GPT PR Review + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + ai-review: + if: ${{ github.actor != 'dependabot[bot]' && vars.ENABLE_GPT_REVIEW != 'false' }} + permissions: + pull-requests: write + contents: read + issues: write + uses: ./.github/workflows/_ai-pr-review.yml + with: + provider_name: GPT + provider_id: gpt + review_module: cicaid_devtools.lib.gpt_review + workflow_file: gpt-pr-review.yml + secrets: + openai_api_key: ${{ secrets.OPENAI_API_KEY }} + deepseek_api_key: ${{ secrets.DEEPSEEK_API_KEY }} + gh_token: ${{ secrets.GITHUB_TOKEN }} + cicaid_pro_token: ${{ secrets.CICAID_PRO_TOKEN }} diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml new file mode 100644 index 0000000..024872c --- /dev/null +++ b/.github/workflows/hadolint.yml @@ -0,0 +1,19 @@ +name: Hadolint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + hadolint: + name: Hadolint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 0000000..18b9c54 --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,27 @@ +name: PR Body Issue Reference Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +jobs: + require-issue-reference: + if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + steps: + - name: Validate pull request body includes issue reference + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + body="${PR_BODY:-}" + + if printf '%s' "$body" | grep -iEq '(closes|fixes|resolves|refs|relates[[:space:]]+to)[[:space:]]+#[0-9]+'; then + echo 'PR body includes a valid issue reference.' + exit 0 + fi + + echo 'Pull request body must include an issue reference such as Closes #123, Fixes #123, Resolves #123, Refs #123, or Relates to #123.' >&2 + exit 1 diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..e3997c7 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,17 @@ +name: ShellCheck + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: ludeeus/action-shellcheck@2.0.0 diff --git a/.github/workflows/update-cicaid.yml b/.github/workflows/update-cicaid.yml new file mode 100644 index 0000000..4f101ce --- /dev/null +++ b/.github/workflows/update-cicaid.yml @@ -0,0 +1,185 @@ +name: Update cicaid + +on: + schedule: + # Daily at 06:00 UTC. NOTE: GitHub disables scheduled workflows in a + # repository with no activity for 60 days, and does not re-enable them + # automatically -- a dormant repo silently stops updating until someone + # re-enables this workflow (or runs it via workflow_dispatch). + - cron: '0 6 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write # gh issue create, for the linked tracking issue + +# One scheduled run at a time; a manual run must not cancel a scheduled one. +concurrency: + group: cicaid-pin-update + cancel-in-progress: false + +jobs: + update: + name: Update ${{ matrix.dependency }} + runs-on: ubuntu-latest + strategy: + # Independent legs: a failure updating one dependency (e.g. the + # private cicaid-pro releases lookup) must not cancel the other. + fail-fast: false + matrix: + dependency: [cicaid-devtools, cicaid-devtools-pro] + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Check for update + id: check + env: + # leonarduk/cicaid-pro is private, so its releases API 404s + # unauthenticated; the script only sends this for that leg. + GITHUB_TOKEN: ${{ secrets.CICAID_PRO_TOKEN }} + run: | + OUTPUT=$(python .github/scripts/update_cicaid_pins.py "${{ matrix.dependency }}" --check) + echo "$OUTPUT" + if [[ "$OUTPUT" == UP-TO-DATE* ]]; then + echo "Already up to date; nothing to do." + exit 0 + fi + # Fail loudly if the helper's output format ever changes, rather + # than parsing garbage into a branch name and a commit message. + # These values are interpolated into shell commands below, so a + # version carrying a metacharacter must stop the run. + CHECK_RE='^UPDATE [a-z0-9-]+ [A-Za-z0-9._+-]+ -> [A-Za-z0-9._+-]+$' + if ! [[ "$OUTPUT" =~ $CHECK_RE ]]; then + echo "Unexpected output from update_cicaid_pins.py: $OUTPUT" >&2 + exit 1 + fi + OLD_VERSION=$(awk '{print $3}' <<<"$OUTPUT") + NEW_VERSION=$(awk '{print $5}' <<<"$OUTPUT") + # PEP 440 local versions (1.2.3+abc123) are legal in a branch name, + # but `gh` URL-encodes the "+" and it reads as a space in the PR UI. + # Only the branch name gets the substitution; the commit message, + # PR title and issue title all keep the true version. + BRANCH_VERSION=${NEW_VERSION//+/-} + if [[ "${{ matrix.dependency }}" == "cicaid-devtools" ]]; then + RELEASE_URL="https://github.com/leonarduk/cicaid/releases/tag/v${NEW_VERSION}" + else + RELEASE_URL="https://github.com/leonarduk/cicaid-pro/releases/tag/v${NEW_VERSION}" + fi + # One redirect for the group rather than four: a repo whose own + # lint runs actionlint with shellcheck enabled (rather than the + # `-shellcheck=` the generated workflow-lint.yml uses) fails the + # job on SC2129 otherwise. + { + echo "old_version=$OLD_VERSION" + echo "new_version=$NEW_VERSION" + echo "branch_version=$BRANCH_VERSION" + echo "release_url=$RELEASE_URL" + } >> "$GITHUB_OUTPUT" + + - name: Skip if a PR for this update already exists + id: skip + if: steps.check.outputs.new_version != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH="deps/${{ matrix.dependency }}-v${{ steps.check.outputs.branch_version }}" + PR_COUNT=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --state open \ + --json number --jq 'length') + if [[ "$PR_COUNT" != "0" ]]; then + echo "Skipping: a PR for $BRANCH is already open." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "No open PR for $BRANCH; proceeding." + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Update pin on a dependency branch + if: steps.check.outputs.new_version != '' && steps.skip.outputs.skip != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "deps/${{ matrix.dependency }}-v${{ steps.check.outputs.branch_version }}" + python .github/scripts/update_cicaid_pins.py "${{ matrix.dependency }}" + git diff --stat + + # A PR opened with the default GITHUB_TOKEN does not trigger this + # repo's other workflows, so the AI review will not run on it. This + # install is therefore the only signal, before a human looks, that the + # bumped pin actually resolves and installs. + - name: Install dependencies from the updated pin + if: steps.check.outputs.new_version != '' && steps.skip.outputs.skip != 'true' + env: + CICAID_PRO_TOKEN: ${{ secrets.CICAID_PRO_TOKEN }} + run: | + set -euo pipefail + PINS=".github/cicaid-pins.env" + CICAID_REF=$(grep -oP '^CICAID_REF=\K(?:main|v[0-9][A-Za-z0-9.+-]*)(?=\r?$)' "$PINS") + CICAID_PRO_REF=$(grep -oP '^CICAID_PRO_REF=\K(?:main|v[0-9][A-Za-z0-9.+-]*)(?=\r?$)' "$PINS") + bash .github/scripts/pip_install_cicaid_pro.sh pip install --retries 10 \ + "cicaid-devtools @ git+https://github.com/leonarduk/cicaid.git@${CICAID_REF}" \ + "cicaid-devtools-pro @ git+https://github.com/leonarduk/cicaid-pro.git@${CICAID_PRO_REF}" + + - name: Create or reuse a linked issue for the update + id: issue + if: steps.check.outputs.new_version != '' && steps.skip.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + TITLE="Update ${{ matrix.dependency }} to v${{ steps.check.outputs.new_version }}" + # Reuse an open issue with this exact title so a retried run links + # to the same issue instead of stacking duplicates. + EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') + if [[ -n "$EXISTING" ]]; then + echo "Reusing issue #$EXISTING for this update." + echo "issue_number=$EXISTING" >> "$GITHUB_OUTPUT" + exit 0 + fi + BODY=$(cat <> "$GITHUB_OUTPUT" + + - name: Push and open pull request + if: steps.check.outputs.new_version != '' && steps.skip.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH="deps/${{ matrix.dependency }}-v${{ steps.check.outputs.branch_version }}" + git add .github/cicaid-pins.env + git commit -m "Update ${{ matrix.dependency }} to v${{ steps.check.outputs.new_version }}" + # The skip step guarantees no open PR exists for this branch, so a + # surviving remote branch is a leftover from a run that failed + # after pushing. Replace it rather than colliding on a + # non-fast-forward push. + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "Deleting stale branch $BRANCH from a previous failed run." + git push origin --delete "$BRANCH" + fi + git push -u origin "$BRANCH" + BODY=$(cat <&2; exit 1; } -while read -r name repo count || [[ -n ${name:-} ]]; do +while read -r name _ _ || [[ -n ${name:-} ]]; do [[ -z "$name" || "$name" == \#* ]] && continue echo "== $name ==" ./pools.sh down "$name"