diff --git a/.github/workflows/jules-ocr-review.yml b/.github/workflows/jules-ocr-review.yml new file mode 100644 index 0000000..db81dc9 --- /dev/null +++ b/.github/workflows/jules-ocr-review.yml @@ -0,0 +1,305 @@ +name: Jules OCR Review + +on: + pull_request: + types: [opened, ready_for_review, closed] + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: jules-ocr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + relay-result: + if: >- + github.event.action == 'opened' && + startsWith(github.event.pull_request.head.ref, 'chore/jules-ocr-review-') + runs-on: ubuntu-latest + + steps: + - name: Relay Jules review and close empty result PR + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const reviewPrNumber = context.payload.pull_request.number; + + const { data: reviewPr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: reviewPrNumber, + }); + + // Safety guard: a review-only Jules task should never change files. + // If it does, leave the PR open for human inspection. + if (reviewPr.changed_files !== 0) { + core.setFailed( + `Jules result PR #${reviewPrNumber} changed ${reviewPr.changed_files} file(s); refusing to auto-close it.`, + ); + return; + } + + const body = reviewPr.body || ""; + const targetMatch = + body.match(/code review of PR #(\d+)/i) || + reviewPr.title.match(/Review PR #(\d+)/i); + + if (!targetMatch) { + core.setFailed("Could not identify the original PR from the Jules result."); + return; + } + + const originalPrNumber = Number(targetMatch[1]); + const issueMatch = body.match(/Fixes #(\d+)/i); + const dispatchIssueNumber = issueMatch ? Number(issueMatch[1]) : null; + const report = body + .replace(/\n\nFixes #\d+[\s\S]*$/i, "") + .trim(); + + const marker = ``; + await github.rest.issues.createComment({ + owner, + repo, + issue_number: originalPrNumber, + body: `${marker} + ### Jules + OpenCodeReview + + ${report} + + Review artifact: #${reviewPrNumber}`, + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: reviewPrNumber, + state: "closed", + }); + + if (dispatchIssueNumber) { + await github.rest.issues.update({ + owner, + repo, + issue_number: dispatchIssueNumber, + state: "closed", + state_reason: "completed", + }); + } + + core.notice( + `Relayed Jules review from #${reviewPrNumber} to PR #${originalPrNumber} and closed the empty review artifact.`, + ); + + dispatch: + if: >- + !startsWith(github.event.pull_request.head.ref, 'chore/jules-ocr-review-') + runs-on: ubuntu-latest + + steps: + - name: Prepare Jules OpenCodeReview dispatch + id: prepare + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr = context.payload.pull_request; + const marker = ``; + const reviewLabel = "jules-review"; + const triggerLabel = "jules"; + + async function ensureLabel(name, color, description) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ + owner, + repo, + name, + color, + description, + }); + } + } + + async function findDispatchIssue() { + const { data: issues } = await github.rest.issues.listForRepo({ + owner, + repo, + state: "all", + labels: reviewLabel, + per_page: 100, + }); + + return issues.find( + (issue) => !issue.pull_request && issue.body?.includes(marker), + ); + } + + await ensureLabel( + reviewLabel, + "5319e7", + "Automated Jules + OpenCodeReview review dispatch", + ); + await ensureLabel( + triggerLabel, + "4285f4", + "Triggers a Jules task through the Jules GitHub App", + ); + + const existing = await findDispatchIssue(); + + if (context.payload.action === "closed") { + if (existing && existing.state !== "closed") { + await github.rest.issues.update({ + owner, + repo, + issue_number: existing.number, + state: "closed", + state_reason: "completed", + }); + } + core.setOutput("should-trigger", "false"); + core.info("PR closed; dispatch issue cleaned up."); + return; + } + + if (pr.draft) { + core.setOutput("should-trigger", "false"); + core.info("Draft PR; Jules review is skipped until ready_for_review."); + return; + } + + if (pr.head.repo.full_name !== `${owner}/${repo}`) { + core.setOutput("should-trigger", "false"); + core.warning( + "Fork PR detected; automatic Jules dispatch is disabled to protect the account task quota.", + ); + return; + } + + // Hard rule: one Jules task per source PR. + if (existing) { + core.setOutput("should-trigger", "false"); + core.info( + `Jules was already dispatched once for PR #${pr.number} via issue #${existing.number}; skipping.`, + ); + return; + } + + const shortSha = pr.head.sha.slice(0, 7); + const title = `[Jules OCR] Review PR #${pr.number} @ ${shortSha}`; + const body = `${marker} + ## Review target + + - Pull request: #${pr.number} + - Base: \`${pr.base.ref}\` + - Head: \`${pr.head.ref}\` + - Expected head SHA: \`${pr.head.sha}\` + + ## Task + + Perform a **review-only** code review of PR #${pr.number} using **OpenCodeReview Delegation Mode** with Jules as the host model. + + Do not configure or use an external LLM API. The purpose of delegation mode is to reuse the Jules model/task allowance attached to this GitHub App session. + + Do **not** modify repository files. Keep the result PR empty. Jules issue tasks publish a PR even for review-only work; put the complete review report in your final summary / result PR body so automation can relay it back to PR #${pr.number} and close the empty artifact. + + ### Required procedure + + 1. Fetch the target refs and verify the reviewed head resolves to \`${pr.head.sha}\`. + 2. Install the latest OCR CLI if needed: + \`npm install -g @alibaba-group/open-code-review@latest\` + 3. Run the delegation scope preview against the PR: + \`ocr delegate preview --from origin/${pr.base.ref} --to origin/${pr.head.ref} --format json\` + If this installed OCR version does not support \`--format json\`, rerun without that flag. + 4. Use \`ocr delegate rule \` for the reviewable files returned by the preview. + 5. Inspect the actual git diff plus any full files/tests needed for context, then perform the review yourself as the host agent. + 6. Report only actionable **critical, high, or medium** confidence findings. For each finding include severity, file/line, why it is a problem, and the smallest safe fix. + 7. If there are no critical/high/medium findings, state that explicitly. + + Keep this task review-only and ensure the complete report is present in the final result PR body.`; + + const created = await github.rest.issues.create({ + owner, + repo, + title, + body, + labels: [reviewLabel], + }); + const issue = created.data; + + // The normal GITHUB_TOKEN prepares the issue only. Jules did not + // react to a github-actions[bot] label event in the live test. + // The next step adds "jules" with a user-scoped GitHub token. + core.setOutput("issue-number", String(issue.number)); + core.setOutput("should-trigger", "true"); + + const prCommentMarker = ""; + const commentBody = `${prCommentMarker} + Jules + OpenCodeReview review prepared for \`${shortSha}\`: #${issue.number}`; + + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: pr.number, + per_page: 100, + }); + + const previous = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && + comment.body?.includes(prCommentMarker), + ); + + if (previous) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: previous.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: commentBody, + }); + } + + - name: Verify user trigger token + if: steps.prepare.outputs.should-trigger == 'true' + shell: bash + env: + JULES_GITHUB_TOKEN: ${{ secrets.JULES_GITHUB_TOKEN }} + run: | + if [ -z "$JULES_GITHUB_TOKEN" ]; then + echo "::error::Missing JULES_GITHUB_TOKEN. Add a fine-grained GitHub PAT for your account, scoped to this repository with Issues: Read and write. It is used only to add the 'jules' label; no Jules API key or model API is used." + exit 1 + fi + + - name: Trigger Jules with user identity + if: steps.prepare.outputs.should-trigger == 'true' + uses: actions/github-script@v8 + env: + ISSUE_NUMBER: ${{ steps.prepare.outputs.issue-number }} + with: + github-token: ${{ secrets.JULES_GITHUB_TOKEN }} + script: | + const issueNumber = Number(process.env.ISSUE_NUMBER); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ["jules"], + }); + core.notice( + `Jules review triggered through issue #${issueNumber} using the user's GitHub identity.`, + );