name: repo-pipeline on: workflow_call: inputs: repo_type: description: Repository type (ios, node, python, custom) required: false type: string default: ios xcode_project: description: Xcode project path for ios repos required: false type: string default: CamperLogBook.xcodeproj xcode_scheme: description: Xcode scheme for ios repos required: false type: string default: CamperLogBook lint_command: description: Optional lint command override required: false type: string default: "" build_command: description: Optional build command override required: false type: string default: "" test_command: description: Optional test command override required: false type: string default: "" jobs: ci: name: ci runs-on: ubuntu-latest steps: - name: Checkout caller repository uses: actions/checkout@v4 - name: Resolve commands id: resolve shell: bash run: | set -euo pipefail repo_type='${{ inputs.repo_type }}' lint='${{ inputs.lint_command }}' build='${{ inputs.build_command }}' test='${{ inputs.test_command }}' xcode_project='${{ inputs.xcode_project }}' xcode_scheme='${{ inputs.xcode_scheme }}' if [ -z "$lint" ]; then case "$repo_type" in ios) lint="echo 'No default ios lint command on ubuntu runner. Set lint_command override if needed.'" ;; node) lint="npm run lint --if-present" ;; python) lint="python3 -m pip install -U pip && python3 -m pip install ruff && ruff check ." ;; *) lint="echo 'No lint default for repo_type=$repo_type'" ;; esac fi if [ -z "$build" ]; then case "$repo_type" in ios) build="echo 'No default ios build command on ubuntu runner. Use Xcode Cloud or set build_command override.'" ;; node) build="npm run build --if-present" ;; python) build="echo 'No default build step for python'" ;; *) build="echo 'No build default for repo_type=$repo_type'" ;; esac fi if [ -z "$test" ]; then case "$repo_type" in ios) test="echo 'No default ios test command on ubuntu runner. Use Xcode Cloud or set test_command override.'" ;; node) test="npm test --if-present" ;; python) test="pytest -q" ;; *) test="echo 'No test default for repo_type=$repo_type'" ;; esac fi { echo "lint=$lint" echo "build=$build" echo "test=$test" } >> "$GITHUB_OUTPUT" - name: Lint shell: bash run: | set -euo pipefail eval "${{ steps.resolve.outputs.lint }}" - name: Build shell: bash run: | set -euo pipefail eval "${{ steps.resolve.outputs.build }}" - name: Test shell: bash run: | set -euo pipefail eval "${{ steps.resolve.outputs.test }}" security-scan: name: security-scan runs-on: ubuntu-latest permissions: contents: read security-events: write pull-requests: read steps: - name: Checkout caller repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install gitleaks shell: bash run: | set -euo pipefail GITLEAKS_VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar -xz sudo mv gitleaks /usr/local/bin/gitleaks gitleaks version - name: Gitleaks scan shell: bash run: | set -euo pipefail gitleaks detect --source . --no-git --verbose - name: Install Semgrep shell: bash run: | set -euo pipefail python3 -m pip install --upgrade pip python3 -m pip install semgrep semgrep --version - name: Semgrep scan shell: bash run: | set -euo pipefail semgrep --config p/default --error - name: Dependency Review if: ${{ github.event_name == 'pull_request' }} uses: actions/dependency-review-action@v4 ai-review: name: ai-review if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest permissions: contents: read pull-requests: write issues: write steps: - name: Generate Claude review env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} PR_TITLE: ${{ github.event.pull_request.title }} run: | set -euo pipefail if [ -z "${ANTHROPIC_API_KEY:-}" ]; then echo "::notice::ANTHROPIC_API_KEY not set – Claude review skipped" exit 0 fi python3 << 'PYEOF' import os, json, urllib.request token = os.environ["GITHUB_TOKEN"] repo = os.environ["REPO"] pr_num = os.environ["PR_NUMBER"] headers_gh = {"Authorization": f"Bearer {token}", "User-Agent": "vanity-dev-engine", "Accept": "application/vnd.github.v3+json"} # Skip if Claude review already exists req = urllib.request.Request( f"https://api.github.com/repos/{repo}/issues/{pr_num}/comments?per_page=100", headers=headers_gh) with urllib.request.urlopen(req) as r: comments = json.loads(r.read()) if any("### Claude" in (c.get("body") or "") for c in comments): print("Claude review already present – skipping generation.") raise SystemExit(0) # Fetch PR diff (truncated to 12 000 chars to stay within token limit) req_diff = urllib.request.Request( f"https://api.github.com/repos/{repo}/pulls/{pr_num}", headers={**headers_gh, "Accept": "application/vnd.github.v3.diff"}) with urllib.request.urlopen(req_diff) as r: diff = r.read().decode("utf-8", errors="replace")[:12000] # Fetch PR body req_pr = urllib.request.Request( f"https://api.github.com/repos/{repo}/pulls/{pr_num}", headers=headers_gh) with urllib.request.urlopen(req_pr) as r: pr_data = json.loads(r.read()) pr_body = (pr_data.get("body") or "")[:800] prompt = f"""You are a senior iOS Swift developer reviewing a pull request. Analyse the changes carefully and write a concise code review. PR title: {os.environ["PR_TITLE"]} PR description: {pr_body} Git diff (may be truncated): {diff} Reply with EXACTLY this structure – no deviations: ### Claude DoD status: PASS Blocker: 0 Major: 0 Only set DoD status to FAIL or raise Blocker/Major above 0 when you find real defects that must be fixed before merging.""" payload = json.dumps({ "model": "claude-opus-4-6", "max_tokens": 1500, "messages": [{"role": "user", "content": prompt}] }).encode() req_ai = urllib.request.Request( "https://api.anthropic.com/v1/messages", data=payload, headers={"x-api-key": os.environ["ANTHROPIC_API_KEY"], "anthropic-version": "2023-06-01", "content-type": "application/json"}) with urllib.request.urlopen(req_ai) as r: review = json.loads(r.read())["content"][0]["text"] # Post comment body_payload = json.dumps({"body": review}).encode() req_post = urllib.request.Request( f"https://api.github.com/repos/{repo}/issues/{pr_num}/comments", data=body_payload, headers={**headers_gh, "Content-Type": "application/json"}) with urllib.request.urlopen(req_post) as r: result = json.loads(r.read()) print(f"Claude review posted: {result['html_url']}") PYEOF - name: Generate ChatGPT review env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GITHUB_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} PR_TITLE: ${{ github.event.pull_request.title }} run: | set -euo pipefail if [ -z "${OPENAI_API_KEY:-}" ]; then echo "::notice::OPENAI_API_KEY not set – ChatGPT review skipped" exit 0 fi python3 << 'PYEOF' import os, json, urllib.request token = os.environ["GITHUB_TOKEN"] repo = os.environ["REPO"] pr_num = os.environ["PR_NUMBER"] headers_gh = {"Authorization": f"Bearer {token}", "User-Agent": "vanity-dev-engine", "Accept": "application/vnd.github.v3+json"} # Skip if ChatGPT review already exists req = urllib.request.Request( f"https://api.github.com/repos/{repo}/issues/{pr_num}/comments?per_page=100", headers=headers_gh) with urllib.request.urlopen(req) as r: comments = json.loads(r.read()) if any("### ChatGPT" in (c.get("body") or "") for c in comments): print("ChatGPT review already present – skipping generation.") raise SystemExit(0) # Fetch PR diff req_diff = urllib.request.Request( f"https://api.github.com/repos/{repo}/pulls/{pr_num}", headers={**headers_gh, "Accept": "application/vnd.github.v3.diff"}) with urllib.request.urlopen(req_diff) as r: diff = r.read().decode("utf-8", errors="replace")[:12000] # Fetch PR body req_pr = urllib.request.Request( f"https://api.github.com/repos/{repo}/pulls/{pr_num}", headers=headers_gh) with urllib.request.urlopen(req_pr) as r: pr_data = json.loads(r.read()) pr_body = (pr_data.get("body") or "")[:800] prompt = f"""You are a senior iOS Swift developer reviewing a pull request. Analyse the changes carefully and write a concise code review. PR title: {os.environ["PR_TITLE"]} PR description: {pr_body} Git diff (may be truncated): {diff} Reply with EXACTLY this structure – no deviations: ### ChatGPT DoD status: PASS Blocker: 0 Major: 0 Only set DoD status to FAIL or raise Blocker/Major above 0 when you find real defects that must be fixed before merging.""" payload = json.dumps({ "model": "gpt-4o", "max_tokens": 1500, "messages": [{"role": "user", "content": prompt}] }).encode() req_ai = urllib.request.Request( "https://api.openai.com/v1/chat/completions", data=payload, headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}", "content-type": "application/json"}) with urllib.request.urlopen(req_ai) as r: review = json.loads(r.read())["choices"][0]["message"]["content"] # Post comment body_payload = json.dumps({"body": review}).encode() req_post = urllib.request.Request( f"https://api.github.com/repos/{repo}/issues/{pr_num}/comments", data=body_payload, headers={**headers_gh, "Content-Type": "application/json"}) with urllib.request.urlopen(req_post) as r: result = json.loads(r.read()) print(f"ChatGPT review posted: {result['html_url']}") PYEOF - name: Validate ChatGPT and Claude review status uses: actions/github-script@v7 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; const pull_number = context.payload.pull_request ? context.payload.pull_request.number : context.payload.issue?.number; if (!pull_number) { core.setFailed("No pull request context found for ai-review validation."); return; } const pr = await github.rest.pulls.get({ owner, repo, pull_number }); const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pull_number, per_page: 100 }); const bodyParts = [pr.data.body || "", ...comments.map(c => c.body || "")]; const allText = bodyParts.join("\n\n").toLowerCase(); const hasSection = (name) => allText.includes(`### ${name}`.toLowerCase()); const hasPass = (name) => { const regex = new RegExp(`###\\s*${name}[\\s\\S]*?dod status\\s*:\\s*pass`, "i"); return bodyParts.some(part => regex.test(part || "")); }; const hasZeroBlocker = (name) => { const regex = new RegExp(`###\\s*${name}[\\s\\S]*?blocker\\s*:\\s*0`, "i"); return bodyParts.some(part => regex.test(part || "")); }; const hasZeroMajor = (name) => { const regex = new RegExp(`###\\s*${name}[\\s\\S]*?major\\s*:\\s*0`, "i"); return bodyParts.some(part => regex.test(part || "")); }; const checks = [ { name: "ChatGPT", section: hasSection("ChatGPT"), pass: hasPass("ChatGPT"), blocker: hasZeroBlocker("ChatGPT"), major: hasZeroMajor("ChatGPT") }, { name: "Claude", section: hasSection("Claude"), pass: hasPass("Claude"), blocker: hasZeroBlocker("Claude"), major: hasZeroMajor("Claude") } ]; const failures = checks.flatMap(c => { const missing = []; if (!c.section) missing.push("missing section"); if (!c.pass) missing.push("missing `DoD status: PASS`"); if (!c.blocker) missing.push("missing `Blocker: 0`"); if (!c.major) missing.push("missing `Major: 0`"); return missing.length ? [`${c.name}: ${missing.join(", ")}`] : []; }); if (failures.length) { core.setFailed(`AI review gate failed:\n- ${failures.join("\n- ")}`); } else { core.info("AI review gate passed."); }