|
stages: |
|
- review |
|
|
|
variables: |
|
GIT_DEPTH: "0" |
|
GIT_STRATEGY: fetch |
|
|
|
droid_code_review: |
|
stage: review |
|
image: alpine:3.20 |
|
resource_group: "droid-review-$CI_MERGE_REQUEST_IID" |
|
rules: |
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' |
|
before_script: |
|
- set -euo pipefail |
|
- apk add --no-cache bash curl git jq python3 |
|
- curl -fsSL https://app.factory.ai/cli | sh |
|
- export PATH="$HOME/.local/bin:$PATH" |
|
- droid --version |
|
- git config user.name "Droid Agent" |
|
- git config user.email "droidagent@factory.ai" |
|
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" |
|
script: |
|
- set -euo pipefail |
|
- git diff "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...$CI_COMMIT_SHA" > diff.txt |
|
- curl --fail --silent --show-error \ |
|
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ |
|
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \ |
|
-o existing_notes.json |
|
- curl --fail --silent --show-error \ |
|
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ |
|
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/discussions?per_ |
|
page=100" \ |
|
-o discussions.json |
|
- curl --fail --silent --show-error \ |
|
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ |
|
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/changes" \ |
|
| jq '[.changes[] | {filename: .new_path, patch: .diff}]' > files.json |
|
- printf '%s' "${CI_MERGE_REQUEST_DESCRIPTION:-}" > mr_description.txt |
|
- cat > prompt.txt <<'EOF' |
|
You are an automated code review system for GitLab merge requests. Review the MR diff and report |
|
actionable problems only. |
|
|
|
Available inputs in the current directory: |
|
- diff.txt: git diff between the target branch and this commit. |
|
- files.json: array of {filename, patch} entries with diff hunks (use positions from patches). |
|
- existing_notes.json: high-level notes already posted on the MR (avoid duplicates). |
|
- discussions.json: inline discussion threads (use your replies here when relevant). |
|
- mr_description.txt: merge request description for context. |
|
|
|
Task: Produce comments.json with up to 10 issues. Each entry must be either: |
|
1. New inline note: |
|
{ |
|
"path": "path/to/file.js", |
|
"line": 42, |
|
"body": "Actionable feedback", |
|
"position_type": "text" |
|
} |
|
• "line" is the new line number in the diff. |
|
• Only comment on lines changed in this MR. |
|
2. Reply to an existing discussion: |
|
{ |
|
"discussion_id": "thread-id", |
|
"note_id": 123456, |
|
"body": "Follow-up message" |
|
} |
|
- discussion_id and note_id must come from discussions.json. |
|
|
|
Focus on critical correctness issues such as: |
|
- Dead/unreachable code |
|
- Broken control flow or missing returns |
|
- Async/await misuse |
|
- Mutable React state / reducer bugs |
|
- useEffect dependency mistakes |
|
- Incorrect operators (== vs === etc.) |
|
- Off-by-one errors and incorrect indexing |
|
- Overflow/underflow risks |
|
- Regex catastrophic backtracking |
|
- Missing base cases in recursion |
|
- Dangerous type coercion |
|
- Null/undefined dereferences |
|
- Resource leaks |
|
- SQL/XSS injection or security risks |
|
- Concurrency and race conditions |
|
- Missing error handling in critical paths |
|
|
|
Comment requirements: |
|
- State the exact problem and why it matters. |
|
- Provide a concrete fix (include code suggestion blocks when helpful). |
|
- No style nitpicks or subjective opinions. |
|
- Skip items already resolved in existing_notes.json or discussions.json. |
|
|
|
If no issues exist, output []. |
|
EOF |
|
- droid exec --auto high --model claude-sonnet-4-5-20250929 -f prompt.txt |
|
- if [ ! -f comments.json ]; then |
|
echo "comments.json was not generated"; exit 1; |
|
fi |
|
- python3 - <<'PY' |
|
import json, os, sys, urllib.request |
|
|
|
api = os.environ["CI_API_V4_URL"] |
|
project = os.environ["CI_PROJECT_ID"] |
|
mr_iid = os.environ["CI_MERGE_REQUEST_IID"] |
|
token = os.environ["GITLAB_TOKEN"] |
|
base_sha = os.environ.get("CI_MERGE_REQUEST_DIFF_BASE_SHA") or os.environ.get("CI_COMMIT_BEFORE_SHA") |
|
or "" |
|
head_sha = os.environ.get("CI_COMMIT_SHA") |
|
start_sha = base_sha |
|
headers = {"PRIVATE-TOKEN": token, "Content-Type": "application/json"} |
|
|
|
with open("comments.json", "r", encoding="utf-8") as f: |
|
comments = json.load(f) |
|
|
|
if not isinstance(comments, list): |
|
print("comments.json is not a list; skipping submission") |
|
sys.exit(0) |
|
|
|
def request(method, endpoint, payload): |
|
data = json.dumps(payload).encode("utf-8") |
|
req = urllib.request.Request( |
|
url=f"{api}/projects/{project}{endpoint}", |
|
data=data, |
|
headers=headers, |
|
method=method |
|
) |
|
with urllib.request.urlopen(req) as resp: |
|
resp.read() |
|
|
|
new_notes = [c for c in comments if isinstance(c, dict) and "path" in c and "line" in c and "body" in |
|
c] |
|
replies = [c for c in comments if isinstance(c, dict) and "discussion_id" in c and "note_id" in c and |
|
"body" in c] |
|
|
|
for comment in new_notes: |
|
position = { |
|
"position_type": comment.get("position_type", "text"), |
|
"base_sha": base_sha, |
|
"start_sha": start_sha, |
|
"head_sha": head_sha, |
|
"new_path": comment["path"], |
|
"new_line": comment["line"], |
|
} |
|
payload = {"body": comment["body"], "position": position} |
|
request("POST", f"/merge_requests/{mr_iid}/discussions", payload) |
|
|
|
for reply in replies: |
|
payload = {"body": reply["body"], "in_reply_to_id": reply["note_id"]} |
|
request("POST", f"/merge_requests/{mr_iid}/discussions/{reply['discussion_id']}/notes", payload) |
|
|
|
if not new_notes and not replies: |
|
with open("existing_notes.json", "r", encoding="utf-8") as f: |
|
existing = json.load(f) |
|
has_no_issues = any( |
|
isinstance(note, dict) |
|
and isinstance(note.get("author"), dict) |
|
and "[bot]" in note["author"].get("username", "") |
|
and note.get("body") and ("no issues found" in note["body"].lower() or "✅" in note["body"]) |
|
for note in existing |
|
) |
|
if not has_no_issues: |
|
payload = {"body": "✅ No issues found in the current changes."} |
|
request("POST", f"/merge_requests/{mr_iid}/notes", payload) |
|
PY |
|
artifacts: |
|
when: on_failure |
|
paths: |
|
- diff.txt |
|
- files.json |
|
- existing_notes.json |
|
- discussions.json |
|
- prompt.txt |
|
- mr_description.txt |
|
- comments.json |
|
- "$HOME/.factory/logs/" |