mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-17 04:41:21 +00:00
clang_tidy.yml is back to main's version. clang_tidy_fix.yml runs on every PR push but skips its job on the first attempt; /bot fix-tidy re-runs it, so run_clang_tidy.sh --fix runs in the PR's own context with a read-only token and the PR's cache, never main's, and the PR's author can ask for it. When that re-run finishes, the bot posts the patch as review suggestions as before. When the clang-tidy step fails, the bot also comments asking the author to run the script or the command, edits that comment on later failures, and deletes it once the check passes.
468 lines
23 KiB
YAML
468 lines
23 KiB
YAML
name: PR clang-tidy Bot
|
|
|
|
# Helps a pull request past the clang-tidy check.
|
|
#
|
|
# When the check fails on a PR's changes, a comment asks the author to fix it,
|
|
# either with scripts/run_clang_tidy.sh --fix or by commenting:
|
|
# /bot fix-tidy
|
|
#
|
|
# That command, from the PR's author or an account with write access, re-runs
|
|
# the PR's clang-tidy fix run (clang_tidy_fix.yml), which makes the fix in the
|
|
# PR's own context. When it finishes, the fix is posted as one review: each
|
|
# include whose place falls on a line the PR's diff shows is a suggestion GitHub
|
|
# can commit in one click, and the review's body holds the whole patch with a
|
|
# git apply command. Findings --fix cannot fix, such as a using in a header,
|
|
# are left for the check to report.
|
|
#
|
|
# No code from the PR runs here. Nothing is pushed, and the patch is only
|
|
# posted when every line it adds is an #include in a C++ file the PR changes.
|
|
#
|
|
# workflow_run matches the workflows by their name: keep "clang-tidy" and
|
|
# "clang-tidy fix" in step with clang_tidy.yml and clang_tidy_fix.yml, and
|
|
# TIDY_STEP below with the clang-tidy step's name in clang_tidy.yml.
|
|
|
|
on:
|
|
issue_comment:
|
|
types:
|
|
- created
|
|
workflow_run:
|
|
workflows:
|
|
- clang-tidy
|
|
- clang-tidy fix
|
|
types:
|
|
- completed
|
|
|
|
permissions: {}
|
|
|
|
jobs:
|
|
request:
|
|
# Skips the job unless a PR comment mentions the command.
|
|
if: >-
|
|
github.repository == 'OrcaSlicer/OrcaSlicer'
|
|
&& github.event_name == 'issue_comment'
|
|
&& github.event.issue.pull_request != null
|
|
&& contains(github.event.comment.body, '/bot fix-tidy')
|
|
permissions:
|
|
actions: write # re-run the fix run
|
|
pull-requests: read # pulls.get
|
|
issues: write # feedback comment + reactions
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- name: Re-run the PR's clang-tidy fix
|
|
uses: actions/github-script@v9
|
|
with:
|
|
script: |
|
|
const MARKER = '<!-- pr-tidy-bot -->';
|
|
const FIX_WORKFLOW = 'clang_tidy_fix.yml';
|
|
|
|
const { owner, repo } = context.repo;
|
|
const issue = context.payload.issue;
|
|
const comment = context.payload.comment;
|
|
|
|
// Ignores a comment whose sender is not its author.
|
|
if (context.payload.sender.login !== comment.user.login || comment.user.type !== 'User') {
|
|
core.info('Ignoring a comment not written by its sender, or by a bot.');
|
|
return;
|
|
}
|
|
|
|
const commandLine = (comment.body || '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.find((line) => /^\/bot\s+fix-tidy\b/i.test(line));
|
|
if (!commandLine) {
|
|
core.info('No /bot fix-tidy command found.');
|
|
return;
|
|
}
|
|
|
|
const commenter = comment.user.login;
|
|
// Answers only the PR's author and maintainers, so the command cannot be used to spam.
|
|
if (commenter !== issue.user.login) {
|
|
const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter });
|
|
if (!['admin', 'write'].includes(access.permission)) {
|
|
core.info(`Ignoring /bot fix-tidy from @${commenter}: not the PR's author and no write access.`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const react = (content) =>
|
|
github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content });
|
|
async function refuse(reason) {
|
|
await react('-1');
|
|
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` });
|
|
}
|
|
|
|
await react('eyes');
|
|
|
|
if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) {
|
|
return refuse('`/bot fix-tidy` takes no arguments.');
|
|
}
|
|
|
|
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number });
|
|
if (pr.state !== 'open') {
|
|
return refuse('the PR is not open.');
|
|
}
|
|
|
|
const sha = pr.head.sha.slice(0, 10);
|
|
const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({
|
|
owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1
|
|
});
|
|
const run = runs[0];
|
|
if (!run) {
|
|
return refuse(`the PR's checks have not started on ${sha} yet. Comment \`/bot fix-tidy\` again once they have.`);
|
|
}
|
|
if (run.status !== 'completed') {
|
|
// The first attempt is the one every push starts, and it makes no fix.
|
|
return refuse(run.run_attempt > 1
|
|
? `a clang-tidy fix for ${sha} is already [running](${run.html_url}). I'll post it when it finishes.`
|
|
: `the PR's checks on ${sha} are still starting. Comment \`/bot fix-tidy\` again in a minute.`);
|
|
}
|
|
await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id });
|
|
core.info(`Re-ran ${run.html_url}`);
|
|
|
|
suggest:
|
|
# The fix run's re-runs only. Its first attempt makes no fix.
|
|
if: >-
|
|
github.repository == 'OrcaSlicer/OrcaSlicer'
|
|
&& github.event_name == 'workflow_run'
|
|
&& github.event.workflow_run.name == 'clang-tidy fix'
|
|
&& github.event.workflow_run.event == 'pull_request'
|
|
&& github.event.workflow_run.run_attempt > 1
|
|
concurrency:
|
|
group: ${{ github.workflow }}-suggest-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
|
cancel-in-progress: false
|
|
permissions:
|
|
actions: read # download the fix
|
|
contents: read # check out the PR head
|
|
pull-requests: write # the review
|
|
issues: write # feedback comment + reactions
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Find the PR and who asked
|
|
id: find
|
|
uses: actions/github-script@v9
|
|
with:
|
|
script: |
|
|
const MARKER = '<!-- pr-tidy-bot -->';
|
|
const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/;
|
|
|
|
const { owner, repo } = context.repo;
|
|
const run = context.payload.workflow_run;
|
|
|
|
// workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
|
|
const { data: prs } = await github.rest.pulls.list({
|
|
owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
|
|
});
|
|
const pr = prs[0];
|
|
if (!pr) {
|
|
core.info('No open PR for this run.');
|
|
return;
|
|
}
|
|
|
|
// The latest /bot fix-tidy comment is the one this run answers.
|
|
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
|
|
const request = comments.reverse().find((c) => c.user.type === 'User' && /^\s*\/bot\s+fix-tidy\s*$/im.test(c.body || ''));
|
|
const mention = request ? `@${request.user.login} ` : '';
|
|
async function refuse(reason) {
|
|
if (request) {
|
|
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: request.id, content: '-1' });
|
|
}
|
|
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `${MARKER}\n${mention}${reason}` });
|
|
}
|
|
|
|
if (pr.head.sha !== run.head_sha) {
|
|
return refuse('the PR changed while I was making its clang-tidy fix. Comment `/bot fix-tidy` again.');
|
|
}
|
|
if (run.conclusion !== 'success') {
|
|
return refuse(`I couldn't make the clang-tidy fix. See the [run log](${run.html_url}).`);
|
|
}
|
|
|
|
// The right-side lines of each changed C++ file's diff: the lines a
|
|
// review comment can be placed on. A file too large for GitHub to
|
|
// show its diff has none, and gets no suggestions.
|
|
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 });
|
|
const ranges = {};
|
|
for (const file of files) {
|
|
if (file.status === 'removed' || !CPP.test(file.filename)) {
|
|
continue;
|
|
}
|
|
ranges[file.filename] = [...(file.patch || '').matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)]
|
|
.map((m) => [Number(m[1]), Number(m[1]) + Number(m[2] ?? 1) - 1])
|
|
.filter(([first, last]) => last >= first);
|
|
}
|
|
|
|
core.setOutput('pr', pr.number);
|
|
core.setOutput('head-sha', pr.head.sha);
|
|
core.setOutput('head-repo', run.head_repository.full_name);
|
|
core.setOutput('ranges', JSON.stringify(ranges));
|
|
core.setOutput('mention', mention);
|
|
core.setOutput('request-id', request ? request.id : '');
|
|
|
|
- name: Download the fix
|
|
if: steps.find.outputs.pr != ''
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
name: clang-tidy-fix
|
|
run-id: ${{ github.event.workflow_run.id }}
|
|
github-token: ${{ github.token }}
|
|
path: ${{ runner.temp }}/clang-tidy-fix
|
|
|
|
# The PR's own files, only to apply the patch to. Nothing in them is run.
|
|
- name: Checkout the PR head
|
|
if: steps.find.outputs.pr != ''
|
|
uses: actions/checkout@v7
|
|
with:
|
|
repository: ${{ steps.find.outputs.head-repo }}
|
|
ref: ${{ steps.find.outputs.head-sha }}
|
|
path: head
|
|
fetch-depth: 1
|
|
lfs: 'false'
|
|
persist-credentials: false
|
|
sparse-checkout: |
|
|
src
|
|
tests
|
|
|
|
# The fix was made on the PR's merge commit, so it is applied to the head
|
|
# and diffed again, giving line numbers in the PR's own files.
|
|
- name: Check the fix and place its suggestions
|
|
id: place
|
|
if: steps.find.outputs.pr != ''
|
|
env:
|
|
RANGES: ${{ steps.find.outputs.ranges }}
|
|
run: |
|
|
python3 -I - "$RUNNER_TEMP/clang-tidy-fix/fix.patch" head "$RUNNER_TEMP" <<'EOF'
|
|
import json, os, re, subprocess, sys
|
|
|
|
patch_path, head, out = sys.argv[1:]
|
|
ranges = json.loads(os.environ["RANGES"])
|
|
|
|
def output(**values):
|
|
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
|
for key, value in values.items():
|
|
f.write(f"{key}={value}\n")
|
|
|
|
patch = open(patch_path, encoding="utf-8").read()
|
|
if not patch.strip():
|
|
output(result="empty")
|
|
sys.exit()
|
|
|
|
# Every line the patch adds must be an #include or blank, in a C++ file the
|
|
# PR changes, and it may not remove, create, rename or change the mode of
|
|
# anything.
|
|
include = re.compile(r'\s*#\s*include\s*(<[\w./+-]+>|"[\w./+-]+")\s*')
|
|
header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)")
|
|
problems = []
|
|
path = None
|
|
for line in patch.splitlines():
|
|
if line.startswith("diff --git "):
|
|
match = re.fullmatch(r"diff --git a/(.+) b/\1", line)
|
|
path = match.group(1) if match else None
|
|
if path not in ranges:
|
|
problems.append(f"{line}: not a C++ file the PR changes")
|
|
# git appends a tab to the --- and +++ lines of a path that contains a space.
|
|
elif line.removesuffix("\t") in (f"--- a/{path}", f"+++ b/{path}") or header.fullmatch(line):
|
|
pass
|
|
elif line.startswith(" ") or line == "\\ No newline at end of file":
|
|
pass
|
|
elif line.startswith("+") and (include.fullmatch(line[1:]) or not line[1:].strip()):
|
|
pass
|
|
else:
|
|
problems.append(f"{path}: {line}")
|
|
if problems:
|
|
for problem in problems:
|
|
print(f"::error::Refused patch line {problem}")
|
|
output(result="rejected")
|
|
sys.exit()
|
|
|
|
if subprocess.run(["git", "-C", head, "apply", "--index", os.path.abspath(patch_path)]).returncode != 0:
|
|
output(result="apply-failed")
|
|
sys.exit()
|
|
|
|
git = ["git", "-C", head, "-c", "core.quotePath=false", "diff", "--cached", "--no-color", "--no-ext-diff",
|
|
"--src-prefix=a/", "--dst-prefix=b/"]
|
|
with open(os.path.join(out, "head.patch"), "w", encoding="utf-8") as f:
|
|
f.write(subprocess.run(git, check=True, capture_output=True, encoding="utf-8").stdout)
|
|
|
|
# Each run of added lines becomes one suggestion on the line it goes after
|
|
# (before, at the top of a file): that line, replaced by itself and the
|
|
# includes. It can only sit on a line the PR's diff shows.
|
|
hunks = []
|
|
path = None
|
|
for line in subprocess.run(git + ["-U0"], check=True, capture_output=True, encoding="utf-8").stdout.splitlines():
|
|
if line.startswith("diff --git "):
|
|
path = re.fullmatch(r"diff --git a/(.+) b/\1", line).group(1)
|
|
elif line.startswith("@@ "):
|
|
hunks.append((path, int(re.match(r"@@ -(\d+)", line).group(1)), []))
|
|
elif hunks and line.startswith("+") and not line.startswith("+++ "):
|
|
hunks[-1][2].append(line[1:])
|
|
|
|
suggestions = []
|
|
total = suggested = 0
|
|
for path, after, added in hunks:
|
|
count = sum(bool(include.fullmatch(line)) for line in added)
|
|
total += count
|
|
original = subprocess.run(["git", "-C", head, "show", f"HEAD:{path}"], check=True, capture_output=True,
|
|
encoding="utf-8").stdout.splitlines()
|
|
line = max(after, 1)
|
|
if line > len(original) or not any(first <= line <= last for first, last in ranges[path]):
|
|
continue
|
|
body = "\n".join([original[line - 1], *added] if after else [*added, original[0]])
|
|
# A fence longer than any run of backticks in the line it repeats.
|
|
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", body)])
|
|
suggestions.append({"path": path, "line": line, "side": "RIGHT",
|
|
"body": f"{fence}suggestion\n{body}\n{fence}"})
|
|
suggested += count
|
|
with open(os.path.join(out, "suggestions.json"), "w", encoding="utf-8") as f:
|
|
json.dump(suggestions, f)
|
|
output(result="ok", includes=total, suggested=suggested)
|
|
EOF
|
|
|
|
- name: Post the fix
|
|
if: always() && steps.find.outputs.pr != ''
|
|
uses: actions/github-script@v9
|
|
env:
|
|
PR: ${{ steps.find.outputs.pr }}
|
|
MENTION: ${{ steps.find.outputs.mention }}
|
|
REQUEST_ID: ${{ steps.find.outputs.request-id }}
|
|
HEAD_SHA: ${{ steps.find.outputs.head-sha }}
|
|
RESULT: ${{ steps.place.outputs.result }}
|
|
INCLUDES: ${{ steps.place.outputs.includes }}
|
|
SUGGESTED: ${{ steps.place.outputs.suggested }}
|
|
with:
|
|
script: |
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const MARKER = '<!-- pr-tidy-bot -->';
|
|
// GitHub's limit on a review body is 65536 characters.
|
|
const MAX_PATCH = 60000;
|
|
|
|
const { owner, repo } = context.repo;
|
|
const { PR, MENTION, REQUEST_ID, HEAD_SHA, RESULT, INCLUDES, SUGGESTED, RUNNER_TEMP } = process.env;
|
|
const issue_number = Number(PR);
|
|
const sha = HEAD_SHA.slice(0, 10);
|
|
const fixUrl = context.payload.workflow_run.html_url;
|
|
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
|
|
|
const react = (content) => REQUEST_ID
|
|
? github.rest.reactions.createForIssueComment({ owner, repo, comment_id: Number(REQUEST_ID), content })
|
|
: Promise.resolve();
|
|
async function reply(body, content) {
|
|
await react(content);
|
|
await github.rest.issues.createComment({ owner, repo, issue_number, body: `${MARKER}\n${MENTION}${body}` });
|
|
}
|
|
|
|
const read = (...parts) => fs.readFileSync(path.join(RUNNER_TEMP, ...parts), 'utf8');
|
|
const passes = RESULT && read('clang-tidy-fix', 'status').trim() === '0';
|
|
|
|
if (RESULT === 'empty') {
|
|
return passes
|
|
? reply(`clang-tidy passes on ${sha}. There is nothing to fix.`, '+1')
|
|
: reply(`clang-tidy has no automatic fix for what it reports on ${sha}. The findings, in the [fix run's log](${fixUrl}), need a manual edit.`, '-1');
|
|
}
|
|
if (RESULT === 'rejected') {
|
|
return reply(`the clang-tidy fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`, '-1');
|
|
}
|
|
if (RESULT === 'apply-failed') {
|
|
return reply(`the clang-tidy fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again.`, '-1');
|
|
}
|
|
if (RESULT !== 'ok') {
|
|
return reply(`something went wrong. See the [run log](${runUrl}).`, '-1');
|
|
}
|
|
|
|
const patch = read('head.patch');
|
|
const suggestions = JSON.parse(read('suggestions.json'));
|
|
|
|
const one = INCLUDES === '1';
|
|
const includes = one ? '1 missing include' : `${INCLUDES} missing includes`;
|
|
const inline = SUGGESTED === '0'
|
|
? (one ? 'It goes on a line this PR does not change, so I can\'t suggest it inline.'
|
|
: 'They go on lines this PR does not change, so I can\'t suggest them inline.')
|
|
: SUGGESTED === INCLUDES
|
|
? (one ? 'It is suggested inline below.' : 'Each is suggested inline below.')
|
|
: `${SUGGESTED} of them are suggested inline below; the rest go on lines this PR does not change.`;
|
|
const remaining = passes
|
|
? `With ${one ? 'it' : 'them'}, clang-tidy passes.`
|
|
: `Some findings remain that need a manual edit; see the [fix run's log](${fixUrl}).`;
|
|
// A fence longer than any run of backticks in the patch's context lines.
|
|
const fence = '`'.repeat(Math.max(3, ...[...patch.matchAll(/`+/g)].map((m) => m[0].length + 1)));
|
|
const apply = patch.length > MAX_PATCH
|
|
? `The full patch is too large to show here. Download \`fix.patch\` from the [fix run](${fixUrl}) and run \`git apply fix.patch\`.`
|
|
: `To apply ${one ? 'it' : 'all of them'}, run this in your checkout of the PR branch:\n\n${fence}sh\ngit apply <<'EOF'\n${patch}EOF\n${fence}`;
|
|
const body = (withSuggestions) => `${MARKER}\n${MENTION}clang-tidy's fix for ${sha} adds ${includes}. ` +
|
|
`${withSuggestions ? `${inline} ` : ''}${remaining}\n\n${apply}`;
|
|
|
|
const review = { owner, repo, pull_number: issue_number, commit_id: HEAD_SHA, event: 'COMMENT' };
|
|
try {
|
|
await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions });
|
|
} catch (error) {
|
|
// A suggestion GitHub will not place still leaves the patch.
|
|
core.warning(`Posting the suggestions failed (${error.message}); posting the patch alone.`);
|
|
await github.rest.pulls.createReview({ ...review, body: body(false) });
|
|
}
|
|
await react('+1');
|
|
|
|
nudge:
|
|
if: >-
|
|
github.repository == 'OrcaSlicer/OrcaSlicer'
|
|
&& github.event_name == 'workflow_run'
|
|
&& github.event.workflow_run.name == 'clang-tidy'
|
|
&& github.event.workflow_run.event == 'pull_request'
|
|
concurrency:
|
|
group: ${{ github.workflow }}-nudge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
|
cancel-in-progress: false
|
|
permissions:
|
|
actions: read # the check's steps
|
|
pull-requests: read # find the PR
|
|
issues: write # the comment
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- name: Ask for a fix when the check fails
|
|
uses: actions/github-script@v9
|
|
with:
|
|
script: |
|
|
const MARKER = '<!-- pr-tidy-bot-nudge -->';
|
|
// The clang-tidy step's name in clang_tidy.yml. A failure in any other
|
|
// step is the CI's, not the PR's.
|
|
const TIDY_STEP = 'Run clang-tidy on the changed lines';
|
|
|
|
const { owner, repo } = context.repo;
|
|
const run = context.payload.workflow_run;
|
|
|
|
// workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
|
|
const { data: prs } = await github.rest.pulls.list({
|
|
owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
|
|
});
|
|
const pr = prs.find((p) => p.head.sha === run.head_sha);
|
|
if (!pr) {
|
|
core.info('No open PR at this run\'s commit; a newer run will answer.');
|
|
return;
|
|
}
|
|
|
|
const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, run_id: run.id });
|
|
const failed = jobs.some((job) => (job.steps || []).some((step) => step.name === TIDY_STEP && step.conclusion === 'failure'));
|
|
|
|
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
|
|
const previous = comments.find((c) => c.user.type === 'Bot' && (c.body || '').startsWith(MARKER));
|
|
|
|
if (!failed) {
|
|
// Removes the request once the check passes.
|
|
if (previous && run.conclusion === 'success') {
|
|
await github.rest.issues.deleteComment({ owner, repo, comment_id: previous.id });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const body = `${MARKER}\n@${pr.user.login} the clang-tidy check found problems in this PR's changes ` +
|
|
`at ${run.head_sha.slice(0, 10)} ([log](${run.html_url})). Missing includes can be fixed either way:\n\n` +
|
|
'- run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result, or\n' +
|
|
'- comment `/bot fix-tidy`, and I\'ll suggest the fix here for you to apply.\n\n' +
|
|
'Other findings, such as a `using` in a header, need a manual edit.';
|
|
// Edits the earlier request instead of posting another, so each push does not notify again.
|
|
if (previous) {
|
|
await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body });
|
|
} else {
|
|
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
|
}
|