Skip to content

feat(#350): voice agents — first-class voice in scenario.run() #605

feat(#350): voice agents — first-class voice in scenario.run()

feat(#350): voice agents — first-class voice in scenario.run() #605

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.");
}