mirror of
https://github.com/VOREStation/VOREStation.git
synced 2026-08-23 12:08:28 +01:00
Added missing CI scripts (#16832)
This commit is contained in:
@@ -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)
|
||||
@@ -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"`
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
Reference in New Issue
Block a user