feat(#350): voice agents — first-class voice in scenario.run() #605
Workflow file for this run
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: Low-Risk PR Evaluation | |
| on: | |
| pull_request: | |
| types: [opened, reopened, synchronize] | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: "PR number to evaluate" | |
| required: true | |
| type: number | |
| permissions: | |
| pull-requests: write | |
| contents: read | |
| checks: write | |
| concurrency: | |
| group: low-risk-eval-${{ github.event.pull_request.number || inputs.pr_number }} | |
| cancel-in-progress: true | |
| jobs: | |
| evaluate: | |
| # Skip fork PRs: secrets are unavailable and GITHUB_TOKEN is read-only | |
| if: >- | |
| github.event_name == 'workflow_dispatch' || | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repo | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Resolve PR number | |
| id: pr | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| if [ -n "${{ inputs.pr_number }}" ]; then | |
| echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Remove stale low-risk-change label | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const prNumber = ${{ steps.pr.outputs.number }}; | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| name: "low-risk-change", | |
| }); | |
| core.info(`Removed stale low-risk-change label from PR #${prNumber}.`); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| core.info("No low-risk-change label to remove."); | |
| } | |
| - name: Fetch PR metadata and diff | |
| id: pr-data | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ steps.pr.outputs.number }} | |
| run: | | |
| gh pr view "$PR_NUMBER" --json title,body --template '{{.title}}' > /tmp/pr_title.txt | |
| gh pr view "$PR_NUMBER" --json title,body --template '{{.body}}' > /tmp/pr_body.txt | |
| gh pr view "$PR_NUMBER" --json files --jq '.files[].path' > /tmp/pr_files.txt | |
| # GitHub's diff API rejects diffs > 20000 lines with HTTP 406. Treat | |
| # that the same as our own oversized check — large PRs need manual | |
| # review, and `set -e` killing the script before the size branch | |
| # can run is the bug we're guarding against here. | |
| if ! gh pr diff "$PR_NUMBER" > /tmp/pr_diff.txt 2>/tmp/pr_diff_err.txt; then | |
| echo "gh pr diff failed (likely diff exceeds API line limit):" | |
| cat /tmp/pr_diff_err.txt || true | |
| echo "oversized=true" >> "$GITHUB_OUTPUT" | |
| else | |
| # Fail closed if diff exceeds model window — large PRs could hide risky changes | |
| DIFF_LIMIT=100000 | |
| DIFF_SIZE=$(wc -c < /tmp/pr_diff.txt) | |
| if [ "$DIFF_SIZE" -gt "$DIFF_LIMIT" ]; then | |
| echo "PR diff is ${DIFF_SIZE} chars (> ${DIFF_LIMIT}); too large for automated evaluation." | |
| echo "oversized=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "oversized=false" >> "$GITHUB_OUTPUT" | |
| cp /tmp/pr_diff.txt /tmp/pr_diff_truncated.txt | |
| fi | |
| fi | |
| - name: Fail fast for oversized diffs | |
| id: oversized | |
| if: steps.pr-data.outputs.oversized == 'true' | |
| run: | | |
| echo "qualifies=false" >> "$GITHUB_OUTPUT" | |
| echo "scope=Diff too large for automated evaluation" >> "$GITHUB_OUTPUT" | |
| echo "reasoning=This PR's diff exceeds the size limit for automated low-risk evaluation. Manual review required." >> "$GITHUB_OUTPUT" | |
| - name: Check for restricted paths | |
| id: path-check | |
| if: steps.pr-data.outputs.oversized == 'false' | |
| run: | | |
| RESTRICTED_PATTERN='^(\.github/workflows/|(auth|security|migrations)/|.*/(auth|security|migrations)/)' | |
| if grep -qE "$RESTRICTED_PATTERN" /tmp/pr_files.txt; then | |
| echo "Restricted paths detected:" | |
| grep -E "$RESTRICTED_PATTERN" /tmp/pr_files.txt | |
| echo "blocked=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "blocked=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Fail fast for restricted paths | |
| id: restricted | |
| if: steps.path-check.outputs.blocked == 'true' | |
| run: | | |
| echo "qualifies=false" >> "$GITHUB_OUTPUT" | |
| echo "scope=Changes include restricted paths (workflows, auth, migrations, etc.)" >> "$GITHUB_OUTPUT" | |
| echo "reasoning=This PR modifies files in restricted directories that require manual review per policy." >> "$GITHUB_OUTPUT" | |
| - name: Evaluate PR with OpenAI | |
| id: evaluate | |
| if: steps.pr-data.outputs.oversized == 'false' && steps.path-check.outputs.blocked == 'false' | |
| env: | |
| OPENAI_API_KEY: ${{ secrets.LOW_RISK_OPENAI_API_KEY }} | |
| run: | | |
| POLICY=$(cat docs/LOW_RISK_PULL_REQUESTS.md) | |
| PR_TITLE=$(cat /tmp/pr_title.txt) | |
| PR_BODY=$(cat /tmp/pr_body.txt) | |
| PR_DIFF=$(cat /tmp/pr_diff_truncated.txt) | |
| # Build the JSON payload using jq to handle escaping | |
| PAYLOAD=$(jq -n \ | |
| --arg policy "$POLICY" \ | |
| --arg title "$PR_TITLE" \ | |
| --arg body "$PR_BODY" \ | |
| --arg diff "$PR_DIFF" \ | |
| '{ | |
| model: "gpt-5-mini", | |
| response_format: { | |
| type: "json_schema", | |
| json_schema: { | |
| name: "low_risk_evaluation", | |
| strict: true, | |
| schema: { | |
| type: "object", | |
| properties: { | |
| qualifies: { type: "boolean", description: "true if the PR meets ALL low-risk criteria" }, | |
| reasoning: { type: "string", description: "2-3 sentence explanation of the assessment" }, | |
| scope: { type: "string", description: "Brief summary of what the PR changes" } | |
| }, | |
| required: ["qualifies", "reasoning", "scope"], | |
| additionalProperties: false | |
| } | |
| } | |
| }, | |
| messages: [ | |
| { | |
| role: "system", | |
| content: ("You evaluate pull requests against a low-risk change policy.\n\nHere is the policy:\n\n" + $policy + "\n\nEvaluate the PR and return JSON with exactly these fields:\n- \"qualifies\": boolean (true if the PR meets ALL low-risk criteria)\n- \"reasoning\": string (2-3 sentence explanation of your assessment)\n- \"scope\": string (brief summary of what the PR changes)") | |
| }, | |
| { | |
| role: "user", | |
| content: ("PR Title: " + $title + "\n\nPR Description:\n" + $body + "\n\nDiff:\n" + $diff) | |
| } | |
| ] | |
| }') | |
| RESPONSE=$(curl -s --max-time 60 https://api.openai.com/v1/chat/completions \ | |
| -H "Content-Type: application/json" \ | |
| -H "Authorization: Bearer $OPENAI_API_KEY" \ | |
| -d "$PAYLOAD") | |
| # Extract the assistant's message content | |
| RESULT=$(echo "$RESPONSE" | jq -r '.choices[0].message.content') | |
| if [ "$RESULT" = "null" ] || [ -z "$RESULT" ]; then | |
| echo "OpenAI API error:" | |
| echo "$RESPONSE" | jq . | |
| exit 1 | |
| fi | |
| echo "$RESULT" > /tmp/evaluation.json | |
| QUALIFIES=$(echo "$RESULT" | jq -r '.qualifies') | |
| REASONING=$(echo "$RESULT" | jq -r '.reasoning') | |
| SCOPE=$(echo "$RESULT" | jq -r '.scope') | |
| echo "qualifies=$QUALIFIES" >> "$GITHUB_OUTPUT" | |
| SCOPE_DELIM="$(openssl rand -hex 16)" | |
| { | |
| echo "scope<<$SCOPE_DELIM" | |
| echo "$SCOPE" | |
| echo "$SCOPE_DELIM" | |
| } >> "$GITHUB_OUTPUT" | |
| REASONING_DELIM="$(openssl rand -hex 16)" | |
| { | |
| echo "reasoning<<$REASONING_DELIM" | |
| echo "$REASONING" | |
| echo "$REASONING_DELIM" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Apply label and comment | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| PR_NUMBER: ${{ steps.pr.outputs.number }} | |
| QUALIFIES: ${{ steps.oversized.outputs.qualifies || steps.restricted.outputs.qualifies || steps.evaluate.outputs.qualifies }} | |
| SCOPE: ${{ steps.oversized.outputs.scope || steps.restricted.outputs.scope || steps.evaluate.outputs.scope }} | |
| REASONING: ${{ steps.oversized.outputs.reasoning || steps.restricted.outputs.reasoning || steps.evaluate.outputs.reasoning }} | |
| with: | |
| script: | | |
| const prNumber = parseInt(process.env.PR_NUMBER, 10); | |
| const qualifies = process.env.QUALIFIES === 'true'; | |
| const scope = process.env.SCOPE; | |
| const reasoning = process.env.REASONING; | |
| // Delete previous bot assessment comments to avoid clutter | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| }); | |
| for (const comment of comments) { | |
| if ( | |
| comment.user?.login === "github-actions[bot]" && | |
| comment.body?.startsWith("**Automated low-risk assessment**") | |
| ) { | |
| await github.rest.issues.deleteComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: comment.id, | |
| }); | |
| } | |
| } | |
| if (qualifies) { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| labels: ["low-risk-change"], | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: [ | |
| "**Automated low-risk assessment**", | |
| "", | |
| `This PR was evaluated against the repository's [Low-Risk Pull Requests](docs/LOW_RISK_PULL_REQUESTS.md) procedure.`, | |
| `- **Scope:** ${scope}`, | |
| `- **Exclusions confirmed:** no changes to auth, security settings, database schema, business-critical logic, or external integrations.`, | |
| `- **Classification:** \`low-risk-change\` under the documented policy.`, | |
| "", | |
| `> ${reasoning}`, | |
| "", | |
| "This classification allows merging without manual review once all required CI checks are passing and branch protection rules are satisfied.", | |
| ].join("\n"), | |
| }); | |
| // Directly create a passing check run for the approval check. | |
| // We can't re-run the workflow (creates zombie runs) and | |
| // GITHUB_TOKEN label events don't trigger other workflows. | |
| const pr = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| await github.rest.checks.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| head_sha: pr.data.head.sha, | |
| name: "check-approval-or-label", | |
| status: "completed", | |
| conclusion: "success", | |
| output: { | |
| title: "Low-risk label applied by automation", | |
| summary: "PR was labeled `low-risk-change` by the automated evaluation workflow.", | |
| }, | |
| }); | |
| core.info(`PR #${prNumber} labeled as low-risk-change and approval check set to success.`); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: [ | |
| "**Automated low-risk assessment**", | |
| "", | |
| `This PR was evaluated against the repository's [Low-Risk Pull Requests](docs/LOW_RISK_PULL_REQUESTS.md) procedure and **does not qualify** as low risk.`, | |
| "", | |
| `> ${reasoning}`, | |
| "", | |
| "This PR requires a manual review before merging.", | |
| ].join("\n"), | |
| }); | |
| core.info(`PR #${prNumber} does not qualify as low-risk.`); | |
| } | |
| - name: Refresh approval check for firefighting or approvals | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| PR_NUMBER: ${{ steps.pr.outputs.number }} | |
| with: | |
| script: | | |
| const prNumber = parseInt(process.env.PR_NUMBER, 10); | |
| const pr = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| const { data: labels } = await github.rest.issues.listLabelsOnIssue({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| }); | |
| const labelNames = labels.map(l => l.name); | |
| // firefighting label → pass the check regardless of evaluation | |
| if (labelNames.includes("firefighting")) { | |
| await github.rest.checks.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| head_sha: pr.data.head.sha, | |
| name: "check-approval-or-label", | |
| status: "completed", | |
| conclusion: "success", | |
| output: { title: "Approved", summary: "PR has firefighting label." }, | |
| }); | |
| core.info("Refreshed approval check for firefighting label."); | |
| return; | |
| } | |
| // Existing approval → pass the check | |
| const reviews = await github.rest.pulls.listReviews({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| const approvedUsers = new Set(); | |
| for (const review of reviews.data) { | |
| if (review.state === "APPROVED") { | |
| approvedUsers.add(review.user.login); | |
| } | |
| } | |
| if (approvedUsers.size >= 1) { | |
| await github.rest.checks.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| head_sha: pr.data.head.sha, | |
| name: "check-approval-or-label", | |
| status: "completed", | |
| conclusion: "success", | |
| output: { title: "Approved", summary: `PR has ${approvedUsers.size} approval(s).` }, | |
| }); | |
| core.info("Refreshed approval check for existing approval."); | |
| } |