mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
458 lines
22 KiB
YAML
458 lines
22 KiB
YAML
name: Pi Auto Review
|
|
|
|
on:
|
|
pull_request:
|
|
types: [ready_for_review, opened, synchronize]
|
|
workflow_call:
|
|
inputs:
|
|
pr_number:
|
|
description: 'PR number to review'
|
|
required: true
|
|
type: number
|
|
extra_prompt:
|
|
description: 'Additional reviewer instructions appended to the standard review prompt'
|
|
required: false
|
|
type: string
|
|
default: ''
|
|
triggered_by:
|
|
description: 'GitHub username that triggered this review (for audit only)'
|
|
required: false
|
|
type: string
|
|
default: ''
|
|
secrets:
|
|
DEEPSEEK_API_KEY:
|
|
required: false
|
|
WINDMILL_EE_PRIVATE_ACCESS:
|
|
required: false
|
|
|
|
concurrency:
|
|
group: pi-review-${{ inputs.pr_number || github.event.pull_request.number }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
pi-review:
|
|
runs-on: ubicloud-standard-2
|
|
timeout-minutes: 30
|
|
# A non-fork PR (head.repo.fork == false) can only be opened by someone with push
|
|
# access to this repo, so fork==false already enforces write access. Do NOT re-add
|
|
# an author_association gate: the pull_request webhook payload reports private org
|
|
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
|
|
# skips auto-review for every private member.
|
|
if: |
|
|
github.event_name == 'workflow_call' ||
|
|
(
|
|
github.event.pull_request.draft == false &&
|
|
github.event.pull_request.head.repo.fork == false
|
|
)
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
pull-requests: write
|
|
steps:
|
|
- name: Check Pi configuration
|
|
id: pi_config
|
|
env:
|
|
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
|
run: |
|
|
if [ -n "$DEEPSEEK_API_KEY" ]; then
|
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
|
echo "DEEPSEEK_API_KEY is not configured; skipping Pi review."
|
|
fi
|
|
|
|
- name: Resolve PR metadata
|
|
if: steps.pi_config.outputs.enabled == 'true'
|
|
id: pr
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
|
|
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
EVENT_TITLE: ${{ github.event.pull_request.title }}
|
|
EVENT_BODY: ${{ github.event.pull_request.body }}
|
|
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
|
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
|
|
EVENT_ACTION: ${{ github.event.action }}
|
|
run: |
|
|
if [ -n "$INPUT_PR_NUMBER" ]; then
|
|
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
|
|
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author)
|
|
PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
|
|
BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
|
|
BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
|
|
HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid')
|
|
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
|
|
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
|
|
IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
|
|
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""')
|
|
else
|
|
PR_NUMBER="$EVENT_PR_NUMBER"
|
|
BASE_REF="$EVENT_BASE_REF"
|
|
BASE_SHA="$EVENT_BASE_SHA"
|
|
HEAD_SHA="$EVENT_HEAD_SHA"
|
|
PR_TITLE="$EVENT_TITLE"
|
|
PR_BODY="$EVENT_BODY"
|
|
IS_FORK="$EVENT_FORK"
|
|
PR_AUTHOR="$EVENT_AUTHOR"
|
|
fi
|
|
# Fork PRs run untrusted code with secrets present, so the automatic
|
|
# pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER
|
|
# means we arrived via workflow_call (a maintainer /pi comment gated by
|
|
# check-write-access), so allow forks only on that path.
|
|
if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then
|
|
echo "Skipping Pi review for fork PR (automatic trigger)."
|
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
# An agent-driven PR flips to ready only after a clean /review round on
|
|
# a draft, marked by an author comment naming the head SHA (pr skill,
|
|
# "Review rounds"). Re-reviewing that same head on ready_for_review is
|
|
# redundant. The marker alone is author attestation, so also require
|
|
# reviewer evidence: a Codex review (posted by github-actions[bot], not
|
|
# forgeable by the author) that predates the marker and carries a
|
|
# non-blocking verdict. Comment-triggered and synchronize runs never
|
|
# skip. Keep the three copies of this check in sync (pr-ready-review /
|
|
# codex-pr-review / pi-pr-review); a shared local action would need the
|
|
# repo checked out before the check, which the fork paths here
|
|
# deliberately avoid.
|
|
if [ "$EVENT_ACTION" = "ready_for_review" ] && [ -z "$INPUT_PR_NUMBER" ]; then
|
|
# Fetch failures fail open (no skip): an API hiccup must run the
|
|
# review, never skip it or fail the job.
|
|
COMMENTS=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]'
|
|
MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \
|
|
'[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS")
|
|
CODEX_VERDICT=''
|
|
if [ -n "$MARKER_TIME" ]; then
|
|
# Only Codex evidence that predates the marker counts: the ready-
|
|
# triggered Codex run itself posts after the flip and must not
|
|
# vouch for a sibling reviewer's skip.
|
|
CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \
|
|
'[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \
|
|
| grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true)
|
|
fi
|
|
if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then
|
|
echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review."
|
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
fi
|
|
# PR title/body are attacker-controlled free text. Use an unguessable
|
|
# per-run delimiter so a fork can't embed a fixed heredoc terminator to
|
|
# inject extra outputs — e.g. is_fork=false (last-write-wins), which
|
|
# would re-enable the EE checkout and trusted-path settings for forks.
|
|
RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
|
TITLE_EOF="TITLE_EOF_${RAND}"
|
|
BODY_EOF="BODY_EOF_${RAND}"
|
|
{
|
|
echo "skip=false"
|
|
echo "is_fork=$IS_FORK"
|
|
echo "pr_number=$PR_NUMBER"
|
|
echo "base_ref=$BASE_REF"
|
|
echo "base_sha=$BASE_SHA"
|
|
echo "head_sha=$HEAD_SHA"
|
|
echo "pr_author=$PR_AUTHOR"
|
|
echo "title<<$TITLE_EOF"
|
|
printf '%s\n' "$PR_TITLE"
|
|
echo "$TITLE_EOF"
|
|
echo "body<<$BODY_EOF"
|
|
printf '%s\n' "$PR_BODY"
|
|
echo "$BODY_EOF"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Checkout repository
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
|
uses: actions/checkout@v5
|
|
with:
|
|
ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
|
|
fetch-depth: 1
|
|
# Don't persist github.token in .git/config: the review agent can read
|
|
# the checkout, and on the fork path that token (issue/PR write) would
|
|
# otherwise be exfiltratable. All later git ops target the public origin
|
|
# and need no auth; EE checkout and gh use their own explicit tokens.
|
|
persist-credentials: false
|
|
|
|
# Never expose the EE private-repo token to untrusted fork code. Skipping
|
|
# this step leaves steps.ee.outputs.available empty, so the EE checkout and
|
|
# substitution steps below are skipped too.
|
|
- name: Check EE access
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true'
|
|
id: ee
|
|
env:
|
|
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
|
run: |
|
|
if [ -n "$EE_TOKEN" ]; then
|
|
echo "available=true" >> "$GITHUB_OUTPUT"
|
|
echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "available=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Checkout EE repository
|
|
if: steps.ee.outputs.available == 'true'
|
|
uses: actions/checkout@v5
|
|
with:
|
|
repository: windmill-labs/windmill-ee-private
|
|
path: ./windmill-ee-private
|
|
ref: ${{ steps.ee.outputs.ee_repo_ref }}
|
|
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
|
fetch-depth: 1
|
|
|
|
- name: Substitute EE code
|
|
if: steps.ee.outputs.available == 'true'
|
|
run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
|
|
|
|
- name: Set up Node.js
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
|
|
- name: Install Pi CLI
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
|
# Pinned: this job holds DEEPSEEK_API_KEY and PR write access, and an
|
|
# unpinned reviewer also makes verdicts non-reproducible across runs.
|
|
run: npm install --global @earendil-works/pi-coding-agent@0.84.1
|
|
|
|
- name: Pre-fetch base and head refs for the PR
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
|
env:
|
|
PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
|
|
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
|
run: |
|
|
git fetch --no-tags origin \
|
|
"$PR_BASE_REF" \
|
|
"+refs/pull/$PR_NUMBER/head"
|
|
|
|
- name: Fetch prior PR discussion
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
REPO: ${{ github.repository }}
|
|
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
|
run: |
|
|
# Write outside the checkout: on the fork path the merge tree is
|
|
# attacker-controlled, and a committed symlink at this path would
|
|
# redirect the write.
|
|
gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
|
|
--jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
|
|
> "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json"
|
|
|
|
- name: Write Pi review context
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
|
env:
|
|
PR_REPOSITORY: ${{ github.repository }}
|
|
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
|
PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
|
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
|
PR_TITLE: ${{ steps.pr.outputs.title }}
|
|
PR_BODY: ${{ steps.pr.outputs.body }}
|
|
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
|
|
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
|
run: |
|
|
node <<'NODE'
|
|
const fs = require('fs');
|
|
const tmp = process.env.RUNNER_TEMP;
|
|
const lines = [
|
|
`Repository: ${process.env.PR_REPOSITORY}`,
|
|
`PR number: ${process.env.PR_NUMBER}`,
|
|
];
|
|
if (process.env.PR_AUTHOR) {
|
|
lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`);
|
|
}
|
|
lines.push(
|
|
`Base SHA: ${process.env.PR_BASE_SHA}`,
|
|
`Head SHA: ${process.env.PR_HEAD_SHA}`,
|
|
'',
|
|
'PR title:',
|
|
process.env.PR_TITLE || '(empty)',
|
|
'',
|
|
'PR body:',
|
|
process.env.PR_BODY || '(empty)',
|
|
'',
|
|
'Changed commits command:',
|
|
`git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
|
|
'',
|
|
'Changed files command:',
|
|
`git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
|
|
'',
|
|
'Full review diff command:',
|
|
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
|
|
);
|
|
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
|
|
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
|
|
}
|
|
if (fs.existsSync(`${tmp}/prior-comments.json`)) {
|
|
try {
|
|
const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8'));
|
|
if (Array.isArray(comments) && comments.length > 0) {
|
|
lines.push(
|
|
'',
|
|
'Prior PR discussion (most recent up to 20 comments):',
|
|
'',
|
|
'If you have already reviewed this PR (look for your own earlier "## Pi Review (DeepSeek V4)" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.',
|
|
''
|
|
);
|
|
for (const c of comments) {
|
|
lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', '');
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`);
|
|
NODE
|
|
|
|
- name: Run Pi review
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
|
env:
|
|
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
|
PI_SKIP_VERSION_CHECK: '1'
|
|
PR_IS_FORK: ${{ steps.pr.outputs.is_fork }}
|
|
PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
|
|
PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
|
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
|
run: |
|
|
set -o pipefail
|
|
PI_HARDEN_FLAGS=()
|
|
# Keep generated files (final message, events, context) outside the
|
|
# checkout: on the fork path a committed symlink at any of these paths
|
|
# would redirect our write and could overwrite an action's code that
|
|
# then runs with our credentials. RUNNER_TEMP is outside the checkout.
|
|
OUT_DIR="$RUNNER_TEMP"
|
|
CTX="$RUNNER_TEMP/pr-review-context.md"
|
|
if [ "$PR_IS_FORK" = "true" ]; then
|
|
# Fork code is untrusted. Read the review policy/prompt from the base
|
|
# ref (git show) rather than the attacker-controlled merge checkout,
|
|
# so a malicious fork can't rewrite the reviewer's own instructions,
|
|
# and drop the bash tool so the agent has no shell to exfiltrate with.
|
|
git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/pi-prompt.md
|
|
git show "origin/$PR_BASE_REF:.github/pi/pr-review.prompt.md" >> /tmp/pi-prompt.md
|
|
PI_TOOLS=read,grep,find,ls
|
|
|
|
# The agent has no shell, so pre-compute the diff (base...head SHAs are
|
|
# trusted) into the context file it reads. It may still read fork files
|
|
# by absolute path for extra context — reads are safe.
|
|
{
|
|
echo ""
|
|
echo "## Pre-computed review diff (base...head)"
|
|
echo "You have no shell. The full diff is below. The repository checkout"
|
|
echo "is at $GITHUB_WORKSPACE — you may read files there by absolute path."
|
|
echo '```diff'
|
|
git -C "$GITHUB_WORKSPACE" diff --unified=0 "$PR_BASE_SHA...$PR_HEAD_SHA"
|
|
echo '```'
|
|
} >> "$CTX"
|
|
|
|
# Pi resolves ALL project config from <cwd>/.pi (settings/packages,
|
|
# extensions, skills, themes, prompts, SYSTEM.md); inside the fork
|
|
# checkout a fork could inject any to run code or rewrite our system
|
|
# prompt. Discovery is cwd-based, so run from a fresh empty dir.
|
|
PI_WORKDIR=$(mktemp -d)
|
|
cd "$PI_WORKDIR"
|
|
|
|
# Belt-and-suspenders on top of the isolated cwd: refuse discovery of
|
|
# extensions/skills/templates/themes/context-files, and PI_OFFLINE=1 to
|
|
# block any startup network op or package install. PI_OFFLINE gates only
|
|
# startup network ops, not the provider inference call.
|
|
PI_HARDEN_FLAGS=(--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files)
|
|
export PI_OFFLINE=1
|
|
else
|
|
cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md
|
|
PI_TOOLS=read,grep,find,ls,bash
|
|
fi
|
|
# The context file lives in RUNNER_TEMP (outside the checkout); tell the
|
|
# agent its absolute path.
|
|
printf '\nReview context file (absolute path): %s\n' "$CTX" >> /tmp/pi-prompt.md
|
|
# DeepSeek's reasoning_effort accepts low/high/max and silently maps both
|
|
# medium and xhigh onto high. Set the level explicitly rather than letting
|
|
# pi's default clamp onto it, so a change to either the default or the
|
|
# clamping is a visible diff here instead of a silent shift in review depth.
|
|
pi -p \
|
|
--provider deepseek \
|
|
--model deepseek-v4-pro \
|
|
--thinking high \
|
|
--tools "$PI_TOOLS" \
|
|
"${PI_HARDEN_FLAGS[@]}" \
|
|
--mode json \
|
|
< /tmp/pi-prompt.md \
|
|
| tee "$OUT_DIR/pi-events.jsonl" \
|
|
| jq -rc --unbuffered '
|
|
if .type == "agent_start" then "🤖 pi agent started"
|
|
elif .type == "turn_start" then "── turn ──"
|
|
elif .type == "message_end" then
|
|
"[\(.message.role)] " + (
|
|
(.message.content // [])
|
|
| map(
|
|
if .type == "text" then "text(\(.text | length)c)"
|
|
elif .type == "tool_use" then "🔧 \(.name) \(.input | @json | .[:160])"
|
|
elif .type == "tool_result" then "✅ result"
|
|
else .type
|
|
end
|
|
)
|
|
| join(" | ")
|
|
)
|
|
elif .type == "turn_end" then "── turn done (\((.toolResults // []) | length) tool result(s)) ──"
|
|
elif .type == "agent_end" then "🏁 pi agent done"
|
|
else empty
|
|
end
|
|
'
|
|
|
|
jq -r '
|
|
select(.type == "agent_end")
|
|
| .messages
|
|
| map(select(.role == "assistant"))
|
|
| last
|
|
| (.content[]? | select(.type == "text") | .text)
|
|
' "$OUT_DIR/pi-events.jsonl" > "$OUT_DIR/pi-final-message.md"
|
|
|
|
# The final message often opens with chatter ("Now I have all the context
|
|
# I need..."), which would land above the verdict in the posted comment.
|
|
# Keep the trim conditional: without the heading there is nothing to cut
|
|
# and the range expression would empty the file.
|
|
if grep -q '^## Pi Review' "$OUT_DIR/pi-final-message.md"; then
|
|
sed -i -n '/^## Pi Review/,$p' "$OUT_DIR/pi-final-message.md"
|
|
fi
|
|
|
|
- name: Post Pi review comment
|
|
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
|
uses: actions/github-script@v7
|
|
env:
|
|
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
|
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
|
GH_JOB_TOKEN: ${{ github.token }}
|
|
with:
|
|
github-token: ${{ github.token }}
|
|
script: |
|
|
const fs = require('fs');
|
|
const path = `${process.env.RUNNER_TEMP}/pi-final-message.md`;
|
|
if (!fs.existsSync(path)) {
|
|
core.info('Pi did not produce a final message; skipping PR comment.');
|
|
return;
|
|
}
|
|
let body = fs.readFileSync(path, 'utf8').trim();
|
|
if (!body) {
|
|
core.info('Pi final message was empty; skipping PR comment.');
|
|
return;
|
|
}
|
|
// Defense-in-depth for fork reviews: the model call needs the provider
|
|
// credential in the environment (readable via /proc/self/environ), and
|
|
// the posted comment is an exfiltration channel that bypasses GitHub
|
|
// Actions log masking. Strip the credential if it leaked into the text.
|
|
for (const s of [process.env.DEEPSEEK_API_KEY, process.env.GH_JOB_TOKEN]) {
|
|
if (typeof s === 'string' && s.length >= 8) {
|
|
body = body.split(s).join('[REDACTED]');
|
|
}
|
|
}
|
|
body = body.trim();
|
|
if (!body) {
|
|
core.info('Pi final message was empty after redaction; skipping PR comment.');
|
|
return;
|
|
}
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: Number(process.env.PR_NUMBER),
|
|
body,
|
|
});
|