diff --git a/.github/workflows/qoder-assistant.yml b/.github/workflows/qoder-assistant.yml new file mode 100644 index 000000000..67f719ed7 --- /dev/null +++ b/.github/workflows/qoder-assistant.yml @@ -0,0 +1,249 @@ +name: Qoder Assistant + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +permissions: + contents: read + +concurrency: + group: qoder-assistant-${{ github.event.comment.id || github.run_id }} + cancel-in-progress: true + +jobs: + assistant-gate: + name: Check assistant eligibility + if: >- + github.event.comment.user.type != 'Bot' && + ( + startsWith(github.event.comment.body, '@qoder') || + startsWith(github.event.comment.body, '/qoder') + ) && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: [self-hosted, Linux, anolisa-agent-review] + permissions: + contents: read + pull-requests: read + issues: read + outputs: + allowed: ${{ steps.resolve.outputs.allowed }} + reason: ${{ steps.resolve.outputs.reason }} + steps: + - name: Resolve comment command and actor permission + id: resolve + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + allow() { + echo "allowed=true" >> "$GITHUB_OUTPUT" + echo "reason=allowed" >> "$GITHUB_OUTPUT" + } + + deny() { + local reason="$1" + echo "allowed=false" >> "$GITHUB_OUTPUT" + echo "reason=${reason}" >> "$GITHUB_OUTPUT" + echo "Qoder assistant 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 + } + + body="$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH")" + actor="$(jq -r '.comment.user.login // ""' "$GITHUB_EVENT_PATH")" + + if [[ "$actor" == *"[bot]" ]]; then + deny "bot-comment" + exit 0 + fi + + first_line="${body%%$'\n'*}" + first_line="${first_line%$'\r'}" + if [[ "$first_line" != @qoder* && "$first_line" != /qoder* ]]; then + deny "no-qoder-command" + exit 0 + fi + + permission="$(permission_for "$actor")" + if ! has_write_access "$permission"; then + deny "actor-${actor}-permission-${permission}" + exit 0 + fi + + allow + + qoder-assistant: + name: Run Qoder assistant + needs: assistant-gate + if: needs.assistant-gate.outputs.allowed == 'true' + runs-on: [self-hosted, Linux, anolisa-agent-review] + timeout-minutes: 45 + permissions: + contents: read + issues: write + 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 + 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: Build Qoder assistant prompt + id: prompt + env: + REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + + prompt_file="" + trap 'rm -f "${prompt_file:-}"' EXIT + + comment_body="$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH")" + comment_id="$(jq -r '.comment.id // ""' "$GITHUB_EVENT_PATH")" + thread_id="$(jq -r '.comment.node_id // ""' "$GITHUB_EVENT_PATH")" + author="$(jq -r '.comment.user.login // ""' "$GITHUB_EVENT_PATH")" + url="$(jq -r '.comment.html_url // ""' "$GITHUB_EVENT_PATH")" + review_id="$(jq -r '.comment.pull_request_review_id // ""' "$GITHUB_EVENT_PATH")" + reply_to_comment_id="$(jq -r '.comment.in_reply_to_id // ""' "$GITHUB_EVENT_PATH")" + + is_pr="false" + issue_or_pr_number="" + if [[ "$EVENT_NAME" == "issue_comment" ]]; then + issue_or_pr_number="$(jq -r '.issue.number // ""' "$GITHUB_EVENT_PATH")" + if [[ "$(jq -r 'has("issue") and (.issue.pull_request != null)' "$GITHUB_EVENT_PATH")" == "true" ]]; then + is_pr="true" + fi + elif [[ "$EVENT_NAME" == "pull_request_review_comment" ]]; then + is_pr="true" + issue_or_pr_number="$(jq -r '.pull_request.number // ""' "$GITHUB_EVENT_PATH")" + fi + + trusted_context="" + if [[ -f AGENTS.md ]]; then + trusted_context=$'\n--- AGENTS.md ---\n'"$(cat AGENTS.md)"$'\n' + fi + + prompt_file="$(mktemp)" + cat > "$prompt_file" <> "$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 assistant + id: qoder + 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 }} + + - 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 diff --git a/.github/workflows/qoder-review.yml b/.github/workflows/qoder-review.yml new file mode 100644 index 000000000..8a3a2f1aa --- /dev/null +++ b/.github/workflows/qoder-review.yml @@ -0,0 +1,374 @@ +name: Qoder AI Review + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + 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 + +permissions: + contents: read + +concurrency: + group: qoder-review-${{ github.event.pull_request.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) + ) + runs-on: [self-hosted, Linux, anolisa-agent-review] + permissions: + contents: 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 }} + 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' }} + TRIGGER_ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + + allow() { + local pr_number="$1" + local component="$2" + echo "allowed=true" >> "$GITHUB_OUTPUT" + echo "reason=allowed" >> "$GITHUB_OUTPUT" + echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" + echo "component=${component}" >> "$GITHUB_OUTPUT" + } + + deny() { + local reason="$1" + local pr_number="${2:-}" + local component="${3:-auto}" + echo "allowed=false" >> "$GITHUB_OUTPUT" + echo "reason=${reason}" >> "$GITHUB_OUTPUT" + echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" + echo "component=${component}" >> "$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" + + if [[ "$EVENT_PR_DRAFT" == "true" ]]; then + deny "draft-pr" "$pr_number" "$component" + exit 0 + fi + elif [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + pr_number="$INPUT_PR_NUMBER" + author="" + actor="$TRIGGER_ACTOR" + component="$INPUT_COMPONENT" + + 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" + 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" + 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" + exit 0 + fi + + allow "$pr_number" "$component" + + 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 + 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 }} + 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: List PR files + id: files + uses: actions/github-script@v7 + with: + script: | + const [owner, repo] = context.repo.repo + ? [context.repo.owner, context.repo.repo] + : process.env.GITHUB_REPOSITORY.split('/'); + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: Number(process.env.PR_NUMBER), + per_page: 100, + }); + core.setOutput('files', files.map((file) => file.filename).join('\n')); + + - name: Build Qoder prompt + id: prompt + env: + REPO: ${{ github.repository }} + PR_FILES: ${{ steps.files.outputs.files }} + run: | + set -euo pipefail + + prompt_file="" + files_file="$(mktemp)" + trap 'rm -f "${prompt_file:-}" "${files_file:-}"' EXIT + printf '%s\n' "$PR_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" + + prompt_context="" + for context_file in "${context_files[@]}"; do + prompt_context="${prompt_context}"$'\n'"--- ${context_file} ---"$'\n' + prompt_context="${prompt_context}$(cat "$context_file")"$'\n' + done + + changed_files="$(sed -n '1,120p' "$files_file")" + + prompt_file="$(mktemp)" + cat > "$prompt_file" <> "$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 + 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 }} + + - 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