Added missing CI scripts (#16832)

This commit is contained in:
Selis
2025-01-04 02:57:58 +01:00
committed by GitHub
parent c2efe31640
commit 35467d64eb
14 changed files with 722 additions and 355 deletions
+5
View File
@@ -0,0 +1,5 @@
<!-- Write **BELOW** The Headers and **ABOVE** The comments else it may not be viewable. -->
## About The Pull Request
<!-- Describe The Pull Request. -->
@@ -1,15 +0,0 @@
Taken mostly from https://tgstation13.org/wiki/Guide_to_Changelogs#Updating_the_Changelog with a few changes.
First, install Python 2.7. Yes, it's outdated, but that's what the script is made to work with.
Make sure that Python 2.7 and its Scripts/ folder is in PATH
Upgrade pip, as outlined here https://pip.pypa.io/en/stable/installing/
Once pip is installed, you will need to run two commands from your terminal or command prompt to install packages
1. 'pip install PyYaml'
2. 'pip install beautifulsoup4'
Once all this is done, you should just be able to run ss13_genchangelog.py and sit back.
If you get errors running it, two things are possible.
1. You installed something wrong.
2. Someone formatted their changelog wrong.
Check where the error was, do a bit of troubleshooting, see if it wasn't #2. Fixing the syntax in the yaml files is pretty simple.
@@ -1,4 +0,0 @@
@echo off
rem Cheridan asked for this. - N3X
call python ss13_genchangelog.py ../../html/changelog.html ../../html/changelogs
pause
@@ -1,121 +0,0 @@
'''
Usage:
$ python ss13_autochangelog.py [--dry-run] html/changelogs [PR_number] [PR_author] [PR_Body]
ss13_autochangelog.py - Generate changelog YAML files from pull request.
'''
from __future__ import print_function
import os, sys, re, time, argparse
from datetime import datetime, date
from time import time
today = date.today()
dateformat = "%d %B %Y"
opt = argparse.ArgumentParser()
opt.add_argument('target_dir', help='The location to write changelog files to.')
opt.add_argument('pr_numb', help='The number of the pull request.')
opt.add_argument('pr_author', help='The author of the pull request. If specific authors aren\'t specified, the PR author will be used')
opt.add_argument('pr_body', help='The body of the pull request to parse for changelogs.')
args = opt.parse_args()
all_changelog_entries = {}
validPrefixes = {
"fix": 'bugfix',
"fixes": 'bugfix',
"bugfix": 'bugfix',
"wip": 'wip',
"tweak": 'tweak',
"tweaks": 'tweak',
"rsctweak": 'tweak',
"soundadd": 'soundadd',
"sounddel": 'sounddel',
"add": 'rscadd',
"adds": 'rscadd',
"rscadd": 'rscadd',
"del": 'rscdel',
"dels": 'rscdel',
"delete": 'rscdel',
"deletes": 'rscdel',
"rscdeldel": 'rscdel',
"imageadd": 'imageadd',
"imagedel": 'imagedel',
"maptweak": 'maptweak',
"remap": 'maptweak',
"remaps": 'maptweak',
"typo": 'spellcheck',
"spellcheck": 'spellcheck',
"experimental": 'experiment',
"experiments": 'experiment',
"experiment": 'experiment'
}
incltag = False
new_logs = {}
author = args.pr_author
new = 0
# Parse PR body for changelog entries
print('Reading changelogs...')
for line in args.pr_body.splitlines():
print(f"Checking line '{line}'")
if line[:1] == "🆑": # Find the start of the changelog
print("Found opening :cl: tag")
if incltag == True: # If we're already reading logs, skip
continue
incltag = True
# Fetch the author name
author = line[1:]
author.strip()
if not len(author):
author = args.pr_author
if author not in new_logs:
new_logs[author] = [] # Make array entry for the author
continue
# If we hit a /cl, we're no longer reading logs
elif line == "/🆑":
print("Found closing /:cl: tag")
incltag = False
# If we aren't reading logs, we don't care about any other line contents
if not incltag:
continue
# Split line into tag (icon) and body (comment)
body = re.split("[ ,:-]", line, 1)
if len(body) != 2:
continue # If there's just one word, then it can't really be a changelog, now can it
if body[0] in validPrefixes:
tag = validPrefixes[body[0]]
body = body[1].strip(" ,-:\t\n")
else: # If the tag is invalid, just default to rscadd
tag = "rscadd"
body = line.strip(" ,-:\t\n")
new_logs[author].append(f" - {tag}: \"{body}\"")
new += 1
print(f"Writing {new} new changelog entries.") # f supposedly formats new into the var
for auth in new_logs:
# Sanitize authors without changes
if not len(new_logs[auth]):
continue
f = open(os.path.join(args.target_dir, f"{auth}{args.pr_numb}.yml"), 'w')
print(f"Writing changes to {f}")
f.write(f'author: {auth}\n')
f.write('delete-after: True\n')
f.write('changes:\n')
for log in new_logs[auth]:
f.write(f'{log}\n')
f.close()
@@ -1,215 +0,0 @@
'''
Usage:
$ python ss13_genchangelog.py [--dry-run] html/changelog.html html/changelogs/
ss13_genchangelog.py - Generate changelog from YAML.
Copyright 2013 Rob "N3X15" Nelson <nexis@7chan.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
'''
from __future__ import print_function
import yaml, os, glob, sys, re, time, argparse
from datetime import datetime, date
from time import time
today = date.today()
dateformat = "%d %B %Y"
opt = argparse.ArgumentParser()
opt.add_argument('-d', '--dry-run', dest='dryRun', default=False, action='store_true', help='Only parse changelogs and, if needed, the targetFile. (A .dry_changelog.yml will be output for debugging purposes.)')
opt.add_argument('targetFile', help='The HTML changelog we wish to update.')
opt.add_argument('ymlDir', help='The directory of YAML changelogs we will use.')
args = opt.parse_args()
all_changelog_entries = {}
validPrefixes = [
'bugfix',
'wip',
'tweak',
'soundadd',
'sounddel',
'rscdel',
'rscadd',
'imageadd',
'imagedel',
'maptweak',
'spellcheck',
'experiment'
]
def dictToTuples(inp):
return [(k, v) for k, v in inp.items()]
changelog_cache = os.path.join(args.ymlDir, '.all_changelog.yml')
failed_cache_read = True
if os.path.isfile(changelog_cache):
try:
with open(changelog_cache) as f:
(_, all_changelog_entries) = yaml.load_all(f)
failed_cache_read = False
# Convert old timestamps to newer format.
new_entries = {}
for _date in all_changelog_entries.keys():
ty = type(_date).__name__
# print(ty)
if ty in ['str', 'unicode']:
temp_data = all_changelog_entries[_date]
_date = datetime.strptime(_date, dateformat).date()
new_entries[_date] = temp_data
else:
new_entries[_date] = all_changelog_entries[_date]
all_changelog_entries = new_entries
except Exception as e:
print("Failed to read cache:")
print(e, file=sys.stderr)
if args.dryRun:
changelog_cache = os.path.join(args.ymlDir, '.dry_changelog.yml')
if failed_cache_read and os.path.isfile(args.targetFile):
from bs4 import BeautifulSoup
from bs4.element import NavigableString
print(' Generating cache...')
with open(args.targetFile, 'r') as f:
soup = BeautifulSoup(f)
for e in soup.find_all('div', {'class':'commit'}):
entry = {}
date = datetime.strptime(e.h2.string.strip(), dateformat).date() # key
for authorT in e.find_all('h3', {'class':'author'}):
author = authorT.string
# Strip suffix
if author.endswith('updated:'):
author = author[:-8]
author = author.strip()
# Find <ul>
ulT = authorT.next_sibling
while(ulT.name != 'ul'):
ulT = ulT.next_sibling
changes = []
for changeT in ulT.children:
if changeT.name != 'li': continue
val = changeT.decode_contents(formatter="html")
newdat = {changeT['class'][0] + '': val + ''}
if newdat not in changes:
changes += [newdat]
if len(changes) > 0:
entry[author] = changes
if date in all_changelog_entries:
all_changelog_entries[date].update(entry)
else:
all_changelog_entries[date] = entry
del_after = []
errors = False
print('Reading changelogs...')
for fileName in glob.glob(os.path.join(args.ymlDir, "*.yml")):
name, ext = os.path.splitext(os.path.basename(fileName))
if name.startswith('.'): continue
if name == 'example': continue
fileName = os.path.abspath(fileName)
print(' Reading {}...'.format(fileName))
cl = {}
with open(fileName, 'r') as f:
cl = yaml.load(f)
f.close()
if today not in all_changelog_entries:
all_changelog_entries[today] = {}
author_entries = all_changelog_entries[today].get(cl['author'], [])
if len(cl['changes']):
new = 0
for change in cl['changes']:
if change not in author_entries:
(change_type, _) = dictToTuples(change)[0]
if change_type not in validPrefixes:
errors = True
print(' {0}: Invalid prefix {1}'.format(fileName, change_type), file=sys.stderr)
author_entries += [change]
new += 1
all_changelog_entries[today][cl['author']] = author_entries
if new > 0:
print(' Added {0} new changelog entries.'.format(new))
if cl.get('delete-after', False):
if os.path.isfile(fileName):
if args.dryRun:
print(' Would delete {0} (delete-after set)...'.format(fileName))
else:
del_after += [fileName]
if args.dryRun: continue
cl['changes'] = []
with open(fileName, 'w') as f:
yaml.dump(cl, f, default_flow_style=False)
targetDir = os.path.dirname(args.targetFile)
with open(args.targetFile.replace('.htm', '.dry.htm') if args.dryRun else args.targetFile, 'w') as changelog:
with open(os.path.join(targetDir, 'templates', 'header.html'), 'r') as h:
for line in h:
changelog.write(line)
for _date in reversed(sorted(all_changelog_entries.keys())):
entry_htm = '\n'
entry_htm += '\t\t\t<h2 class="date">{date}</h2>\n'.format(date=_date.strftime(dateformat))
write_entry = False
for author in sorted(all_changelog_entries[_date].keys()):
if len(all_changelog_entries[_date]) == 0: continue
author_htm = '\t\t\t<h3 class="author">{author} updated:</h3>\n'.format(author=author)
author_htm += '\t\t\t<ul class="changes bgimages16">\n'
changes_added = []
for (css_class, change) in (dictToTuples(e)[0] for e in all_changelog_entries[_date][author]):
if change in changes_added: continue
write_entry = True
changes_added += [change]
author_htm += '\t\t\t\t<li class="{css_class}">{change}</li>\n'.format(css_class=css_class, change=change.strip())
author_htm += '\t\t\t</ul>\n'
if len(changes_added) > 0:
entry_htm += author_htm
if write_entry:
changelog.write(entry_htm)
with open(os.path.join(targetDir, 'templates', 'footer.html'), 'r') as h:
for line in h:
changelog.write(line)
with open(changelog_cache, 'w') as f:
cache_head = 'DO NOT EDIT THIS FILE BY HAND! AUTOMATICALLY GENERATED BY ss13_genchangelog.py.'
yaml.dump_all([cache_head, all_changelog_entries], f, default_flow_style=False)
if len(del_after):
print('Cleaning up...')
for fileName in del_after:
if os.path.isfile(fileName):
print(' Deleting {0} (delete-after set)...'.format(fileName))
os.remove(fileName)
if errors:
sys.exit(1)
+42
View File
@@ -0,0 +1,42 @@
import { parseChangelog } from "./changelogParser.js";
const safeYml = (string) =>
string.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
export function changelogToYml(changelog, login) {
const author = changelog.author || login;
const ymlLines = [];
ymlLines.push(`author: "${safeYml(author)}"`);
ymlLines.push(`delete-after: True`);
ymlLines.push(`changes:`);
for (const change of changelog.changes) {
ymlLines.push(
` - ${change.type.changelogKey}: "${safeYml(change.description)}"`
);
}
return ymlLines.join("\n");
}
export async function processAutoChangelog({ github, context }) {
const changelog = parseChangelog(context.payload.pull_request.body);
if (!changelog || changelog.changes.length === 0) {
console.log("no changelog found");
return;
}
const yml = changelogToYml(
changelog,
context.payload.pull_request.user.login
);
github.rest.repos.createOrUpdateFileContents({
owner: context.repo.owner,
repo: context.repo.repo,
path: `html/changelogs/AutoChangeLog-pr-${context.payload.pull_request.number}.yml`,
message: `Automatic changelog for PR #${context.payload.pull_request.number} [ci skip]`,
content: Buffer.from(yml).toString("base64"),
});
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { changelogToYml } from "./autoChangelog.js";
import { parseChangelog } from "./changelogParser.js";
assert.equal(
changelogToYml(
parseChangelog(`
My cool PR!
:cl: DenverCoder9
add: Adds new stuff
add: Adds more stuff
/:cl:
`)
),
`author: "DenverCoder9"
delete-after: True
changes:
- rscadd: "Adds new stuff"
- rscadd: "Adds more stuff"`
);
+120
View File
@@ -0,0 +1,120 @@
/**
* A map of changelog phrases to meta-information.
*
* The first entry in the list is used in the changelog YML file as the key when
* used, but other than that all entries are equivalent.
*
* placeholders - The default messages, if the changelog has this then we pretend it
* doesn't exist.
*/
export const CHANGELOG_ENTRIES = [
[
["rscadd", "add", "adds"],
{
placeholders: [
"Added new mechanics or gameplay changes",
"Added more things",
],
},
],
[
["bugfix", "fix", "fixes"],
{
placeholders: ["fixed a few things"],
},
],
[
["rscdel", "del", "dels"],
{
placeholders: ["Removed old things"],
},
],
[
["qol", "tweak"],
{
placeholders: ["made something easier to use"],
},
],
[
["maptweak", "remap"],
{
placeholders: ["tweaked something in a map"],
},
],
[
["sound"],
{
placeholders: ["added/modified/removed audio or sound effects"],
},
],
[
["image"],
{
placeholders: ["added/modified/removed some icons or images"],
},
],
[
["spellcheck", "typo"],
{
placeholders: ["fixed a few typos"],
},
],
[
["balance"],
{
placeholders: ["rebalanced something"],
},
],
[
["code_imp", "code"],
{
placeholders: ["changed some code"],
},
],
[
["refactor"],
{
placeholders: ["refactored some code"],
},
],
[
["config"],
{
placeholders: ["changed some config setting"],
},
],
[
["admin"],
{
placeholders: ["messed with admin stuff"],
},
],
[
["server"],
{
placeholders: ["something server ops should know"],
},
],
];
// Valid changelog openers
export const CHANGELOG_OPEN_TAGS = [":cl:", "??"];
// Valid changelog closers
export const CHANGELOG_CLOSE_TAGS = ["/:cl:", "/ :cl:", ":/cl:", "/??", "/ ??"];
// Placeholder value for an author
export const CHANGELOG_AUTHOR_PLACEHOLDER_NAME = "optional name here";
@@ -0,0 +1,80 @@
import * as changelogConfig from "./changelogConfig.js";
const REGEX_CHANGELOG_LINE = /^(\w+): (.+)$/;
const CHANGELOG_KEYS_TO_ENTRY = {};
for (const [types, entry] of changelogConfig.CHANGELOG_ENTRIES) {
const entryWithChangelogKey = {
...entry,
changelogKey: types[0],
};
for (const type of types) {
CHANGELOG_KEYS_TO_ENTRY[type] = entryWithChangelogKey;
}
}
function parseChangelogBody(lines, openTag) {
const [changelogOpening] = lines.splice(0, 1);
const author =
changelogOpening.substring(openTag.length).trim() || undefined;
const changelog = {
author,
changes: [],
};
for (const line of lines) {
if (line.trim().length === 0) {
continue;
}
for (const closeTag of changelogConfig.CHANGELOG_CLOSE_TAGS) {
if (line.startsWith(closeTag)) {
return changelog;
}
}
const match = line.match(REGEX_CHANGELOG_LINE);
if (match) {
const [_, type, description] = match;
const entry = CHANGELOG_KEYS_TO_ENTRY[type] || CHANGELOG_KEYS_TO_ENTRY["rscadd"];
if (entry.placeholders.includes(description)) {
continue;
}
if (entry) {
changelog.changes.push({
type: entry,
description,
});
}
} else {
const lastChange = changelog.changes[changelog.changes.length - 1];
if (lastChange) {
lastChange.description += `\n${line}`;
}
}
}
return changelog;
}
export function parseChangelog(text) {
const lines = text.split("\n").map((line) => line.trim());
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
for (const openTag of changelogConfig.CHANGELOG_OPEN_TAGS) {
if (line.startsWith(openTag)) {
return parseChangelogBody(lines.slice(index), openTag);
}
}
}
return undefined;
}
@@ -0,0 +1,72 @@
import { strict as assert } from "node:assert";
import { parseChangelog } from "./changelogParser.js";
// Basic test
const basicChangelog = parseChangelog(`
My cool PR!
:cl: DenverCoder9
add: Adds new stuff
/:cl:
`);
assert.equal(basicChangelog.author, "DenverCoder9");
assert.equal(basicChangelog.changes.length, 1);
assert.equal(basicChangelog.changes[0].type.changelogKey, "rscadd");
assert.equal(basicChangelog.changes[0].description, "Adds new stuff");
// Multi-line test
const multiLineChangelog = parseChangelog(`
My cool PR!
:cl:
add: Adds new stuff
to the game
/:cl:
`);
assert.equal(multiLineChangelog.author, undefined);
assert.equal(multiLineChangelog.changes.length, 1);
assert.equal(multiLineChangelog.changes[0].type.changelogKey, "rscadd");
assert.equal(
multiLineChangelog.changes[0].description,
"Adds new stuff\nto the game"
);
// Placeholders
const placeholderChangelog = parseChangelog(`
My cool PR!
:cl:
add: Added new mechanics or gameplay changes
/:cl:
`);
assert.equal(placeholderChangelog.changes.length, 0);
// No changelog
const noChangelog = parseChangelog(`
My cool PR!
`);
assert.equal(noChangelog, undefined);
// No /:cl:
const noCloseChangelog = parseChangelog(`
My cool PR!
:cl:
add: Adds new stuff
`);
assert.equal(noCloseChangelog.changes.length, 1);
assert.equal(noCloseChangelog.changes[0].type.changelogKey, "rscadd");
assert.equal(noCloseChangelog.changes[0].description, "Adds new stuff");
// :cl: with arbitrary text
const arbitraryTextChangelog = parseChangelog(`
My cool PR!
:cl:
Adds new stuff
/:cl:
`);
assert.equal(arbitraryTextChangelog.changes.length, 0);
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
@@ -0,0 +1,41 @@
import fs from "fs";
const REGEX_COMMENT = /<!--.+?-->/g;
// Make sure we only remove default comments
const comments = [];
for (const match of fs
.readFileSync(".github/PULL_REQUEST_TEMPLATE.md", { encoding: "utf8" })
.matchAll(REGEX_COMMENT)) {
comments.push(match[0]);
}
function escapeRegex(string) {
return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
}
export async function removeGuideComments({ github, context }) {
let newBody = context.payload.pull_request.body;
if (!newBody) {
console.log("PR body is empty, skipping...");
return;
}
for (const comment of comments) {
newBody = newBody.replace(
new RegExp(`^\\s*${escapeRegex(comment)}\\s*`, "gm"),
"\n"
);
}
if (newBody !== context.payload.pull_request.body) {
await github.rest.pulls.update({
pull_number: context.payload.pull_request.number,
repo: context.repo.repo,
owner: context.repo.owner,
body: newBody,
});
}
}
+293
View File
@@ -0,0 +1,293 @@
const LABEL = "🤖 Flaky Test Report";
const TITLE_BOT_HEADER = "title: ";
// Only check jobs that start with these.
// Helps make sure we don't restart something like screenshot tests or linters, which are not known to be flaky.
const CONSIDERED_JOBS = [
"Integration Tests",
];
async function getFailedJobsForRun(github, context, workflowRunId, runAttempt) {
const {
data: { jobs },
} = await github.rest.actions.listJobsForWorkflowRunAttempt({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: workflowRunId,
attempt_number: runAttempt,
});
return jobs
.filter((job) => job.conclusion === "failure")
.filter((job) =>
CONSIDERED_JOBS.some((title) => job.name.startsWith(title))
);
}
export async function rerunFlakyTests({ github, context }) {
const failingJobs = await getFailedJobsForRun(
github,
context,
context.payload.workflow_run.id,
context.payload.workflow_run.run_attempt
);
if (failingJobs.length > 1) {
console.log("Multiple jobs failing. PROBABLY not flaky, not rerunning.");
return;
}
if (failingJobs.length === 0) {
throw new Error(
"rerunFlakyTests should not have run on a run with no failing jobs"
);
}
github.rest.actions.reRunWorkflowFailedJobs({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
}
// Tries its best to extract a useful error title and message for the given log
export function extractDetails(log) {
// Strip off timestamp
const lines = log.split(/^[0-9.:T\-]*?Z /gm);
const failureRegex = /^\t?FAILURE #(?<number>[0-9]+): (?<headline>.+)/;
const groupRegex = /^##\[group\](?<group>.+)/;
const failures = [];
let lastGroup = "root";
let loggingFailure;
const newFailure = (failureMatch) => {
const { headline } = failureMatch.groups;
loggingFailure = {
headline,
group: lastGroup.replace("/datum/unit_test/", ""),
details: [],
};
};
for (const line of lines) {
const groupMatch = line.match(groupRegex);
if (groupMatch) {
lastGroup = groupMatch.groups.group.trim();
continue;
}
const failureMatch = line.match(failureRegex);
if (loggingFailure === undefined) {
if (!failureMatch) {
continue;
}
newFailure(failureMatch);
} else if (failureMatch || line.startsWith("##")) {
failures.push(loggingFailure);
loggingFailure = undefined;
if (failureMatch) {
newFailure(failureMatch);
}
} else {
loggingFailure.details.push(line.trim());
}
}
// We had no logged failures, there's not really anything we can do here
if (failures.length === 0) {
return {
title: "Flaky test failure with no obvious source",
failures,
};
}
// We *could* create multiple failures for multiple groups.
// This would be important if we had multiple flaky tests at the same time.
// I'm choosing not to because it complicates this logic a bit, has the ability to go terribly wrong,
// and also because there's something funny to me about that increasing the urgency of fixing
// flaky tests. If it becomes a serious issue though, I would not mind this being fixed.
const uniqueGroups = new Set(failures.map((failure) => failure.group));
if (uniqueGroups.size > 1) {
return {
title: `Multiple flaky test failures in ${Array.from(uniqueGroups)
.sort()
.join(", ")}`,
failures,
};
}
const failGroup = failures[0].group;
if (failures.length > 1) {
return {
title: `Multiple errors in flaky test ${failGroup}`,
failures,
};
}
const failure = failures[0];
// Common patterns where we can always get a detailed title
const runtimeMatch = failure.headline.match(/Runtime in .+?: (?<error>.+)/);
if (runtimeMatch) {
const runtime = runtimeMatch.groups.error.trim();
const invalidTimerMatch = runtime.match(/^Invalid timer:.+object:(?<object>[^[]+).*delegate:(?<proc>.+?), source:/);
if (invalidTimerMatch) {
return {
title: `Flaky test ${failGroup}: Invalid timer: ${invalidTimerMatch.groups.proc.trim()} on ${invalidTimerMatch.groups.object.trim()}`,
failures,
};
}
return {
title: `Flaky test ${failGroup}: ${runtime}`,
failures,
};
}
const hardDelMatch = failure.headline.match(/^(?<object>\/[\w/]+) hard deleted .* times out of a total del count of/);
if (hardDelMatch) {
return {
title: `Flaky hard delete: ${hardDelMatch.groups.object}`,
failures,
};
}
// Try to normalize the title and remove anything that might be variable
const normalizedError = failure.headline.replace(/\s*at .+?:[0-9]+.*/g, ""); // "<message> at code.dm:123"
return {
title: `Flaky test ${failGroup}: ${normalizedError}`,
failures,
};
}
async function getExistingIssueId(graphql, context, title) {
// Hope you never have more than 100 of these open!
const {
repository: {
issues: { nodes: openFlakyTestIssues },
},
} = await graphql(
`
query ($owner: String!, $repo: String!, $label: String!) {
repository(owner: $owner, name: $repo) {
issues(
labels: [$label]
first: 100
orderBy: { field: CREATED_AT, direction: DESC }
states: [OPEN]
) {
nodes {
number
title
body
}
}
}
}
`,
{
owner: context.repo.owner,
repo: context.repo.repo,
label: LABEL,
}
);
const exactTitle = openFlakyTestIssues.find((issue) => issue.title === title);
if (exactTitle !== undefined) {
return exactTitle.number;
}
const foundInBody = openFlakyTestIssues.find((issue) =>
issue.body.includes(`<!-- ${TITLE_BOT_HEADER}${exactTitle} -->`)
);
if (foundInBody !== undefined) {
return foundInBody.number;
}
return undefined;
}
function createBody({ title, failures }, runUrl) {
return `
<!-- This issue can be renamed, but do not change the next comment! -->
<!-- title: ${title} -->
Flaky tests were detected in [this test run](${runUrl}). This means that there was a failure that was cleared when the tests were simply restarted.
Failures:
\`\`\`
${failures
.map(
(failure) =>
`${failure.group}: ${failure.headline}\n\t${failure.details.join("\n")}`
)
.join("\n")}
\`\`\`
`.replace(/^\s*/gm, "");
}
export async function reportFlakyTests({ github, context }) {
const failedJobsFromLastRun = await getFailedJobsForRun(
github,
context,
context.payload.workflow_run.id,
context.payload.workflow_run.run_attempt - 1
);
// This could one day be relaxed if we face serious enough flaky test problems, so we're going to loop anyway
if (failedJobsFromLastRun.length !== 1) {
console.log(
"Multiple jobs failing after retry, assuming maintainer rerun."
);
return;
}
for (const job of failedJobsFromLastRun) {
const { data: log } =
await github.rest.actions.downloadJobLogsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
job_id: job.id,
});
const details = extractDetails(log);
const existingIssueId = await getExistingIssueId(
github.graphql,
context,
details.title
);
if (existingIssueId !== undefined) {
// Maybe in the future, if it's helpful, update the existing issue with new links
console.log(`Existing issue found: #${existingIssueId}`);
return;
}
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: details.title,
labels: [LABEL],
body: createBody(
details,
`https://github.com/${context.repo.owner}/${
context.repo.repo
}/actions/runs/${context.payload.workflow_run.id}/attempts/${
context.payload.workflow_run.run_attempt - 1
}`
),
});
}
}
@@ -0,0 +1,45 @@
import { strict as assert } from "node:assert";
import fs from "node:fs";
import { extractDetails } from "./rerunFlakyTests.js";
function extractDetailsFromPayload(filename) {
return extractDetails(
fs.readFileSync(`tests/flakyTestPayloads/${filename}.txt`, {
encoding: "utf8",
})
);
}
const chatClient = extractDetailsFromPayload("chat_client");
assert.equal(
chatClient.title,
"Flaky hard delete: /datum/computer_file/program/chatclient"
);
assert.equal(chatClient.failures.length, 1);
const monkeyBusiness = extractDetailsFromPayload("monkey_business");
assert.equal(
monkeyBusiness.title,
"Flaky test monkey_business: Cannot execute null.resolve()."
);
assert.equal(monkeyBusiness.failures.length, 1);
const shapeshift = extractDetailsFromPayload("shapeshift");
assert.equal(
shapeshift.title,
"Multiple errors in flaky test shapeshift_spell"
);
assert.equal(shapeshift.failures.length, 16);
const multipleFailures = extractDetailsFromPayload("multiple_failures");
assert.equal(
multipleFailures.title,
"Multiple flaky test failures in more_shapeshift_spell, shapeshift_spell"
);
assert.equal(multipleFailures.failures.length, 2);
const invalidTimer = extractDetailsFromPayload("invalid_timer");
assert.equal(
invalidTimer.title,
"Flaky test monkey_business: Invalid timer: /datum/looping_sound/proc/start_sound_loop() on /datum/looping_sound/showering"
);