mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-07 02:46:42 +02:00
580 lines
30 KiB
YAML
580 lines
30 KiB
YAML
name: Issue & PR Automation
|
|
|
|
on:
|
|
issues:
|
|
types: [opened]
|
|
pull_request_target:
|
|
types: [opened]
|
|
issue_comment:
|
|
types: [created]
|
|
pull_request_review_comment:
|
|
types: [created]
|
|
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
pull-requests: write
|
|
id-token: write
|
|
|
|
env:
|
|
# Single source of truth for the model used by both triage and composer.
|
|
TRIAGE_MODEL: anthropic/claude-opus-4.7
|
|
COMPOSER_MODEL: anthropic/claude-opus-4.7
|
|
|
|
jobs:
|
|
analyze-issue:
|
|
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
|
|
|
- name: Check if first-time contributor
|
|
id: check-first-time
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
|
run: |
|
|
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues?state=all&creator=$ISSUE_AUTHOR&per_page=100" \
|
|
--jq "[.[] | select(.number != ${{ github.event.issue.number }}) ] | length" \
|
|
|| echo "0")
|
|
|
|
if [ "$ISSUE_COUNT" = "0" ]; then
|
|
echo "is_first_time=true" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Parse issue template fields
|
|
env:
|
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
|
run: |
|
|
node <<'EOF'
|
|
const fs = require('node:fs');
|
|
const body = process.env.ISSUE_BODY || '';
|
|
// GitHub issue templates render fields as `### Heading\nValue` blocks.
|
|
// Split on `###` at line start to recover them.
|
|
const fields = {};
|
|
const sections = body.split(/^###\s+/m);
|
|
for (const section of sections.slice(1)) {
|
|
const nl = section.indexOf('\n');
|
|
if (nl < 0) continue;
|
|
const heading = section.slice(0, nl).trim();
|
|
const value = section.slice(nl + 1).trim();
|
|
fields[heading] = value === '_No response_' ? '' : value;
|
|
}
|
|
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
|
|
// Convenience extractions for the prompt — empty string if missing.
|
|
const get = (k) => fields[k] || '';
|
|
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
|
|
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
|
|
fs.writeFileSync('/tmp/issue-browser.txt', get('Which browser is affected?'));
|
|
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
|
|
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
|
|
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
|
|
EOF
|
|
echo "Parsed fields:"
|
|
cat /tmp/issue-fields.json
|
|
|
|
- name: Build repo context
|
|
env:
|
|
ISSUE_TITLE: ${{ github.event.issue.title }}
|
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
|
run: |
|
|
cp CLAUDE.md /tmp/repo-context.txt
|
|
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
|
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
|
|
|
# List all source files for the AI to choose from
|
|
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
|
|
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
|
|
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
|
|
| sed 's|^\./||' | sort > /tmp/all-source-files.txt
|
|
|
|
- name: Write shared knowledge files (scope + pricing)
|
|
run: |
|
|
cat > /tmp/scope-and-pricing.md <<'EOF'
|
|
# PROJECT SCOPE
|
|
|
|
- **Donut Browser** — this repo. A Tauri desktop launcher (Rust + Next.js) that
|
|
downloads, manages, and launches anti-detect browser profiles. In-scope for bug
|
|
reports about profile management, downloads, sync, proxy, VPN, the launcher UI,
|
|
its API, MCP server, and the bundled `donut-sync` self-hosted server.
|
|
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
|
|
bugs are in-scope here unless they are obviously upstream Chromium issues.
|
|
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
|
|
contribute to Camoufox and CANNOT fix bugs in it.
|
|
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
|
|
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
|
|
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
|
|
https://github.com/daijro/camoufox/issues.
|
|
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
|
|
in-scope here.
|
|
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
|
|
supported. Feature requests asking for them are out of scope.
|
|
|
|
# PAID vs FREE FEATURES
|
|
|
|
Source: donutbrowser.com pricing tiers (verbatim from translations).
|
|
|
|
## Free (no account required)
|
|
- Unlimited local profiles
|
|
- Chromium (Wayfern) and Firefox (Camoufox) browser engines
|
|
- Proxy support (HTTP/SOCKS5)
|
|
- VPN support (WireGuard)
|
|
- Profile Management API & MCP (list / create / launch / kill / config)
|
|
- Cookie & Extension Management
|
|
- Set as default browser
|
|
- **Profile sync IS FREE if the user self-hosts the `donut-sync` server**
|
|
|
|
## Pro ($16/mo) — adds:
|
|
- Browser Manipulation API & MCP (`type_text`, `click_element`,
|
|
`evaluate_javascript`, `screenshot`, `navigate`, etc.)
|
|
- Cross-OS fingerprinting (e.g. macOS user appearing as Windows)
|
|
- Profile Synchronizer for Wayfern
|
|
- 20 cloud profile backup (cloud sync via donutbrowser.com)
|
|
- Commercial use license
|
|
|
|
## Team ($80/mo) — adds:
|
|
- 100 cloud profile sync
|
|
- Team collaboration, profile sharing, unlimited seats
|
|
|
|
# ANTI-PATTERNS
|
|
|
|
- **Regression**: user explicitly mentions a previous version that worked
|
|
differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT
|
|
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
|
|
which exact version was the last working one and what changed.
|
|
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
|
|
behavior. Redirect, do not collect logs.
|
|
- **Fork-support request**: asks the maintainer to support an alternative
|
|
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
|
|
"clear", "reasonable", "well-thought-out", etc.
|
|
- **AI-generated / template-violating report**: report doesn't follow the
|
|
template, may cite "official documentation" via context7, deepwiki, or any
|
|
non-`donutbrowser.com` / non-`github.com/zhom` URL. The only authoritative
|
|
sources are this GitHub repo and donutbrowser.com.
|
|
- **Speculation about internals**: never write a "Possible cause" / "Likely
|
|
cause" / "Root cause" section. Never cite internal file paths or line
|
|
numbers. Never speculate about how subscription / paid-plan checks work.
|
|
|
|
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
|
|
|
|
- macOS: `~/Library/Logs/Donut Browser/`
|
|
- Linux: `~/.local/share/DonutBrowser/logs/`
|
|
- Windows: `%APPDATA%\DonutBrowser\logs\`
|
|
EOF
|
|
|
|
- name: Build triage system prompt
|
|
run: |
|
|
# The static system prompt has apostrophes ("doesn't", "official docs"
|
|
# etc.) that collide with shell single-quoting if embedded directly in
|
|
# the jq filter. Build the full prompt to a file instead, then load it
|
|
# via --rawfile in the next step.
|
|
{
|
|
cat <<'TRIAGE_HEAD'
|
|
You are a triage classifier for the Donut Browser GitHub repo. Classify the issue and pick at most 20 source files for a composer to read.
|
|
|
|
TRIAGE_HEAD
|
|
cat /tmp/scope-and-pricing.md
|
|
printf '\n\n# REPO GUIDELINES\n'
|
|
cat /tmp/repo-context.txt
|
|
cat <<'TRIAGE_TAIL'
|
|
|
|
# OUTPUT
|
|
Return ONLY valid JSON. No preamble, no code fences. Schema:
|
|
{
|
|
"language": "en" or ISO 639-1 code,
|
|
"classification": one of ["bug-in-scope", "bug-upstream-camoufox", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
|
|
"operating_system": "macos" | "windows" | "linux" | "unknown",
|
|
"is_paid_feature": true | false,
|
|
"user_followed_template": true | false,
|
|
"regression_signal": quoted user snippet or null,
|
|
"user_cited_external_docs": URL string or null,
|
|
"files_to_read": array of at most 20 file paths from the list,
|
|
"notes": one short sentence describing what you observed
|
|
}
|
|
|
|
Classification guidance:
|
|
- "bug-upstream-camoufox": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.
|
|
- "bug-template-violation": missing or filled-in nonsense for required template fields.
|
|
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
|
|
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
|
|
- "regression": user names a prior version that worked.
|
|
|
|
File selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is upstream-Camoufox, fork-request, or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
|
|
TRIAGE_TAIL
|
|
} > /tmp/triage-system.txt
|
|
wc -c /tmp/triage-system.txt
|
|
|
|
- name: Stage 1 — Triage and file selection
|
|
env:
|
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
|
run: |
|
|
# The triage call returns ONLY JSON. It classifies the issue and picks a
|
|
# short list of source files for the composer to read.
|
|
PAYLOAD=$(jq -n \
|
|
--arg model "$TRIAGE_MODEL" \
|
|
--rawfile system_prompt /tmp/triage-system.txt \
|
|
--rawfile title /tmp/issue-title.txt \
|
|
--rawfile body /tmp/issue-body.txt \
|
|
--rawfile fields /tmp/issue-fields.json \
|
|
--rawfile files /tmp/all-source-files.txt \
|
|
'{
|
|
model: $model,
|
|
messages: [
|
|
{ role: "system", content: $system_prompt },
|
|
{ role: "user",
|
|
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
|
|
]
|
|
}')
|
|
|
|
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
|
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$PAYLOAD")
|
|
|
|
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
|
|
|
|
# Strip ```json fences if the model couldn't help itself.
|
|
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
|
|
|
|
# Validate; if the model returned junk, fall back to a minimal stub so the
|
|
# composer still gets called and produces SOMETHING.
|
|
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
|
|
echo "::warning::Triage returned non-JSON; using fallback classification"
|
|
cat /tmp/triage-raw.txt
|
|
jq -n '{
|
|
language: "en",
|
|
classification: "bug-in-scope",
|
|
operating_system: "unknown",
|
|
is_paid_feature: false,
|
|
user_followed_template: true,
|
|
regression_signal: null,
|
|
user_cited_external_docs: null,
|
|
files_to_read: [],
|
|
notes: "triage call failed; defaulting"
|
|
}' > /tmp/triage.json
|
|
fi
|
|
|
|
echo "Triage result:"
|
|
cat /tmp/triage.json
|
|
|
|
- name: Read files chosen by triage
|
|
run: |
|
|
: > /tmp/file-context.txt
|
|
# files_to_read may be empty (e.g. upstream Camoufox) — that's fine.
|
|
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
|
|
filepath=$(echo "$filepath" | xargs)
|
|
[ -z "$filepath" ] && continue
|
|
# Reject paths that escape the repo or look fishy
|
|
case "$filepath" in
|
|
/*|*..*|*$'\n'*) continue ;;
|
|
esac
|
|
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
|
echo "=== $filepath ===" >> /tmp/file-context.txt
|
|
cat "$filepath" >> /tmp/file-context.txt
|
|
echo "" >> /tmp/file-context.txt
|
|
fi
|
|
done
|
|
# Cap total context at 100 KB to keep token cost bounded.
|
|
head -c 100000 /tmp/file-context.txt > /tmp/file-context.capped.txt
|
|
mv /tmp/file-context.capped.txt /tmp/file-context.txt
|
|
wc -c /tmp/file-context.txt
|
|
|
|
- name: Build composer system prompt
|
|
run: |
|
|
# Same reason as the triage prompt: lots of apostrophes, no shell-quoting
|
|
# gymnastics. Build it to a file, load via --rawfile.
|
|
{
|
|
cat <<'COMPOSER_HEAD'
|
|
You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue — use the classification verbatim, do not re-litigate it.
|
|
|
|
COMPOSER_HEAD
|
|
cat /tmp/scope-and-pricing.md
|
|
printf '\n\n# REPO GUIDELINES\n'
|
|
cat /tmp/repo-context.txt
|
|
cat <<'COMPOSER_TAIL'
|
|
|
|
# RULES — STRICT
|
|
|
|
## Output shape
|
|
- One sentence acknowledging the report.
|
|
- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.
|
|
- Maximum 15 lines.
|
|
- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.
|
|
- No closing pleasantries ("please let me know", "happy to help", etc.).
|
|
|
|
## Forbidden — never do these
|
|
- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You do not have enough information; speculation is always wrong here.
|
|
- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.
|
|
- NEVER reference how subscription / paid-plan checks work internally. You do not know whether the user's claim is correct.
|
|
- NEVER call a report "well-documented", "well-structured", "clear", "thorough", "reasonable", "well-thought-out", or any similar evaluation. You are triage, not peer review.
|
|
- NEVER list more than one OS log path. Use ONLY the path matching the user's reported OS. If OS is unknown, ask for it instead of listing all three.
|
|
- NEVER validate a feature request as "a clear enhancement" / "a reasonable request" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).
|
|
- NEVER call a report "a known and expected behavior" or "a false positive" if the user mentions a regression. The triage tells you when this applies.
|
|
|
|
## Classification handling
|
|
The triage classification (`triage.classification`) determines the response shape:
|
|
|
|
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
|
|
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
|
|
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
|
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
|
|
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
|
|
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
|
|
- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.
|
|
|
|
## Paid-feature awareness
|
|
If `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.
|
|
|
|
## Language
|
|
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
|
|
|
|
## OS-specific log paths
|
|
Use ONLY the one matching `triage.operating_system`:
|
|
- macos: `~/Library/Logs/Donut Browser/`
|
|
- linux: `~/.local/share/DonutBrowser/logs/`
|
|
- windows: `%APPDATA%\DonutBrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\DonutBrowser\logs`)
|
|
- unknown: ask the user to share their OS first.
|
|
COMPOSER_TAIL
|
|
} > /tmp/composer-system.txt
|
|
wc -c /tmp/composer-system.txt
|
|
|
|
- name: Stage 2 — Compose response
|
|
env:
|
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
|
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
|
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
|
|
run: |
|
|
GREETING=""
|
|
if [ "$IS_FIRST_TIME" = "true" ]; then
|
|
# Use printf with %s so the apostrophe inside the string never has to
|
|
# cross a shell single-quote boundary.
|
|
printf '%s' 'This is the first issue from this user — start the comment with "Thanks for opening your first issue!" on its own line.' > /tmp/greeting.txt
|
|
else
|
|
: > /tmp/greeting.txt
|
|
fi
|
|
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
|
|
|
|
PAYLOAD=$(jq -n \
|
|
--arg model "$COMPOSER_MODEL" \
|
|
--rawfile system_prompt /tmp/composer-system.txt \
|
|
--rawfile title /tmp/issue-title.txt \
|
|
--rawfile body /tmp/issue-body.txt \
|
|
--rawfile author /tmp/issue-author.txt \
|
|
--rawfile fields /tmp/issue-fields.json \
|
|
--rawfile triage /tmp/triage.json \
|
|
--rawfile greeting /tmp/greeting.txt \
|
|
--rawfile files /tmp/file-context.txt \
|
|
'{
|
|
model: $model,
|
|
messages: [
|
|
{ role: "system", content: $system_prompt },
|
|
{ role: "user",
|
|
content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end)
|
|
+ "Title: " + $title
|
|
+ "\nAuthor: " + $author
|
|
+ "\n\n## Triage result\n" + $triage
|
|
+ "\n\n## Parsed template fields\n" + $fields
|
|
+ "\n\n## Raw issue body\n" + $body
|
|
+ "\n\n## Source files (selected by triage)\n" + $files) }
|
|
]
|
|
}')
|
|
|
|
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
|
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$PAYLOAD")
|
|
|
|
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
|
|
|
|
if [ ! -s /tmp/ai-comment.txt ]; then
|
|
echo "::error::Composer returned empty response"
|
|
echo "Raw response:"
|
|
echo "$RESPONSE"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Strip forbidden sections (defense in depth)
|
|
run: |
|
|
# Even with explicit prompt rules, LLMs sometimes still emit "Possible cause"
|
|
# and friends. Strip any such heading + its block. Also drop any stray
|
|
# `Label:` lines from earlier prompt iterations.
|
|
python3 - <<'EOF'
|
|
import re
|
|
path = '/tmp/ai-comment.txt'
|
|
text = open(path).read()
|
|
# Drop forbidden section headers and everything until a blank line or another header.
|
|
forbidden = re.compile(
|
|
r'^\s*\**\s*(?:possible|likely|root|probable)\s+cause\b.*?(?=^\s*$|\n##|\n\*\*[A-Z]|\Z)',
|
|
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
|
)
|
|
text = forbidden.sub('', text)
|
|
# Drop stale Label: lines (we don't label anymore).
|
|
text = re.sub(r'^\s*Label:\s*.*$', '', text, flags=re.MULTILINE)
|
|
# Collapse 3+ blank lines.
|
|
text = re.sub(r'\n{3,}', '\n\n', text).strip() + '\n'
|
|
open(path, 'w').write(text)
|
|
EOF
|
|
|
|
- name: Post comment (no labeling)
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
run: |
|
|
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
|
|
|
analyze-pr:
|
|
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
|
|
|
- name: Check if first-time contributor
|
|
id: check-first-time
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
|
run: |
|
|
PR_COUNT=$(gh api "/repos/${{ github.repository }}/pulls?state=all&per_page=100" \
|
|
--jq "[.[] | select(.user.login == \"$PR_AUTHOR\" and .number != ${{ github.event.pull_request.number }})] | length" \
|
|
|| echo "0")
|
|
|
|
if [ "$PR_COUNT" = "0" ]; then
|
|
echo "is_first_time=true" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Gather PR context
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
run: |
|
|
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
|
|
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
|
> /tmp/pr-files.txt
|
|
|
|
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
|
|
--header "Accept: application/vnd.github.diff" \
|
|
> /tmp/pr-diff-full.txt 2>/dev/null || true
|
|
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
|
|
|
|
cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
|
|
head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt
|
|
cp CLAUDE.md /tmp/repo-context.txt
|
|
|
|
: > /tmp/related-file-contents.txt
|
|
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
|
|
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
|
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
|
|
cat "$filepath" >> /tmp/related-file-contents.txt
|
|
echo "" >> /tmp/related-file-contents.txt
|
|
fi
|
|
done
|
|
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt
|
|
|
|
- name: Analyze PR with AI
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
PR_BODY: ${{ github.event.pull_request.body }}
|
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
|
PR_BASE: ${{ github.event.pull_request.base.ref }}
|
|
PR_HEAD: ${{ github.event.pull_request.head.ref }}
|
|
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
|
|
run: |
|
|
GREETING=""
|
|
if [ "$IS_FIRST_TIME" = "true" ]; then
|
|
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
|
|
fi
|
|
|
|
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt
|
|
printf '%s' "${PR_BODY:-}" > /tmp/pr-body.txt
|
|
printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
|
|
printf '%s' "$PR_BASE" > /tmp/pr-base.txt
|
|
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
|
|
printf '%s' "$GREETING" > /tmp/greeting.txt
|
|
|
|
PAYLOAD=$(jq -n \
|
|
--arg model "$COMPOSER_MODEL" \
|
|
--rawfile title /tmp/pr-title.txt \
|
|
--rawfile body /tmp/pr-body.txt \
|
|
--rawfile author /tmp/pr-author.txt \
|
|
--rawfile base /tmp/pr-base.txt \
|
|
--rawfile head /tmp/pr-head.txt \
|
|
--rawfile files /tmp/pr-files.txt \
|
|
--rawfile diff /tmp/pr-diff.txt \
|
|
--rawfile greeting /tmp/greeting.txt \
|
|
--rawfile repo_context /tmp/repo-context.txt \
|
|
--rawfile contributing /tmp/contributing.txt \
|
|
--rawfile file_context /tmp/pr-file-context.txt \
|
|
'{
|
|
model: $model,
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to the full changed files and the diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added/changed, check if all 7 translation files (en, es, fr, ja, pt, ru, zh) in src/i18n/locales/ were updated\n - If Tauri commands were added/removed, the unused-commands test in lib.rs needs updating\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
|
|
},
|
|
{
|
|
role: "user",
|
|
content: (
|
|
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
|
|
"Review this PR:\n\nTitle: " + $title +
|
|
"\nAuthor: " + $author +
|
|
"\nBase: " + $base + " <- Head: " + $head +
|
|
"\n\nDescription:\n" + $body +
|
|
"\n\nChanged files:\n" + $files +
|
|
"\n\nDiff:\n" + $diff +
|
|
"\n\nFull file contents:\n" + $file_context
|
|
)
|
|
}
|
|
]
|
|
}')
|
|
|
|
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
|
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$PAYLOAD")
|
|
|
|
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
|
|
|
|
if [ ! -s /tmp/ai-comment.txt ]; then
|
|
echo "::error::AI response was empty"
|
|
echo "Raw response:"
|
|
echo "$RESPONSE"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Post comment
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
run: |
|
|
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
|
|
|
opencode-command:
|
|
if: |
|
|
github.repository == 'zhom/donutbrowser' &&
|
|
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
|
(contains(github.event.comment.body, ' /oc') ||
|
|
startsWith(github.event.comment.body, '/oc') ||
|
|
contains(github.event.comment.body, ' /opencode') ||
|
|
startsWith(github.event.comment.body, '/opencode'))
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
|
|
|
- name: Run opencode
|
|
uses: anomalyco/opencode/github@557734bd130a68188454bc691e153f9f3731830e #v1.14.31
|
|
env:
|
|
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
with:
|
|
model: zai-coding-plan/glm-4.7
|