[nightly][agent-sec-core] adapter directories /usr/share/anolisa/adapters/sec-core/{openclaw,hermes} not created after install #89
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Qoder AI Review | |
| on: | |
| pull_request_target: | |
| types: [opened, ready_for_review] | |
| issue_comment: | |
| types: [created] | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: 'Pull request number to review' | |
| required: true | |
| type: number | |
| component: | |
| description: 'Primary component to emphasize during review' | |
| required: false | |
| default: auto | |
| type: choice | |
| options: | |
| - auto | |
| - general | |
| - docs | |
| - ci | |
| - anolisa | |
| - tokenless | |
| - agent-sec-core | |
| - agentsight | |
| - agent-memory | |
| - skillfs | |
| - ws-ckpt | |
| - os-skills | |
| - copilot-shell | |
| - cosh-ng | |
| - anvil | |
| - ktuner | |
| review_scope: | |
| description: 'auto reviews new commits, falls back to full when needed, and skips an already-reviewed head; full always reviews all; incremental requires a reliable delta' | |
| required: false | |
| default: auto | |
| type: choice | |
| options: | |
| - auto | |
| - full | |
| - incremental | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: qoder-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.run_id }} | |
| cancel-in-progress: true | |
| jobs: | |
| review-gate: | |
| name: Check review eligibility | |
| if: >- | |
| github.event_name == 'workflow_dispatch' || | |
| ( | |
| github.event_name == 'pull_request_target' && | |
| github.event.pull_request.draft == false && | |
| contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) | |
| ) || | |
| ( | |
| github.event_name == 'issue_comment' && | |
| github.event.issue.pull_request != null && | |
| github.event.comment.user.type != 'Bot' && | |
| contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && | |
| ( | |
| startsWith(github.event.comment.body, '@qoder review') || | |
| startsWith(github.event.comment.body, '/qoder review') | |
| ) | |
| ) | |
| runs-on: [self-hosted, Linux, anolisa-agent-review] | |
| permissions: | |
| contents: read | |
| issues: read | |
| pull-requests: read | |
| outputs: | |
| allowed: ${{ steps.resolve.outputs.allowed }} | |
| reason: ${{ steps.resolve.outputs.reason }} | |
| pr_number: ${{ steps.resolve.outputs.pr_number }} | |
| component: ${{ steps.resolve.outputs.component }} | |
| review_scope: ${{ steps.resolve.outputs.review_scope }} | |
| steps: | |
| - name: Resolve PR and actor permission | |
| id: resolve | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| EVENT_PR_NUMBER: ${{ github.event.pull_request.number || '' }} | |
| EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login || '' }} | |
| EVENT_PR_DRAFT: ${{ github.event.pull_request.draft || false }} | |
| INPUT_PR_NUMBER: ${{ inputs.pr_number || '' }} | |
| INPUT_COMPONENT: ${{ inputs.component || 'auto' }} | |
| INPUT_REVIEW_SCOPE: ${{ inputs.review_scope || 'auto' }} | |
| TRIGGER_ACTOR: ${{ github.actor }} | |
| run: | | |
| set -euo pipefail | |
| allow() { | |
| local pr_number="$1" | |
| local component="$2" | |
| local review_scope="$3" | |
| echo "allowed=true" >> "$GITHUB_OUTPUT" | |
| echo "reason=allowed" >> "$GITHUB_OUTPUT" | |
| echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" | |
| echo "component=${component}" >> "$GITHUB_OUTPUT" | |
| echo "review_scope=${review_scope}" >> "$GITHUB_OUTPUT" | |
| } | |
| deny() { | |
| local reason="$1" | |
| local pr_number="${2:-}" | |
| local component="${3:-auto}" | |
| local review_scope="${4:-${INPUT_REVIEW_SCOPE:-auto}}" | |
| echo "allowed=false" >> "$GITHUB_OUTPUT" | |
| echo "reason=${reason}" >> "$GITHUB_OUTPUT" | |
| echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" | |
| echo "component=${component}" >> "$GITHUB_OUTPUT" | |
| echo "review_scope=${review_scope}" >> "$GITHUB_OUTPUT" | |
| echo "Qoder review skipped: ${reason}" | |
| } | |
| permission_for() { | |
| local user="$1" | |
| gh api "repos/${REPO}/collaborators/${user}/permission" --jq '.permission' 2>/dev/null || echo "none" | |
| } | |
| has_write_access() { | |
| case "$1" in | |
| admin|maintain|write) return 0 ;; | |
| *) return 1 ;; | |
| esac | |
| } | |
| if [[ "$EVENT_NAME" == "pull_request_target" ]]; then | |
| pr_number="$EVENT_PR_NUMBER" | |
| author="$EVENT_PR_AUTHOR" | |
| actor="$TRIGGER_ACTOR" | |
| component="auto" | |
| review_scope="auto" | |
| if [[ "$EVENT_PR_DRAFT" == "true" ]]; then | |
| deny "draft-pr" "$pr_number" "$component" "$review_scope" | |
| exit 0 | |
| fi | |
| elif [[ "$EVENT_NAME" == "issue_comment" ]]; then | |
| pr_number="$(jq -r '.issue.number // ""' "$GITHUB_EVENT_PATH")" | |
| actor="$(jq -r '.comment.user.login // ""' "$GITHUB_EVENT_PATH")" | |
| component="auto" | |
| body="$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH")" | |
| first_line="${body%%$'\n'*}" | |
| first_line="${first_line%$'\r'}" | |
| if [[ "$(jq -r '.issue.pull_request != null' "$GITHUB_EVENT_PATH")" != "true" ]]; then | |
| deny "comment-not-on-pr" "$pr_number" "$component" | |
| exit 0 | |
| fi | |
| if [[ "$first_line" =~ ^(@qoder|/qoder)[[:space:]]+review[[:space:]]*$ ]]; then | |
| review_scope="auto" | |
| elif [[ "$first_line" =~ ^(@qoder|/qoder)[[:space:]]+review[[:space:]]+full[[:space:]]*$ ]]; then | |
| review_scope="full" | |
| else | |
| deny "unsupported-review-command" "$pr_number" "$component" | |
| exit 0 | |
| fi | |
| if ! gh api "repos/${REPO}/pulls/${pr_number}" --jq '.number' >/dev/null 2>&1; then | |
| deny "pr-not-found" "$pr_number" "$component" "$review_scope" | |
| exit 0 | |
| fi | |
| author="" | |
| elif [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then | |
| pr_number="$INPUT_PR_NUMBER" | |
| author="" | |
| actor="$TRIGGER_ACTOR" | |
| component="$INPUT_COMPONENT" | |
| review_scope="${INPUT_REVIEW_SCOPE:-auto}" | |
| if ! gh api "repos/${REPO}/pulls/${pr_number}" --jq '.number' >/dev/null 2>&1; then | |
| deny "pr-not-found" "$pr_number" "$component" | |
| exit 0 | |
| fi | |
| else | |
| deny "unsupported-event" | |
| exit 0 | |
| fi | |
| actor_permission="$(permission_for "$actor")" | |
| if ! has_write_access "$actor_permission"; then | |
| deny "actor-${actor}-permission-${actor_permission}" "$pr_number" "$component" "$review_scope" | |
| exit 0 | |
| fi | |
| if [[ -n "$author" ]]; then | |
| author_permission="$(permission_for "$author")" | |
| if ! has_write_access "$author_permission"; then | |
| deny "author-${author}-permission-${author_permission}" "$pr_number" "$component" "$review_scope" | |
| exit 0 | |
| fi | |
| fi | |
| pr_state="$(gh api "repos/${REPO}/pulls/${pr_number}" --jq '.state' 2>/dev/null || echo "unknown")" | |
| if [[ "$pr_state" != "open" ]]; then | |
| deny "pr-state-${pr_state}" "$pr_number" "$component" "$review_scope" | |
| exit 0 | |
| fi | |
| allow "$pr_number" "$component" "$review_scope" | |
| qoder-review: | |
| name: Run Qoder review | |
| needs: review-gate | |
| if: needs.review-gate.outputs.allowed == 'true' | |
| runs-on: [self-hosted, Linux, anolisa-agent-review] | |
| timeout-minutes: 45 | |
| permissions: | |
| contents: read | |
| issues: read | |
| pull-requests: write | |
| # Qoder Action requests a GitHub OIDC token with audience qoder-action, | |
| # then exchanges it with QODER_PERSONAL_ACCESS_TOKEN for a short-lived | |
| # Qoder GitHub App installation token used by the pinned action. | |
| id-token: write | |
| env: | |
| PR_NUMBER: ${{ needs.review-gate.outputs.pr_number }} | |
| REQUESTED_COMPONENT: ${{ needs.review-gate.outputs.component }} | |
| REQUESTED_REVIEW_SCOPE: ${{ needs.review-gate.outputs.review_scope }} | |
| TRIGGER_EVENT: ${{ github.event_name }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Setup Node.js for Qoder Action | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Ensure Qoder Action dependencies | |
| run: | | |
| set -euo pipefail | |
| missing=() | |
| command -v curl >/dev/null 2>&1 || missing+=(curl) | |
| command -v jq >/dev/null 2>&1 || missing+=(jq) | |
| if [[ "${#missing[@]}" -gt 0 ]]; then | |
| sudo apt-get update | |
| sudo apt-get install -y "${missing[@]}" | |
| fi | |
| - name: Resolve review scope and context | |
| id: review-context | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER: ${{ env.PR_NUMBER }} | |
| REQUESTED_REVIEW_SCOPE: ${{ env.REQUESTED_REVIEW_SCOPE }} | |
| TRIGGER_EVENT: ${{ env.TRIGGER_EVENT }} | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const prNumber = Number(process.env.PR_NUMBER); | |
| const requestedScope = process.env.REQUESTED_REVIEW_SCOPE || 'auto'; | |
| const automaticTrigger = process.env.TRIGGER_EVENT === 'pull_request_target'; | |
| const qoderLogins = new Set(['qoderai', 'qoderai[bot]']); | |
| const compact = (value, limit) => String(value || '') | |
| .replace(/\s+/g, ' ') | |
| .trim() | |
| .slice(0, limit); | |
| const bounded = (value, limit) => String(value || '').trim().slice(0, limit); | |
| const isQoder = (login) => qoderLogins.has(String(login || '').toLowerCase()); | |
| const isBot = (login) => String(login || '').toLowerCase().endsWith('[bot]'); | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| }); | |
| let prComments = []; | |
| let reviewNodes = []; | |
| let threadNodes = []; | |
| let linkedIssues = []; | |
| let discussionContextAvailable = false; | |
| let linkedIssueContextStatus = 'unavailable'; | |
| let reviewHistoryStatus = 'unavailable'; | |
| let threadContextStatus = 'unavailable'; | |
| let reviewHistoryHasPreviousPage = false; | |
| let reviewHistoryCursor = null; | |
| try { | |
| const contextData = await github.graphql( | |
| `query($owner: String!, $repo: String!, $number: Int!) { | |
| repository(owner: $owner, name: $repo) { | |
| pullRequest(number: $number) { | |
| comments(last: 20) { | |
| nodes { author { login } authorAssociation body createdAt } | |
| } | |
| reviews(last: 100) { | |
| pageInfo { hasPreviousPage startCursor } | |
| nodes { | |
| author { login } | |
| authorAssociation | |
| body | |
| state | |
| submittedAt | |
| commit { oid } | |
| } | |
| } | |
| reviewThreads(last: 100) { | |
| pageInfo { hasPreviousPage } | |
| nodes { | |
| isResolved | |
| path | |
| initialComments: comments(first: 1) { | |
| nodes { id author { login } authorAssociation body createdAt } | |
| } | |
| recentComments: comments(last: 20) { | |
| pageInfo { hasPreviousPage } | |
| nodes { id author { login } authorAssociation body createdAt } | |
| } | |
| } | |
| } | |
| closingIssuesReferences(first: 5) { | |
| pageInfo { hasNextPage } | |
| nodes { | |
| number | |
| title | |
| body | |
| state | |
| url | |
| repository { nameWithOwner } | |
| comments(last: 5) { | |
| nodes { author { login } authorAssociation body createdAt } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }`, | |
| { owner, repo, number: prNumber }, | |
| ); | |
| const pullRequest = contextData?.repository?.pullRequest; | |
| if (!pullRequest) { | |
| throw new Error('pull request context was null'); | |
| } | |
| prComments = pullRequest.comments?.nodes || []; | |
| reviewNodes = pullRequest.reviews?.nodes || []; | |
| reviewHistoryHasPreviousPage = Boolean( | |
| pullRequest.reviews?.pageInfo?.hasPreviousPage, | |
| ); | |
| reviewHistoryCursor = pullRequest.reviews?.pageInfo?.startCursor || null; | |
| reviewHistoryStatus = 'complete'; | |
| threadNodes = pullRequest.reviewThreads?.nodes || []; | |
| threadContextStatus = ( | |
| pullRequest.reviewThreads?.pageInfo?.hasPreviousPage || | |
| threadNodes.some((thread) => | |
| thread.recentComments?.pageInfo?.hasPreviousPage) | |
| ) ? 'partial' : 'complete'; | |
| linkedIssues = pullRequest.closingIssuesReferences?.nodes || []; | |
| discussionContextAvailable = true; | |
| linkedIssueContextStatus = pullRequest.closingIssuesReferences?.pageInfo?.hasNextPage | |
| ? 'partial' | |
| : 'complete'; | |
| } catch (error) { | |
| core.warning(`Unable to load PR discussion context: ${error.message}`); | |
| } | |
| if (discussionContextAvailable) { | |
| try { | |
| let qoderReviewCount = reviewNodes.filter((review) => | |
| isQoder(review.author?.login) && | |
| review.commit?.oid && | |
| review.state !== 'PENDING').length; | |
| while (reviewHistoryHasPreviousPage && qoderReviewCount < 10) { | |
| if (!reviewHistoryCursor) { | |
| throw new Error('review history cursor was missing'); | |
| } | |
| const olderData = await github.graphql( | |
| `query($owner: String!, $repo: String!, $number: Int!, $before: String!) { | |
| repository(owner: $owner, name: $repo) { | |
| pullRequest(number: $number) { | |
| reviews(last: 100, before: $before) { | |
| pageInfo { hasPreviousPage startCursor } | |
| nodes { | |
| author { login } | |
| authorAssociation | |
| body | |
| state | |
| submittedAt | |
| commit { oid } | |
| } | |
| } | |
| } | |
| } | |
| }`, | |
| { owner, repo, number: prNumber, before: reviewHistoryCursor }, | |
| ); | |
| const olderReviews = olderData?.repository?.pullRequest?.reviews; | |
| if (!olderReviews) { | |
| throw new Error('older review history was null'); | |
| } | |
| const olderNodes = olderReviews.nodes || []; | |
| reviewNodes = [...olderNodes, ...reviewNodes]; | |
| qoderReviewCount += olderNodes.filter((review) => | |
| isQoder(review.author?.login) && | |
| review.commit?.oid && | |
| review.state !== 'PENDING').length; | |
| reviewHistoryHasPreviousPage = Boolean( | |
| olderReviews.pageInfo?.hasPreviousPage, | |
| ); | |
| reviewHistoryCursor = olderReviews.pageInfo?.startCursor || null; | |
| } | |
| } catch (error) { | |
| reviewHistoryStatus = 'partial'; | |
| core.warning(`Unable to paginate older Qoder review history: ${error.message}`); | |
| } | |
| } | |
| if (!discussionContextAvailable) { | |
| try { | |
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| per_page: 100, | |
| }); | |
| reviewNodes = reviews.map((review) => ({ | |
| author: { login: review.user?.login }, | |
| authorAssociation: review.author_association, | |
| body: review.body, | |
| state: review.state, | |
| submittedAt: review.submitted_at, | |
| commit: { oid: review.commit_id }, | |
| })); | |
| reviewHistoryStatus = 'complete'; | |
| } catch (error) { | |
| core.warning(`Unable to load fallback review history: ${error.message}`); | |
| } | |
| } | |
| const referencedIssueNumbers = []; | |
| const addReferencedIssue = (rawNumber) => { | |
| const issueNumber = Number(rawNumber); | |
| if (issueNumber !== prNumber && !referencedIssueNumbers.includes(issueNumber)) { | |
| referencedIssueNumbers.push(issueNumber); | |
| } | |
| }; | |
| const prReferenceText = `${pr.title || ''}\n${pr.body || ''}`; | |
| const issueReferencePattern = /(?:^|[^\w/.-])(?:([\w.-]+)\/([\w.-]+))?#(\d+)\b/g; | |
| for (const match of prReferenceText.matchAll(issueReferencePattern)) { | |
| const [, refOwner, refRepo, rawNumber] = match; | |
| if (refOwner && ( | |
| refOwner.toLowerCase() !== owner.toLowerCase() || | |
| refRepo.toLowerCase() !== repo.toLowerCase() | |
| )) { | |
| continue; | |
| } | |
| addReferencedIssue(rawNumber); | |
| } | |
| const issueUrlPattern = /https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)\b/g; | |
| for (const match of prReferenceText.matchAll(issueUrlPattern)) { | |
| const [, refOwner, refRepo, rawNumber] = match; | |
| if ( | |
| refOwner.toLowerCase() === owner.toLowerCase() && | |
| refRepo.toLowerCase() === repo.toLowerCase() | |
| ) { | |
| addReferencedIssue(rawNumber); | |
| } | |
| } | |
| const linkedIssueKeys = new Set(linkedIssues.map((issue) => | |
| `${issue.repository?.nameWithOwner || `${owner}/${repo}`}#${issue.number}`.toLowerCase())); | |
| const missingIssueNumbers = referencedIssueNumbers | |
| .filter((issueNumber) => !linkedIssueKeys.has(`${owner}/${repo}#${issueNumber}`.toLowerCase())) | |
| .slice(0, Math.max(0, 5 - linkedIssues.length)); | |
| if (missingIssueNumbers.length > 0) { | |
| try { | |
| const issueAliases = missingIssueNumbers.map((issueNumber, index) => ` | |
| issue${index}: issue(number: ${issueNumber}) { | |
| number | |
| title | |
| body | |
| state | |
| url | |
| repository { nameWithOwner } | |
| comments(last: 5) { | |
| nodes { author { login } authorAssociation body createdAt } | |
| } | |
| }`).join('\n'); | |
| const referencedIssueData = await github.graphql( | |
| `query($owner: String!, $repo: String!) { | |
| repository(owner: $owner, name: $repo) { | |
| ${issueAliases} | |
| } | |
| }`, | |
| { owner, repo }, | |
| ); | |
| linkedIssues.push(...Object.values(referencedIssueData?.repository || {}).filter(Boolean)); | |
| if (linkedIssueContextStatus === 'unavailable') { | |
| linkedIssueContextStatus = 'partial'; | |
| } | |
| } catch (error) { | |
| linkedIssueContextStatus = 'partial'; | |
| core.warning(`Unable to load explicitly referenced issues: ${error.message}`); | |
| } | |
| } | |
| linkedIssues = linkedIssues.slice(0, 5); | |
| const normalizedReviews = reviewNodes.map((review) => ({ | |
| login: review.author?.login, | |
| association: review.authorAssociation, | |
| body: review.body, | |
| state: review.state, | |
| submittedAt: review.submittedAt, | |
| commitId: review.commit?.oid, | |
| })); | |
| const qoderReviews = normalizedReviews | |
| .filter((review) => isQoder(review.login) && review.commitId && review.state !== 'PENDING') | |
| .sort((left, right) => new Date(left.submittedAt || 0) - new Date(right.submittedAt || 0)); | |
| const latestQoderReview = qoderReviews.at(-1); | |
| const fullFiles = async () => github.paginate(github.rest.pulls.listFiles, { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| per_page: 100, | |
| }); | |
| let reviewMode = 'full'; | |
| let rangeBase = pr.base.sha; | |
| let rangeHead = pr.head.sha; | |
| let files = []; | |
| let skip = false; | |
| if (automaticTrigger && latestQoderReview) { | |
| skip = true; | |
| reviewMode = 'automatic-already-reviewed'; | |
| } else if (requestedScope !== 'full' && latestQoderReview) { | |
| try { | |
| const { data: comparison } = await github.rest.repos.compareCommits({ | |
| owner, | |
| repo, | |
| base: latestQoderReview.commitId, | |
| head: pr.head.sha, | |
| }); | |
| if (comparison.status === 'identical') { | |
| skip = true; | |
| reviewMode = 'already-reviewed'; | |
| } else if (comparison.status === 'ahead') { | |
| const comparisonFiles = comparison.files || []; | |
| const comparisonCommits = comparison.commits || []; | |
| const hasMergeCommit = comparisonCommits.some((commit) => commit.parents.length > 1); | |
| const hasTruncatedCommits = comparison.ahead_by > comparisonCommits.length; | |
| const hasTruncatedFiles = comparisonFiles.length >= 300; | |
| if (hasMergeCommit || hasTruncatedCommits || hasTruncatedFiles) { | |
| if (requestedScope === 'incremental') { | |
| skip = true; | |
| reviewMode = 'incremental-unavailable'; | |
| } else { | |
| reviewMode = 'full-unreliable-increment'; | |
| } | |
| } else { | |
| reviewMode = 'incremental'; | |
| rangeBase = latestQoderReview.commitId; | |
| files = comparisonFiles; | |
| if (files.length === 0) { | |
| skip = true; | |
| reviewMode = 'no-diff'; | |
| } | |
| } | |
| } else { | |
| if (requestedScope === 'incremental') { | |
| skip = true; | |
| reviewMode = 'incremental-unavailable'; | |
| } else { | |
| reviewMode = 'rewritten-history'; | |
| } | |
| } | |
| } catch (error) { | |
| core.warning(`Unable to compare the last Qoder review commit: ${error.message}`); | |
| if (requestedScope === 'incremental') { | |
| skip = true; | |
| reviewMode = 'incremental-unavailable'; | |
| } else { | |
| reviewMode = 'rewritten-history'; | |
| } | |
| } | |
| } | |
| if (requestedScope === 'incremental' && !latestQoderReview) { | |
| skip = true; | |
| reviewMode = 'incremental-unavailable'; | |
| } | |
| if (!skip && reviewMode !== 'incremental' && files.length === 0) { | |
| files = await fullFiles(); | |
| } | |
| const initialThreadComment = (thread) => thread.initialComments?.nodes?.[0]; | |
| const formatThread = (thread) => { | |
| const initial = initialThreadComment(thread); | |
| if (!initial) { | |
| return null; | |
| } | |
| const replies = (thread.recentComments?.nodes || []) | |
| .filter((comment) => comment.id !== initial.id) | |
| .slice(-4); | |
| const comments = [initial, ...replies] | |
| .map((comment) => | |
| `${comment.createdAt || 'unknown-time'} [${comment.authorAssociation || 'NONE'}] ${comment.author?.login || 'unknown'}: ${compact(comment.body, 700)}`) | |
| .join('\n'); | |
| return bounded( | |
| `[${thread.isResolved ? 'resolved' : 'open'}] ${thread.path || 'general'}\n${comments}`, | |
| 3200, | |
| ); | |
| }; | |
| const isReviewCommand = (body) => | |
| /^(@qoder|\/qoder)\s+review(?:\s+full)?\s*$/i.test(String(body || '').split(/\r?\n/, 1)[0]); | |
| const qoderThreadNodes = threadNodes | |
| .filter((thread) => isQoder(initialThreadComment(thread)?.author?.login)); | |
| const humanThreadNodes = threadNodes | |
| .filter((thread) => { | |
| const login = initialThreadComment(thread)?.author?.login; | |
| return login && !isQoder(login) && !isBot(login); | |
| }); | |
| const hasTruncatedThreadReplies = threadNodes.some((thread) => { | |
| const initial = initialThreadComment(thread); | |
| return (thread.recentComments?.nodes || []) | |
| .filter((comment) => comment.id !== initial?.id).length > 4; | |
| }); | |
| if ( | |
| qoderThreadNodes.length > 20 || | |
| humanThreadNodes.length > 12 || | |
| hasTruncatedThreadReplies | |
| ) { | |
| threadContextStatus = 'partial'; | |
| } | |
| const qoderThreads = qoderThreadNodes | |
| .slice(-20) | |
| .map(formatThread) | |
| .filter(Boolean); | |
| const humanThreads = humanThreadNodes | |
| .slice(-12) | |
| .map(formatThread) | |
| .filter(Boolean); | |
| const humanComments = prComments | |
| .filter((comment) => { | |
| const login = comment.author?.login; | |
| return login && !isQoder(login) && !isBot(login) && !isReviewCommand(comment.body); | |
| }) | |
| .slice(-20) | |
| .map((comment) => | |
| `${comment.createdAt || 'unknown-time'} [${comment.authorAssociation || 'NONE'}] ${comment.author.login}: ${compact(comment.body, 700)}`); | |
| const humanReviews = normalizedReviews | |
| .filter((review) => | |
| review.login && | |
| !isQoder(review.login) && | |
| !isBot(review.login) && | |
| review.state !== 'PENDING' && | |
| compact(review.body, 1)) | |
| .slice(-10) | |
| .map((review) => | |
| `${review.submittedAt || 'unknown-time'} [${review.state}/${review.association || 'NONE'}] ${review.login}: ${compact(review.body, 1200)}`); | |
| const prContext = [ | |
| `TITLE: ${compact(pr.title, 500) || '(none)'}`, | |
| `AUTHOR: ${pr.user?.login || 'unknown'}`, | |
| `STATE: ${pr.state}; DRAFT: ${pr.draft}`, | |
| `BASE: ${pr.base.ref}@${pr.base.sha}`, | |
| `HEAD: ${pr.head.label || pr.head.ref}@${pr.head.sha}`, | |
| `LABELS: ${(pr.labels || []).map((label) => label.name).filter(Boolean).join(', ') || '(none)'}`, | |
| 'DESCRIPTION:', | |
| bounded(pr.body, 6000) || '(none)', | |
| ].join('\n'); | |
| const formatIssue = (issue) => { | |
| const issueRepository = issue.repository?.nameWithOwner || `${owner}/${repo}`; | |
| const isLocalIssue = issueRepository.toLowerCase() === | |
| `${owner}/${repo}`.toLowerCase(); | |
| const issueComments = (issue.comments?.nodes || []) | |
| .filter((comment) => { | |
| const login = comment.author?.login; | |
| return login && !isQoder(login) && !isBot(login); | |
| }) | |
| .slice(-5) | |
| .map((comment) => { | |
| const association = isLocalIssue | |
| ? (comment.authorAssociation || 'NONE') | |
| : 'EXTERNAL_REPOSITORY'; | |
| return `${comment.createdAt || 'unknown-time'} [${association}] ${comment.author.login}: ${compact(comment.body, 700)}`; | |
| }); | |
| return bounded([ | |
| `ISSUE: ${issueRepository}#${issue.number}`, | |
| `ASSOCIATION_SCOPE: ${isLocalIssue ? 'local-repository' : 'external-repository'}`, | |
| `STATE: ${issue.state}; URL: ${issue.url}`, | |
| `TITLE: ${compact(issue.title, 500)}`, | |
| 'DESCRIPTION:', | |
| bounded(issue.body, 3000) || '(none)', | |
| 'RECENT_ISSUE_COMMENTS:', | |
| issueComments.length > 0 ? issueComments.join('\n') : '(none)', | |
| ].join('\n'), 7000); | |
| }; | |
| const linkedIssueContext = [ | |
| `LINKED_ISSUE_CONTEXT_STATUS: ${linkedIssueContextStatus}`, | |
| linkedIssues.length > 0 ? linkedIssues.map(formatIssue).join('\n\n') : '(none found)', | |
| ].join('\n'); | |
| const conversation = [ | |
| `DISCUSSION_CONTEXT_STATUS: ${discussionContextAvailable ? 'complete' : 'unavailable'}`, | |
| 'RECENT_PR_COMMENTS:', | |
| humanComments.length > 0 ? humanComments.join('\n') : '(none)', | |
| 'HUMAN_REVIEW_SUMMARIES:', | |
| humanReviews.length > 0 ? humanReviews.join('\n') : '(none)', | |
| 'HUMAN_REVIEW_THREADS:', | |
| humanThreads.length > 0 ? humanThreads.join('\n\n') : '(none)', | |
| ].join('\n'); | |
| const qoderReviewSummaries = qoderReviews | |
| .filter((review) => compact(review.body, 1)) | |
| .slice(-10) | |
| .map((review) => | |
| `${review.submittedAt || 'unknown-time'} [${review.state}] commit=${review.commitId}: ${compact(review.body, 1600)}`); | |
| const history = [ | |
| `THREAD_CONTEXT_STATUS: ${threadContextStatus}`, | |
| `REVIEW_HISTORY_STATUS: ${reviewHistoryStatus}`, | |
| `LATEST_QODER_REVIEW_COMMIT: ${latestQoderReview?.commitId || '(none)'}`, | |
| 'RECENT_QODER_REVIEW_SUMMARIES:', | |
| qoderReviewSummaries.length > 0 ? qoderReviewSummaries.join('\n\n') : '(none)', | |
| 'QODER_REVIEW_THREADS:', | |
| qoderThreads.length > 0 ? qoderThreads.join('\n\n') : '(none)', | |
| ].join('\n'); | |
| core.setOutput('skip', String(skip)); | |
| core.setOutput('mode', reviewMode); | |
| core.setOutput('range_base', rangeBase); | |
| core.setOutput('range_head', rangeHead); | |
| core.setOutput('files', files.map((file) => file.filename).join('\n')); | |
| core.setOutput('pr_context', prContext); | |
| core.setOutput('linked_issues', linkedIssueContext); | |
| core.setOutput('conversation', conversation); | |
| core.setOutput('history', history); | |
| - name: Build Qoder prompt | |
| id: prompt | |
| if: steps.review-context.outputs.skip != 'true' | |
| env: | |
| REPO: ${{ github.repository }} | |
| REVIEW_MODE: ${{ steps.review-context.outputs.mode }} | |
| REVIEW_RANGE_BASE: ${{ steps.review-context.outputs.range_base }} | |
| REVIEW_RANGE_HEAD: ${{ steps.review-context.outputs.range_head }} | |
| REVIEW_FILES: ${{ steps.review-context.outputs.files }} | |
| PR_CONTEXT: ${{ steps.review-context.outputs.pr_context }} | |
| LINKED_ISSUES_CONTEXT: ${{ steps.review-context.outputs.linked_issues }} | |
| CONVERSATION_CONTEXT: ${{ steps.review-context.outputs.conversation }} | |
| REVIEW_HISTORY: ${{ steps.review-context.outputs.history }} | |
| run: | | |
| set -euo pipefail | |
| prompt_file="" | |
| files_file="$(mktemp)" | |
| trap 'rm -f "${prompt_file:-}" "${files_file:-}"' EXIT | |
| printf '%s\n' "$REVIEW_FILES" > "$files_file" | |
| add_component_context() { | |
| case "$1" in | |
| anolisa) add_context_file "src/anolisa/AGENTS.md" ;; | |
| agent-sec-core) add_context_file "src/agent-sec-core/AGENTS.md" ;; | |
| agentsight) add_context_file "src/agentsight/AGENTS.md" ;; | |
| skillfs) add_context_file "src/skillfs/AGENTS.md" ;; | |
| cosh-ng) add_context_file "src/cosh-ng/AGENTS.md" ;; | |
| anvil) add_context_file "src/anvil/AGENTS.md" ;; | |
| esac | |
| } | |
| add_context_file() { | |
| local name="$1" | |
| [[ -f "$name" ]] || return 0 | |
| case " ${context_files[*]-} " in | |
| *" ${name} "*) ;; | |
| *) context_files+=("$name") ;; | |
| esac | |
| } | |
| add_nearest_agents() { | |
| local file="$1" | |
| local dir | |
| dir="$(dirname "$file")" | |
| while [[ "$dir" != "." && "$dir" != "/" ]]; do | |
| add_context_file "${dir}/AGENTS.md" | |
| dir="$(dirname "$dir")" | |
| done | |
| } | |
| context_files=() | |
| add_context_file "AGENTS.md" | |
| component="$REQUESTED_COMPONENT" | |
| if [[ "$component" == "auto" ]]; then | |
| component="general" | |
| if grep -qE '^(\.github/|scripts/.*ci|AGENTS\.md$)' "$files_file"; then | |
| component="ci" | |
| fi | |
| if grep -qE '^(docs/|.*README.*|.*CHANGELOG.*|.*CONTRIBUTING.*|specs/)' "$files_file"; then | |
| [[ "$component" == "general" ]] && component="docs" | |
| fi | |
| if grep -qE '^src/anolisa/' "$files_file"; then [[ "$component" == "general" ]] && component="anolisa"; fi | |
| if grep -qE '^src/tokenless/' "$files_file"; then [[ "$component" == "general" ]] && component="tokenless"; fi | |
| if grep -qE '^src/agent-sec-core/' "$files_file"; then [[ "$component" == "general" ]] && component="agent-sec-core"; fi | |
| if grep -qE '^src/agentsight/' "$files_file"; then [[ "$component" == "general" ]] && component="agentsight"; fi | |
| if grep -qE '^src/agent-memory/' "$files_file"; then [[ "$component" == "general" ]] && component="agent-memory"; fi | |
| if grep -qE '^src/skillfs/' "$files_file"; then [[ "$component" == "general" ]] && component="skillfs"; fi | |
| if grep -qE '^src/ws-ckpt/' "$files_file"; then [[ "$component" == "general" ]] && component="ws-ckpt"; fi | |
| if grep -qE '^src/os-skills/' "$files_file"; then [[ "$component" == "general" ]] && component="os-skills"; fi | |
| if grep -qE '^src/copilot-shell/' "$files_file"; then [[ "$component" == "general" ]] && component="copilot-shell"; fi | |
| if grep -qE '^src/cosh-ng/' "$files_file"; then [[ "$component" == "general" ]] && component="cosh-ng"; fi | |
| if grep -qE '^src/anvil/' "$files_file"; then [[ "$component" == "general" ]] && component="anvil"; fi | |
| if grep -qE '^src/ktuner/' "$files_file"; then [[ "$component" == "general" ]] && component="ktuner"; fi | |
| else | |
| add_component_context "$component" | |
| fi | |
| while IFS= read -r changed_file; do | |
| [[ -n "$changed_file" ]] || continue | |
| add_nearest_agents "$changed_file" | |
| done < "$files_file" | |
| add_component_context "$component" | |
| context_manifest="$(printf '%s\n' "${context_files[@]}")" | |
| changed_files="$(sed -n '1,120p' "$files_file")" | |
| review_policy=".github/review/general.md" | |
| if [[ ! -f "$review_policy" ]]; then | |
| echo "Missing Qoder review policy: $review_policy" >&2 | |
| exit 1 | |
| fi | |
| prompt_file="$(mktemp)" | |
| cp "$review_policy" "$prompt_file" | |
| cat >> "$prompt_file" <<EOF | |
| RUNTIME_CONTEXT: | |
| REPO:${REPO} PR_NUMBER:${PR_NUMBER} | |
| OUTPUT_LANGUAGE: Chinese | |
| PRIMARY_COMPONENT: ${component} | |
| REVIEW_MODE: ${REVIEW_MODE} | |
| REVIEW_RANGE: ${REVIEW_RANGE_BASE}..${REVIEW_RANGE_HEAD} | |
| CONTEXT_MANIFEST: | |
| ${context_manifest} | |
| PR_CONTEXT_UNTRUSTED: | |
| ${PR_CONTEXT} | |
| LINKED_ISSUES_CONTEXT_UNTRUSTED: | |
| ${LINKED_ISSUES_CONTEXT} | |
| PR_CONVERSATION_CONTEXT_UNTRUSTED: | |
| ${CONVERSATION_CONTEXT} | |
| QODER_HISTORY_CONTEXT_UNTRUSTED: | |
| ${REVIEW_HISTORY} | |
| REVIEW_SCOPE_FILES_FIRST_120: | |
| ${changed_files} | |
| EOF | |
| delimiter="QODER_PROMPT_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" | |
| while grep -Fxq "$delimiter" "$prompt_file"; do | |
| delimiter="QODER_PROMPT_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" | |
| done | |
| { | |
| echo "prompt<<$delimiter" | |
| cat "$prompt_file" | |
| echo "$delimiter" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Prepare Qoder runtime state | |
| env: | |
| HOME: ${{ runner.temp }}/qoder-home-${{ github.run_id }}-${{ github.run_attempt }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$HOME" | |
| rm -f "$HOME/.local/bin/qoder-github-mcp-server" | |
| - name: Run Qoder PR review | |
| id: qoder | |
| if: steps.review-context.outputs.skip != 'true' | |
| uses: QoderAI/qoder-action@0881d27d15a7a93778ebc5c942d11da313ee8b7e | |
| env: | |
| HOME: ${{ runner.temp }}/qoder-home-${{ github.run_id }}-${{ github.run_attempt }} | |
| with: | |
| qoder_personal_access_token: ${{ secrets.QODER_PERSONAL_ACCESS_TOKEN }} | |
| prompt: ${{ steps.prompt.outputs.prompt }} | |
| flags: | | |
| --model performance | |
| - name: Clean Qoder runtime state | |
| if: always() | |
| env: | |
| HOME: ${{ runner.temp }}/qoder-home-${{ github.run_id }}-${{ github.run_attempt }} | |
| run: | | |
| set -euo pipefail | |
| git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" 2>/dev/null || true | |
| qoder_output_file="${{ steps.qoder.outputs.output_file || '' }}" | |
| if [[ "$qoder_output_file" == /tmp/qoder-output-*.log ]]; then | |
| rm -f "$qoder_output_file" | |
| rm -f "${qoder_output_file/qoder-output-/qoder-error-}" | |
| fi | |
| if [[ -f "$HOME/.qoder.json" ]]; then | |
| tmp_config="$(mktemp)" | |
| if jq 'del(.mcpServers.qoder_github) | if (.mcpServers == {}) then del(.mcpServers) else . end' \ | |
| "$HOME/.qoder.json" > "$tmp_config"; then | |
| mv "$tmp_config" "$HOME/.qoder.json" | |
| else | |
| rm -f "$tmp_config" | |
| fi | |
| fi | |
| if [[ "$HOME" == "$RUNNER_TEMP"/qoder-home-* ]]; then | |
| rm -rf "$HOME" | |
| fi |