vanity-dev-engine/.github/workflows/repo-pipeline.yml
OliverGiertz 967788e045 feat(ai-review): automate Claude and ChatGPT review generation
New steps before validation:
- 'Generate Claude review': calls Anthropic API (claude-opus-4-6),
  posts formatted comment with required DoD/Blocker/Major structure
- 'Generate ChatGPT review': calls OpenAI API (gpt-4o), same format
- Both steps skip gracefully if API key secret is not set
- Idempotent: skips generation if review comment already exists
- Validation step remains unchanged as final gate

Required secrets in consumer repo: ANTHROPIC_API_KEY, OPENAI_API_KEY
Permission updated: pull-requests/issues write (needed to post comments)
2026-03-11 08:30:26 +00:00

430 lines
16 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
<your review here cover code quality, correctness, Swift best practices,
potential bugs, and suggestions. Be specific and constructive.>
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
<your review here cover code quality, correctness, Swift best practices,
potential bugs, and suggestions. Be specific and constructive.>
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.");
}