mirror of https://github.com/abpframework/abp.git
committed by
GitHub
3 changed files with 584 additions and 0 deletions
@ -0,0 +1,16 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<OutputType>Exe</OutputType> |
||||
|
<TargetFramework>net10.0</TargetFramework> |
||||
|
<ImplicitUsings>enable</ImplicitUsings> |
||||
|
<Nullable>enable</Nullable> |
||||
|
<RootNamespace>Volo.Abp.Docs.SyntaxCheck</RootNamespace> |
||||
|
<AssemblyName>CheckDocsSyntax</AssemblyName> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Scriban" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
@ -0,0 +1,347 @@ |
|||||
|
using System.Text.Json; |
||||
|
using Scriban; |
||||
|
using Scriban.Runtime; |
||||
|
using Scriban.Syntax; |
||||
|
|
||||
|
// Validates the Scriban template syntax embedded in `docs/en/` Markdown files.
|
||||
|
//
|
||||
|
// For each input file we run `Template.Parse` and a strict-mode render with the
|
||||
|
// same parameter set the docs renderer injects at runtime (each docs-params.json
|
||||
|
// `<name>` and its `<name>_Value` companion, plus Document_Language_Code,
|
||||
|
// Document_Version and Release_Status). StrictVariables is enabled on purpose so
|
||||
|
// references that would otherwise be silently rendered as empty strings surface
|
||||
|
// as build failures here.
|
||||
|
//
|
||||
|
// Known limitations:
|
||||
|
// - Partial template inlining is not executed: partial bodies are loaded from
|
||||
|
// external storage at render time, so they cannot be resolved in CI. Files
|
||||
|
// under `docs/en/` currently have no `//[doc-template]` references; if one is
|
||||
|
// added later, errors inside the partial body must be reviewed manually.
|
||||
|
// - Cookie- and query-string-driven parameter overrides are not injected, but
|
||||
|
// their keys still resolve to empty strings because they layer on top of the
|
||||
|
// same `<name>` / `<name>_Value` entries that are already injected.
|
||||
|
|
||||
|
namespace Volo.Abp.Docs.SyntaxCheck; |
||||
|
|
||||
|
internal static class Program |
||||
|
{ |
||||
|
private const string DefaultDocsRoot = "docs/en"; |
||||
|
private const string DocsParamsFileName = "docs-params.json"; |
||||
|
|
||||
|
private static readonly string[] BuiltInVariables = |
||||
|
{ |
||||
|
"Document_Language_Code", |
||||
|
"Document_Version", |
||||
|
"Release_Status" |
||||
|
}; |
||||
|
|
||||
|
public static int Main(string[] args) |
||||
|
{ |
||||
|
var useGitHubAnnotations = Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true"; |
||||
|
|
||||
|
var inputPaths = args.Length == 0 |
||||
|
? new[] { DefaultDocsRoot } |
||||
|
: args; |
||||
|
|
||||
|
var files = new List<string>(); |
||||
|
foreach (var path in inputPaths) |
||||
|
{ |
||||
|
if (File.Exists(path)) |
||||
|
{ |
||||
|
if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
files.Add(Path.GetFullPath(path)); |
||||
|
} |
||||
|
} |
||||
|
else if (Directory.Exists(path)) |
||||
|
{ |
||||
|
foreach (var file in Directory.EnumerateFiles(path, "*.md", SearchOption.AllDirectories)) |
||||
|
{ |
||||
|
files.Add(Path.GetFullPath(file)); |
||||
|
} |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
Console.Error.WriteLine($"WARN: path does not exist: {path}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (files.Count == 0) |
||||
|
{ |
||||
|
Console.WriteLine("No markdown files to check."); |
||||
|
return 0; |
||||
|
} |
||||
|
|
||||
|
Dictionary<string, string> renderParameters; |
||||
|
try |
||||
|
{ |
||||
|
renderParameters = BuildRenderParameters(files); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
Console.Error.WriteLine($"ERROR: failed to load docs-params: {ex.Message}"); |
||||
|
if (useGitHubAnnotations) |
||||
|
{ |
||||
|
Console.WriteLine($"::error::docs-params: {EscapeAnnotation(ex.Message)}"); |
||||
|
} |
||||
|
return 1; |
||||
|
} |
||||
|
|
||||
|
var errorCount = 0; |
||||
|
var warningCount = 0; |
||||
|
var fileIssueCount = 0; |
||||
|
var repoRoot = TryFindRepoRoot(Directory.GetCurrentDirectory()); |
||||
|
|
||||
|
foreach (var file in files) |
||||
|
{ |
||||
|
var fileIssues = CheckFile(file, renderParameters); |
||||
|
if (fileIssues.Count == 0) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
fileIssueCount++; |
||||
|
|
||||
|
foreach (var issue in fileIssues) |
||||
|
{ |
||||
|
if (issue.Severity == IssueSeverity.Error) |
||||
|
{ |
||||
|
errorCount++; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
warningCount++; |
||||
|
} |
||||
|
|
||||
|
var displayPath = repoRoot != null |
||||
|
? Path.GetRelativePath(repoRoot, file) |
||||
|
: file; |
||||
|
|
||||
|
var severityLabel = issue.Severity == IssueSeverity.Error ? "error" : "warning"; |
||||
|
|
||||
|
Console.WriteLine( |
||||
|
$"{displayPath}:{issue.Line}:{issue.Column}: {severityLabel}: [{issue.Kind}] {issue.Message}"); |
||||
|
|
||||
|
if (useGitHubAnnotations) |
||||
|
{ |
||||
|
var command = issue.Severity == IssueSeverity.Error ? "error" : "warning"; |
||||
|
Console.WriteLine( |
||||
|
$"::{command} file={displayPath},line={issue.Line},col={issue.Column}::" + |
||||
|
$"{issue.Kind}: {EscapeAnnotation(issue.Message)}"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Console.WriteLine(); |
||||
|
Console.WriteLine($"Checked {files.Count} markdown file(s). " + |
||||
|
$"{fileIssueCount} file(s) with issues, " + |
||||
|
$"{errorCount} error(s), {warningCount} warning(s)."); |
||||
|
|
||||
|
if (errorCount > 0 || warningCount > 0) |
||||
|
{ |
||||
|
Console.WriteLine(); |
||||
|
Console.WriteLine("Tip: wrap inline Scriban-looking text with `{%{{{ ... }}}%}` " + |
||||
|
"or wrap whole code blocks with `{%{` ... `}%}` to escape Scriban parsing."); |
||||
|
} |
||||
|
|
||||
|
return errorCount > 0 ? 1 : 0; |
||||
|
} |
||||
|
|
||||
|
private static List<Issue> CheckFile(string file, IReadOnlyDictionary<string, string> renderParameters) |
||||
|
{ |
||||
|
var issues = new List<Issue>(); |
||||
|
string content; |
||||
|
try |
||||
|
{ |
||||
|
content = File.ReadAllText(file); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
issues.Add(new Issue("Read", 1, 1, ex.Message, IssueSeverity.Error)); |
||||
|
return issues; |
||||
|
} |
||||
|
|
||||
|
var template = Template.Parse(content, file); |
||||
|
|
||||
|
foreach (var message in template.Messages) |
||||
|
{ |
||||
|
var severity = message.Type switch |
||||
|
{ |
||||
|
Scriban.Parsing.ParserMessageType.Error => IssueSeverity.Error, |
||||
|
Scriban.Parsing.ParserMessageType.Warning => IssueSeverity.Warning, |
||||
|
_ => (IssueSeverity?)null |
||||
|
}; |
||||
|
|
||||
|
if (severity is null) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var kind = severity == IssueSeverity.Error ? "ScribanParseError" : "ScribanParseWarning"; |
||||
|
|
||||
|
issues.Add(new Issue( |
||||
|
kind, |
||||
|
message.Span.Start.Line + 1, |
||||
|
message.Span.Start.Column + 1, |
||||
|
message.Message, |
||||
|
severity.Value)); |
||||
|
} |
||||
|
|
||||
|
if (template.HasErrors) |
||||
|
{ |
||||
|
return issues; |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var context = new TemplateContext |
||||
|
{ |
||||
|
StrictVariables = true |
||||
|
}; |
||||
|
|
||||
|
var scriptObject = new ScriptObject(); |
||||
|
foreach (var entry in renderParameters) |
||||
|
{ |
||||
|
scriptObject[entry.Key] = entry.Value; |
||||
|
} |
||||
|
|
||||
|
context.PushGlobal(scriptObject); |
||||
|
template.Render(context); |
||||
|
} |
||||
|
catch (ScriptRuntimeException ex) |
||||
|
{ |
||||
|
issues.Add(new Issue( |
||||
|
"ScribanRenderError", |
||||
|
ex.Span.Start.Line + 1, |
||||
|
ex.Span.Start.Column + 1, |
||||
|
ex.OriginalMessage, |
||||
|
IssueSeverity.Error)); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
issues.Add(new Issue("ScribanRenderError", 1, 1, ex.Message, IssueSeverity.Error)); |
||||
|
} |
||||
|
|
||||
|
return issues; |
||||
|
} |
||||
|
|
||||
|
private static Dictionary<string, string> BuildRenderParameters(IEnumerable<string> files) |
||||
|
{ |
||||
|
// Reproduces the keys the docs renderer places into its parameter
|
||||
|
// dictionary before rendering a documentation page.
|
||||
|
var parameters = new Dictionary<string, string>(StringComparer.Ordinal); |
||||
|
|
||||
|
foreach (var name in BuiltInVariables) |
||||
|
{ |
||||
|
parameters[name] = string.Empty; |
||||
|
} |
||||
|
|
||||
|
foreach (var paramName in DiscoverParameterNames(files)) |
||||
|
{ |
||||
|
parameters[paramName] = string.Empty; |
||||
|
parameters[paramName + "_Value"] = string.Empty; |
||||
|
} |
||||
|
|
||||
|
return parameters; |
||||
|
} |
||||
|
|
||||
|
private static HashSet<string> DiscoverParameterNames(IEnumerable<string> files) |
||||
|
{ |
||||
|
var names = new HashSet<string>(StringComparer.Ordinal); |
||||
|
var visitedDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
||||
|
|
||||
|
foreach (var file in files) |
||||
|
{ |
||||
|
var dir = Path.GetDirectoryName(file); |
||||
|
while (!string.IsNullOrEmpty(dir) && visitedDirs.Add(dir)) |
||||
|
{ |
||||
|
var candidate = Path.Combine(dir, DocsParamsFileName); |
||||
|
if (File.Exists(candidate)) |
||||
|
{ |
||||
|
AddNamesFromDocsParamsFile(candidate, names); |
||||
|
} |
||||
|
|
||||
|
var parent = Path.GetDirectoryName(dir); |
||||
|
if (string.IsNullOrEmpty(parent) || parent == dir) |
||||
|
{ |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
dir = parent; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return names; |
||||
|
} |
||||
|
|
||||
|
private static void AddNamesFromDocsParamsFile(string path, HashSet<string> sink) |
||||
|
{ |
||||
|
// A malformed docs-params.json would silently shrink the injected
|
||||
|
// variable set and turn parameter file regressions into hard-to-debug
|
||||
|
// "variable not found" errors on otherwise-fine markdown. Surface the
|
||||
|
// failure directly so the contributor fixes the JSON instead.
|
||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(path)); |
||||
|
|
||||
|
if (!doc.RootElement.TryGetProperty("parameters", out var parameters) || |
||||
|
parameters.ValueKind != JsonValueKind.Array) |
||||
|
{ |
||||
|
throw new InvalidDataException( |
||||
|
$"{path}: expected a top-level `parameters` array."); |
||||
|
} |
||||
|
|
||||
|
foreach (var parameter in parameters.EnumerateArray()) |
||||
|
{ |
||||
|
if (parameter.TryGetProperty("name", out var nameElement) && |
||||
|
nameElement.ValueKind == JsonValueKind.String) |
||||
|
{ |
||||
|
var name = nameElement.GetString(); |
||||
|
if (!string.IsNullOrWhiteSpace(name)) |
||||
|
{ |
||||
|
sink.Add(name); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static string? TryFindRepoRoot(string startDir) |
||||
|
{ |
||||
|
var current = new DirectoryInfo(startDir); |
||||
|
while (current != null) |
||||
|
{ |
||||
|
var gitPath = Path.Combine(current.FullName, ".git"); |
||||
|
if (Directory.Exists(gitPath) || File.Exists(gitPath)) |
||||
|
{ |
||||
|
return current.FullName; |
||||
|
} |
||||
|
|
||||
|
current = current.Parent; |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
private static string EscapeAnnotation(string text) |
||||
|
{ |
||||
|
// GitHub workflow command escaping. `%` must be encoded first so the
|
||||
|
// sequences we introduce below are not double-escaped.
|
||||
|
return text |
||||
|
.Replace("%", "%25") |
||||
|
.Replace("\r", "%0D") |
||||
|
.Replace("\n", "%0A") |
||||
|
.Replace(":", "%3A") |
||||
|
.Replace(",", "%2C"); |
||||
|
} |
||||
|
|
||||
|
private enum IssueSeverity |
||||
|
{ |
||||
|
Warning, |
||||
|
Error |
||||
|
} |
||||
|
|
||||
|
private readonly record struct Issue( |
||||
|
string Kind, |
||||
|
int Line, |
||||
|
int Column, |
||||
|
string Message, |
||||
|
IssueSeverity Severity); |
||||
|
} |
||||
@ -0,0 +1,221 @@ |
|||||
|
# Validates Scriban template syntax in PR-changed Markdown files under docs/en/, |
||||
|
# so escape issues are caught before they reach the published documentation. |
||||
|
|
||||
|
name: Check Docs Syntax |
||||
|
|
||||
|
on: |
||||
|
pull_request: |
||||
|
paths: |
||||
|
- 'docs/en/**/*.md' |
||||
|
- 'docs/en/docs-params.json' |
||||
|
- '.github/scripts/CheckDocsSyntax/**' |
||||
|
- '.github/workflows/check-docs-syntax.yml' |
||||
|
|
||||
|
permissions: |
||||
|
contents: read |
||||
|
pull-requests: write |
||||
|
|
||||
|
jobs: |
||||
|
check-scriban-syntax: |
||||
|
name: Validate Scriban syntax in docs/en |
||||
|
runs-on: ubuntu-latest |
||||
|
|
||||
|
steps: |
||||
|
- name: Checkout |
||||
|
uses: actions/checkout@v4 |
||||
|
|
||||
|
- name: Setup .NET |
||||
|
uses: actions/setup-dotnet@v4 |
||||
|
with: |
||||
|
dotnet-version: '10.0.x' |
||||
|
|
||||
|
- name: Build syntax checker |
||||
|
run: dotnet build .github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj -c Release --nologo -v minimal |
||||
|
|
||||
|
- name: Get changed markdown files |
||||
|
id: changed |
||||
|
uses: actions/github-script@v7 |
||||
|
with: |
||||
|
script: | |
||||
|
const prNumber = context.payload.pull_request.number; |
||||
|
const changed = []; |
||||
|
let paramsChanged = false; |
||||
|
let page = 1; |
||||
|
while (true) { |
||||
|
const { data: files } = await github.rest.pulls.listFiles({ |
||||
|
owner: context.repo.owner, |
||||
|
repo: context.repo.repo, |
||||
|
pull_number: prNumber, |
||||
|
per_page: 100, |
||||
|
page, |
||||
|
}); |
||||
|
const PARAMS_PATH = 'docs/en/docs-params.json'; |
||||
|
for (const f of files) { |
||||
|
const isMutation = |
||||
|
f.status === 'added' || f.status === 'modified' || f.status === 'renamed'; |
||||
|
if (!isMutation) continue; |
||||
|
// For renames, GitHub puts the new path in `filename` and the |
||||
|
// old one in `previous_filename`. Detect docs-params.json on |
||||
|
// either side so renames into / out of that path still trigger |
||||
|
// the parameter-file validation path. |
||||
|
if (f.filename === PARAMS_PATH || f.previous_filename === PARAMS_PATH) { |
||||
|
paramsChanged = true; |
||||
|
} |
||||
|
if (f.filename.startsWith('docs/en/') && f.filename.endsWith('.md')) { |
||||
|
changed.push(f.filename); |
||||
|
} |
||||
|
} |
||||
|
if (files.length < 100) break; |
||||
|
page++; |
||||
|
} |
||||
|
core.setOutput('files', changed.join('\n')); |
||||
|
core.setOutput('count', changed.length.toString()); |
||||
|
core.setOutput('paramsChanged', paramsChanged ? 'true' : 'false'); |
||||
|
core.info(`Markdown files to check: ${changed.length}`); |
||||
|
core.info(`docs-params.json changed: ${paramsChanged}`); |
||||
|
for (const f of changed) { |
||||
|
core.info(` - ${f}`); |
||||
|
} |
||||
|
|
||||
|
- name: Run syntax checker |
||||
|
id: checker |
||||
|
if: steps.changed.outputs.count != '0' || steps.changed.outputs.paramsChanged == 'true' |
||||
|
env: |
||||
|
CHANGED_FILES: ${{ steps.changed.outputs.files }} |
||||
|
PARAMS_CHANGED: ${{ steps.changed.outputs.paramsChanged }} |
||||
|
run: | |
||||
|
mapfile -t files <<< "$CHANGED_FILES" |
||||
|
args=() |
||||
|
for f in "${files[@]}"; do |
||||
|
if [ -n "$f" ] && [ -f "$f" ]; then |
||||
|
args+=("$f") |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
if [ ${#args[@]} -eq 0 ]; then |
||||
|
if [ "$PARAMS_CHANGED" = "true" ] && [ -f "docs/en/index.md" ]; then |
||||
|
# No markdown changed, but docs-params.json did. Run the checker |
||||
|
# against a single known-clean page so BuildRenderParameters / |
||||
|
# docs-params.json parsing actually executes and fails fast on a |
||||
|
# malformed parameter file. |
||||
|
echo "docs-params.json changed but no markdown changed; validating params via docs/en/index.md." |
||||
|
args+=("docs/en/index.md") |
||||
|
else |
||||
|
echo "No existing markdown files to check (all changes are deletions)." |
||||
|
exit 0 |
||||
|
fi |
||||
|
fi |
||||
|
|
||||
|
# Capture the checker's stdout so a follow-up step can post it as a PR |
||||
|
# comment when the run fails, while still streaming it to the job log. |
||||
|
set -o pipefail |
||||
|
dotnet run --project .github/scripts/CheckDocsSyntax/CheckDocsSyntax.csproj \ |
||||
|
-c Release --no-build -- "${args[@]}" 2>&1 | tee checker-output.txt |
||||
|
|
||||
|
- name: Upsert PR comment on failure |
||||
|
if: failure() && steps.checker.conclusion == 'failure' |
||||
|
uses: actions/github-script@v7 |
||||
|
with: |
||||
|
script: | |
||||
|
const fs = require('fs'); |
||||
|
const MARKER = '<!-- check-docs-syntax-bot -->'; |
||||
|
const prNumber = context.payload.pull_request.number; |
||||
|
|
||||
|
let report = ''; |
||||
|
try { |
||||
|
report = fs.readFileSync('checker-output.txt', 'utf8').trim(); |
||||
|
} catch (e) { |
||||
|
report = '(checker output was not captured)'; |
||||
|
} |
||||
|
|
||||
|
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; |
||||
|
const body = [ |
||||
|
MARKER, |
||||
|
'### Docs syntax check failed', |
||||
|
'', |
||||
|
'The Scriban syntax checker reported issues in the Markdown files this PR changes. Wrap inline Scriban-looking text with `{%{{{ ... }}}%}` or wrap whole code blocks with `{%{` ... `}%}` to keep it from being parsed as a template.', |
||||
|
'', |
||||
|
'<details><summary>Checker output</summary>', |
||||
|
'', |
||||
|
'```', |
||||
|
report, |
||||
|
'```', |
||||
|
'', |
||||
|
'</details>', |
||||
|
'', |
||||
|
`[Full run log](${runUrl})`, |
||||
|
].join('\n'); |
||||
|
|
||||
|
// Find an existing bot comment to update (idempotent across re-runs). |
||||
|
let existing = null; |
||||
|
for (let page = 1; ; page++) { |
||||
|
const { data } = await github.rest.issues.listComments({ |
||||
|
owner: context.repo.owner, |
||||
|
repo: context.repo.repo, |
||||
|
issue_number: prNumber, |
||||
|
per_page: 100, |
||||
|
page, |
||||
|
}); |
||||
|
existing = data.find(c => c.body && c.body.startsWith(MARKER)); |
||||
|
if (existing || data.length < 100) break; |
||||
|
} |
||||
|
|
||||
|
if (existing) { |
||||
|
await github.rest.issues.updateComment({ |
||||
|
owner: context.repo.owner, |
||||
|
repo: context.repo.repo, |
||||
|
comment_id: existing.id, |
||||
|
body, |
||||
|
}); |
||||
|
core.info(`Updated existing bot comment (#${existing.id}).`); |
||||
|
} else { |
||||
|
const { data: created } = await github.rest.issues.createComment({ |
||||
|
owner: context.repo.owner, |
||||
|
repo: context.repo.repo, |
||||
|
issue_number: prNumber, |
||||
|
body, |
||||
|
}); |
||||
|
core.info(`Created bot comment (#${created.id}).`); |
||||
|
} |
||||
|
|
||||
|
- name: Resolve previous failure comment on success |
||||
|
# Clear any stale failure comment whenever this workflow run is green, |
||||
|
# even if the syntax checker step was skipped (e.g. when a later |
||||
|
# commit reverts the earlier failure so no markdown files appear in |
||||
|
# the PR's net diff). |
||||
|
if: success() |
||||
|
uses: actions/github-script@v7 |
||||
|
with: |
||||
|
script: | |
||||
|
const MARKER = '<!-- check-docs-syntax-bot -->'; |
||||
|
const prNumber = context.payload.pull_request.number; |
||||
|
|
||||
|
for (let page = 1; ; page++) { |
||||
|
const { data } = await github.rest.issues.listComments({ |
||||
|
owner: context.repo.owner, |
||||
|
repo: context.repo.repo, |
||||
|
issue_number: prNumber, |
||||
|
per_page: 100, |
||||
|
page, |
||||
|
}); |
||||
|
|
||||
|
const existing = data.find(c => c.body && c.body.startsWith(MARKER)); |
||||
|
if (existing) { |
||||
|
const body = [ |
||||
|
MARKER, |
||||
|
'### Docs syntax check passed', |
||||
|
'', |
||||
|
'The previously reported issues are no longer present in this PR.', |
||||
|
].join('\n'); |
||||
|
await github.rest.issues.updateComment({ |
||||
|
owner: context.repo.owner, |
||||
|
repo: context.repo.repo, |
||||
|
comment_id: existing.id, |
||||
|
body, |
||||
|
}); |
||||
|
core.info(`Cleared bot comment (#${existing.id}).`); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
if (data.length < 100) break; |
||||
|
} |
||||
Loading…
Reference in new issue