diff --git a/.github/checklist_check.py b/.github/checklist_check.py new file mode 100644 index 0000000..9a717e1 --- /dev/null +++ b/.github/checklist_check.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Checks QA-CHECKLIST.md against CHANGELOG.md (docs/development.md#qa-checklist). CI's Changelog job runs it on every +pull request; run it locally with `python3 .github/checklist_check.py QA-CHECKLIST.md CHANGELOG.md`. + +The QA checklist is what admins on the QA server test, in Discord, before a version goes to staging. Every pull request +adds its version's items, the way it adds its changelog entry. Checked: + - versions: every changelog version from 0.4.3 on has a "## x.y.z (YYYY-MM-DD)" section here, with the same date, in + the same order, and there are no others; + - a version with nothing anyone can test by hand has only its heading (the same escape as a heading-only changelog + entry); otherwise only "- " item lines; + - each item: "- **Name:** what to do. You should see what happens.", with a name unique in its version and the + words "You should see" saying what a pass looks like; + - plain words, as in the changelog: no backticks, file names, paths, command flags, settings keys or environment + variable names, since admins read these in Discord. +""" +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from changelog_check import CODE # noqa: E402 + +FIRST = (0, 4, 3) +VERSION = re.compile(r"^## (\d+)\.(\d+)\.(\d+) \((\d{4}-\d{2}-\d{2})\)$") +ITEM = re.compile(r"^- \*\*([^*:]+):\*\* (.+)$") + + +def versions(text): + found = [] + for number, line in enumerate(text.splitlines(), 1): + match = VERSION.match(line.strip()) + if match: + found.append((tuple(int(match.group(i)) for i in (1, 2, 3)), match.group(4), number)) + return found + + +def check(checklist, changelog): + problems = [] + wanted = [(v, d) for v, d, _ in versions(changelog) if v >= FIRST] + have = versions(checklist) + if [(v, d) for v, d, _ in have] != wanted: + missing = [v for v, _ in wanted if v not in [h[0] for h in have]] + extra = [v for v, _, _ in have if v not in [w[0] for w in wanted]] + problems.append((1, "versions don't match the changelog from 0.4.3 on (same versions, dates and order): missing %s, extra %s" + % (", ".join("%d.%d.%d" % v for v in missing) or "none", ", ".join("%d.%d.%d" % v for v in extra) or "none"))) + + current = None + names = set() + for number, line in enumerate(checklist.splitlines(), 1): + text = line.strip() + if VERSION.match(text): + current = text + names = set() + continue + if current is None or not text: + continue + if text.startswith("#"): + problems.append((number, "only version headings: '%s'" % text)) + continue + item = ITEM.match(text) + if not item: + problems.append((number, "only '- **Name:** what to do. You should see ...' lines inside a version")) + continue + name, body = item.group(1).strip(), item.group(2) + if name.lower() in names: + problems.append((number, "item '%s' appears twice in this version" % name)) + names.add(name.lower()) + if "you should see" not in body.lower(): + problems.append((number, "item '%s' must say 'You should see' what a pass looks like" % name)) + for pattern, what in CODE: + if pattern.search(text): + problems.append((number, "plain words only: '%s' has %s" % (name, what))) + return problems + + +BAD_CHANGELOG = """# Changelog + +## 0.4.4 (2026-10-25) + +## 0.4.3 (2026-10-25) +""" + +BAD = """# QA checklist + +## 0.4.4 (2026-10-25) +- **Logo:** look at the logo. +- **Logo:** open `provisioning.yml`. You should see it. +Some loose text. +### Section +""" + +GOOD = """# QA checklist + +Intro text is free. + +## 0.4.4 (2026-10-25) +- **Logo:** open the web app and look at the header. You should see the Dev logo. + +## 0.4.3 (2026-10-25) +""" + + +def self_test(): + found = check(BAD, BAD_CHANGELOG) + expected = ["versions don't match", "must say 'You should see'", "appears twice", "backticks", "a file name", + "only '- **Name:**", "only version headings"] + missing = [e for e in expected if not any(e in p for _, p in found)] + good = check(GOOD, BAD_CHANGELOG) + for _, p in found: + print(" caught: " + p) + if missing or good: + print("SELF-TEST FAILED: missed %s; good sample problems %s" % (missing, good)) + return 1 + print("Self-test passed: every rule caught its sample, and the good sample passed.") + return 0 + + +def main(argv): + if argv[1:] == ["--self-test"]: + return self_test() + if len(argv) != 3: + print(__doc__) + return 2 + with open(argv[1], encoding="utf-8") as handle: + checklist = handle.read() + with open(argv[2], encoding="utf-8") as handle: + changelog = handle.read() + problems = check(checklist, changelog) + for number, problem in problems: + if os.environ.get("GITHUB_ACTIONS"): + print("::error file=%s,line=%d::%s" % (argv[1], number, problem)) + else: + print("%s:%d: %s" % (argv[1], number, problem)) + if problems: + print("%d problem(s): see the QA checklist rule in docs/development.md#qa-checklist." % len(problems)) + return 1 + print("%s matches the changelog and follows the QA checklist rule." % argv[1]) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ad1ad8a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,97 @@ +# Dependency updates (docs/development.md#dependency-updates). Everything goes to dev, the default branch, and moves up +# to qa, staging and main like any other change. Each kind of dependency gets at most three pull requests a week, every +# Monday: its patch and minor updates together, its major updates together, and its security fixes together (those +# arrive as soon as GitHub publishes the advisory, not on the schedule). Patch and minor merge on their own once build +# passes; major ones wait for review (.github/workflows/dependency-updates.yml). Group names ending in "-security" are +# how that workflow tells a security fix from an ordinary update; when Dependabot's commit message carries no sizes +# it can read, the workflow reads the "from A to B" lines itself. +version: 2 + +updates: + # Backend: the NuGet packages of every project in src/Requestarr.sln. + - package-ecosystem: nuget + directory: /src + schedule: + interval: weekly + day: monday + time: "06:00" + open-pull-requests-limit: 5 + labels: [dependencies] + commit-message: + prefix: "Backend packages" + groups: + backend: + applies-to: version-updates + update-types: [minor, patch] + backend-major: + applies-to: version-updates + update-types: [major] + backend-security: + applies-to: security-updates + patterns: ["*"] + + # Web app: package.json and yarn.lock. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + open-pull-requests-limit: 5 + labels: [dependencies] + commit-message: + prefix: "Web app packages" + groups: + frontend: + applies-to: version-updates + update-types: [minor, patch] + frontend-major: + applies-to: version-updates + update-types: [major] + frontend-security: + applies-to: security-updates + patterns: ["*"] + + # The actions the workflows in .github/workflows use. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + open-pull-requests-limit: 5 + labels: [dependencies] + commit-message: + prefix: "GitHub Actions" + groups: + actions: + applies-to: version-updates + update-types: [minor, patch] + actions-major: + applies-to: version-updates + update-types: [major] + actions-security: + applies-to: security-updates + patterns: ["*"] + + # The base images in docker/Dockerfile (Node for the web build, the .NET SDK and runtime). + - package-ecosystem: docker + directory: /docker + schedule: + interval: weekly + day: monday + time: "06:00" + open-pull-requests-limit: 5 + labels: [dependencies] + commit-message: + prefix: "Base images" + groups: + images: + applies-to: version-updates + update-types: [minor, patch] + images-major: + applies-to: version-updates + update-types: [major] + images-security: + applies-to: security-updates + patterns: ["*"] diff --git a/.github/dependency_updates.py b/.github/dependency_updates.py new file mode 100644 index 0000000..f6f58e8 --- /dev/null +++ b/.github/dependency_updates.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""Dependabot's pull requests into dev (docs/development.md#dependency-updates). The Dependency updates workflow runs it. + + sort a new or changed update: label it "automatic update" (patch or minor) or "needs review" (major, or a + change Dependabot can't size), plus "security" for a security fix and "for admins" when that fix is in + something Requestarr ships (the backend, the base images, or a web app package the built app contains). + update the oldest open "automatic update": bring it up to date with dev, give it the next version and its + changelog section, run build on that commit and merge it once build passes (hold it if build fails). One + at a time, so two updates never take the same version; the next one goes when this one has merged. + --self-test checks the parts that decide, against built-in samples. + +Needs git, the GitHub CLI (GH_TOKEN) and the repository checked out with full history. +""" +import base64 +import datetime +import json +import os +import re +import subprocess +import sys +import time + +AUTOMATIC = "automatic update" +REVIEW = "needs review" +SECURITY = "security" +FOR_ADMINS = "for admins" +LABELS = { + AUTOMATIC: ("0e8a16", "Patch or minor dependency update: merges into dev on its own once build passes"), + REVIEW: ("d93f0b", "Major dependency update, or one that failed: waits for Claude Code to review"), + SECURITY: ("b60205", "Fixes a published security problem"), + FOR_ADMINS: ("5319e7", "Security fix in something Requestarr ships: its changelog tells admins"), +} + +# Dependabot's own names for a grouped security update when the repository groups them (before dependabot.yml did). +DEFAULT_SECURITY_GROUPS = {"npm_and_yarn", "nuget", "github_actions", "docker"} + +SECURITY_LINE = ("- **Security update:** parts Requestarr is built from were updated to fix known security problems, " + "so update when you can.") + +FAILED = ("failure", "timed_out", "startup_failure") + +COMMIT_MESSAGE = "Version {version} and its changelog section for this dependency update" + + +def run(*args, check=True, capture=True): + result = subprocess.run(args, check=False, text=True, capture_output=capture) + if check and result.returncode != 0: + sys.stderr.write(result.stderr or "") + raise SystemExit("%s failed (exit %d)" % (" ".join(args), result.returncode)) + return (result.stdout or "").strip() + + +def gh(*args, check=True): + return run("gh", *args, check=check) + + +def git(*args, check=True): + return run("git", *args, check=check) + + +def gh_api(method, path, payload, tries=4): + # Payload on stdin: a blob of yarn.lock is far longer than one command-line argument may be. Tried again after a + # pause, because GitHub's API can take a few seconds to know a commit that just reached dev ("not a valid tree"). + for attempt in range(tries): + result = subprocess.run(["gh", "api", "-X", method, path, "--input", "-"], input=json.dumps(payload), + text=True, capture_output=True) + if result.returncode == 0: + return json.loads(result.stdout) + sys.stderr.write(result.stderr or result.stdout or "") + if attempt + 1 < tries: + time.sleep(15) + return None + + +def publish(repo, branch): + """Makes the local HEAD commit again through GitHub's API and moves the branch to it; False if GitHub refused. + + A commit this workflow pushes with git is unsigned, and the repository's rules want an extra approval for those. + One made through the API with the workflow's token is signed by GitHub, so the update can merge on its own.""" + parents = git("rev-list", "--parents", "-n", "1", "HEAD").split()[1:] + # Start from the parent closest to the result (dev, when dev was merged in), so only a few files are sent. + base = min(parents, key=lambda p: len(git("diff", "--name-only", "--no-renames", p, "HEAD").splitlines())) + entries = [] + for line in git("diff", "--name-status", "--no-renames", base, "HEAD").splitlines(): + status, path = line.split("\t", 1) + if status == "D": + entries.append({"path": path, "mode": "100644", "type": "blob", "sha": None}) + continue + mode = git("ls-tree", "HEAD", "--", path).split()[0] + content = base64.b64encode(subprocess.run(["git", "show", "HEAD:" + path], capture_output=True).stdout) + blob = gh_api("POST", "repos/%s/git/blobs" % repo, {"content": content.decode(), "encoding": "base64"}) + if blob is None: + return False + entries.append({"path": path, "mode": mode, "type": "blob", "sha": blob["sha"]}) + tree = gh_api("POST", "repos/%s/git/trees" % repo, {"base_tree": git("rev-parse", base + "^{tree}"), + "tree": entries}) + if tree is None or tree["sha"] != git("rev-parse", "HEAD^{tree}"): + sys.stderr.write("the tree GitHub built differs from the local one\n") + return False + commit = gh_api("POST", "repos/%s/git/commits" % repo, {"message": git("log", "-1", "--format=%B", "HEAD"), + "tree": tree["sha"], "parents": parents}) + if commit is None or gh_api("PATCH", "repos/%s/git/refs/heads/%s" % (repo, branch), + {"sha": commit["sha"], "force": False}) is None: + return False + git("fetch", "--quiet", "origin", branch) + git("reset", "--quiet", "--hard", commit["sha"]) + return True + + +# ---- deciding --------------------------------------------------------------------------------------------------- + +def automatic(update_type): + return update_type in ("version-update:semver-patch", "version-update:semver-minor") + + +def _version(text): + numbers = re.findall(r"\d+", text.lstrip("vV"))[:3] + return tuple(int(n) for n in numbers) + (0,) * (3 - len(numbers)) if numbers else None + + +def size_from_text(text): + """The biggest change among the "from A to B" lines of Dependabot's commit message, named the way fetch-metadata + names it, for when fetch-metadata can't read the message (Dependabot's newer grouped format).""" + pairs = re.findall(r"(?:Updates|Bump) `?[^`\s]+`? from (\S+) to (\S+?)(?:\s|$)", text) + rank = 0 + for old, new in pairs: + a, b = _version(old), _version(new) + if a is None or b is None: + return "" + rank = max(rank, 3 if a[0] != b[0] else 2 if a[1] != b[1] else 1) + return {1: "version-update:semver-patch", 2: "version-update:semver-minor", 3: "version-update:semver-major"}.get(rank, "") + + +def group_from_text(text): + match = re.search(r"Bumps the (\S+) group", text) + return match.group(1) if match else "" + + +def is_security(group): + return bool(group) and (group.endswith("security") or group in DEFAULT_SECURITY_GROUPS) + + +def parse_yarn_lock(text): + """{"name@range": (name, {dependency name: range})} for a yarn 1 lock file.""" + entries, keys, deps, in_deps = {}, None, None, False + for line in text.splitlines(): + if not line.strip() or line.startswith("#"): + continue + if not line.startswith(" "): + keys = [k.strip().strip('"') for k in line.rstrip(":").split(",")] + deps, in_deps = {}, False + for key in keys: + entries[key] = (key.rsplit("@", 1)[0], deps) + continue + stripped = line.strip() + if line.startswith(" ") and not line.startswith(" "): + in_deps = stripped in ("dependencies:", "optionalDependencies:") + continue + if in_deps and line.startswith(" "): + name, _, wanted = stripped.partition(" ") + deps[name.strip('"')] = wanted.strip().strip('"') + return entries + + +def shipped_npm_packages(package_json, yarn_lock): + """Every package the built web app can contain: the dependencies (not devDependencies) and all they pull in.""" + entries = parse_yarn_lock(yarn_lock) + todo = ["%s@%s" % (n, r) for n, r in json.loads(package_json).get("dependencies", {}).items()] + seen, names = set(), set() + while todo: + key = todo.pop() + if key in seen or key not in entries: + continue + seen.add(key) + name, deps = entries[key] + names.add(name) + todo.extend("%s@%s" % d for d in deps.items()) + return names + + +def ships(ecosystem, dependency_names, package_json="", yarn_lock=""): + """Whether an update changes something Requestarr ships: the backend, the image, or the built web app.""" + if ecosystem in ("nuget", "docker", "docker_compose"): + return True + if ecosystem in ("npm_and_yarn", "npm"): + shipped = shipped_npm_packages(package_json, yarn_lock) + return any(name in shipped for name in dependency_names) + return False + + +# ---- versions --------------------------------------------------------------------------------------------------- + +def top_version(changelog): + match = re.search(r"^## (\d+)\.(\d+)\.(\d+)", changelog, re.MULTILINE) + return tuple(int(x) for x in match.groups()) if match else (0, 0, 0) + + +def next_version(*changelogs): + major, minor, patch = max(top_version(c) for c in changelogs) + return (major, minor, patch + 1) + + +def with_section(changelog, version, day, security_line): + """The changelog with a new version section on top (above the first "## x.y.z").""" + heading = "## %d.%d.%d (%s)\n" % (version + (day,)) + body = "\n### For admins\n%s\n" % security_line if security_line else "" + match = re.search(r"^## \d+\.\d+\.\d+", changelog, re.MULTILINE) + at = match.start() if match else len(changelog) + return changelog[:at] + heading + body + "\n" + changelog[at:] + + +def with_assembly_version(props, version): + return re.sub(r"[^<]*", "%d.%d.%d.*" % version, + props, count=1) + + +# ---- GitHub ----------------------------------------------------------------------------------------------------- + +def ensure_labels(repo): + existing = {l["name"] for l in json.loads(gh("label", "list", "--repo", repo, "--limit", "200", "--json", "name"))} + for name, (color, description) in LABELS.items(): + if name not in existing: + gh("label", "create", name, "--repo", repo, "--color", color, "--description", description) + + +def comment_once(repo, number, marker, text): + bodies = json.loads(gh("pr", "view", str(number), "--repo", repo, "--json", "comments"))["comments"] + if not any(marker in c["body"] for c in bodies): + gh("pr", "comment", str(number), "--repo", repo, "--body", "%s\n\n" % (text, marker)) + + +def hold(repo, number, why): + gh("pr", "edit", str(number), "--repo", repo, "--add-label", REVIEW, "--remove-label", AUTOMATIC, check=False) + gh("pr", "merge", str(number), "--repo", repo, "--disable-auto", check=False) + comment_once(repo, number, "dependency-updates:hold", "Held for review: %s Claude Code reviews it, fixes what " + "breaks and merges it in a normal task (docs/development.md#dependency-updates)." % why) + + +def sort(): + repo, number = os.environ["REPO"], os.environ["PR"] + update_type, group = os.environ.get("UPDATE_TYPE", ""), os.environ.get("GROUP", "") + ecosystem = os.environ.get("ECOSYSTEM", "") + names = [d["dependencyName"] for d in json.loads(os.environ.get("DEPENDENCIES") or "[]")] + ensure_labels(repo) + if not update_type or not group: + commits = json.loads(gh("api", "repos/%s/pulls/%s/commits" % (repo, number), "--jq", "[.[].commit.message]")) + update_type = update_type or size_from_text("\n".join(commits)) + group = group or group_from_text("\n".join(commits)) + + labels = [] + if is_security(group): + labels.append(SECURITY) + package_json = open("package.json", encoding="utf-8").read() if os.path.exists("package.json") else "{}" + yarn_lock = open("yarn.lock", encoding="utf-8").read() if os.path.exists("yarn.lock") else "" + if ships(ecosystem, names, package_json, yarn_lock): + labels.append(FOR_ADMINS) + + current = {l["name"] for l in json.loads(gh("pr", "view", number, "--repo", repo, "--json", "labels"))["labels"]} + if REVIEW in current: + print("#%s is already held for review" % number) + elif automatic(update_type): + labels.append(AUTOMATIC) + else: + labels.append(REVIEW) + + if labels: + gh("pr", "edit", number, "--repo", repo, "--add-label", ",".join(labels)) + if REVIEW in labels: + hold(repo, number, "a %s update (%s)." % (update_type.replace("version-update:semver-", "") or "differently " + "versioned", ", ".join(names) or "see the description")) + print("#%s: %s %s, group %s, %s -> %s" % (number, ecosystem, update_type, group or "none", ", ".join(names), labels)) + + +def dispatched_builds(repo, sha): + # Only the build this workflow starts counts: its commit can also queue pull request runs that wait for an + # approval nobody gives ("action_required"), and those aren't failures. + runs = json.loads(gh("run", "list", "--repo", repo, "--workflow", "build.yml", "--commit", sha, + "--json", "status,conclusion,event,databaseId")) + return [r for r in runs if r["event"] == "workflow_dispatch"] + + +def wait_for_build(repo, sha, minutes=45): + deadline = time.time() + minutes * 60 + while True: + runs = dispatched_builds(repo, sha) + if (runs and all(r["status"] == "completed" for r in runs)) or time.time() > deadline: + return runs + time.sleep(30) + + +def update(): + repo = os.environ["REPO"] + ensure_labels(repo) + # An update that merged or was held makes way for the next one in the same run. + for _ in range(10): + if not update_next(repo): + return + + +def update_next(repo): + """Moves the oldest automatic update on; True when it merged or was held, so the next one can go.""" + waiting =json.loads(gh("pr", "list", "--repo", repo, "--base", "dev", "--state", "open", "--label", AUTOMATIC, + "--json", "number,headRefName,labels,author,createdAt,isDraft")) + waiting = [p for p in waiting if p["author"]["login"] in ("app/dependabot", "dependabot[bot]", "dependabot") + and REVIEW not in {l["name"] for l in p["labels"]}] + if not waiting: + print("No automatic update is waiting.") + return False + pr =sorted(waiting, key=lambda p: p["createdAt"])[0] + number, branch = pr["number"], pr["headRefName"] + labels = {l["name"] for l in pr["labels"]} + print("Next automatic update: #%d (%s); %d waiting" % (number, branch, len(waiting))) + + git("fetch", "--quiet", "origin", "dev", "qa", "staging", branch) + git("checkout", "--quiet", "-B", "update", "origin/" + branch) + tops = {b: git("show", "origin/%s:CHANGELOG.md" % b) for b in ("dev", "qa", "staging")} + current = open("CHANGELOG.md", encoding="utf-8").read() + up_to_date = run("git", "merge-base", "--is-ancestor", "origin/dev", "HEAD", check=False) is not None and \ + subprocess.run(["git", "merge-base", "--is-ancestor", "origin/dev", "HEAD"]).returncode == 0 + versioned = top_version(current) > max(top_version(t) for t in tops.values()) + + if not (up_to_date and versioned): + if not up_to_date: + merged = subprocess.run(["git", "merge", "--no-edit", "--quiet", "origin/dev"], text=True, capture_output=True) + if merged.returncode != 0: + conflicted = git("diff", "--name-only", "--diff-filter=U").splitlines() + others = [f for f in conflicted if f not in ("CHANGELOG.md", "QA-CHECKLIST.md", "src/Directory.Build.props")] + if others: + git("merge", "--abort", check=False) + hold(repo, number, "it conflicts with dev in %s." % ", ".join(others)) + return True + # Dependabot never changes these three: start from dev's and add this update's version on top. Nobody can test a + # dependency update by hand, so its QA checklist section is the heading only. + git("checkout", "origin/dev", "--", "CHANGELOG.md", "QA-CHECKLIST.md", "src/Directory.Build.props") + version = next_version(*tops.values()) + day = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") + line = SECURITY_LINE if FOR_ADMINS in labels else "" + changelog = with_section(open("CHANGELOG.md", encoding="utf-8").read(), version, day, line) + open("CHANGELOG.md", "w", encoding="utf-8", newline="\n").write(changelog) + checklist = with_section(open("QA-CHECKLIST.md", encoding="utf-8").read(), version, day, "") + open("QA-CHECKLIST.md", "w", encoding="utf-8", newline="\n").write(checklist) + props = open("src/Directory.Build.props", encoding="utf-8").read() + open("src/Directory.Build.props", "w", encoding="utf-8", newline="").write(with_assembly_version(props, version)) + git("add", "CHANGELOG.md", "QA-CHECKLIST.md", "src/Directory.Build.props") + merging = subprocess.run(["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"], capture_output=True).returncode == 0 + if merging or subprocess.run(["git", "diff", "--cached", "--quiet"]).returncode != 0: + git("commit", "--quiet", "--no-verify", "-m", COMMIT_MESSAGE.format(version="%d.%d.%d" % version)) + if not publish(repo, branch): + hold(repo, number, "its version and changelog section could not be added (GitHub refused the commit).") + return True + print("#%d: version %d.%d.%d added" % ((number,) + version)) + + sha = git("rev-parse", "HEAD") + if not dispatched_builds(repo, sha): + # Commits made by this workflow don't start workflows, so build is started here on this commit. + gh("workflow", "run", "build.yml", "--repo", repo, "--ref", branch, "-f", "base=dev") + print("#%d: build started on %s" % (number, sha[:7])) + # Wait for it here: GitHub starts no workflow when a build this workflow started finishes, nor when auto-merge + # that it turned on merges, and the hourly run can come late or not at all. + runs = wait_for_build(repo, sha) + if any(r["conclusion"] in FAILED for r in runs) and not any(r["conclusion"] == "success" for r in runs): + hold(repo, number, "build failed on it.") + return True + if any(r["conclusion"] == "success" for r in runs): + if subprocess.run(["gh", "pr", "merge", str(number), "--repo", repo, "--merge"]).returncode == 0: + print("#%d: build passed, merged into dev" % number) + return True + gh("pr", "merge", str(number), "--repo", repo, "--auto", "--merge") + print("#%d: merges on its own once build passes" % number) + return False + + +# ---- self-test -------------------------------------------------------------------------------------------------- + +LOCK = '''# yarn lockfile v1 + + +"app-dep@^1.0.0": + version "1.2.0" + dependencies: + deep-dep "^2.0.0" + +deep-dep@^2.0.0, deep-dep@^2.1.0: + version "2.1.0" + +tool@3.0.0: + version "3.0.0" + dependencies: + tool-dep "~1.0.0" + +tool-dep@~1.0.0: + version "1.0.4" +''' + + +GROUPED = """Bump fast-uri + +Bumps the npm_and_yarn group with 2 updates in the / directory: [fast-uri](x) and [ajv](y). + + +Updates `fast-uri` from 3.1.8 to 3.1.9 +- [Release notes](x) + +Updates `ajv` from 8.17.1 to 8.18.0 +""" + + +def self_test(): + package = json.dumps({"dependencies": {"app-dep": "^1.0.0"}, "devDependencies": {"tool": "3.0.0"}}) + checks = [ + (automatic("version-update:semver-patch") and automatic("version-update:semver-minor"), "patch and minor merge"), + (not automatic("version-update:semver-major") and not automatic(""), "major and unsized updates wait"), + (is_security("frontend-security") and is_security("npm_and_yarn") and not is_security("frontend"), + "security groups are recognised"), + (shipped_npm_packages(package, LOCK) == {"app-dep", "deep-dep"}, "the web app's packages, followed through"), + (ships("npm_and_yarn", ["deep-dep"], package, LOCK), "a package the app pulls in ships"), + (not ships("npm_and_yarn", ["tool-dep"], package, LOCK), "a build tool's package doesn't ship"), + (ships("nuget", ["NLog"]) and ships("docker", ["dotnet/aspnet"]) and not ships("github_actions", ["x"]), + "backend and images ship, workflow actions don't"), + (next_version("## 0.4.2 (x)", "## 0.3.4 (x)", "## 0.5.0 (x)") == (0, 5, 1), "next version is above all three"), + (with_section("# Changelog\n\nintro\n\n## 0.4.2 (2026-10-25)\n", (0, 4, 3), "2026-10-26", "") + == "# Changelog\n\nintro\n\n## 0.4.3 (2026-10-26)\n\n## 0.4.2 (2026-10-25)\n", "a heading-only version"), + ("### For admins\n- **Security update:**" in with_section("## 0.1.0 (x)\n", (0, 1, 1), "d", SECURITY_LINE), + "a security fix that ships tells admins"), + (size_from_text(GROUPED) == "version-update:semver-minor", "a grouped update is sized by its biggest change"), + (size_from_text("Updates `fast-uri` from 3.1.8 to 3.1.9\n") == "version-update:semver-patch", "a patch update"), + (size_from_text("Bump actions/checkout from 4 to 5\n") == "version-update:semver-major", "a major update"), + (size_from_text("Bump node from 20-bookworm-slim to 24-bookworm-slim") == "version-update:semver-major", + "an image tag"), + (size_from_text("nothing to size") == "", "no versions, no size"), + (group_from_text(GROUPED) == "npm_and_yarn" and group_from_text("Bump x") == "", "the group from the message"), + (with_assembly_version("0.4.2.*", (0, 4, 3)) + == "0.4.3.*", "the assembly version follows"), + ] + failed = [what for ok, what in checks if not ok] + for what in failed: + print("self-test failed: " + what) + print("self-test: %d of %d passed" % (len(checks) - len(failed), len(checks))) + return 1 if failed else 0 + + +if __name__ == "__main__": + command = sys.argv[1] if len(sys.argv) > 1 else "" + if command == "--self-test": + sys.exit(self_test()) + if command == "sort": + sort() + elif command == "update": + update() + else: + print(__doc__) + sys.exit(1) diff --git a/.github/version_logo_glyphs.py b/.github/version_logo_glyphs.py new file mode 100644 index 0000000..ddd629c --- /dev/null +++ b/.github/version_logo_glyphs.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Writes src/Requestarr.Http/Frontend/Mappers/VersionLogoGlyphs.cs: the outlines of the letters the production header +logo's version bar can show ("v", the digits and ".", plus "BETA", which sets the size) and the font's kerning between +them, taken from Inter Display Black, the font of the branch labels on the dev, QA and beta logos. Requestarr draws the +bar itself, so the version always matches the one running. + +Run it again only to change the font or the letters: + + pip install fonttools uharfbuzz + python3 .github/version_logo_glyphs.py path/to/InterDisplay-Black.otf + +InterDisplay-Black.otf is in extras/otf/ of Inter's release zip (github.com/rsms/inter, SIL Open Font License 1.1). +""" +import os +import sys + +import uharfbuzz as hb +from fontTools.pens.boundsPen import BoundsPen +from fontTools.pens.svgPathPen import SVGPathPen +from fontTools.ttLib import TTFont + +LETTERS = "v0123456789.BETA" +OUT = os.path.join(os.path.dirname(__file__), "..", "src", "Requestarr.Http", "Frontend", "Mappers", "VersionLogoGlyphs.cs") + + +def main(path): + font = TTFont(path) + glyphs = font.getGlyphSet() + cmap = font.getBestCmap() + name = font["name"].getDebugName(4) + version = font["name"].getDebugName(5) + + rows = [] + for letter in LETTERS: + glyph = cmap[ord(letter)] + pen = SVGPathPen(glyphs, lambda v: f"{v:g}") + glyphs[glyph].draw(pen) + bounds = BoundsPen(glyphs) + glyphs[glyph].draw(bounds) + x_min, y_min, x_max, y_max = bounds.bounds + rows.append(f' {{ \'{letter}\', new Glyph({glyphs[glyph].width}, {x_min:g}, {y_min:g}, {x_max:g}, {y_max:g}, "{pen.getCommands()}") }},') + + # Kerning as a shaper applies it: the first letter's advance in a shaped pair, less its own advance. + shaper = hb.Font(hb.Face(hb.Blob.from_file_path(path))) + kerning = [] + for first in LETTERS: + for second in LETTERS: + buffer = hb.Buffer() + buffer.add_str(first + second) + buffer.guess_segment_properties() + hb.shape(shaper, buffer, {}) + names = [font.getGlyphName(info.codepoint) for info in buffer.glyph_infos] + positions = buffer.glyph_positions + if names != [cmap[ord(first)], cmap[ord(second)]] or positions[0].x_offset or positions[1].x_offset: + sys.exit(f"{first + second}: the font does more than kern this pair; the logo can't draw it") + adjust = positions[0].x_advance - glyphs[cmap[ord(first)]].width + if adjust: + kerning.append(f' {{ "{first + second}", {adjust} }},') + + text = f"""// Generated by .github/version_logo_glyphs.py from {name} ({version}). Do not edit by hand. +// Inter: Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter), SIL Open Font License 1.1. +using System.Collections.Generic; + +namespace Requestarr.Http.Frontend.Mappers +{{ + public static class VersionLogoGlyphs + {{ + public const int UnitsPerEm = {font["head"].unitsPerEm}; + + public static readonly IReadOnlyDictionary Letters = new Dictionary + {{ +{os.linesep.join(rows)} + }}; + + public static readonly IReadOnlyDictionary Kerning = new Dictionary + {{ +{os.linesep.join(kerning)} + }}; + + public record Glyph(double Advance, double XMin, double YMin, double XMax, double YMax, string Path); + }} +}} +""" + with open(OUT, "w", newline="\n", encoding="utf-8") as f: + f.write(text.replace(os.linesep, "\n")) + print(f"wrote {os.path.normpath(OUT)}: {len(LETTERS)} letters from {name}") + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f55edb3..a1dd4b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,13 @@ on: push: branches: ["**"] pull_request: + # Also started by the Dependency updates workflow on an update it has just versioned: commits that workflow pushes + # don't start workflows themselves. "base" is the branch the update goes into, for the Changelog job. workflow_dispatch: + inputs: + base: + description: For a dependency update, the branch it goes into (checks its version against that branch) + default: "" concurrency: group: build-${{ github.ref }} @@ -140,8 +146,11 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Checker self-test (every rule must catch its sample) - run: python3 .github/changelog_check.py --self-test + - name: Checker self-tests (every rule must catch its sample) + run: | + python3 .github/changelog_check.py --self-test + python3 .github/checklist_check.py --self-test + python3 docker/qa-gate.py --self-test - name: CHANGELOG.md has a new version for this change, written to the rule env: EVENT: ${{ github.event_name }} @@ -149,9 +158,15 @@ jobs: BASE_REF: ${{ github.event.pull_request.base.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_REF: ${{ github.event.pull_request.head.ref }} + DISPATCH_BASE: ${{ inputs.base }} run: | + if [ "$EVENT" = "workflow_dispatch" ] && [ -n "$DISPATCH_BASE" ]; then + ./release.sh check-changelog "origin/$DISPATCH_BASE" HEAD "$DISPATCH_BASE" HEAD "$GITHUB_REF_NAME" + exit 0 + fi if [ "$EVENT" != "pull_request" ]; then python3 .github/changelog_check.py CHANGELOG.md + python3 .github/checklist_check.py QA-CHECKLIST.md CHANGELOG.md exit 0 fi ./release.sh check-changelog "$BASE_SHA" HEAD "$BASE_REF" "$HEAD_SHA" "$HEAD_REF" diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml new file mode 100644 index 0000000..8c4dc9e --- /dev/null +++ b/.github/workflows/dependency-updates.yml @@ -0,0 +1,70 @@ +name: Dependency updates + +# Dependabot's pull requests into dev (docs/development.md#dependency-updates; .github/dependency_updates.py does the +# work). A new update is labelled: patch and minor "automatic update", major "needs review" (held, with a comment). +# Automatic updates go one at a time: the next version and its changelog section are added, build is started on that +# commit and waited for, then the update is merged (or held if build failed) and the next one starts. It also runs when +# dev moves and every hour, so a waiting update always carries a version above dev's, qa's and staging's. +on: + pull_request: + types: [opened, reopened, synchronize] + branches: [dev] + push: + branches: [dev] + schedule: + - cron: "23 * * * *" + workflow_dispatch: + +# Pull requests opened by Dependabot get a read-only token unless the workflow asks for more. +permissions: + contents: write + pull-requests: write + actions: write + +concurrency: + group: dependency-updates + +jobs: + sort: + name: Sort a new update + if: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - id: meta + uses: dependabot/fetch-metadata@v2 + - name: Label it automatic or held for review, and security + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + GROUP: ${{ steps.meta.outputs.dependency-group }} + ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} + DEPENDENCIES: ${{ steps.meta.outputs.updated-dependencies-json }} + run: python3 .github/dependency_updates.py sort + + update: + name: Version and merge the next automatic update + needs: sort + if: > + always() && needs.sort.result != 'failure' && + (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'dependabot[bot]') + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + - name: Self-test + run: python3 .github/dependency_updates.py --self-test + - name: Next automatic update + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + python3 .github/dependency_updates.py update diff --git a/.gitignore b/.gitignore index 571d582..be2e874 100644 --- a/.gitignore +++ b/.gitignore @@ -204,3 +204,6 @@ docker/secrets.env deploy/staging/.env deploy/staging/backups/ deploy/staging/.previous-version + +# Python bytecode from the helper scripts +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 899775b..2c4cb5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,77 @@ with nothing in it is left out, and a version with nothing for either has only i in Discord: both sections in the admin channel, and **For users** in the announcements channel, word for word. Each version heading carries the day it was made, which its card in Discord shows. +## 0.4.17 (2026-10-26) + +## 0.4.16 (2026-10-26) + +### For users +- **A tidier bug reports channel:** reports now stay in the order they were sent with the Report a problem button below them, and sending one no longer leaves a private reply pointing at a deleted message. Your report's card in the channel is the confirmation. + +### For admins +- **Fixed: Passes on the QA checklist:** pressing Passes no longer answers "Something went wrong"; the card shows who passed it, as before. +- **Failing several checklist items in a row:** admins working through the checklist are no longer told to wait between reports. Members still are. +- **Every test server report reaches GitHub:** on the dev, QA and beta servers each bug report is filed on GitHub. A report that couldn't be filed says exactly why on its card, and is filed on its own once it can be, with the card updated to show the link. +- **The QA checklist has its own group:** its channel now sits in an admin-only Requestarr QA group, moved there with its cards, so testing stays apart from running the server. +- **No more "edited" on panels:** the admin panel, the welcome message and the Report a problem message are no longer marked edited each time they move below a new post or Requestarr restarts. + +## 0.4.15 (2026-10-25) + +### For users +- **Report a problem:** a new bug reports channel has a Report a problem button. Pick the part of Requestarr, say what happened and what you expected, and it reaches the people who build Requestarr. Reports are public, so the form tells you that before you send. + +### For admins +- **Test checklist on the QA server:** every version on its way to staging comes with a list of things to test, posted in Discord. Admins mark each one as passing or failing, and a failing one opens a bug report linked to it. +- **Bug reports become issues:** reports can be filed on GitHub automatically, with the version, the part of Requestarr and who reported it. It's off unless you turn it on with your own access key, and each person can only send a few reports in a row. + +## 0.4.14 (2026-10-25) + +## 0.4.13 (2026-10-25) + +### For admins +- **Readable sign-in logo:** the logo on the web app's sign-in page now uses its light arrow in both themes, so it stands out against the dark gray box behind it. +- **Real logo while loading:** while the web app starts, it now shows your environment's logo, the same one as the header, instead of the old purple square with an R. + +## 0.4.12 (2026-10-25) + +### For admins +- **The bot keeps its own picture and banner:** Requestarr now gives its bot the Requestarr picture and your environment's banner, and updates them when a new release changes them, so you no longer upload them by hand in Discord. A picture you chose yourself is kept, and you can turn this off in the provisioning file. + +## 0.4.11 (2026-10-25) + +### For users +- **Softer logo:** the arrow in Requestarr's logo is now a soft charcoal instead of solid black, so it sits more gently next to the teal. + +### For admins +- **Logo without a box:** in the web app the header and sign-in logo, and the icon in your browser tab, no longer sit on a square of their own color; the page shows through around them in both themes. +- **Made for the purple header:** the light theme's purple header has its own logo, and on Beta the label bar there is white so it doesn't disappear into the purple. +- **Charcoal arrow everywhere:** the light theme's icons, home screen icons and tiles use the softer charcoal arrow too. + +## 0.4.10 (2026-10-25) + +### For admins +- **Dark theme icons:** in the dark theme, Requestarr's icons now sit on the same charcoal gray as the app's header instead of black, so they no longer look like a hole in the page. +- **Bigger logo in the web app:** the logo in the header and on the sign-in page is now full height, with the R beside a colored bar naming the environment (Dev, QA or Beta), so it's easier to see at a glance. +- **Version on the logo:** on your live install, the header and sign-in logo shows the version you're running on a green bar, and it updates by itself when you upgrade. + +## 0.4.9 (2026-10-25) + +## 0.4.8 (2026-10-25) + +## 0.4.7 (2026-10-25) + +## 0.4.6 (2026-10-25) + +## 0.4.5 (2026-10-25) + +## 0.4.4 (2026-10-25) + +## 0.4.3 (2026-10-25) + +### For admins +- **Kept up to date:** the parts Requestarr is built from are now updated every week, and a version that fixes a security problem in something Requestarr ships says so here, so you know to update. +- **Safer logs:** a web address or user name with a line break in it can no longer add fake lines to Requestarr's log. + ## 0.4.2 (2026-10-25) ### For users diff --git a/CLAUDE.md b/CLAUDE.md index b9f0d15..13b137e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,11 @@ Code workspace. - Namespaces: copied Servarr foundation keeps `NzbDrone.*`; all new code is `Requestarr.*`. - Changelog (every pull request): bump `` in `src/Directory.Build.props` and add a `## x.y.z (YYYY-MM-DD)` section (the day it was made) at the top of `CHANGELOG.md` with only **For users** and **For admins**, in plain words with a bold lead per line, no code-style text, no placeholders, empty sections left out (work neither sees is not listed). Requestarr posts it in Discord as a lasting record. CI's Changelog job (`.github/changelog_check.py`) and a unit test enforce it. Details: `docs/development.md#changelog`. +- QA checklist (every pull request): add this version's test items at the top of `QA-CHECKLIST.md` under the same `## x.y.z (YYYY-MM-DD)` heading as the changelog: `- **Name:** what to do. You should see what a pass looks like.`, in plain words an admin can follow in Discord; a version nobody can test by hand has the heading only. CI (`.github/checklist_check.py`) enforces it. Details: `docs/development.md#qa-checklist`. +- QA gate (start of every goal, before the task itself): run `./release.sh qa-gate`. When it's clear (QA's checklist complete, no item failing, no bug report from QA open), promote QA to staging first, through the normal release flow (`./release.sh promote-staging "the QA checklist, completed by "`, after the full suite and every live check), and say so in the reply and banner. When it's blocked, don't promote, and say what's left in one line. Staging moves otherwise only on the owner's explicit word, with `--owner-override ""`. If the QA stack isn't running or the gate can't be read, say so and don't promote. + +- Dependency updates: Dependabot's pull requests go into `dev` (the default branch); patch and minor merge on their own, major ones are labelled "needs review" and wait for a normal task (`docs/development.md#dependency-updates`). At the end of every task, list security fixes merged into `dev` that `qa` doesn't have yet (`gh pr list --base dev --state merged --label security`) and name them in the banner's "Needs you" line, so the owner can promote sooner; mention open "needs review" updates in the reply comm. + ## Gotchas - License is GPL-3.0 because Servarr code is GPL-3.0. Keep Servarr copyright headers on copied files; do not add code under incompatible licenses. diff --git a/QA-CHECKLIST.md b/QA-CHECKLIST.md new file mode 100644 index 0000000..35c62a1 --- /dev/null +++ b/QA-CHECKLIST.md @@ -0,0 +1,53 @@ +# QA checklist + +What admins on the QA server test, in Discord, before a version goes to staging. Requestarr posts these items in the QA checklist channel for every version QA has that staging doesn't; admins press Passes or Failing on each. When every item has passed and no bug report from QA is still open, the version can go to staging. + +Every pull request adds its version's items here, the way it adds its changelog entry: each one says what to do and what you should see, in plain words. A version with nothing anyone can test by hand has only its heading. See docs/development.md#qa-checklist. + +## 0.4.17 (2026-10-26) + +## 0.4.16 (2026-10-26) +- **Passes without errors:** Press Passes on a checklist item, then press it again. You should see the card show who passed it and when, and no "Something went wrong" message. +- **Failing in a row:** Press Failing on three items one after another and send a short report for each. You should see a form open every time, each card marked failing with its own report, and never "You've sent a report just now". +- **Member limit still holds:** In the bug reports channel, press Report a problem and send a report, then press it again straight away. You should see Requestarr ask you to wait. +- **Bug reports channel order:** Open the bug reports channel after sending a report, on your phone and on your computer. You should see the reports in the order they were sent, the Report a problem message last, and no reply pointing at a deleted message. +- **Why a report isn't on GitHub:** Look at the card of a report you sent. You should see either the link to its GitHub issue, or the exact reason it isn't there yet, such as the server having no GitHub access key. +- **QA group:** Look at the server's channel list. You should see the QA checklist channel in its own Requestarr QA group with all its cards, and a member who isn't an admin shouldn't see that group. + +## 0.4.15 (2026-10-25) +- **Test checklist:** Open the QA checklist channel in the Requestarr QA group. You should see a card for each thing to test in the versions QA has that staging doesn't, each with Passes and Failing buttons, and a summary card at the top. +- **Passing an item:** Press Passes on one card. You should see the card say who passed it and when, and the summary count go up by one. +- **Failing an item:** Press Failing on another card, pick the part of Requestarr, describe what went wrong and send it. You should see the card marked failing with a link to its bug report, and the summary say the checklist can't complete while it fails. +- **Report a problem:** In the bug reports channel in the Requestarr Users group, press Report a problem, pick a part, read the note that reports are public, and send a short test report. You should see a card for your report in the channel, and the link to its issue on GitHub once it's filed. +- **Report limit:** Send several reports one after another. You should see Requestarr ask you to wait once you reach the limit, and no more cards posted until then. + +## 0.4.14 (2026-10-25) + +## 0.4.13 (2026-10-25) +- **Sign-in logo:** Sign out of the web app and look at the sign-in page in the light theme, then in the dark theme. You should see the logo with a light arrow, easy to read on the gray box, in both. +- **Loading screen:** Reload the web app and watch it while it starts. You should see the QA logo, the same one as the header's, with no purple square. + +## 0.4.12 (2026-10-25) +- **Bot picture and banner:** In the Requestarr QA server, click the bot's name to open its profile. You should see the plain Requestarr picture and the yellow QA banner. + +## 0.4.11 (2026-10-25) +- **Logo on the purple header:** Switch the web app to the light theme and look at the header. You should see the QA bar and the R straight on the purple, with no white box around them. +- **Charcoal arrow:** In the light theme, look at the header logo and the icon in the browser tab. You should see the arrow in a soft charcoal instead of solid black. + +## 0.4.10 (2026-10-25) +- **Dark theme icons:** Switch the web app to the dark theme and look at the header and the browser tab. You should see the logo and the icon on charcoal gray, never on black. +- **Full-height logo:** Look at the logo at the top left of the header. You should see the yellow QA bar beside a full-height R, not a small square. + +## 0.4.9 (2026-10-25) + +## 0.4.8 (2026-10-25) + +## 0.4.7 (2026-10-25) + +## 0.4.6 (2026-10-25) + +## 0.4.5 (2026-10-25) + +## 0.4.4 (2026-10-25) + +## 0.4.3 (2026-10-25) diff --git a/deploy/staging/compose.yaml b/deploy/staging/compose.yaml index 55007a7..332d1bb 100644 --- a/deploy/staging/compose.yaml +++ b/deploy/staging/compose.yaml @@ -109,6 +109,8 @@ services: DISCORD_APPLICATION_ID: ${BETA_DISCORD_APPLICATION_ID:?set BETA_DISCORD_APPLICATION_ID in .env} DISCORD_GUILD_ID: ${BETA_DISCORD_GUILD_ID:?set BETA_DISCORD_GUILD_ID in .env} DISCORD_ADMIN_ROLE_ID: ${BETA_DISCORD_ADMIN_ROLE_ID:-} + # Every bug report from beta becomes a GitHub issue; without the key it waits on the server until one is set. + REQUESTARR_GITHUB_ISSUES_TOKEN: ${GITHUB_ISSUES_TOKEN:-} TMDB_API_KEY: ${TMDB_API_KEY:-} SONARR_API_KEY: ${BETA_SONARR_API_KEY} RADARR_API_KEY: ${BETA_RADARR_API_KEY} diff --git a/deploy/staging/env.example b/deploy/staging/env.example index f7c3982..333903f 100644 --- a/deploy/staging/env.example +++ b/deploy/staging/env.example @@ -16,7 +16,8 @@ BETA_DOWNLOADS_FOLDER= BETA_DOWNLOADS_PATH=/downloads # --- Ports on this server ------------------------------------------------------------------------------------------- -# Three free ports, different from your production Sonarr and Radarr. +# Three free ports, different from your production Sonarr and Radarr. Beta's band is the standard ports with a 1 in +# front, so the first digit says which environment you're on: 19797, 18989 and 17878. BETA_REQUESTARR_PORT= BETA_SONARR_PORT= BETA_RADARR_PORT= @@ -48,6 +49,11 @@ BETA_DISCORD_GUILD_ID= # Optional: an existing role whose members are Requestarr admins (otherwise Requestarr makes "Requestarr Admin"). BETA_DISCORD_ADMIN_ROLE_ID= +# --- Bug reports ---------------------------------------------------------------------------------------------------- +# A fine-grained GitHub token with only "Issues: read and write" on RunemarkLabs/Requestarr. Each bug report from beta +# becomes an issue there; until this is set, reports wait on the server and are filed once it is. +GITHUB_ISSUES_TOKEN= + # --- The Movie Database (optional: cast and details on request cards) ----------------------------------------------- TMDB_API_KEY= diff --git a/docker/Dockerfile b/docker/Dockerfile index 9862dc0..cf6ad21 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -15,7 +15,7 @@ RUN yarn build --env production FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS backend ARG TARGETARCH WORKDIR /src -COPY .editorconfig LICENSE CHANGELOG.md ./ +COPY .editorconfig LICENSE CHANGELOG.md QA-CHECKLIST.md ./ COPY src/ src/ # global.json pins the local SDK patch; the image's 10.0 SDK is used as-is. RUN case "$TARGETARCH" in arm64) RID=linux-arm64 ;; *) RID=linux-x64 ;; esac \ diff --git a/docker/compose.yaml b/docker/compose.yaml index 2627a7c..d84414f 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -32,6 +32,8 @@ services: DISCORD_APPLICATION_ID: ${STACK_DISCORD_APPLICATION_ID:-} DISCORD_TEST_GUILD_ID: ${STACK_DISCORD_GUILD_ID:-} DISCORD_ADMIN_ROLE_ID: ${STACK_DISCORD_ADMIN_ROLE_ID:-} + # Files bug reports as GitHub issues (QA: GITHUB_ISSUES_TOKEN, dev: DEV_GITHUB_ISSUES_TOKEN in secrets.env). + REQUESTARR_GITHUB_ISSUES_TOKEN: ${STACK_GITHUB_ISSUES_TOKEN:-} volumes: - requestarr-config:/config - ./provisioning.yml:/provisioning/provisioning.yml:ro diff --git a/docker/dev.ps1 b/docker/dev.ps1 index f0078a9..b1e2a68 100644 --- a/docker/dev.ps1 +++ b/docker/dev.ps1 @@ -19,6 +19,7 @@ # .\docker\dev.ps1 fake-download movie "" make a title in the test Radarr look downloaded, like a real import # .\docker\dev.ps1 fake-download tv "<title>" <season> the same for a season in the test Sonarr # .\docker\dev.ps1 fast-votes on|off|status test timings for removal votes (vote 5 min, answer 5, reminder at 3) +# .\docker\dev.ps1 qa-gate [--json] is the QA checklist complete with no bug report from QA open? (exit 0: clear) # # Needs Docker Desktop and Git, and Python 3 for check, the resets and fake-download. Secret values are # read from docker\secrets.env and never printed. If PowerShell says running scripts is disabled, run once: @@ -143,13 +144,14 @@ function Get-StackInfo([string]$which) { Name = 'QA'; Project = 'requestarr-qa'; Secrets = $s Env = @{ REQUESTARR_STACK = 'qa' - STACK_REQUESTARR_PORT = (Value $s 'REQUESTARR_PORT' '9797') - STACK_SONARR_TEST_PORT = (Value $s 'SONARR_TEST_PORT' '18989') - STACK_RADARR_TEST_PORT = (Value $s 'RADARR_TEST_PORT' '17878') + STACK_REQUESTARR_PORT = (Value $s 'REQUESTARR_PORT' '29797') + STACK_SONARR_TEST_PORT = (Value $s 'SONARR_TEST_PORT' '28989') + STACK_RADARR_TEST_PORT = (Value $s 'RADARR_TEST_PORT' '27878') STACK_DISCORD_BOT_TOKEN = $s['DISCORD_BOT_TOKEN'] STACK_DISCORD_APPLICATION_ID = $s['DISCORD_APPLICATION_ID'] STACK_DISCORD_GUILD_ID = $s['DISCORD_TEST_GUILD_ID'] STACK_DISCORD_ADMIN_ROLE_ID = $s['DISCORD_ADMIN_ROLE_ID'] + STACK_GITHUB_ISSUES_TOKEN = $s['GITHUB_ISSUES_TOKEN'] # Set when the stack is built (Build-Image), the only time Requestarr's container is recreated. REQUESTARR_STAGED_VERSIONS = '' } @@ -160,13 +162,14 @@ function Get-StackInfo([string]$which) { Name = 'Dev'; Project = 'requestarr-dev'; Secrets = $s Env = @{ REQUESTARR_STACK = 'dev' - STACK_REQUESTARR_PORT = (Value $s 'DEV_REQUESTARR_PORT' '19797') - STACK_SONARR_TEST_PORT = (Value $s 'DEV_SONARR_TEST_PORT' '28989') - STACK_RADARR_TEST_PORT = (Value $s 'DEV_RADARR_TEST_PORT' '27878') + STACK_REQUESTARR_PORT = (Value $s 'DEV_REQUESTARR_PORT' '39797') + STACK_SONARR_TEST_PORT = (Value $s 'DEV_SONARR_TEST_PORT' '38989') + STACK_RADARR_TEST_PORT = (Value $s 'DEV_RADARR_TEST_PORT' '37878') STACK_DISCORD_BOT_TOKEN = $s['DEV_DISCORD_BOT_TOKEN'] STACK_DISCORD_APPLICATION_ID = $s['DEV_DISCORD_APPLICATION_ID'] STACK_DISCORD_GUILD_ID = $s['DEV_DISCORD_GUILD_ID'] STACK_DISCORD_ADMIN_ROLE_ID = $s['DEV_DISCORD_ADMIN_ROLE_ID'] + STACK_GITHUB_ISSUES_TOKEN = $s['DEV_GITHUB_ISSUES_TOKEN'] } } } @@ -464,8 +467,14 @@ switch ($Command) { if ($code -ne 0) { exit $code } } 'fast-votes' { Require-Docker; $script:Info = Get-StackInfo $Stack; Fast-Votes (First-Rest) } + 'qa-gate' { + $script:Info = Get-StackInfo $Stack + $vars = @{ QA_GATE_URL = "http://localhost:$($script:Info.Env.STACK_REQUESTARR_PORT)"; REQUESTARR_API_KEY = $script:Info.Secrets['REQUESTARR_API_KEY'] } + $code = With-Env $vars { Invoke-Python (@((Join-Path $Dir 'qa-gate.py')) + $Rest) } + exit $code + } default { - Get-Content $PSCommandPath | Select-Object -Skip 1 -First 25 | ForEach-Object { $_ -replace '^# ?', '' } + Get-Content $PSCommandPath | Select-Object -Skip 1 -First 26 | ForEach-Object { $_ -replace '^# ?', '' } exit 1 } } diff --git a/docker/dev.sh b/docker/dev.sh index 43ea67c..c91b9e6 100644 --- a/docker/dev.sh +++ b/docker/dev.sh @@ -19,6 +19,7 @@ # docker/dev.sh fake-download movie "<title>" make a title in the test Radarr look downloaded, like a real import # docker/dev.sh fake-download tv "<title>" <n> the same for season n in the test Sonarr # docker/dev.sh fast-votes on|off|status test timings for removal votes (vote 5 min, answer 5, reminder at 3) +# docker/dev.sh qa-gate [--json] is the QA checklist complete with no bug report from QA open? (exit 0: clear) # # On Windows, docker/dev.ps1 does the same from PowerShell. Secret values are read from docker/secrets.env # and never printed. @@ -85,23 +86,25 @@ prepare() { case "$STACK" in qa) STACK_NAME="QA" - STACK_REQUESTARR_PORT="${REQUESTARR_PORT:-9797}" - STACK_SONARR_TEST_PORT="${SONARR_TEST_PORT:-18989}" - STACK_RADARR_TEST_PORT="${RADARR_TEST_PORT:-17878}" + STACK_REQUESTARR_PORT="${REQUESTARR_PORT:-29797}" + STACK_SONARR_TEST_PORT="${SONARR_TEST_PORT:-28989}" + STACK_RADARR_TEST_PORT="${RADARR_TEST_PORT:-27878}" STACK_DISCORD_BOT_TOKEN="${DISCORD_BOT_TOKEN:-}" STACK_DISCORD_APPLICATION_ID="${DISCORD_APPLICATION_ID:-}" STACK_DISCORD_GUILD_ID="${DISCORD_TEST_GUILD_ID:-}" STACK_DISCORD_ADMIN_ROLE_ID="${DISCORD_ADMIN_ROLE_ID:-}" + STACK_GITHUB_ISSUES_TOKEN="${GITHUB_ISSUES_TOKEN:-}" ;; dev) STACK_NAME="Dev" - STACK_REQUESTARR_PORT="${DEV_REQUESTARR_PORT:-19797}" - STACK_SONARR_TEST_PORT="${DEV_SONARR_TEST_PORT:-28989}" - STACK_RADARR_TEST_PORT="${DEV_RADARR_TEST_PORT:-27878}" + STACK_REQUESTARR_PORT="${DEV_REQUESTARR_PORT:-39797}" + STACK_SONARR_TEST_PORT="${DEV_SONARR_TEST_PORT:-38989}" + STACK_RADARR_TEST_PORT="${DEV_RADARR_TEST_PORT:-37878}" STACK_DISCORD_BOT_TOKEN="${DEV_DISCORD_BOT_TOKEN:-}" STACK_DISCORD_APPLICATION_ID="${DEV_DISCORD_APPLICATION_ID:-}" STACK_DISCORD_GUILD_ID="${DEV_DISCORD_GUILD_ID:-}" STACK_DISCORD_ADMIN_ROLE_ID="${DEV_DISCORD_ADMIN_ROLE_ID:-}" + STACK_GITHUB_ISSUES_TOKEN="${DEV_GITHUB_ISSUES_TOKEN:-}" ;; *) die "--stack is qa or dev (got '$STACK')" ;; esac @@ -112,7 +115,7 @@ prepare() { REQUESTARR_STAGED_VERSIONS="" export REQUESTARR_STACK STACK_REQUESTARR_PORT STACK_SONARR_TEST_PORT STACK_RADARR_TEST_PORT \ STACK_DISCORD_BOT_TOKEN STACK_DISCORD_APPLICATION_ID STACK_DISCORD_GUILD_ID STACK_DISCORD_ADMIN_ROLE_ID \ - REQUESTARR_STAGED_VERSIONS + STACK_GITHUB_ISSUES_TOKEN REQUESTARR_STAGED_VERSIONS } # QA only: the versions staging has (what was last published for it, staged-versions.py), which QA's what's-new @@ -338,8 +341,13 @@ case "${1:-}" in python -I "$DIR/fake-download.py" "$@" ;; fast-votes) prepare; fast_votes "${2:-}" ;; + qa-gate) + shift + prepare + QA_GATE_URL="http://localhost:$STACK_REQUESTARR_PORT" python -I "$DIR/qa-gate.py" "$@" + ;; *) - sed -n '2,24p' "$0" + sed -n '2,25p' "$0" exit 1 ;; esac diff --git a/docker/fake-download.py b/docker/fake-download.py index cd44e7a..f180f9f 100644 --- a/docker/fake-download.py +++ b/docker/fake-download.py @@ -28,8 +28,8 @@ STACKS = ("requestarr-qa", "requestarr-dev") PROJECT = os.environ.get("REQUESTARR_COMPOSE_PROJECT", "requestarr-qa") APPS = { - "movie": {"service": "radarr-test", "app": "Radarr", "port": "RADARR_TEST_PORT", "default_port": "17878", "key": "RADARR_TEST_API_KEY"}, - "tv": {"service": "sonarr-test", "app": "Sonarr", "port": "SONARR_TEST_PORT", "default_port": "18989", "key": "SONARR_TEST_API_KEY"}, + "movie": {"service": "radarr-test", "app": "Radarr", "port": "RADARR_TEST_PORT", "default_port": "27878", "key": "RADARR_TEST_API_KEY"}, + "tv": {"service": "sonarr-test", "app": "Sonarr", "port": "SONARR_TEST_PORT", "default_port": "28989", "key": "SONARR_TEST_API_KEY"}, } diff --git a/docker/provisioning.yml b/docker/provisioning.yml index 21871b0..c6c4f52 100644 --- a/docker/provisioning.yml +++ b/docker/provisioning.yml @@ -26,6 +26,12 @@ discord: # (the server owner always is). Optionally use an existing role instead: adminRoleId: ${DISCORD_ADMIN_ROLE_ID:-} +# The bots' picture and banner: Requestarr's always win on the test servers, so a picture uploaded by hand before +# Requestarr managed it is replaced (each environment's banner: dev, QA). +botProfile: + keepUpToDate: true + takeOver: true + # Optional: cast from The Movie Database (leave TMDB_API_KEY unset to skip). tmdb: apiKey: ${TMDB_API_KEY:-} diff --git a/docker/qa-gate.py b/docker/qa-gate.py new file mode 100644 index 0000000..73f6119 --- /dev/null +++ b/docker/qa-gate.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""The gate before staging: is QA's test checklist complete, with no item failing and no bug report from QA still open? + +Run through the stack scripts, which pass the stack's address and API key (never printed): + .\\docker\\dev.ps1 qa-gate docker/dev.sh qa-gate ./release.sh qa-gate +(-Stack dev / --stack dev reads the dev stack instead, where the checklist is tried out.) + +Reads the checklist from Requestarr (GET /api/v1/qa/checklist) and the open issues from GitHub (gh): any open issue +labelled with this server's environment ("env: qa") and a version the checklist covers counts, whether or not it came +from a checklist item. A report from the server that isn't on GitHub (filing off, no key, or GitHub refused it) blocks +too, whatever its version: it's an open QA bug the issue list can't show. + +Exit 0: clear (staging may have these versions). 1: blocked. 2: couldn't tell (stack down, gh missing). +--json prints the verdict as JSON for release.sh. --self-test checks the verdict rules (CI runs it). +""" +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request + +REPOSITORY = os.environ.get("BUG_REPORTS_REPOSITORY", "RunemarkLabs/Requestarr") +WHY = {"Off": "filing is turned off on the server", "NoToken": "the server has no GitHub access key", + "Pending": "it was being filed when the server stopped", "Failed": "GitHub refused it or couldn't be reached"} + + +def checklist(): + # A saved answer instead of the stack (the live test writes one at each step, in the same format). + if os.environ.get("QA_GATE_FILE"): + with open(os.environ["QA_GATE_FILE"], encoding="utf-8") as handle: + return json.load(handle) + url = os.environ["QA_GATE_URL"].rstrip("/") + "/api/v1/qa/checklist" + request = urllib.request.Request(url, headers={"X-Api-Key": os.environ.get("REQUESTARR_API_KEY", ""), "Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=15) as response: + return json.load(response) + + +def open_issues(environment, versions): + out = subprocess.run(["gh", "issue", "list", "--repo", REPOSITORY, "--state", "open", "--label", "env: " + environment, + "--limit", "500", "--json", "number,title,url,labels"], capture_output=True, text=True, check=True).stdout + wanted = {"version: " + v for v in versions} + return [i for i in json.loads(out) if wanted & {label["name"] for label in i["labels"]}] + + +def decide(data, issues): + """The verdict from the server's answer and the open issues: clear only when every item passed, none is failing, + no issue from this server is open and every report from it is on GitHub.""" + state = data["checklist"] + # Older servers sent only the reports for the checklist's versions. + unfiled = data["unfiledReports"] if "unfiledReports" in data else [r for r in data.get("bugReports", []) if r.get("filing") != "Filed"] + complete = state["complete"] + clear = complete and state["failing"] == 0 and not issues and not unfiled + verdict = { + "clear": clear, "environment": state["environment"], "versions": state["versions"], "total": state["total"], "passed": state["passed"], + "failing": state["failing"], "complete": complete, "completedBy": state.get("completedBy"), "completedAt": state.get("completedAt"), + "openIssues": [{"number": i["number"], "title": i["title"], "url": i["url"]} for i in issues], + "unfiledReports": [{"id": r["id"], "filing": r.get("filing"), "problem": r.get("filingProblem")} for r in unfiled], + } + return verdict, unfiled + + +def self_test(): + state = {"environment": "qa", "versions": ["0.4.16"], "total": 2, "passed": 2, "failing": 0, "complete": True, "items": []} + issue = {"number": 9, "title": "t", "url": "u"} + kept = {"id": 3, "filing": "NoToken", "filingProblem": None} + cases = [ + ("complete, nothing open", {"checklist": state, "unfiledReports": []}, [], True), + ("an open issue", {"checklist": state, "unfiledReports": []}, [issue], False), + ("a report not on GitHub", {"checklist": state, "unfiledReports": [kept]}, [], False), + ("an older server's unfiled report", {"checklist": state, "bugReports": [dict(kept, filing="Off")]}, [], False), + ("an older server's filed report", {"checklist": state, "bugReports": [dict(kept, filing="Filed")]}, [], True), + ("a failing item", {"checklist": dict(state, failing=1, passed=1), "unfiledReports": []}, [], False), + ] + wrong = [name for name, data, issues, clear in cases if decide(data, issues)[0]["clear"] != clear] + for name in wrong: + print("qa-gate self-test: wrong verdict for %s" % name, file=sys.stderr) + print("qa-gate self-test: %d of %d cases right" % (len(cases) - len(wrong), len(cases))) + return 1 if wrong else 0 + + +def main(argv): + if "--self-test" in argv: + return self_test() + as_json = "--json" in argv + try: + data = checklist() + except urllib.error.HTTPError as error: + reason = 'it runs a Requestarr from before the QA checklist' if error.code == 404 else 'HTTP %d' % error.code + print('qa-gate: the stack has no QA checklist to read (%s).' % reason, file=sys.stderr) + return 2 + except (urllib.error.URLError, OSError, ValueError, KeyError) as error: + print("qa-gate: couldn't read the checklist from the stack (%s). Is it running?" % type(error).__name__, file=sys.stderr) + return 2 + + state = data["checklist"] + versions = state["versions"] + try: + issues = open_issues(state["environment"], versions) if versions else [] + except (OSError, subprocess.CalledProcessError) as error: + print("qa-gate: couldn't list the open issues on GitHub with gh (%s)." % type(error).__name__, file=sys.stderr) + return 2 + + verdict, unfiled = decide(data, issues) + clear, complete = verdict["clear"], verdict["complete"] + + if as_json: + print(json.dumps(verdict)) + return 0 if clear else 1 + + print("QA checklist (%s server) for %s: %d of %d passed, %d failing%s." % ( + state["environment"], ", ".join(versions) or "no versions", state["passed"], state["total"], state["failing"], + ", complete, the last item passed by %s at %s" % (state.get("completedBy"), state.get("completedAt")) if complete else "")) + for item in state["items"]: + if item["state"] != "Passed": + print(" %-8s %s: %s%s" % (item["state"].lower(), item["version"], item["name"], " (%s)" % item["issueUrl"] if item.get("issueUrl") else "")) + print("Open bug reports from %s for these versions on GitHub: %d" % (state["environment"], len(issues))) + for issue in issues: + print(" #%d %s %s" % (issue["number"], issue["title"], issue["url"])) + print("Bug reports from %s not on GitHub: %d" % (state["environment"], len(unfiled))) + for report in unfiled: + print(" #%d %s: %s" % (report["id"], report.get("filing"), report.get("filingProblem") or WHY.get(report.get("filing"), "not filed"))) + print("CLEAR: staging may have %s." % ", ".join(versions) if clear else + "BLOCKED: promotion to staging waits until every item has passed and every bug report from QA is on GitHub and closed " + "(or the owner overrides).") + return 0 if clear else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/docker/reset-discord.py b/docker/reset-discord.py index 32325b8..cb630cd 100644 --- a/docker/reset-discord.py +++ b/docker/reset-discord.py @@ -1,7 +1,7 @@ """Remove everything Requestarr created in one local stack's Discord server, for a clean start. -Deletes: Requestarr's channel groups ("Requestarr Admin", "Requestarr Users" and its numbered ones, and the older -"Requestarr Requests") and every channel in them (including archived ones), #requestarr-admin, #requestarr-feed, +Deletes: Requestarr's channel groups ("Requestarr Admin", "Requestarr QA", "Requestarr Users" and its numbered ones, +and the older "Requestarr Requests") and every channel in them (including archived ones), #requestarr-admin, #requestarr-feed, #requestarr-announcements, #requestarr-welcome, #requestarr-access-blocked and members' request channels wherever they are, the per-user "requestarr-*" roles and the shared "Requestarr Members" and "Requestarr Blocked" roles. @@ -21,8 +21,9 @@ import urllib.request API = "https://discord.com/api/v10" -ADMIN_CHANNELS = ("requestarr-admin", "requestarr-feed", "requestarr-announcements", "requestarr-welcome", "requestarr-access-blocked") -CATEGORIES = ("Requestarr Admin", "Requestarr Users", "Requestarr Requests") +ADMIN_CHANNELS = ("requestarr-admin", "requestarr-feed", "requestarr-qa-checklist", "requestarr-announcements", "requestarr-welcome", + "requestarr-access-blocked", "requestarr-bug-reports") +CATEGORIES = ("Requestarr Admin", "Requestarr QA", "Requestarr Users", "Requestarr Requests") USER_ROLE_PREFIX = "requestarr-" SHARED_ROLES = ("Requestarr Members", "Requestarr Blocked") diff --git a/docker/secrets.env.example b/docker/secrets.env.example index 32903c6..c82b19f 100644 --- a/docker/secrets.env.example +++ b/docker/secrets.env.example @@ -25,7 +25,13 @@ DEV_DISCORD_APPLICATION_ID= DEV_DISCORD_GUILD_ID= DEV_DISCORD_ADMIN_ROLE_ID= -# Optional host ports (localhost only). QA: 9797, 18989, 17878. Dev: 19797, 28989, 27878. +# Bug reports become GitHub issues (a fine-grained token with only "Issues: read and write" on RunemarkLabs/Requestarr). +# QA files for real; dev's issues are labelled "test". The same token can go in both. +GITHUB_ISSUES_TOKEN= +DEV_GITHUB_ISSUES_TOKEN= + +# Optional host ports (localhost only). One band per environment, the first digit counting down to the standard port: +# Dev 39797, 38989, 37878. QA 29797, 28989, 27878. (Beta 19797, 18989, 17878; production 9797, 8989, 7878.) # REQUESTARR_PORT= SONARR_TEST_PORT= RADARR_TEST_PORT= # DEV_REQUESTARR_PORT= DEV_SONARR_TEST_PORT= DEV_RADARR_TEST_PORT= diff --git a/docs/development.md b/docs/development.md index fec2413..67968cb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -78,6 +78,29 @@ Not run in CI: **The checks.** CI's `Changelog` job runs `.github/changelog_check.py` (its own self-test first, so a broken rule can't pass silently) and fails a pull request whose changelog has: a section other than For users and For admins, or the same one twice; an empty section heading; loose text that isn't a heading or a `- ` line; code-style text in a line (backticks, a file name, a path, a command flag, a settings key or an environment variable name); a placeholder; a summary phrase with fewer than five words of substance on its line; a version heading without its day; or versions out of order or repeated. Run it yourself with `python3 .github/changelog_check.py CHANGELOG.md`. Then `./release.sh check-changelog` fails a pull request that doesn't change `CHANGELOG.md` or whose top version isn't higher than the base branch's. Into `dev`, `qa` or `staging`, the version must also be higher than the other two branches', unless the pull request already contains that branch, so a fix made in QA or staging never shares a number with `dev`. A merge-back keeps every version from both branches instead of adding one. A unit test checks that the top version matches `<AssemblyVersion>`, that the file is embedded in the build, and that **For users** lines avoid technical words. The pull request template has the same checklist. +## QA checklist + +`QA-CHECKLIST.md` at the repo root says what admins on the QA server test, by hand, before a version goes to staging. Requestarr is built with it and posts it in the QA server's `#requestarr-qa-checklist` channel, in an admin-only group of its own (Requestarr QA, so testing stays apart from running the server): a summary card, then one card per item with **Passes** and **Failing**, for every version QA has that staging doesn't. The dev server shows the running version's items, so the checklist can be tried out there; other servers have none. + +**Every pull request adds its version's items**, the way it adds its changelog entry, at the top of `QA-CHECKLIST.md`, with the same `## x.y.z (YYYY-MM-DD)` heading as the changelog: + +```markdown +## 0.4.15 (2026-10-25) +- **Report a problem:** In the bug reports channel, press Report a problem, pick a part and send a short test report. You should see a card for your report in the channel. +``` + +Each item says what to do and, after "You should see", what a pass looks like, in plain words an admin can follow in Discord: no code-style text, the same as the changelog. A version nobody can test by hand (build and CI work, tooling, dependency updates) has **only its heading**: that's how "nothing to test here" is marked, the same escape as a heading-only changelog entry. The automatic dependency updates add their heading themselves. + +**The checks.** `./release.sh check-changelog` (CI's Changelog job) fails a pull request that doesn't change `QA-CHECKLIST.md`, or whose checklist doesn't match the changelog from 0.4.3 on (same versions, dates and order), or has an item without a bold name, without "You should see", named twice in a version, or with code-style text. `.github/checklist_check.py` does the checking (with a self-test); run it with `python3 .github/checklist_check.py QA-CHECKLIST.md CHANGELOG.md`. A unit test checks the file is embedded and its top version matches the build. + +**In Discord.** Pressing **Passes** records who and when, and the card shows it (the press has no reply of its own: Discord can't turn a card into a plain message, which is why every press used to answer "Something went wrong"); **Failing** opens a bug report (below) linked to the item, and the card shows it. An admin failing items isn't held to the members' report limit, so several can fail in a row. The record is Requestarr's (the portal will show it later); the cards show it. The checklist is complete when every item has passed. A new promotion to QA while a checklist is half done keeps every pass and failure already recorded, adds the new versions' items as not tested yet, and drops the versions staging now has. + +**The gate before staging.** `./release.sh qa-gate` (or `.\docker\dev.ps1 qa-gate`) reads the QA stack's checklist (`GET /api/v1/qa/checklist` with the API key) and lists the open GitHub issues labelled `env: qa` with one of the checklist's versions. It's clear when the checklist is complete, nothing is failing, no such issue is open (whether or not it came from a checklist item) and every bug report from QA is on GitHub: one kept on the server (filing off, no access key, GitHub refused it) is an open QA bug the issue list can't show, so it blocks too, whatever its version, and the gate names it with the reason. `python3 docker/qa-gate.py --self-test` checks these rules (CI runs it). `promote-staging` refuses otherwise, unless the owner overrides it in so many words (`--owner-override "<their words>"`), which the promotion pull request records. At the start of every goal, Claude Code runs the gate; when it's clear, it promotes QA to staging first (repo `CLAUDE.md`). + +**Bug reports.** Every server has `#requestarr-bug-reports` in the Requestarr Users group: members and admins read it, nobody types in it, and its **Report a problem** button opens a menu of Requestarr's parts (one list, `BugReportComponents`, so it grows with the app) and a short form. Requestarr adds the environment, version, time and the reporter's Discord name, keeps the report, posts it as a card and, when the install files reports, opens a GitHub issue labelled `bug report`, `env: …`, `version: …` and `component: …` (and `test` from the dev server). Each person can send a few a day, a couple of minutes apart. The dev, QA and beta servers always file (the key comes from `REQUESTARR_GITHUB_ISSUES_TOKEN`: `DEV_GITHUB_ISSUES_TOKEN` and `GITHUB_ISSUES_TOKEN` in `docker/secrets.env`, `GITHUB_ISSUES_TOKEN` in beta's `.env`). A report that isn't filed says exactly why on its card and is filed each time the bot connects, until it is, without its reporter sending it again; the card then shows the link. Settings: `bugReports:` in the provisioning file (setup guide, "Bug reports"). + +**How the bug reports channel reads.** Reports in the order they came in, the **Report a problem** panel last. The panel moves below each new card as a new message (the old one is removed, never edited first, so nothing is marked "edited" for nothing). The reporter's private menu becomes "Sending your report…" and is removed once the card is posted: it replied to the panel, which moves, and would otherwise be left pointing at a deleted message. A report sent from a failing checklist item answers in the checklist channel instead. + ## Release flow Four branches, each with its own environment. @@ -87,9 +110,9 @@ Four branches, each with its own environment. | `dev` | Every pull request merges here once the `build` check passes. Claude Code's automated and live testing. | The dev stack, on the dev Discord server and bot (`DEV_DISCORD_*`). | | `qa` | The owner's manual QA of a version picked from `dev`. Moves only on the owner's word ("promote to QA"). | The QA stack, on the QA Discord server and bot (`DISCORD_*`). Never touched by automated testing. | | `staging` | Early access testers, on a version the owner passed in QA. Moves only on "QA passed". | The beta stack on the owner's server (`deploy/staging`), the Requestarr Beta server and bot, with its own Sonarr and Radarr that really download. Runs the published `staging` image. | -| `main` | Production: the default branch; only released code. Moves only on the owner's "ship it". | Published as `latest` and version tags. | +| `main` | Production: only released code. Moves only on the owner's "ship it". | Published as `latest` and version tags. | -All four branches have the same rules on GitHub (one ruleset): a pull request is required, `build` must pass, no force push, no deletion, nobody bypasses. +`dev` is the repository's default branch, so Dependabot's pull requests (security fixes included) land there and move up like any other change (see [Dependency updates](#dependency-updates)). All four branches have the same rules on GitHub (one ruleset): a pull request is required, `build` must pass, no force push, no deletion, nobody bypasses. **A change.** Branch from `dev`, open the pull request into `dev`, merge it after `build` passes. Bump the version above `dev`'s, `qa`'s and `staging`'s (the Changelog check says so if it isn't). @@ -101,12 +124,15 @@ All four branches have the same rules on GitHub (one ruleset): a pull request is It takes a version (the newest `dev` commit with that version on top of `CHANGELOG.md`) or a commit, and refuses anything that isn't on `dev`, or that `qa` can't move to without losing a commit. It opens a pull request into `qa` whose description says who asked, in their words (`Requested by: …`), and lists the versions and commits moving. The Promotion record check fails a promotion without that line, and the pull request stays as the record. Claude Code works with the owner's GitHub account, so GitHub can't tell the owner's approval from Claude Code's; the rule is that Claude Code promotes only on the owner's word, and the record says whose. Once `build` passes, `qa` is fast-forwarded to exactly that commit: the same commits as on `dev`, nothing newer, no merge commit. GitHub marks the pull request merged. Then `.\docker\dev.ps1 reset-qa` (or `docker/dev.sh reset-qa`) so the QA stack runs `qa`. Work on `dev` carries on meanwhile. -**Promote to staging** (only when the owner says "QA passed", or names a version). First run the full suite and every live check, and report them ("Testing scope" in the workspace rules: nothing reaches staging without this). Then: +**Promote to staging** (when the QA gate is clear, checked at the start of every goal, or when the owner says so). First run the full suite and every live check, and report them ("Testing scope" in the workspace rules: nothing reaches staging without this). Then: ```bash -./release.sh promote-staging 0.3.0 'the owner: "QA passed"' +./release.sh promote-staging 0.3.0 'the QA checklist, completed by <admin>' +./release.sh promote-staging 0.3.0 'the owner: "promote it anyway"' --owner-override 'the owner: "promote it anyway"' ``` +Without `--owner-override`, it runs the QA gate ([QA checklist](#qa-checklist)) and refuses unless it's clear; the pull request records the checklist and who completed it, or the owner's override. + The same as a promotion to QA, one step up: only `qa`'s commits, a pull request into `staging` with `Requested by:`, then a fast-forward of `staging`. The push to `staging` runs the Image workflow, which publishes `ghcr.io/runemarklabs/requestarr:staging` and `:0.3.0`. Then `.\docker\dev.ps1 up` so the QA stack drops the versions staging now has from its posts, and on the owner's server `./update.sh` in the staging folder (the setup guide's "Running the beta (staging) environment"). **Ship it** (staging to `main`, only on the owner's "ship it"): not scripted yet. It will be the same shape, and the push to `main` publishes the version tags and `latest`. @@ -152,6 +178,36 @@ The "what's new" posts in `#requestarr-admin` (both sections) and `#requestarr-a On every start QA and dev make the channels match: cards for versions that no longer apply are deleted, a card whose words changed is edited in place, and from the first card out of order onward everything is posted again, so the list always reads oldest to newest. Requestarr finds its own cards by their title (`Requestarr x.y.z · 6 changes for admins, 4 for users`, `Requestarr x.y.z · 4 new things for you`) among the newest 1,000 messages of each channel. Text posts from before cards (`📋 **Requestarr x.y.z: what's new**`, `✨ **What's new in Requestarr x.y.z**`) are found too and replaced by cards: on QA and dev on every start, on production and staging once, keeping the whole history. Everything else in the channels is left alone. +## Dependency updates + +`dev` is the repository's default branch, so Dependabot's pull requests, version updates and security fixes alike, go into `dev` and move up through `qa`, `staging` and `main` like any other change. `main` stays production with the same rules as before. + +**What arrives, and when** (`.github/dependabot.yml`). Four kinds of dependency, each checked every Monday: + +| Kind | Where | Groups | +|---|---|---| +| Backend packages (NuGet) | every project in `src/Requestarr.sln` | `backend` (patch and minor), `backend-major`, `backend-security` | +| Web app packages (npm, yarn) | `package.json`, `yarn.lock` | `frontend`, `frontend-major`, `frontend-security` | +| GitHub Actions | `.github/workflows` | `actions`, `actions-major`, `actions-security` | +| Base images (Docker) | `docker/Dockerfile` | `images`, `images-major`, `images-security` | + +So each kind sends at most one pull request a week for its patch and minor updates and one for its major updates. Security fixes come as soon as GitHub publishes the advisory, also one pull request per kind. + +**What happens to them** (`.github/workflows/dependency-updates.yml`, `.github/dependency_updates.py`): + +- A new update is sorted. Patch and minor get the **automatic update** label. Major updates, and updates Dependabot can't size, get **needs review** and a comment, and wait. A security fix follows the same rule by size, and also gets **security**. It gets **for admins** too when the fix is in something Requestarr ships: the backend, the base images, or a web app package the built app contains (followed through `yarn.lock` from `dependencies`, not `devDependencies`). +- Automatic updates go one at a time, oldest first. The workflow brings the update up to date with `dev`, adds the next version above `dev`'s, `qa`'s and `staging`'s to `src/Directory.Build.props` and `CHANGELOG.md` (in a commit made through GitHub's API, which GitHub signs: `dev`'s rules ask for an extra approval for changes they can't attribute, and a commit the workflow pushed with git is unsigned), starts `build` on that commit, and turns on GitHub's auto-merge. The workflow waits for that `build`, then merges the update if it passed (the full build: backend tests on Linux and Windows, the web app, the image, the secret scan and the Changelog check) and starts on the next one. +- The changelog section is the version heading only, which the changelog rule allows for a change nobody would notice. A security fix labelled **for admins** gets one line under For admins: "**Security update:** parts Requestarr is built from were updated to fix known security problems, so update when you can." +- If an automatic update conflicts with `dev` (other than the version and changelog, which it rewrites), can't be pushed, or fails `build`, it is moved to **needs review** with a comment. +- The workflow runs when an update opens or changes, when `dev` moves, and every hour as a fallback (GitHub can run scheduled workflows late or skip them). It waits for the builds it starts itself, because GitHub starts no workflow when they finish or when auto-merge it turned on merges. +- A package used in more than one project must have its version set in one place, or an update moves only some of them and `build` fails. NUnit, for example, comes only from `Requestarr.Test.Common`. + +**Major updates and held updates** are Claude Code's: review the changes, fix what breaks, and merge in a normal task (a pull request into `dev` with its own version and changelog, or the held one finished and merged by hand). + +**Security fixes reach the owner** three ways: the pull request carries the **security** label; a fix in something Requestarr ships puts a "Security update" line in the version's For admins section, so it shows in the admin channel's version post; and Claude Code checks for security fixes merged into `dev` that `qa` doesn't have yet at the end of every task and names them in the completion banner's "Needs you" line, so the owner can promote sooner than usual. + +Repository settings this relies on: default branch `dev`, auto-merge allowed, Dependabot security updates on. + ## Docker stack Two local stacks run side by side, each with its own Requestarr and dedicated Sonarr and Radarr test instances: @@ -185,11 +241,13 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned If you can't change the policy, `.\docker\dev.cmd` takes the same commands and runs `dev.ps1` for that one run whatever the policy is. +Ports come in one band per environment, so the first digit says which one you're on, counting down to the standard ports: dev 3xxxx, QA 2xxxx, beta 1xxxx (19797, 18989, 17878 on the owner's server), production the standard 9797, 8989 and 7878. Inside Docker every container keeps its standard port, so Sonarr and Radarr reach Requestarr's webhook as `http://requestarr:9797` whatever the band. + | Service | QA stack (localhost only) | Dev stack (localhost only) | Notes | |---|---|---|---| -| Requestarr | http://localhost:9797 | http://localhost:19797 | provisioned from `docker/provisioning.yml` | -| Sonarr (test) | http://localhost:18989 | http://localhost:28989 | root folder `/data/tv`, no login (localhost-bound) | -| Radarr (test) | http://localhost:17878 | http://localhost:27878 | root folder `/data/movies`, no login (localhost-bound) | +| Requestarr | http://localhost:29797 | http://localhost:39797 | provisioned from `docker/provisioning.yml` | +| Sonarr (test) | http://localhost:28989 | http://localhost:38989 | root folder `/data/tv`, no login (localhost-bound) | +| Radarr (test) | http://localhost:27878 | http://localhost:37878 | root folder `/data/movies`, no login (localhost-bound) | Other commands: diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 974347e..ee01173 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -20,7 +20,7 @@ Every Requestarr install uses its own bot. Discord's official reference is https 3. **Installation:** set Installation Contexts to **Guild Install** only, set **Install Link** to **None**, and save. Do this before step 4. Discord refuses to make a bot private while an install link is set ("Private application cannot have a default authorization link"). 4. **Bot:** - - Set the bot username. + - Set the bot username. Leave its icon and banner empty: Requestarr sets them itself (5.13). - Turn **Public Bot** off, so nobody else can add your bot. - Under Privileged Gateway Intents, turn **Server Members Intent** on. Requestarr needs it to detect members leaving. Leave Presence and Message Content off. - Ignore the Bot Permissions grid on this page. It is only a calculator and saves nothing. @@ -698,6 +698,44 @@ accessRequests: blockWithinHours: 24 ``` +### 5.13 The bot's picture and banner + +Requestarr gives its bot the Requestarr picture and a banner, and updates them when a new release changes them, so you never upload them by hand. The picture is the same everywhere. The banner is the teal one, or the dev, QA or beta one on those test servers. + +- It happens when the bot starts, and only when something differs. An unchanged picture is never uploaded again, so it stays well inside Discord's limit on how often a bot can change its picture. If Discord refuses anyway, the log says so and Requestarr tries again on a later start. The bot runs normally either way. +- It needs no permission and no change to the invite link: a bot changes its own picture and banner. +- **Your own picture stays.** Requestarr only replaces a picture or banner it set itself, or puts one in when the bot has none. It remembers Discord's id for each one it sets. If the id changes, someone changed it by hand, and Requestarr leaves it alone from then on. A picture the bot already had before Requestarr managed it counts as your choice too. +- **To let Requestarr replace your own,** set `takeOver: true`: Requestarr's picture and banner then always win, and one changed by hand is replaced on the next start. + +Settings, under `botProfile:` in `provisioning.yml` (each optional), stored so the admin web portal can change them later: + +```yaml +botProfile: + keepUpToDate: true # false: Requestarr never touches the bot's picture or banner + takeOver: false # true: replace one chosen by hand too (Requestarr's always wins) +``` + +### 5.14 Bug reports + +`#requestarr-bug-reports`, in the Requestarr Users group, lets members and admins tell the people who build Requestarr about a problem. Nobody types in it: **Report a problem** opens a menu of Requestarr's parts, then a short form (what happened, what they expected). Requestarr adds your server's environment, its version, the time and the reporter's Discord name, nothing else from your server, and posts the report as a card in the channel, so you see every report. + +**Filing on GitHub is off by default.** Turned on, each report also becomes an issue on Requestarr's public GitHub repository, which anyone can read; the form says so before anything is sent. It needs an access key of your own that can only read and write issues on that repository (GitHub: Settings, Developer settings, Fine-grained tokens, Issues "Read and write", nothing else). Leave it off unless you want your members' reports to be public. Requestarr's own test servers (dev, QA and beta) always file, whatever the setting. + +The access key can also come from the `REQUESTARR_GITHUB_ISSUES_TOKEN` environment variable, when the provisioning file names none. + +**Each card says where its report is.** The link to its GitHub issue, or exactly why it isn't there: filing is turned off, the server has no access key, or what GitHub said (the key was refused, it can't create issues there, the repository wasn't found) or that GitHub couldn't be reached. A report that couldn't be filed is filed the next time the bot connects, for example after you add the key and restart, without anyone sending it again, and its card then shows the link. + +```yaml +bugReports: + fileIssues: false # true: each report becomes a public GitHub issue + githubToken: ${GITHUB_ISSUES_TOKEN} # in secrets.env, never here + repository: RunemarkLabs/Requestarr + reportsPerDay: 5 # per person + minutesBetween: 2 # per person, between two reports +``` + +The limits stop the button flooding anything: someone who reaches them is told how long to wait, and nothing is posted. + ## 6. Connect Plex (optional) _Coming in Phase 3:_ Plex token and library-to-content-type mapping. @@ -767,7 +805,7 @@ Open `.env` and fill in every value; the comments say what each is. In particula - **`BETA_TV_FOLDER`, `BETA_MOVIES_FOLDER`:** the two folders from B.3, full paths. - **`BETA_DOWNLOADS_FOLDER`, `BETA_DOWNLOADS_PATH`:** the same downloads folder, and the same path inside the container, as your production Sonarr's compose file mounts (look for its downloads line, for example `/srv/downloads:/downloads`). Only the downloads folder: if production mounts one folder that holds both downloads and media (a single `/data`), mount just its downloads subfolder here. Imports then copy instead of hardlinking, which uses extra space only while a download is still seeding. -- **The three ports:** free ports, different from production's. `ss -ltn | grep :<port>` prints nothing for a free port. +- **The three ports:** free ports, different from production's. Use beta's band, 19797, 18989 and 17878 (the standard ports with a 1 in front, so production keeps 9797, 8989 and 7878 and the first digit says which environment you're on). `ss -ltn | grep :<port>` prints nothing for a free port. - **`PUID`, `PGID`:** the same as your production Sonarr and Radarr, so Plex can read what beta downloads. - **`BETA_SONARR_API_KEY`, `BETA_RADARR_API_KEY`, `REQUESTARR_API_KEY`:** made up here, one each: `openssl rand -hex 16`. - **`BETA_DISCORD_BOT_TOKEN`, `BETA_DISCORD_APPLICATION_ID`, `BETA_DISCORD_GUILD_ID`:** the beta bot and the Requestarr Beta server (B.1). This file is the only place they're kept. diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index ac7c2ed..0e6bc6e 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -120,7 +120,8 @@ module.exports = (env) => { destination: path.join(distFolder, 'Content/Fonts') }, - // Icon Images: Icons/{light|dark}/{production|dev|qa|beta}/ per theme and environment + // Icon Images: Icons/{light|dark}/{production|dev|qa|beta}/ per theme and environment, and + // Icons/light-purple/ for the light theme's purple header { source: 'frontend/src/Content/Images/Icons/*.*', destination: path.join(distFolder, 'Content/Images/Icons') @@ -129,6 +130,10 @@ module.exports = (env) => { source: 'frontend/src/Content/Images/Icons/light', destination: path.join(distFolder, 'Content/Images/Icons/light') }, + { + source: 'frontend/src/Content/Images/Icons/light-purple', + destination: path.join(distFolder, 'Content/Images/Icons/light-purple') + }, { source: 'frontend/src/Content/Images/Icons/dark', destination: path.join(distFolder, 'Content/Images/Icons/dark') diff --git a/frontend/src/Components/Page/Header/HeaderLogo.tsx b/frontend/src/Components/Page/Header/HeaderLogo.tsx index 58bc1d6..4d060ea 100644 --- a/frontend/src/Components/Page/Header/HeaderLogo.tsx +++ b/frontend/src/Components/Page/Header/HeaderLogo.tsx @@ -6,17 +6,20 @@ interface HeaderLogoProps { } // The logo of the environment the app runs in (production, dev, qa or beta), in the theme the app shows; Auto -// follows the system setting while the page is open. +// follows the system setting while the page is open. The light theme's header is purple, so it has its own set +// (light-purple: beta's bar is white there). Dev, QA and beta have their branch bar beside the R; production's bar +// shows the running version, which the server draws. function HeaderLogo({ className }: HeaderLogoProps) { - const theme = useTheme() === 'dark' ? 'dark' : 'light'; - const { urlBase, iconSet } = window.Requestarr; + const theme = useTheme() === 'dark' ? 'dark' : 'light-purple'; + const { urlBase } = window.Requestarr; + const iconSet = window.Requestarr.iconSet || 'production'; + const file = + iconSet === 'production' ? 'logo-version.svg' : 'logo-header.png'; return ( <img className={className} - src={`${urlBase}/Content/Images/Icons/${theme}/${ - iconSet || 'production' - }/logo.png`} + src={`${urlBase}/Content/Images/Icons/${theme}/${iconSet}/${file}`} alt="Requestarr Logo" /> ); diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css index f364e0b..77cdae3 100644 --- a/frontend/src/Components/Page/Header/PageHeader.css +++ b/frontend/src/Components/Page/Header/PageHeader.css @@ -33,8 +33,9 @@ height: 40px; } +/* The environment's logo is wider than tall (its branch bar beside the R), so it's sized by height. */ .logo { - width: 40px; + width: auto; height: 40px; border-radius: 4px; } @@ -60,7 +61,7 @@ @media only screen and (max-width: $breakpointSmall) { .logoContainer { - flex: 0 0 60px; + flex: 0 0 80px; } .sidebarToggleContainer { diff --git a/frontend/src/Components/Page/LoadingPage.css b/frontend/src/Components/Page/LoadingPage.css index 8683ed4..57d7080 100644 --- a/frontend/src/Components/Page/LoadingPage.css +++ b/frontend/src/Components/Page/LoadingPage.css @@ -4,10 +4,11 @@ background-color: var(--pageBackground); } -.logoFull { +/* The environment's logo is wider than tall, so it's sized by height like the header's. */ +.logo { margin-top: 50px; margin-right: auto; margin-left: auto; - width: 120px; - height: 40px; + width: auto; + height: 48px; } diff --git a/frontend/src/Components/Page/LoadingPage.css.d.ts b/frontend/src/Components/Page/LoadingPage.css.d.ts index 561cb4a..d9c2e2e 100644 --- a/frontend/src/Components/Page/LoadingPage.css.d.ts +++ b/frontend/src/Components/Page/LoadingPage.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'logoFull': string; + 'logo': string; 'page': string; } export const cssExports: CssExports; diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js index 5d9ec5e..d6f812c 100644 --- a/frontend/src/Components/Page/LoadingPage.js +++ b/frontend/src/Components/Page/LoadingPage.js @@ -1,17 +1,14 @@ import React from 'react'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingMessage from 'Components/Loading/LoadingMessage'; +import HeaderLogo from 'Components/Page/Header/HeaderLogo'; import styles from './LoadingPage.css'; -const requestarrLogo = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgNTEyIDUxMiI+CiAgPHRpdGxlPlJlcXVlc3RhcnI8L3RpdGxlPgogIDxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiByeD0iMTEyIiByeT0iMTEyIiBmaWxsPSIjNzA0OGU4Ii8+CiAgPHBhdGggZD0iTTE3NiAzOTIgVjEyOCBIMjc2IEE4MCA4MCAwIDAgMSAyNzYgMjg4IEgxNzYgTTI2OCAyODggTDM0NCAzOTIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSI1NiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo='; - +// While the app loads: the same logo the header shows for the environment and theme. function LoadingPage() { return ( <div className={styles.page}> - <img - className={styles.logoFull} - src={requestarrLogo} - /> + <HeaderLogo className={styles.logo} /> <LoadingMessage /> <LoadingIndicator /> </div> diff --git a/frontend/src/Content/Images/Icons/dark/beta/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/dark/beta/android-chrome-192x192.png index 2249759..3890e5f 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/dark/beta/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/dark/beta/android-chrome-512x512.png index 29ddb05..06edd64 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/dark/beta/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/apple-touch-icon.png b/frontend/src/Content/Images/Icons/dark/beta/apple-touch-icon.png index d87fa15..f752faf 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/dark/beta/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/favicon-16x16.png b/frontend/src/Content/Images/Icons/dark/beta/favicon-16x16.png index 83b65f0..b5ad8c5 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/favicon-16x16.png and b/frontend/src/Content/Images/Icons/dark/beta/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/favicon-32x32.png b/frontend/src/Content/Images/Icons/dark/beta/favicon-32x32.png index 4edba72..db9e757 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/favicon-32x32.png and b/frontend/src/Content/Images/Icons/dark/beta/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/favicon.ico b/frontend/src/Content/Images/Icons/dark/beta/favicon.ico index f72f297..cbf31a0 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/favicon.ico and b/frontend/src/Content/Images/Icons/dark/beta/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/logo-header.png b/frontend/src/Content/Images/Icons/dark/beta/logo-header.png new file mode 100644 index 0000000..c939e7d Binary files /dev/null and b/frontend/src/Content/Images/Icons/dark/beta/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/logo.png b/frontend/src/Content/Images/Icons/dark/beta/logo.png index 4e4c447..ebc234f 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/logo.png and b/frontend/src/Content/Images/Icons/dark/beta/logo.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-144x144.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-144x144.png index c6f7214..b22f588 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-144x144.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-150x150.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-150x150.png index d571bca..531f98b 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-150x150.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-310x150.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-310x150.png index d5542ec..f116e11 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-310x150.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-310x310.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-310x310.png index 69d2d99..c39294d 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-310x310.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/dark/beta/mstile-70x70.png b/frontend/src/Content/Images/Icons/dark/beta/mstile-70x70.png index dbc494f..25e25b1 100644 Binary files a/frontend/src/Content/Images/Icons/dark/beta/mstile-70x70.png and b/frontend/src/Content/Images/Icons/dark/beta/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/dark/dev/android-chrome-192x192.png index 9b5b68f..15701e1 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/dark/dev/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/dark/dev/android-chrome-512x512.png index c3d4d43..594fe6b 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/dark/dev/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/apple-touch-icon.png b/frontend/src/Content/Images/Icons/dark/dev/apple-touch-icon.png index 80e26cd..00e29b9 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/dark/dev/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/favicon-16x16.png b/frontend/src/Content/Images/Icons/dark/dev/favicon-16x16.png index b2cb025..e9e0702 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/favicon-16x16.png and b/frontend/src/Content/Images/Icons/dark/dev/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/favicon-32x32.png b/frontend/src/Content/Images/Icons/dark/dev/favicon-32x32.png index c99710a..f849464 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/favicon-32x32.png and b/frontend/src/Content/Images/Icons/dark/dev/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/favicon.ico b/frontend/src/Content/Images/Icons/dark/dev/favicon.ico index c3a8d1b..4c2ca36 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/favicon.ico and b/frontend/src/Content/Images/Icons/dark/dev/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/logo-header.png b/frontend/src/Content/Images/Icons/dark/dev/logo-header.png new file mode 100644 index 0000000..f43ec2c Binary files /dev/null and b/frontend/src/Content/Images/Icons/dark/dev/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/logo.png b/frontend/src/Content/Images/Icons/dark/dev/logo.png index c697ffb..8c9d03b 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/logo.png and b/frontend/src/Content/Images/Icons/dark/dev/logo.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-144x144.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-144x144.png index d50773c..e04be1d 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-144x144.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-150x150.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-150x150.png index 9c2d8b6..8a9f088 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-150x150.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-310x150.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-310x150.png index 7043410..a01de01 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-310x150.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-310x310.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-310x310.png index f4d32b0..19976f5 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-310x310.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/dark/dev/mstile-70x70.png b/frontend/src/Content/Images/Icons/dark/dev/mstile-70x70.png index aadc0ca..2ecfd47 100644 Binary files a/frontend/src/Content/Images/Icons/dark/dev/mstile-70x70.png and b/frontend/src/Content/Images/Icons/dark/dev/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/dark/production/android-chrome-192x192.png index be08030..42f6dc3 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/dark/production/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/dark/production/android-chrome-512x512.png index 232fe54..aa8d424 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/dark/production/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/apple-touch-icon.png b/frontend/src/Content/Images/Icons/dark/production/apple-touch-icon.png index 00576f6..16a9e10 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/dark/production/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/favicon-16x16.png b/frontend/src/Content/Images/Icons/dark/production/favicon-16x16.png index 25afb0f..f246092 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/favicon-16x16.png and b/frontend/src/Content/Images/Icons/dark/production/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/favicon-32x32.png b/frontend/src/Content/Images/Icons/dark/production/favicon-32x32.png index d2c5af4..d54dadd 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/favicon-32x32.png and b/frontend/src/Content/Images/Icons/dark/production/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/favicon.ico b/frontend/src/Content/Images/Icons/dark/production/favicon.ico index 26939ac..9adccbe 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/favicon.ico and b/frontend/src/Content/Images/Icons/dark/production/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/dark/production/logo-header.png b/frontend/src/Content/Images/Icons/dark/production/logo-header.png new file mode 100644 index 0000000..98056c7 Binary files /dev/null and b/frontend/src/Content/Images/Icons/dark/production/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/logo.png b/frontend/src/Content/Images/Icons/dark/production/logo.png index 4969138..b0405c8 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/logo.png and b/frontend/src/Content/Images/Icons/dark/production/logo.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-144x144.png b/frontend/src/Content/Images/Icons/dark/production/mstile-144x144.png index 9d4e00b..01bb7c5 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-144x144.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-150x150.png b/frontend/src/Content/Images/Icons/dark/production/mstile-150x150.png index dd43ecb..518e850 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-150x150.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-310x150.png b/frontend/src/Content/Images/Icons/dark/production/mstile-310x150.png index 372c4cd..e19924a 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-310x150.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-310x310.png b/frontend/src/Content/Images/Icons/dark/production/mstile-310x310.png index a3a1093..503db88 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-310x310.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/dark/production/mstile-70x70.png b/frontend/src/Content/Images/Icons/dark/production/mstile-70x70.png index feb2a80..eb824ad 100644 Binary files a/frontend/src/Content/Images/Icons/dark/production/mstile-70x70.png and b/frontend/src/Content/Images/Icons/dark/production/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/dark/qa/android-chrome-192x192.png index 1fb5a46..9936b85 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/dark/qa/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/dark/qa/android-chrome-512x512.png index 1e7bee2..30fcfcb 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/dark/qa/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/apple-touch-icon.png b/frontend/src/Content/Images/Icons/dark/qa/apple-touch-icon.png index e1fcf43..93aacb1 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/dark/qa/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/favicon-16x16.png b/frontend/src/Content/Images/Icons/dark/qa/favicon-16x16.png index d1fdd1f..6c70ad6 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/favicon-16x16.png and b/frontend/src/Content/Images/Icons/dark/qa/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/favicon-32x32.png b/frontend/src/Content/Images/Icons/dark/qa/favicon-32x32.png index b0dc22c..5883a8e 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/favicon-32x32.png and b/frontend/src/Content/Images/Icons/dark/qa/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/favicon.ico b/frontend/src/Content/Images/Icons/dark/qa/favicon.ico index b058223..4405f46 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/favicon.ico and b/frontend/src/Content/Images/Icons/dark/qa/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/logo-header.png b/frontend/src/Content/Images/Icons/dark/qa/logo-header.png new file mode 100644 index 0000000..d7b642a Binary files /dev/null and b/frontend/src/Content/Images/Icons/dark/qa/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/logo.png b/frontend/src/Content/Images/Icons/dark/qa/logo.png index 4040851..6af3754 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/logo.png and b/frontend/src/Content/Images/Icons/dark/qa/logo.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-144x144.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-144x144.png index b1b4edd..dc834f9 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-144x144.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-150x150.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-150x150.png index ebcfad5..af71d2b 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-150x150.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-310x150.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-310x150.png index 372510f..3756915 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-310x150.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-310x310.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-310x310.png index 0430721..4045d4b 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-310x310.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/dark/qa/mstile-70x70.png b/frontend/src/Content/Images/Icons/dark/qa/mstile-70x70.png index 6ddcca7..eca1c8a 100644 Binary files a/frontend/src/Content/Images/Icons/dark/qa/mstile-70x70.png and b/frontend/src/Content/Images/Icons/dark/qa/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/light-purple/beta/logo-header.png b/frontend/src/Content/Images/Icons/light-purple/beta/logo-header.png new file mode 100644 index 0000000..a9af2fa Binary files /dev/null and b/frontend/src/Content/Images/Icons/light-purple/beta/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/light-purple/dev/logo-header.png b/frontend/src/Content/Images/Icons/light-purple/dev/logo-header.png new file mode 100644 index 0000000..9cd59d1 Binary files /dev/null and b/frontend/src/Content/Images/Icons/light-purple/dev/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/light-purple/production/logo-header.png b/frontend/src/Content/Images/Icons/light-purple/production/logo-header.png new file mode 100644 index 0000000..cbb22fa Binary files /dev/null and b/frontend/src/Content/Images/Icons/light-purple/production/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/light-purple/qa/logo-header.png b/frontend/src/Content/Images/Icons/light-purple/qa/logo-header.png new file mode 100644 index 0000000..7c6f633 Binary files /dev/null and b/frontend/src/Content/Images/Icons/light-purple/qa/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/light/beta/android-chrome-192x192.png index 4c8eb82..dda0137 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/light/beta/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/light/beta/android-chrome-512x512.png index 7419317..45f47f6 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/light/beta/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/apple-touch-icon.png b/frontend/src/Content/Images/Icons/light/beta/apple-touch-icon.png index 4bd910b..80f4a21 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/light/beta/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/favicon-16x16.png b/frontend/src/Content/Images/Icons/light/beta/favicon-16x16.png index 516bb16..157d133 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/favicon-16x16.png and b/frontend/src/Content/Images/Icons/light/beta/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/favicon-32x32.png b/frontend/src/Content/Images/Icons/light/beta/favicon-32x32.png index 50bfdb2..011bac3 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/favicon-32x32.png and b/frontend/src/Content/Images/Icons/light/beta/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/favicon.ico b/frontend/src/Content/Images/Icons/light/beta/favicon.ico index 18b28fa..708af3c 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/favicon.ico and b/frontend/src/Content/Images/Icons/light/beta/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/light/beta/logo-header.png b/frontend/src/Content/Images/Icons/light/beta/logo-header.png new file mode 100644 index 0000000..ef9ad97 Binary files /dev/null and b/frontend/src/Content/Images/Icons/light/beta/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/logo.png b/frontend/src/Content/Images/Icons/light/beta/logo.png index 7cf0320..65c0e15 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/logo.png and b/frontend/src/Content/Images/Icons/light/beta/logo.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-144x144.png b/frontend/src/Content/Images/Icons/light/beta/mstile-144x144.png index 78f5d28..482f26b 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-144x144.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-150x150.png b/frontend/src/Content/Images/Icons/light/beta/mstile-150x150.png index bf0f7b2..d043690 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-150x150.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-310x150.png b/frontend/src/Content/Images/Icons/light/beta/mstile-310x150.png index f20d778..bc6a044 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-310x150.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-310x310.png b/frontend/src/Content/Images/Icons/light/beta/mstile-310x310.png index 25fe3a0..336a8cb 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-310x310.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/light/beta/mstile-70x70.png b/frontend/src/Content/Images/Icons/light/beta/mstile-70x70.png index 8697bd3..37f900c 100644 Binary files a/frontend/src/Content/Images/Icons/light/beta/mstile-70x70.png and b/frontend/src/Content/Images/Icons/light/beta/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/light/dev/android-chrome-192x192.png index 04b7e9c..51f2d8c 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/light/dev/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/light/dev/android-chrome-512x512.png index 363a95d..885c515 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/light/dev/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/apple-touch-icon.png b/frontend/src/Content/Images/Icons/light/dev/apple-touch-icon.png index 5dc3f5d..3fb63aa 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/light/dev/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/favicon-16x16.png b/frontend/src/Content/Images/Icons/light/dev/favicon-16x16.png index 13d7717..791770c 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/favicon-16x16.png and b/frontend/src/Content/Images/Icons/light/dev/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/favicon-32x32.png b/frontend/src/Content/Images/Icons/light/dev/favicon-32x32.png index d759d86..27712c1 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/favicon-32x32.png and b/frontend/src/Content/Images/Icons/light/dev/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/favicon.ico b/frontend/src/Content/Images/Icons/light/dev/favicon.ico index a7aa206..d9ee64c 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/favicon.ico and b/frontend/src/Content/Images/Icons/light/dev/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/light/dev/logo-header.png b/frontend/src/Content/Images/Icons/light/dev/logo-header.png new file mode 100644 index 0000000..9cd59d1 Binary files /dev/null and b/frontend/src/Content/Images/Icons/light/dev/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/logo.png b/frontend/src/Content/Images/Icons/light/dev/logo.png index bf60e18..0db1727 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/logo.png and b/frontend/src/Content/Images/Icons/light/dev/logo.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-144x144.png b/frontend/src/Content/Images/Icons/light/dev/mstile-144x144.png index 05affc4..978a319 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-144x144.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-150x150.png b/frontend/src/Content/Images/Icons/light/dev/mstile-150x150.png index a760996..2ac37a1 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-150x150.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-310x150.png b/frontend/src/Content/Images/Icons/light/dev/mstile-310x150.png index c243cea..491166b 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-310x150.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-310x310.png b/frontend/src/Content/Images/Icons/light/dev/mstile-310x310.png index bab1322..fcebf18 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-310x310.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/light/dev/mstile-70x70.png b/frontend/src/Content/Images/Icons/light/dev/mstile-70x70.png index 91c82bc..38255e1 100644 Binary files a/frontend/src/Content/Images/Icons/light/dev/mstile-70x70.png and b/frontend/src/Content/Images/Icons/light/dev/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/light/production/android-chrome-192x192.png index 38cc673..1f252d1 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/light/production/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/light/production/android-chrome-512x512.png index 925d5b2..bd3b489 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/light/production/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/apple-touch-icon.png b/frontend/src/Content/Images/Icons/light/production/apple-touch-icon.png index 8d2def7..1088d6b 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/light/production/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/favicon-16x16.png b/frontend/src/Content/Images/Icons/light/production/favicon-16x16.png index e1ea7dd..62dad0d 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/favicon-16x16.png and b/frontend/src/Content/Images/Icons/light/production/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/favicon-32x32.png b/frontend/src/Content/Images/Icons/light/production/favicon-32x32.png index f915e02..c688afc 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/favicon-32x32.png and b/frontend/src/Content/Images/Icons/light/production/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/favicon.ico b/frontend/src/Content/Images/Icons/light/production/favicon.ico index bd8e899..51488cc 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/favicon.ico and b/frontend/src/Content/Images/Icons/light/production/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/light/production/logo-header.png b/frontend/src/Content/Images/Icons/light/production/logo-header.png new file mode 100644 index 0000000..cbb22fa Binary files /dev/null and b/frontend/src/Content/Images/Icons/light/production/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/logo.png b/frontend/src/Content/Images/Icons/light/production/logo.png index 846622b..7b56fff 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/logo.png and b/frontend/src/Content/Images/Icons/light/production/logo.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-144x144.png b/frontend/src/Content/Images/Icons/light/production/mstile-144x144.png index 7c74ceb..2897ce7 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-144x144.png and b/frontend/src/Content/Images/Icons/light/production/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-150x150.png b/frontend/src/Content/Images/Icons/light/production/mstile-150x150.png index 3afa259..dbf33ce 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-150x150.png and b/frontend/src/Content/Images/Icons/light/production/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-310x150.png b/frontend/src/Content/Images/Icons/light/production/mstile-310x150.png index 1a93be8..2cf41c2 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-310x150.png and b/frontend/src/Content/Images/Icons/light/production/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-310x310.png b/frontend/src/Content/Images/Icons/light/production/mstile-310x310.png index dac5baf..ecc44b9 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-310x310.png and b/frontend/src/Content/Images/Icons/light/production/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/light/production/mstile-70x70.png b/frontend/src/Content/Images/Icons/light/production/mstile-70x70.png index d0b467e..133ffa1 100644 Binary files a/frontend/src/Content/Images/Icons/light/production/mstile-70x70.png and b/frontend/src/Content/Images/Icons/light/production/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/light/qa/android-chrome-192x192.png index e85fab4..9d617c9 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/android-chrome-192x192.png and b/frontend/src/Content/Images/Icons/light/qa/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/light/qa/android-chrome-512x512.png index b77c4b7..b75b0ff 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/android-chrome-512x512.png and b/frontend/src/Content/Images/Icons/light/qa/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/apple-touch-icon.png b/frontend/src/Content/Images/Icons/light/qa/apple-touch-icon.png index f63d6f8..3a3c3a4 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/apple-touch-icon.png and b/frontend/src/Content/Images/Icons/light/qa/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/favicon-16x16.png b/frontend/src/Content/Images/Icons/light/qa/favicon-16x16.png index 9f786be..b250966 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/favicon-16x16.png and b/frontend/src/Content/Images/Icons/light/qa/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/favicon-32x32.png b/frontend/src/Content/Images/Icons/light/qa/favicon-32x32.png index 0cd8aa3..a8937db 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/favicon-32x32.png and b/frontend/src/Content/Images/Icons/light/qa/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/favicon.ico b/frontend/src/Content/Images/Icons/light/qa/favicon.ico index 9961cb7..4a79e68 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/favicon.ico and b/frontend/src/Content/Images/Icons/light/qa/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/light/qa/logo-header.png b/frontend/src/Content/Images/Icons/light/qa/logo-header.png new file mode 100644 index 0000000..7c6f633 Binary files /dev/null and b/frontend/src/Content/Images/Icons/light/qa/logo-header.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/logo.png b/frontend/src/Content/Images/Icons/light/qa/logo.png index 0af0249..115d25c 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/logo.png and b/frontend/src/Content/Images/Icons/light/qa/logo.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-144x144.png b/frontend/src/Content/Images/Icons/light/qa/mstile-144x144.png index ee58c3b..4659c0a 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-144x144.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-150x150.png b/frontend/src/Content/Images/Icons/light/qa/mstile-150x150.png index 57c97a1..8cff71b 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-150x150.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-310x150.png b/frontend/src/Content/Images/Icons/light/qa/mstile-310x150.png index 9165032..52065f1 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-310x150.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-310x310.png b/frontend/src/Content/Images/Icons/light/qa/mstile-310x310.png index 04903d6..afd2e56 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-310x310.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/light/qa/mstile-70x70.png b/frontend/src/Content/Images/Icons/light/qa/mstile-70x70.png index 2a95343..c35b726 100644 Binary files a/frontend/src/Content/Images/Icons/light/qa/mstile-70x70.png and b/frontend/src/Content/Images/Icons/light/qa/mstile-70x70.png differ diff --git a/frontend/src/login.html b/frontend/src/login.html index 96f1f6e..3b7af20 100644 --- a/frontend/src/login.html +++ b/frontend/src/login.html @@ -84,7 +84,7 @@ } .logo { - width: 48px; + width: auto; height: 48px; border-radius: 4px; } @@ -217,9 +217,9 @@ <div class="content"> <div class="panel"> <div class="panel-header"> + <!-- The panel header is dark gray in both themes, so the logo is the dark set's (light arrow) in both. --> <img - data-themed - src="/Content/Images/Icons/__ICON_THEME__/__ICON_SET__/logo.png" + src="/Content/Images/Icons/dark/__ICON_SET__/__HEADER_LOGO__" alt="Requestarr" class="logo" /> diff --git a/package.json b/package.json index 1929164..8a053ac 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@juggle/resize-observer": "3.4.0", "@microsoft/signalr": "8.0.17", "@sentry/browser": "7.119.1", - "@sentry/integrations": "7.119.1", + "@sentry/integrations": "7.120.4", "@types/node": "20.19.43", "@types/react": "18.3.31", "@types/react-dom": "18.3.7", @@ -62,7 +62,7 @@ "react-dnd-touch-backend": "14.1.1", "react-document-title": "2.0.3", "react-dom": "18.3.1", - "react-focus-lock": "2.9.4", + "react-focus-lock": "2.13.7", "react-google-recaptcha": "2.1.0", "react-measure": "1.4.7", "react-popper": "1.3.11", @@ -91,21 +91,21 @@ "@babel/preset-env": "7.29.7", "@babel/preset-react": "7.29.7", "@babel/preset-typescript": "7.29.7", - "@types/lodash": "4.17.26", + "@types/lodash": "4.17.27", "@types/react-document-title": "2.0.10", "@types/react-router-dom": "5.3.3", "@types/react-text-truncate": "0.19.0", "@types/react-window": "1.8.8", "@types/redux-actions": "2.6.5", "@types/webpack-livereload-plugin": "2.3.6", - "@typescript-eslint/eslint-plugin": "8.71.2", - "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/eslint-plugin": "8.72.0", + "@typescript-eslint/parser": "8.72.0", "are-you-es5": "2.1.2", "autoprefixer": "10.6.1", "babel-loader": "9.2.1", "babel-plugin-inline-classnames": "2.0.1", "babel-plugin-transform-react-remove-prop-types": "0.4.24", - "core-js": "3.50.0", + "core-js": "3.51.0", "css-loader": "6.11.0", "css-modules-typescript-loader": "4.0.1", "eslint": "8.57.1", @@ -122,7 +122,7 @@ "html-webpack-plugin": "5.6.8", "loader-utils": "^3.2.1", "mini-css-extract-plugin": "2.10.2", - "postcss": "8.5.30", + "postcss": "8.5.31", "postcss-color-function": "4.1.0", "postcss-loader": "7.3.0", "postcss-mixins": "9.0.4", diff --git a/release.sh b/release.sh index 29d22cf..e3eb091 100755 --- a/release.sh +++ b/release.sh @@ -6,9 +6,13 @@ # ./release.sh drift [<dev ref> <qa ref> [<staging ref>]] # fails if qa has a commit dev doesn't, or staging one qa doesn't (default: origin/dev, origin/qa, origin/staging) # ./release.sh promote-qa <version | commit> "<who asked, and their words>" -# ./release.sh promote-staging <version | commit> "<who asked, and their words>" +# ./release.sh promote-staging <version | commit> "<who asked, and their words>" [--owner-override "<the owner's words>"] # moves qa (or staging) to exactly that point of dev (or qa): a pull request that records who asked, the build # check, then a fast-forward to that commit (nothing newer, no merge commit). Staging's push publishes its image. +# Staging only moves when the QA gate is clear (qa-gate below), unless the owner overrides it in so many words. +# ./release.sh qa-gate [--json] +# is QA's test checklist complete, with no item failing and no bug report from QA still open? Read from the QA +# stack on this machine (docker/qa-gate.py); exit 0 when clear # ./release.sh merge-back # after a fix merged into staging or qa: pull requests that merge staging into qa and qa into dev, each merged # once the build check passes @@ -113,7 +117,19 @@ check_changelog() { file=$(mktemp) git show "$head:CHANGELOG.md" >"$file" python "$here/.github/changelog_check.py" "$file" || { rm -f "$file"; return 1; } - rm -f "$file" + + # What to test: QA-CHECKLIST.md has this version's items, or just its heading when nothing can be tested by hand + # (.github/checklist_check.py says what it checks). + if ! git diff --name-only "$base" "$head" | grep -qx 'QA-CHECKLIST.md'; then + echo "::error::Add this version's test items (or just its heading, when nothing can be tested by hand) at the top of QA-CHECKLIST.md (see docs/development.md#qa-checklist)." + rm -f "$file" + return 1 + fi + local checklist + checklist=$(mktemp) + git show "$head:QA-CHECKLIST.md" >"$checklist" + python "$here/.github/checklist_check.py" "$checklist" "$file" || { rm -f "$file" "$checklist"; return 1; } + rm -f "$file" "$checklist" local new old new=$(top_version "$head") @@ -216,6 +232,22 @@ promote() { git merge-base --is-ancestor "origin/$to" "$target" \ || die "$to has commits that $what doesn't (an older version, or drift: ./release.sh drift), so $to can't move to it" + # Staging: only with QA's test checklist complete and no bug report from QA open, or on the owner's explicit word. + local gate="" + if [ "$to" = staging ]; then + if [ "${5:-}" = "--owner-override" ]; then + [ -n "${6:-}" ] || die "--owner-override needs the owner's words" + gate="Owner override: ${6} (the QA gate was not checked or not clear)" + else + local verdict + verdict=$("$here/docker/dev.sh" qa-gate --json) || { + "$here/docker/dev.sh" qa-gate || true + die "promotion to staging is blocked: QA's test checklist isn't complete, an item is failing, or a bug report from QA is still open. Only the owner can override it: promote-staging <version> \"<who>\" --owner-override \"<the owner's words>\"" + } + gate=$(printf '%s' "$verdict" | python -c 'import json,sys; v=json.load(sys.stdin); print("QA checklist: complete for %s (%d items), the last item passed by %s at %s; no bug report from QA open." % (", ".join(v["versions"]), v["total"], v["completedBy"], v["completedAt"]))') + fi + fi + local version short branch commits versions version=$(top_version "$target") short=$(git rev-parse --short "$target") @@ -231,7 +263,9 @@ promote() { Moves \`$to\` to \`$from\` @ \`$short\` (version $version): exactly these commits, nothing newer. Merged as a fast-forward of \`$to\` once the build check passes. Requested by: $who - +${gate:+ +$gate +} ### Versions now in $name $versions @@ -306,9 +340,10 @@ case "${1:-}" in check-changelog) shift; check_changelog "$@" ;; promote-qa) shift; promote dev qa "$@" ;; promote-staging) shift; promote qa staging "$@" ;; + qa-gate) shift; "$here/docker/dev.sh" qa-gate "$@" ;; merge-back) merge_back ;; *) - sed -n '2,19p' "$0" + sed -n '2,21p' "$0" exit 1 ;; esac diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1fea47e..1d06954 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -74,7 +74,7 @@ <Copyright>Copyright 2026-$([System.DateTime]::Now.ToString('yyyy')) Runemark Labs. Portions copyright 2014-2026 prowlarr.com and the Servarr team (GNU General Public v3)</Copyright> <!-- Should be replaced by CI --> - <AssemblyVersion>0.4.2.*</AssemblyVersion> + <AssemblyVersion>0.4.17.*</AssemblyVersion> <AssemblyConfiguration>$(Configuration)-dev</AssemblyConfiguration> <NeutralLanguage>en</NeutralLanguage> @@ -121,7 +121,7 @@ <ItemGroup Condition="'$(TestProject)'=='true'"> <PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" /> - <PackageReference Include="NUnit" Version="4.5.1" /> + <!-- NUnit comes from Requestarr.Test.Common only, so a dependency update moves every test project at once --> <PackageReference Include="NUnit3TestAdapter" Version="6.3.0" /> <PackageReference Include="NunitXml.TestLogger" Version="8.0.0" /> </ItemGroup> diff --git a/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/ForLogFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/ForLogFixture.cs new file mode 100644 index 0000000..3f78b56 --- /dev/null +++ b/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/ForLogFixture.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Common.Test.ExtensionTests.StringExtensionTests +{ + // Text from a request stays on one log line, so it can't add a fake entry to the log. + [TestFixture] + public class ForLogFixture + { + [TestCase("/api/v1/system", "/api/v1/system")] + [TestCase("/x\n2026-10-25 Info Auth-Success", "/x\\n2026-10-25 Info Auth-Success")] + [TestCase("admin\r\nfake", "admin\\r\\nfake")] + [TestCase("", "")] + [TestCase(null, null)] + public void should_keep_text_on_one_line(string input, string expected) + { + input.ForLog().Should().Be(expected); + } + } +} diff --git a/src/NzbDrone.Common/EnsureThat/ExceptionFactory.cs b/src/NzbDrone.Common/EnsureThat/ExceptionFactory.cs index d3f24a4..bfa5b3c 100644 --- a/src/NzbDrone.Common/EnsureThat/ExceptionFactory.cs +++ b/src/NzbDrone.Common/EnsureThat/ExceptionFactory.cs @@ -1,5 +1,6 @@ using System; using NLog; +using NzbDrone.Common.Extensions; namespace NzbDrone.Common.EnsureThat { @@ -9,13 +10,13 @@ internal static class ExceptionFactory internal static ArgumentException CreateForParamValidation(string paramName, string message) { - Logger.Warn(message); + Logger.Warn(message.ForLog()); return new ArgumentException(message, paramName); } internal static ArgumentNullException CreateForParamNullValidation(string paramName, string message) { - Logger.Warn(message); + Logger.Warn(message.ForLog()); return new ArgumentNullException(paramName, message); } } diff --git a/src/NzbDrone.Common/Extensions/StringExtensions.cs b/src/NzbDrone.Common/Extensions/StringExtensions.cs index b36f81c..5433a68 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -13,6 +13,12 @@ public static class StringExtensions { private static readonly Regex CamelCaseRegex = new Regex("(?<!^)[A-Z]", RegexOptions.Compiled); + // Text from a request (a path, a user name) as one log line: a line break in it can't start a fake entry. + public static string ForLog(this string text) + { + return text?.Replace("\r", "\\r").Replace("\n", "\\n"); + } + public static string NullSafe(this string target) { return ((object)target).NullSafe().ToString(); diff --git a/src/NzbDrone.Core.Test/Requestarr/Discord/BotProfileFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Discord/BotProfileFixture.cs new file mode 100644 index 0000000..9a4740d --- /dev/null +++ b/src/NzbDrone.Core.Test/Requestarr/Discord/BotProfileFixture.cs @@ -0,0 +1,123 @@ +using System.IO; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Test.Framework; +using Requestarr.Core.Announcements; +using Requestarr.Core.Discord; +using Requestarr.Core.Discord.Gateway; +using Requestarr.Core.Provisioning; +using Requestarr.Core.Test.AnimeDub; + +namespace Requestarr.Core.Test.Discord +{ + // The bot keeps its own Discord picture and banner up to date, without overwriting one someone chose. + [TestFixture] + public class BotProfileFixture : CoreTest<BotProfileService> + { + private static readonly BotImageRecord Ours = new("ourid", "ourfile"); + + [TestCase(false)] + [TestCase(true)] + public void a_bot_without_a_picture_gets_requestarrs(bool takeOver) + { + BotProfileService.Decide(null, null, "ourfile", takeOver).Should().Be(BotImageAction.Set); + BotProfileService.Decide(null, Ours, "ourfile", takeOver).Should().Be(BotImageAction.Set); + } + + [Test] + public void requestarrs_own_picture_is_left_when_current_and_replaced_when_a_release_brings_a_new_one() + { + BotProfileService.Decide("ourid", Ours, "ourfile", false).Should().Be(BotImageAction.UpToDate); + BotProfileService.Decide("ourid", Ours, "newfile", false).Should().Be(BotImageAction.Set); + } + + [Test] + public void a_picture_someone_chose_is_left_alone_unless_take_over_is_on() + { + // Changed by hand after Requestarr set it: Discord gave it a new id. + BotProfileService.Decide("theirid", Ours, "ourfile", false).Should().Be(BotImageAction.LeftAlone); + BotProfileService.Decide("theirid", Ours, "ourfile", true).Should().Be(BotImageAction.Set); + + // There before Requestarr managed it. + BotProfileService.Decide("theirid", null, "ourfile", false).Should().Be(BotImageAction.LeftAlone); + BotProfileService.Decide("theirid", null, "ourfile", true).Should().Be(BotImageAction.Set); + } + + [TestCase("dev", "requestarr-banner-dev.png")] + [TestCase("qa", "requestarr-banner-qa.png")] + [TestCase("beta", "requestarr-banner-beta.png")] + [TestCase("production", "requestarr-banner-teal.png")] + public void each_environment_has_its_banner_and_every_bot_the_same_picture(string iconSet, string banner) + { + BotProfileService.BannerFile(iconSet).Should().Be(banner); + DiscordAssets.Bytes(banner).Should().NotBeNullOrEmpty(); + DiscordAssets.Bytes(BotProfileService.AvatarFile).Should().NotBeNullOrEmpty(); + } + + [Test] + public void the_built_in_picture_and_banners_are_the_delivered_files() + { + foreach (var file in new[] { "requestarr-avatar.png", "requestarr-banner-dev.png", "requestarr-banner-qa.png", "requestarr-banner-beta.png", "requestarr-banner-teal.png" }) + { + DiscordAssets.Bytes(file).Should().Equal(File.ReadAllBytes(Item4To6Fixture.RepoFile("src", "NzbDrone.Core", "Requestarr", "Discord", "Assets", file)), file); + } + } + + [Test] + public void what_requestarr_set_is_remembered_as_discords_id_and_the_files_digest() + { + var record = new BotImageRecord("a1b2", BotProfileService.Digest(new byte[] { 1, 2, 3 })); + + BotImageRecord.Parse(record.ToString()).Should().Be(record); + BotImageRecord.Parse(string.Empty).Should().BeNull(); + BotImageRecord.Parse("|x").Should().BeNull(); + BotProfileService.Digest(new byte[] { 1, 2, 3 }).Should().Be("039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81"); + } + + [Test] + public void provisioning_sets_the_settings_it_names_and_leaves_the_rest() + { + var config = new Mock<IConfigService>(); + var settings = new BotProfileSettings(config.Object); + + settings.Apply(new BotProfileProvisioning { KeepUpToDate = false }); + config.VerifySet(c => c.BotProfileKeepUpToDate = false); + config.VerifySet(c => c.BotProfileTakeOver = It.IsAny<bool>(), Times.Never()); + + settings.Apply(null); + config.VerifySet(c => c.BotProfileKeepUpToDate = It.IsAny<bool>(), Times.Once()); + } + + [Test] + public void the_settings_are_read_from_the_provisioning_file() + { + var document = new ProvisioningLoader(null, null).Parse("botProfile:\n keepUpToDate: false\n takeOver: true\n"); + + document.BotProfile.KeepUpToDate.Should().BeFalse(); + document.BotProfile.TakeOver.Should().BeTrue(); + } + + [Test] + public void when_it_is_off_the_bot_is_not_touched() + { + Mocker.GetMock<IBotProfileSettings>().SetupGet(s => s.KeepUpToDate).Returns(false); + + Subject.SyncAsync().GetAwaiter().GetResult(); + + Mocker.GetMock<IDiscordClientHost>().VerifyGet(h => h.Client, Times.Never()); + Mocker.GetMock<IConfigService>().VerifySet(c => c.BotProfileAvatarSet = It.IsAny<string>(), Times.Never()); + } + + [Test] + public void with_no_bot_connected_nothing_happens_and_nothing_fails() + { + Mocker.GetMock<IBotProfileSettings>().SetupGet(s => s.KeepUpToDate).Returns(true); + Mocker.GetMock<IReleaseEnvironment>().SetupGet(e => e.IconSet).Returns("dev"); + + Subject.Invoking(s => s.SyncAsync().GetAwaiter().GetResult()).Should().NotThrow(); + Mocker.GetMock<IConfigService>().VerifySet(c => c.BotProfileAvatarSet = It.IsAny<string>(), Times.Never()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/FakeDiscordGuild.cs b/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/FakeDiscordGuild.cs index adcc114..bfb2566 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/FakeDiscordGuild.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/FakeDiscordGuild.cs @@ -29,6 +29,9 @@ public class FakeChannel public List<ulong> MessageIds { get; } = new(); public HashSet<ulong> Pinned { get; } = new(); + // Each message edit, by message ID (Discord marks an edited message "edited"). + public List<ulong> Edited { get; } = new(); + public InteractionReply PinnedMessage => Pinned.Count == 1 ? Messages[MessageIds.IndexOf(Pinned.Single())] : null; } @@ -225,6 +228,7 @@ public Task<bool> EditMessageAsync(ulong channelId, ulong messageId, Interaction return Task.FromResult(false); } + channel.Edited.Add(messageId); channel.Messages[index] = message.KeepEmbeds ? new InteractionReply { Content = message.Content, Buttons = message.Buttons, SelectMenu = message.SelectMenu, MoreMenus = message.MoreMenus, Ephemeral = message.Ephemeral, Embeds = channel.Messages[index].Embeds } : message; @@ -288,11 +292,20 @@ public Task<bool> DeleteMessageAsync(ulong channelId, ulong messageId) public Task<IReadOnlyList<ChannelMessage>> GetOwnMessagesAsync(ulong channelId, int limit) { IReadOnlyList<ChannelMessage> messages = Channels.TryGetValue(channelId, out var channel) - ? channel.MessageIds.Zip(channel.Messages, (id, m) => new ChannelMessage(id, m.IsCards ? string.Join("\n", m.Cards.Select(c => c.Text)) : m.Content, m.Embeds.FirstOrDefault()?.Title, m.Embeds.FirstOrDefault()?.Description, CustomIds(m))).TakeLast(limit).ToList() + ? channel.MessageIds.Zip(channel.Messages, (id, m) => Read(id, m)).TakeLast(limit).ToList() : new List<ChannelMessage>(); return Task.FromResult(messages); } + private static ChannelMessage Read(ulong id, InteractionReply m) => new( + id, + m.IsCards ? string.Join("\n", m.Cards.Select(c => c.Text)) : m.Content, + m.Embeds.FirstOrDefault()?.Title, + m.Embeds.FirstOrDefault()?.Description, + CustomIds(m), + m.Buttons.Concat(m.Cards.SelectMany(c => c.Buttons)).Select(b => b.Label).ToList(), + m.IsCards); + private static List<string> CustomIds(InteractionReply message) => message.Buttons.Concat(message.Cards.SelectMany(c => c.Buttons)).Select(b => b.CustomId).Concat(new[] { message.SelectMenu }.Concat(message.MoreMenus).Where(x => x != null).Select(x => x.CustomId)).ToList(); diff --git a/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/LiveDev.cs b/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/LiveDev.cs index 121b32a..4791e70 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/LiveDev.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/LiveDev.cs @@ -17,9 +17,9 @@ public static class LiveDev public static ulong GuildId => ulong.Parse(Environment.GetEnvironmentVariable("DEV_DISCORD_GUILD_ID")); - public static string RadarrUrl => $"http://localhost:{Environment.GetEnvironmentVariable("DEV_RADARR_TEST_PORT") ?? "27878"}"; + public static string RadarrUrl => $"http://localhost:{Environment.GetEnvironmentVariable("DEV_RADARR_TEST_PORT") ?? "37878"}"; - public static string SonarrUrl => $"http://localhost:{Environment.GetEnvironmentVariable("DEV_SONARR_TEST_PORT") ?? "28989"}"; + public static string SonarrUrl => $"http://localhost:{Environment.GetEnvironmentVariable("DEV_SONARR_TEST_PORT") ?? "38989"}"; public static string RadarrKey => Environment.GetEnvironmentVariable("RADARR_TEST_API_KEY"); diff --git a/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/RecordingResponder.cs b/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/RecordingResponder.cs index 2a7729c..2b3f9cb 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/RecordingResponder.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Discord/Harness/RecordingResponder.cs @@ -102,6 +102,7 @@ public Task FollowupAsync(InteractionReply reply) public Task UpdateMessageAsync(InteractionReply reply) { EnsureFirstResponse("update the message"); + EnsureLayoutKept(reply); _responses.Add(new RecordedResponse(ResponseKind.MessageUpdate, reply, false)); Materialise(reply); return Task.CompletedTask; @@ -114,6 +115,8 @@ public Task EditAsync(InteractionReply reply) throw new InvalidOperationException("Discord has no original response to edit before the interaction is deferred"); } + EnsureLayoutKept(reply); + _responses.Add(new RecordedResponse(ResponseKind.Edit, reply, false)); Materialise(reply); return Task.CompletedTask; @@ -150,6 +153,30 @@ public Task DeletePrivateRepliesAsync() return Task.CompletedTask; } + // Set once Requestarr removes the private message the component belongs to. + public bool OriginalDeleted { get; private set; } + + public Task DeleteOriginalAsync() + { + if (!HasResponded) + { + throw new InvalidOperationException("Discord has no original response to delete before the interaction is answered"); + } + + OriginalDeleted = true; + return Task.CompletedTask; + } + + // Discord refuses to turn a message with cards (the newer layout) back into a plain one. + private void EnsureLayoutKept(InteractionReply reply) + { + if (_guild != null && MessageId.HasValue && _guild.Channels.TryGetValue(_channelId, out var channel) && + channel.MessageIds.IndexOf(MessageId.Value) is var index and >= 0 && channel.Messages[index].IsCards && !reply.IsCards) + { + throw new InvalidOperationException("MESSAGE_CANNOT_REMOVE_COMPONENTS_V2_FLAG: a message with cards can't become a plain message"); + } + } + private void EnsureFirstResponse(string action) { if (HasResponded) diff --git a/src/NzbDrone.Core.Test/Requestarr/Qa/BugReportFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Qa/BugReportFixture.cs new file mode 100644 index 0000000..956fa1e --- /dev/null +++ b/src/NzbDrone.Core.Test/Requestarr/Qa/BugReportFixture.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using Requestarr.Core.Announcements; +using Requestarr.Core.Discord; +using Requestarr.Core.Discord.Gateway; +using Requestarr.Core.Discord.Interactions; +using Requestarr.Core.Qa; + +namespace Requestarr.Core.Test.Qa +{ + // Bug reports from the Report a problem button: kept, posted, filed on GitHub only when the install files them, limited + // per person, and holding nothing beyond what the reporter typed plus environment, version, time, part and name. + [TestFixture] + public class BugReportFixture : CoreTest<BugReportService> + { + private readonly List<BugReport> _reports = new(); + private BugReportGuard _guard; + private DateTimeOffset _now = new(2026, 10, 25, 12, 0, 0, TimeSpan.Zero); + + [SetUp] + public void Setup() + { + _reports.Clear(); + _guard = null; + Subject.Now = () => _now; + + Mocker.GetMock<IBugReportRepository>().Setup(r => r.Insert(It.IsAny<BugReport>())).Returns<BugReport>(r => + { + r.Id = _reports.Count + 1; + _reports.Add(r); + return r; + }); + Mocker.GetMock<IBugReportRepository>().Setup(r => r.Find(It.IsAny<int>())).Returns<int>(id => _reports.SingleOrDefault(r => r.Id == id)); + Mocker.GetMock<IBugReportRepository>().Setup(r => r.All()).Returns(() => _reports); + Mocker.GetMock<IBugReportGuardRepository>().Setup(r => r.Find("42")).Returns(() => _guard); + Mocker.GetMock<IBugReportGuardRepository>().Setup(r => r.Upsert(It.IsAny<BugReportGuard>())).Returns<BugReportGuard>(g => _guard = g); + Mocker.GetMock<IGuildSetupService>().Setup(g => g.GetConfig()).Returns(new DiscordGuildConfig { BugReportsChannelId = "500" }); + Mocker.GetMock<IDiscordGuildGateway>().Setup(g => g.SendMessageAsync(500, It.IsAny<InteractionReply>())).ReturnsAsync(900UL); + Mocker.GetMock<IReleaseEnvironment>().SetupGet(e => e.IconSet).Returns("qa"); + Settings(file: false, token: null); + } + + [Test] + public async Task with_filing_off_a_report_is_kept_and_posted_but_not_sent_anywhere() + { + var result = await Subject.SubmitAsync(42, "Alex", "requests", "The button did nothing", "A request", null); + + result.Report.Should().BeEquivalentTo(new { Component = "requests", WhatHappened = "The button did nothing", Expected = "A request", Environment = "qa", ReporterName = "Alex", Filing = BugReportFiling.Off, MessageId = "900" }); + Mocker.GetMock<IDiscordGuildGateway>().Verify(g => g.SendMessageAsync(500, It.Is<InteractionReply>(m => m.Cards[0].Text.Contains("Bug report #1"))), Times.Once()); + Mocker.GetMock<IGitHubIssues>().VerifyNoOtherCalls(); + result.Message.Should().Contain("<#500>"); + QaText.ReportCard(result.Report).Should().Contain("filing reports is turned off in this server's settings"); + } + + [Test] + public async Task with_filing_on_it_becomes_an_issue_labelled_with_environment_version_and_part_and_the_card_shows_the_link() + { + Settings(file: true, token: "secret"); + Mocker.GetMock<IGitHubIssues>() + .Setup(g => g.CreateAsync("RunemarkLabs/Requestarr", "secret", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new IssueResult(new FiledIssue(77, "https://github.com/RunemarkLabs/Requestarr/issues/77"), null)); + + var result = await Subject.SubmitAsync(42, "Alex", "web-app", "The logo is missing", null, null); + + result.Report.Should().BeEquivalentTo(new { Filing = BugReportFiling.Filed, IssueNumber = 77, IssueUrl = "https://github.com/RunemarkLabs/Requestarr/issues/77" }); + var labels = new[] { "bug report", "env: qa", $"version: {Changelog.Release(NzbDrone.Common.EnvironmentInfo.BuildInfo.Version)}", "component: web-app" }; + Mocker.GetMock<IGitHubIssues>().Verify(g => g.CreateAsync("RunemarkLabs/Requestarr", "secret", "[QA] Web app: The logo is missing", It.IsAny<string>(), It.Is<IReadOnlyList<string>>(l => l.SequenceEqual(labels)), It.IsAny<CancellationToken>())); + Mocker.GetMock<IDiscordGuildGateway>().Verify(g => g.EditMessageAsync(500, 900, It.Is<InteractionReply>(m => m.Cards[0].Text.Contains("issue #77"))), Times.Once()); + } + + [Test] + public async Task on_but_without_an_access_key_nothing_is_filed_and_the_card_says_why() + { + Settings(file: true, token: null); + + var result = await Subject.SubmitAsync(42, "Alex", "other", "Something", null, null); + + result.Report.Filing.Should().Be(BugReportFiling.NoToken); + Mocker.GetMock<IGitHubIssues>().VerifyNoOtherCalls(); + QaText.ReportCard(result.Report).Should().Contain("no GitHub access key"); + } + + [Test] + public async Task a_failing_checklist_item_gets_the_report_linked() + { + var item = new QaChecklistEntry(new Version(0, 4, 15), "0.4.15/report-limit", "Report limit", "Do it. You should see it."); + Mocker.GetMock<IQaChecklistService>().Setup(c => c.Find(item.Key)).Returns(item); + + var result = await Subject.SubmitAsync(42, "Alex", "admin", "It let me through", null, item.Key); + + result.Report.ChecklistItemKey.Should().Be(item.Key); + Mocker.GetMock<IQaChecklistService>().Verify(c => c.MarkFailingAsync(item.Key, 42, "Alex", result.Report), Times.Once()); + QaText.IssueBody(result.Report, item).Should().Contain("**From the QA checklist:** Report limit (0.4.15)"); + } + + [Test] + public async Task each_person_waits_between_reports_and_has_a_daily_ceiling() + { + (await Subject.SubmitAsync(42, "Alex", "other", "One", null, null)).Report.Should().NotBeNull(); + + var second = await Subject.SubmitAsync(42, "Alex", "other", "Two", null, null); + second.Report.Should().BeNull(); + second.Message.Should().Contain("You can send another"); + Subject.WaitLeft(42).Should().Be(TimeSpan.FromMinutes(2)); + + for (var i = 0; i < 4; i++) + { + _now = _now.AddMinutes(3); + (await Subject.SubmitAsync(42, "Alex", "other", "More", null, null)).Report.Should().NotBeNull(); + } + + // Five today: the next waits until the first is a day old. + _now = _now.AddMinutes(3); + Subject.WaitLeft(42).Should().Be(TimeSpan.FromDays(1) - TimeSpan.FromMinutes(15)); + _reports.Should().HaveCount(5); + } + + [Test] + public async Task an_admin_failing_checklist_items_isnt_held_to_the_limit_and_uses_none_of_it() + { + for (var i = 0; i < 3; i++) + { + (await Subject.SubmitAsync(42, "Alex", "admin", "Failing " + i, null, null, limited: false)).Report.Should().NotBeNull(); + } + + _reports.Should().HaveCount(3); + Subject.WaitLeft(42).Should().BeNull("a member's limit is untouched by checklist work"); + } + + [Test] + public async Task a_refused_report_says_exactly_why_and_is_filed_on_the_next_try_without_being_sent_again() + { + Settings(file: true, token: "secret"); + var github = Mocker.GetMock<IGitHubIssues>(); + github.Setup(g => g.CreateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new IssueResult(null, "GitHub refused the access key (HTTP 401): it's wrong, revoked or expired")); + + var report = (await Subject.SubmitAsync(42, "Alex", "requests", "Stuck", null, null)).Report; + + report.Filing.Should().Be(BugReportFiling.Failed); + QaText.ReportCard(report).Should().Contain("HTTP 401").And.Contain("tries again"); + + github.Setup(g => g.CreateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new IssueResult(new FiledIssue(5, "https://github.com/RunemarkLabs/Requestarr/issues/5"), null)); + await Subject.RetryUnfiledAsync(); + await Subject.RetryUnfiledAsync(); + + _reports.Should().ContainSingle().Which.Should().BeEquivalentTo(new { Filing = BugReportFiling.Filed, IssueNumber = 5, FilingProblem = (string)null }); + github.Verify(g => g.CreateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()), Times.Exactly(2)); + Mocker.GetMock<IDiscordGuildGateway>().Verify(g => g.EditMessageAsync(500, 900, It.Is<InteractionReply>(m => m.Cards[0].Text.Contains("issue #5"))), Times.Once()); + } + + [Test] + public async Task a_report_kept_for_want_of_a_key_is_filed_once_the_key_is_there_and_its_checklist_item_follows() + { + var item = new QaChecklistEntry(new Version(0, 4, 16), "0.4.16/one", "One", "Do it. You should see it."); + Mocker.GetMock<IQaChecklistService>().Setup(c => c.Find(item.Key)).Returns(item); + Settings(file: true, token: null); + + var report = (await Subject.SubmitAsync(42, "Alex", "admin", "Broken", null, item.Key, limited: false)).Report; + report.Filing.Should().Be(BugReportFiling.NoToken); + + Settings(file: true, token: "secret"); + Mocker.GetMock<IGitHubIssues>() + .Setup(g => g.CreateAsync(It.IsAny<string>(), "secret", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new IssueResult(new FiledIssue(6, "https://github.com/RunemarkLabs/Requestarr/issues/6"), null)); + await Subject.RetryUnfiledAsync(); + + report.Filing.Should().Be(BugReportFiling.Filed); + Mocker.GetMock<IQaChecklistService>().Verify(c => c.RefreshItemAsync(item.Key), Times.Once()); + } + + [Test] + public void the_test_servers_always_file_and_the_key_can_come_from_the_environment() + { + var environment = new Mock<IReleaseEnvironment>(); + var config = new Mock<NzbDrone.Core.Configuration.IConfigService>(); + var settings = new BugReportSettings(config.Object, environment.Object) { Variable = v => v == BugReportSettings.TokenVariable ? " from-env " : null }; + + foreach (var set in new[] { "dev", "qa", "beta" }) + { + environment.SetupGet(e => e.IconSet).Returns(set); + settings.FileIssues.Should().BeTrue(set + " files every report"); + } + + environment.SetupGet(e => e.IconSet).Returns("production"); + settings.FileIssues.Should().BeFalse("someone else's install files only when its admin turns it on"); + settings.Token.Should().Be("from-env"); + + settings.Apply(new Requestarr.Core.Provisioning.BugReportsProvisioning { GithubToken = "from-provisioning" }); + settings.Token.Should().Be("from-provisioning"); + } + + [Test] + public void an_issue_holds_only_what_was_typed_and_what_requestarr_adds_and_pings_nobody() + { + var report = new BugReport { Id = 3, Component = "removals", WhatHappened = "Vote never closed @everyone", Expected = "It closes", Environment = "dev", Version = "0.4.15", ReporterName = "Alex", Created = _now.UtcDateTime }; + var body = QaText.IssueBody(report, null); + + body.Should().Contain("Vote never closed @​everyone").And.Contain("It closes").And.Contain("| Part | Removal votes |").And.Contain("| Environment | Dev |") + .And.Contain("| Version | 0.4.15 |").And.Contain("| Reporter (Discord) | Alex |").And.NotContain("@everyone"); + QaText.IssueLabels(report).Should().Contain("test", "the dev server's issues are marked as tests"); + QaText.ReportCard(report).Should().NotContain("@everyone"); + } + + [Test] + public void the_parts_are_one_list_and_anything_else_is_other() + { + BugReportComponents.All.Select(c => c.Key).Should().Equal("requests", "approvals", "availability", "removals", "access", "admin", "web-app", "other"); + BugReportComponents.NameOf("nonsense").Should().Be("Other"); + } + + private void Settings(bool file, string token) + { + var settings = Mocker.GetMock<IBugReportSettings>(); + settings.SetupGet(s => s.FileIssues).Returns(file); + settings.SetupGet(s => s.Token).Returns(token); + settings.SetupGet(s => s.Repository).Returns(BugReportSettings.DefaultRepository); + settings.SetupGet(s => s.ReportsPerDay).Returns(BugReportSettings.DefaultReportsPerDay); + settings.SetupGet(s => s.MinutesBetween).Returns(BugReportSettings.DefaultMinutesBetween); + } + } +} diff --git a/src/NzbDrone.Core.Test/Requestarr/Qa/GitHubIssuesFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Qa/GitHubIssuesFixture.cs new file mode 100644 index 0000000..c946060 --- /dev/null +++ b/src/NzbDrone.Core.Test/Requestarr/Qa/GitHubIssuesFixture.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NLog; +using NUnit.Framework; +using Requestarr.Core.Qa; + +namespace Requestarr.Core.Test.Qa +{ + // Filing on GitHub: each label is made sure of first (an existing one is fine), then the issue, all with the + // install's own key; a refusal gives no issue, never throws, and says why in words an admin can act on. + [TestFixture] + public class GitHubIssuesFixture + { + [Test] + public async Task labels_first_then_the_issue_with_the_installs_own_key() + { + var sent = new List<(string Path, string Auth, string Body)>(); + var github = new GitHubIssues(LogManager.GetLogger("test")) + { + Transport = async (request, token) => + { + sent.Add((request.RequestUri.AbsolutePath, request.Headers.Authorization?.ToString(), await request.Content.ReadAsStringAsync(token))); + return request.RequestUri.AbsolutePath.EndsWith("/labels") + ? new HttpResponseMessage(HttpStatusCode.UnprocessableEntity) + : new HttpResponseMessage(HttpStatusCode.Created) { Content = new StringContent("{\"number\":12,\"html_url\":\"https://github.com/o/r/issues/12\"}") }; + } + }; + + var issue = await github.CreateAsync("o/r", "key", "Title", "Body", new[] { "bug report", "env: qa" }, CancellationToken.None); + + issue.Should().Be(new IssueResult(new FiledIssue(12, "https://github.com/o/r/issues/12"), null)); + sent.Select(s => s.Path).Should().Equal("/repos/o/r/labels", "/repos/o/r/labels", "/repos/o/r/issues"); + sent.Should().OnlyContain(s => s.Auth == "Bearer key"); + sent.Last().Body.Should().Contain("\"labels\":[\"bug report\",\"env: qa\"]").And.Contain("\"title\":\"Title\""); + } + + [Test] + public async Task a_refusal_gives_no_issue_and_says_what_github_meant() + { + var github = new GitHubIssues(LogManager.GetLogger("test")) { Transport = (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)) }; + + var result = await github.CreateAsync("o/r", "key", "T", "B", new[] { "x" }, CancellationToken.None); + + result.Issue.Should().BeNull(); + result.Problem.Should().Be("the access key isn't allowed to create issues on o/r (HTTP 403)"); + GitHubIssues.Refused(HttpStatusCode.Unauthorized, "o/r").Should().Contain("HTTP 401"); + } + + [Test] + public async Task github_out_of_reach_gives_no_issue_and_says_so() + { + var github = new GitHubIssues(LogManager.GetLogger("test")) { Transport = (_, _) => throw new HttpRequestException("no route") }; + + var result = await github.CreateAsync("o/r", "key", "T", "B", new[] { "x" }, CancellationToken.None); + + result.Should().Be(new IssueResult(null, "GitHub couldn't be reached (HttpRequestException)")); + } + } +} diff --git a/src/NzbDrone.Core.Test/Requestarr/Qa/LiveQaFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Qa/LiveQaFixture.cs new file mode 100644 index 0000000..42659c5 --- /dev/null +++ b/src/NzbDrone.Core.Test/Requestarr/Qa/LiveQaFixture.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Discord; +using FluentAssertions; +using NLog; +using NUnit.Framework; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common.Categories; +using Requestarr.Core.Access; +using Requestarr.Core.Admin; +using Requestarr.Core.Announcements; +using Requestarr.Core.Audit; +using Requestarr.Core.Backends; +using Requestarr.Core.Discord; +using Requestarr.Core.Discord.Gateway; +using Requestarr.Core.Discord.Handlers; +using Requestarr.Core.Discord.Interactions; +using Requestarr.Core.MediaServers; +using Requestarr.Core.Qa; +using Requestarr.Core.Requests; +using Requestarr.Core.Test.Discord.Harness; +using Requestarr.Core.Users; + +namespace Requestarr.Core.Test.Qa +{ + // Live check in the dev Discord server (DEV_DISCORD_* from docker/secrets.env, the dev stack's bot stopped: + // dev.ps1 -Stack dev down, or reset-dev --stopped). The QA checklist is posted for the running version in its own + // Requestarr QA group; the server owner (an admin) passes an item, uses up his own report limit from the bug reports + // panel, then fails three items in a row, each opening its own linked report; the test member reports a problem and + // hits the limit; the bug reports channel is read back in order; then every item passes and the checklist completes. + // With DEV_GITHUB_ISSUES_TOKEN set, reports are filed as real issues (labelled "test") and closed at the end. Clicks + // go through the real handlers; the effects are read back from Discord. The checklist's state is saved after each + // step in the API's format (LIVE_QA_STATE_DIR) for docker/qa-gate.py. Its own report cards are removed at the end. + [TestFixture] + [ManualTest] + public class LiveQaFixture : DbTest + { + [Test] + public async Task checklist_failing_item_bug_reports_limit_and_completion_in_the_dev_server() + { + if (string.IsNullOrWhiteSpace(LiveDev.Token)) + { + Assert.Ignore("DEV_DISCORD_BOT_TOKEN not set"); + } + + UseRealHttp(); + var settings = new DiscordSettingsStore(); + settings.Set(LiveDev.Settings()); + + Mocker.SetConstant<IDiscordSettingsStore>(settings); + Mocker.SetConstant<IRequestarrUserRepository>(Mocker.Resolve<RequestarrUserRepository>()); + Mocker.SetConstant<IUserIdentityRepository>(Mocker.Resolve<UserIdentityRepository>()); + Mocker.SetConstant<IUserMediaAccessRepository>(Mocker.Resolve<UserMediaAccessRepository>()); + Mocker.SetConstant<IMediaServerAccountRepository>(Mocker.Resolve<MediaServerAccountRepository>()); + Mocker.SetConstant<IMediaServerAccessRequestRepository>(Mocker.Resolve<MediaServerAccessRequestRepository>()); + Mocker.SetConstant<IAuditRepository>(Mocker.Resolve<AuditRepository>()); + Mocker.SetConstant<IDiscordGuildConfigRepository>(Mocker.Resolve<DiscordGuildConfigRepository>()); + Mocker.SetConstant<IAuditService>(Mocker.Resolve<AuditService>()); + Mocker.SetConstant<IBackendRepository>(Mocker.Resolve<BackendRepository>()); + Mocker.SetConstant<IRequestRepository>(Mocker.Resolve<RequestRepository>()); + Mocker.SetConstant<IRequestSubscriberRepository>(Mocker.Resolve<RequestSubscriberRepository>()); + Mocker.SetConstant<NzbDrone.Core.Configuration.IConfigRepository>(Mocker.Resolve<NzbDrone.Core.Configuration.ConfigRepository>()); + Mocker.SetConstant<NzbDrone.Core.Configuration.IConfigService>(Mocker.Resolve<NzbDrone.Core.Configuration.ConfigService>()); + Mocker.SetConstant<IMediaServerConnections>(new MediaServerConnections()); + Mocker.SetConstant<IAccessRequestRepository>(Mocker.Resolve<AccessRequestRepository>()); + Mocker.SetConstant<IWelcomeGuardRepository>(Mocker.Resolve<WelcomeGuardRepository>()); + Mocker.SetConstant<IReleaseEnvironment>(new ReleaseEnvironment { Variable = v => v == ReleaseEnvironment.EnvironmentVariable ? "dev" : null }); + Mocker.SetConstant<IQaChecklistItemRepository>(Mocker.Resolve<QaChecklistItemRepository>()); + Mocker.SetConstant<IBugReportRepository>(Mocker.Resolve<BugReportRepository>()); + Mocker.SetConstant<IBugReportGuardRepository>(Mocker.Resolve<BugReportGuardRepository>()); + + // Filing as the dev server would with its own key; without one, reports are kept and posted only. + var token = Environment.GetEnvironmentVariable("DEV_GITHUB_ISSUES_TOKEN"); + var bugSettings = Mocker.Resolve<BugReportSettings>(); + bugSettings.Variable = _ => null; + bugSettings.Apply(new Requestarr.Core.Provisioning.BugReportsProvisioning { GithubToken = token, ReportsPerDay = 2, MinutesBetween = 2 }); + Mocker.SetConstant<IBugReportSettings>(bugSettings); + Mocker.SetConstant<IGitHubIssues>(new GitHubIssues(LogManager.GetLogger("LiveGitHub"))); + + IGuildSetupService guildSetup = null; + IPanelService panels = null; + var host = new DiscordClientHost(settings, + new Lazy<IInteractionDispatcher>(() => null), + new Lazy<IGuildSetupService>(() => guildSetup), + new Lazy<IUserManagementService>(() => null), + new Lazy<IPanelService>(() => panels), + new Lazy<IRequestFeed>(() => Mocker.GetMock<IRequestFeed>().Object), + new Lazy<IAccessRequestService>(() => null), + Mocker.Resolve<IEventAggregator>(), + LogManager.GetLogger("LiveDiscord")); + + var gateway = new DiscordNetGateway(host, settings); + Mocker.SetConstant<IDiscordClientHost>(host); + Mocker.SetConstant<IDiscordGuildGateway>(gateway); + guildSetup = Mocker.Resolve<GuildSetupService>(); + Mocker.SetConstant(guildSetup); + Mocker.SetConstant<ICallerResolver>(Mocker.Resolve<CallerResolver>()); + Mocker.SetConstant<IAdminQueue>(new AdminQueue(Array.Empty<IAdminQueueSource>())); + panels = Mocker.Resolve<PanelService>(); + Mocker.SetConstant(panels); + var checklist = Mocker.Resolve<QaChecklistService>(); + Mocker.SetConstant<IQaChecklistService>(checklist); + var reports = Mocker.Resolve<BugReportService>(); + Mocker.SetConstant<IBugReportService>(reports); + var dispatcher = new InteractionDispatcher(new IInteractionHandler[] { Mocker.Resolve<QaHandler>() }, LogManager.GetLogger("LiveDiscord")); + var stateDir = Environment.GetEnvironmentVariable("LIVE_QA_STATE_DIR") ?? Path.Combine(Path.GetTempPath(), "requestarr-live-qa"); + Directory.CreateDirectory(stateDir); + + // Warnings and errors (a handler that failed, GitHub refusing) are printed at the end. + var problems = new NLog.Targets.MemoryTarget("live-qa") { Layout = "${level}: ${logger}: ${message} ${exception:format=tostring}" }; + LogManager.Configuration ??= new NLog.Config.LoggingConfiguration(); + LogManager.Configuration.AddRule(NLog.LogLevel.Warn, NLog.LogLevel.Fatal, problems); + LogManager.ReconfigExistingLoggers(); + + await host.StartAsync(settings.Current); + await host.WaitUntilReadyAsync(TimeSpan.FromSeconds(60)); + ulong? membersRole = null; + IUser member = null; + + try + { + await checklist.EnsureAsync(); + await panels.EnsureAllAsync(); + + var guild = host.Client.GetGuild(settings.Current.GuildId); + var rest = await host.Client.Rest.GetGuildAsync(guild.Id); + var config = guildSetup.GetConfig(); + var checklistChannel = ulong.Parse(config.QaChecklistChannelId); + var reportsChannel = ulong.Parse(config.BugReportsChannelId); + var channels = await rest.GetChannelsAsync(); + var qaGroup = channels.OfType<ICategoryChannel>().Single(c => c.Name == ChannelLayout.QaCategoryName); + var usersGroup = channels.OfType<ICategoryChannel>().Single(c => c.Name == ChannelLayout.UsersCategoryName); + member = LiveTestMembers.Pick(await rest.GetUsersAsync().FlattenAsync())[0]; + await guild.DownloadUsersAsync(); + var memberPerms = (ulong id) => guild.GetUser(member.Id).GetPermissions(guild.GetChannel(id)); + + // 1. The two channels, read back from Discord. The test member, given the members role every onboarded + // member holds (taken away again at the end), sees the bug reports channel but can't type in it, and + // doesn't see the checklist. + var checklistRest = (ITextChannel)await host.Client.Rest.GetChannelAsync(checklistChannel); + var reportsRest = (ITextChannel)await host.Client.Rest.GetChannelAsync(reportsChannel); + Log($"[channels] #{checklistRest.Name} in {(checklistRest.CategoryId == qaGroup.Id ? qaGroup.Name : "?")}; #{reportsRest.Name} in {(reportsRest.CategoryId == usersGroup.Id ? usersGroup.Name : "?")}"); + Log($"[channels] the test member before they're a member: sees the checklist {memberPerms(checklistChannel).ViewChannel}, sees bug reports {memberPerms(reportsChannel).ViewChannel}"); + membersRole = ulong.Parse(config.MembersRoleId); + await gateway.AddRoleToMemberAsync(member.Id, membersRole.Value); + await Task.Delay(TimeSpan.FromSeconds(2)); + Log($"[channels] the test member with the members role: sees the checklist {memberPerms(checklistChannel).ViewChannel}, sees bug reports {memberPerms(reportsChannel).ViewChannel}, can type there {memberPerms(reportsChannel).SendMessages}"); + checklistRest.CategoryId.Should().Be(qaGroup.Id); + reportsRest.CategoryId.Should().Be(usersGroup.Id); + memberPerms(checklistChannel).ViewChannel.Should().BeFalse(); + (memberPerms(reportsChannel).ViewChannel, memberPerms(reportsChannel).SendMessages).Should().Be((true, false)); + + // 2. The checklist as posted: a summary, then one card per item with Passes and Failing. + var entries = checklist.State().Items; + async Task<List<IMessage>> Cards() => (await checklistRest.GetMessagesAsync(100).FlattenAsync()).Where(m => m.Author.Id == host.Client.CurrentUser.Id).OrderBy(m => m.Id).ToList<IMessage>(); + var posted = await Cards(); + Log($"[checklist] {posted.Count} message(s) for {string.Join(", ", checklist.State().Versions)}: the summary, then {entries.Count} item(s)"); + Log($"[checklist] {Text(posted[0])}"); + Log($"[checklist] {Text(posted[1])} buttons [{string.Join("] [", Buttons(posted[1]))}]"); + posted.Should().HaveCount(entries.Count + 1); + Buttons(posted[1]).Should().Equal(QaText.PassLabel, QaText.FailLabel); + Save(stateDir, "1-posted", checklist, Mocker.Resolve<IBugReportRepository>()); + + FakeInteraction AsOwner(FakeInteraction i) => i.By(guild.OwnerId, "owner", "the owner").InGuild(guild.Id).InChannel(checklistChannel); + FakeInteraction AsMember(FakeInteraction i) => i.By(member.Id, member.Username, member.GlobalName ?? member.Username).InGuild(guild.Id).InChannel(reportsChannel); + + // 3. The owner passes the first item; the test member, not an admin, can't. + var refused = await Send(dispatcher, FakeInteraction.Button($"{QaRoutes.Check}:pass:{entries[0].Key}").By(member.Id, member.Username).InGuild(guild.Id).InChannel(checklistChannel)); + Log($"[pass] the test member presses Passes: \"{refused.LastReply.Content}\""); + refused.LastReply.Content.Should().Be(QaText.AdminsOnly); + var passed = await Send(dispatcher, AsOwner(FakeInteraction.Button($"{QaRoutes.Check}:pass:{entries[0].Key}"))); + Log($"[pass] the owner presses Passes on \"{entries[0].Name}\": answered with [{string.Join(", ", passed.Responder.Responses.Select(r => r.Kind))}] and no text, the card itself changes"); + passed.Outcome.Should().Be(DispatchOutcome.Handled); + passed.Responder.Responses.Select(r => r.Kind).Should().Equal(ResponseKind.Deferred); + var card = await checklistRest.GetMessageAsync(ulong.Parse(Mocker.Resolve<IQaChecklistItemRepository>().FindByKey(entries[0].Key).MessageId)); + Log($"[pass] card now: {Text(card)}"); + Log($"[pass] summary now: {Text((await Cards())[0])}"); + Text(card).Should().Contain("Passed by the owner"); + + // 4. The owner sends a report from the bug reports panel, so his own report limit is used up... + var ownerReport = await Report(dispatcher, i => AsOwner(i).InChannel(reportsChannel), "other", "Live check: the owner's report from the panel."); + Log($"[limit] the owner's report from the panel: answered [{string.Join(", ", ownerReport.Responder.Responses.Select(r => r.Kind))}], private menu removed {ownerReport.Responder.OriginalDeleted}"); + var ownerAgain = await Send(dispatcher, AsOwner(FakeInteraction.Button(QaRoutes.Report)).InChannel(reportsChannel)); + Log($"[limit] the owner presses Report a problem again: \"{ownerAgain.LastReply.Content}\""); + ownerAgain.LastReply.Content.Should().StartWith("You've sent a report just now"); + + // ...then fails three items in a row: each opens the part menu and the form, and links its own report. + foreach (var entry in entries.Skip(1).Take(3)) + { + var menu = await Send(dispatcher, AsOwner(FakeInteraction.Button($"{QaRoutes.Check}:fail:{entry.Key}"))); + Log($"[fail] Failing on \"{entry.Name}\": {(menu.LastReply.SelectMenu != null ? $"the part menu ({menu.LastReply.SelectMenu.Options.Count} parts)" : $"\"{menu.LastReply.Content}\"")}"); + menu.LastReply.SelectMenu.Should().NotBeNull("an admin working through the checklist isn't held to the report limit"); + var form = await Send(dispatcher, AsOwner(FakeInteraction.SelectMenu(menu.LastReply.SelectMenu.CustomId, "admin"))); + var sent = await Send(dispatcher, AsOwner(FakeInteraction.ModalSubmit(form.Responder.LastModal.CustomId) + .WithField(QaRoutes.WhatField, $"Live check: failing \"{entry.Name}\" opened this report.") + .WithField(QaRoutes.ExpectedField, "A linked bug report (this is a test; it will be closed)."))); + var failing = Mocker.Resolve<IQaChecklistItemRepository>().FindByKey(entry.Key); + var failingCard = await checklistRest.GetMessageAsync(ulong.Parse(failing.MessageId)); + Log($"[fail] sent: \"{sent.LastReply.Content}\""); + Log($"[fail] card now: {Text(failingCard)}"); + failing.State.Should().Be(QaItemState.Failing); + failing.BugReportId.Should().NotBeNull(); + } + + Mocker.Resolve<IQaChecklistItemRepository>().All().Where(r => r.State == QaItemState.Failing).Select(r => r.BugReportId).Should().OnlyHaveUniqueItems().And.HaveCount(3); + Log($"[fail] summary now: {Text((await Cards())[0])}"); + Save(stateDir, "2-failing", checklist, Mocker.Resolve<IBugReportRepository>()); + + // 5. The bug reports channel: the panel, the owner's report card, and the test member's own report. + var panel = (await reportsRest.GetMessagesAsync(20).FlattenAsync()).First(m => Buttons(m).Contains(QaText.ReportLabel)); + Log($"[reports] panel: {Text(panel)}"); + var memberSent = await Report(dispatcher, AsMember, "web-app", "Live check: a report from the test member."); + Log($"[reports] the test member's report: answered [{string.Join(", ", memberSent.Responder.Responses.Select(r => r.Kind))}], private menu removed {memberSent.Responder.OriginalDeleted}"); + memberSent.Responder.OriginalDeleted.Should().BeTrue("the card is the answer; the menu replied to the panel, which moves"); + + // The panel moves below new posts a few seconds after them. The channel reads top to bottom: the reports + // in the order they were sent, then the panel, nothing replying to anything, nothing edited for nothing. + await Task.Delay(TimeSpan.FromSeconds(8)); + var ours = Mocker.Resolve<IBugReportRepository>().All().Select(r => r.MessageId).ToHashSet(); + var cards = (await reportsRest.GetMessagesAsync(50).FlattenAsync()).Where(m => m.Author.Id == host.Client.CurrentUser.Id).OrderBy(m => m.Id).ToList(); + foreach (var m in cards) + { + Log($"[reports] {(m.EditedTimestamp != null ? "(edited) " : string.Empty)}{(m.Reference != null ? "(a reply) " : string.Empty)}{Text(m)}"); + } + + Buttons(cards.Last()).Should().Contain(QaText.ReportLabel, "the panel stays the newest message"); + cards.Last().EditedTimestamp.Should().BeNull("a moved panel is a new message, never edited"); + cards.Should().OnlyContain(m => m.Reference == null); + var order = cards.Where(m => ours.Contains(m.Id.ToString())).Select(m => Mocker.Resolve<IBugReportRepository>().All().Single(r => r.MessageId == m.Id.ToString()).Id).ToList(); + order.Should().BeInAscendingOrder().And.HaveCount(ours.Count); + + // 6. The limit: two a day here, two minutes apart. + var again = await Send(dispatcher, AsMember(FakeInteraction.Button(QaRoutes.Report))); + Log($"[limit] the test member presses Report a problem again: \"{again.LastReply.Content}\""); + again.LastReply.Content.Should().StartWith("You've sent a report just now"); + + // 7. Everything passes, the failing item last (after the fix): complete. + foreach (var entry in entries.Skip(2).Append(entries[1])) + { + await Send(dispatcher, AsOwner(FakeInteraction.Button($"{QaRoutes.Check}:pass:{entry.Key}"))); + } + + var state = checklist.State(); + Log($"[complete] {state.Passed} of {state.Total} passed, {state.Failing} failing, complete {state.Complete}, the last by {state.CompletedBy}"); + Log($"[complete] summary now: {Text((await Cards())[0])}"); + state.Complete.Should().BeTrue(); + Save(stateDir, "3-complete", checklist, Mocker.Resolve<IBugReportRepository>()); + + foreach (var report in Mocker.Resolve<IBugReportRepository>().All()) + { + Log($"[issues] report #{report.Id} ({report.Component}) by {report.ReporterName}: {report.Filing}{(report.IssueUrl != null ? " " + report.IssueUrl : string.Empty)} | card: {QaText.Filing(report)}"); + } + + Log($"[state] saved in {stateDir}: 1-posted.json, 2-failing.json, 3-complete.json"); + } + finally + { + if (membersRole != null) + { + await gateway.RemoveRoleFromMemberAsync(member.Id, membersRole.Value); + } + + // This run's report cards go (the dev stack's own reports stay), and its test issues are closed. + foreach (var report in Mocker.Resolve<IBugReportRepository>().All()) + { + if (ulong.TryParse(report.ChannelId, out var channel) && ulong.TryParse(report.MessageId, out var message)) + { + await gateway.DeleteMessageAsync(channel, message); + } + + if (report.IssueNumber is { } number && !string.IsNullOrWhiteSpace(token)) + { + using var close = GitHubIssues.Build(System.Net.Http.HttpMethod.Patch, $"repos/{bugSettings.Repository}/issues/{number}", token, new { state = "closed", state_reason = "not_planned" }); + using var closed = await new System.Net.Http.HttpClient().SendAsync(close); + Log($"[issues] closed #{number}: HTTP {(int)closed.StatusCode}"); + } + } + + await host.StopAsync(); + + foreach (var problem in problems.Logs.Where(l => !l.Contains("Discord.Net"))) + { + Log("[log] " + problem); + } + } + } + + // What GET /api/v1/qa/checklist returns, for docker/qa-gate.py's QA_GATE_FILE. + private static void Save(string dir, string name, QaChecklistService checklist, IBugReportRepository reports) + { + var state = checklist.State(); + var body = new + { + checklist = state, + bugReports = reports.All().Where(r => state.Versions.Contains(r.Version)) + .Select(r => new { r.Id, r.Component, r.Version, r.Environment, r.Created, r.ChecklistItemKey, Filing = r.Filing.ToString(), r.FilingProblem, r.IssueNumber, r.IssueUrl }), + unfiledReports = reports.All().Where(r => r.Filing != BugReportFiling.Filed) + .Select(r => new { r.Id, r.Component, r.Version, r.Created, Filing = r.Filing.ToString(), r.FilingProblem, What = r.WhatHappened }) + }; + + File.WriteAllText(Path.Combine(dir, name + ".json"), JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + } + + private static List<string> Buttons(IMessage message) => All(message.Components).OfType<ButtonComponent>().Select(b => b.Label).ToList(); + + private static string Text(IMessage message) => + string.Join(" / ", message.Components.OfType<ContainerComponent>().Select(c => string.Join(" ", All(c.Components).OfType<TextDisplayComponent>().Select(t => t.Content)).Replace("\n", " | "))); + + private static IEnumerable<IMessageComponent> All(IEnumerable<IMessageComponent> components) => + components.SelectMany(c => new[] { c }.Concat(All(c switch + { + ActionRowComponent row => row.Components, + ContainerComponent container => container.Components, + _ => Enumerable.Empty<IMessageComponent>() + }))); + + // Report a problem from the panel: the part menu, the form, then the report. + private static async Task<InteractionRun> Report(InteractionDispatcher dispatcher, Func<FakeInteraction, FakeInteraction> who, string part, string what) + { + var open = await Send(dispatcher, who(FakeInteraction.Button(QaRoutes.Report))); + var form = await Send(dispatcher, who(FakeInteraction.SelectMenu(open.LastReply.SelectMenu.CustomId, part))); + return await Send(dispatcher, who(FakeInteraction.ModalSubmit(form.Responder.LastModal.CustomId) + .WithField(QaRoutes.WhatField, what) + .WithField(QaRoutes.ExpectedField, "It reaches the bug reports channel (this is a test; it will be closed)."))); + } + + private static async Task<InteractionRun> Send(InteractionDispatcher dispatcher, FakeInteraction interaction) + { + var built = interaction.Build(); + var responder = new RecordingResponder(null, built.ChannelId, built.MessageId, false); + var outcome = await dispatcher.DispatchAsync(built, responder); + return new InteractionRun(outcome, responder); + } + + private static void Log(string line) => TestContext.Progress.WriteLine(line); + } +} diff --git a/src/NzbDrone.Core.Test/Requestarr/Qa/QaChecklistFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Qa/QaChecklistFixture.cs new file mode 100644 index 0000000..6e34fac --- /dev/null +++ b/src/NzbDrone.Core.Test/Requestarr/Qa/QaChecklistFixture.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using Requestarr.Core.Announcements; +using Requestarr.Core.Discord; +using Requestarr.Core.Qa; +using Requestarr.Core.Test.AnimeDub; + +namespace Requestarr.Core.Test.Qa +{ + // The QA test checklist: QA-CHECKLIST.md, which versions each server tests, and passing, failing and completing it. + [TestFixture] + public class QaChecklistFixture : CoreTest<QaChecklistService> + { + private const string Text = @"# QA checklist + +## 0.4.15 (2026-10-25) +- **Passing an item:** Press Passes. You should see it passed. +- **Failing an item:** Press Failing. You should see it failing. + +## 0.4.14 (2026-10-25) + +## 0.4.13 (2026-10-25) +- **Sign-in logo:** Sign out. You should see the logo. +"; + + private readonly Dictionary<string, QaChecklistItem> _records = new(); + private int _nextId = 1; + + [SetUp] + public void Setup() + { + _records.Clear(); + var items = Mocker.GetMock<IQaChecklistItemRepository>(); + items.Setup(r => r.FindByKey(It.IsAny<string>())).Returns<string>(k => _records.TryGetValue(k, out var r) ? r : null); + items.Setup(r => r.Insert(It.IsAny<QaChecklistItem>())).Returns<QaChecklistItem>(r => + { + r.Id = _nextId++; + _records[r.ItemKey] = r; + return r; + }); + items.Setup(r => r.Update(It.IsAny<QaChecklistItem>())).Returns<QaChecklistItem>(r => _records[r.ItemKey] = r); + + Subject.ChecklistText = () => Text; + Subject.CurrentVersion = () => new Version(0, 4, 15, 123); + OnQa(); + } + + [Test] + public void the_shipped_checklist_is_embedded_and_starts_at_the_build_version() + { + var file = File.ReadAllText(Item4To6Fixture.RepoFile("QA-CHECKLIST.md")); + var props = File.ReadAllText(Item4To6Fixture.RepoFile("src", "Directory.Build.props")); + var build = Regex.Match(props, @"<AssemblyVersion>(\d+\.\d+\.\d+)").Groups[1].Value; + var versions = QaChecklist.Parse(file); + + QaChecklist.Embedded().Replace("\r\n", "\n").Should().Be(file.Replace("\r\n", "\n")); + versions[0].Version.ToString().Should().Be(build, "every pull request adds its version's items (or heading) at the top"); + versions.SelectMany(v => v.Items).Select(i => i.Key).Should().OnlyHaveUniqueItems(); + versions.SelectMany(v => v.Items).Should().OnlyContain(i => i.Text.Contains("You should see")); + } + + [Test] + public void items_get_stable_keys_and_a_version_with_nothing_to_test_has_none() + { + var versions = QaChecklist.Parse(Text); + + versions.Select(v => v.Version.ToString()).Should().Equal("0.4.15", "0.4.14", "0.4.13"); + versions[0].Items.Select(i => i.Key).Should().Equal("0.4.15/passing-an-item", "0.4.15/failing-an-item"); + versions[1].Items.Should().BeEmpty(); + versions[2].Items.Single().Name.Should().Be("Sign-in logo"); + } + + [Test] + public void qa_tests_what_staging_doesnt_have_dev_tries_out_the_running_version_and_other_servers_have_none() + { + var versions = QaChecklist.Parse(Text); + var running = new Version(0, 4, 15, 7); + + QaChecklist.ForServer(versions, Environment("qa", "0.4.13"), running).Select(v => v.Version.ToString()).Should().Equal("0.4.15", "0.4.14"); + QaChecklist.ForServer(versions, Environment("dev"), running).Select(v => v.Version.ToString()).Should().Equal("0.4.15"); + QaChecklist.ForServer(versions, Environment("staging"), running).Should().BeEmpty(); + QaChecklist.ForServer(versions, Environment(null), running).Should().BeEmpty(); + QaChecklist.HasChecklist(Environment("qa")).Should().BeTrue(); + QaChecklist.HasChecklist(Environment(null)).Should().BeFalse(); + } + + [Test] + public async Task the_checklist_completes_when_every_item_has_passed_and_records_who_and_when() + { + Subject.State().Should().BeEquivalentTo(new { Total = 3, Passed = 0, Failing = 0, Complete = false }); + + await Subject.MarkPassedAsync("0.4.15/passing-an-item", 1, "Alex"); + await Subject.MarkPassedAsync("0.4.15/failing-an-item", 1, "Alex"); + Subject.State().Complete.Should().BeFalse(); + + await Subject.MarkPassedAsync("0.4.13/sign-in-logo", 2, "Sam"); + var state = Subject.State(); + + state.Should().BeEquivalentTo(new { Total = 3, Passed = 3, Failing = 0, Complete = true, CompletedBy = "Sam" }); + state.Versions.Should().Equal("0.4.15", "0.4.14", "0.4.13"); + _records["0.4.13/sign-in-logo"].ByDiscordUserId.Should().Be("2"); + _records["0.4.13/sign-in-logo"].At.Should().NotBeNull(); + } + + [Test] + public async Task a_failing_item_links_its_bug_report_and_blocks_completion_until_it_passes() + { + Mocker.GetMock<IBugReportRepository>().Setup(r => r.Find(9)).Returns(new BugReport { Id = 9, IssueUrl = "https://github.com/x/y/issues/1" }); + + foreach (var key in new[] { "0.4.15/passing-an-item", "0.4.13/sign-in-logo" }) + { + await Subject.MarkPassedAsync(key, 1, "Alex"); + } + + await Subject.MarkFailingAsync("0.4.15/failing-an-item", 1, "Alex", new BugReport { Id = 9 }); + var state = Subject.State(); + + state.Should().BeEquivalentTo(new { Passed = 2, Failing = 1, Complete = false }); + state.Items.Single(i => i.Key == "0.4.15/failing-an-item").Should().BeEquivalentTo(new { State = "Failing", BugReportId = 9, IssueUrl = "https://github.com/x/y/issues/1" }); + + await Subject.MarkPassedAsync("0.4.15/failing-an-item", 2, "Sam"); + Subject.State().Should().BeEquivalentTo(new { Passed = 3, Failing = 0, Complete = true }); + } + + [Test] + public async Task a_new_promotion_keeps_what_was_recorded_adds_new_items_and_drops_what_staging_has() + { + await Subject.MarkPassedAsync("0.4.13/sign-in-logo", 1, "Alex"); + await Subject.MarkPassedAsync("0.4.15/passing-an-item", 1, "Alex"); + + // Staging got 0.4.13, and QA got 0.4.16 with one new item. + OnQa("0.4.13"); + Subject.ChecklistText = () => Text.Replace("# QA checklist", "# QA checklist\n\n## 0.4.16 (2026-10-26)\n- **New thing:** Do it. You should see it."); + Subject.CurrentVersion = () => new Version(0, 4, 16, 1); + var state = Subject.State(); + + state.Versions.Should().Equal("0.4.16", "0.4.15", "0.4.14"); + state.Items.Select(i => (i.Key, i.State)).Should().Equal(("0.4.16/new-thing", "Pending"), ("0.4.15/passing-an-item", "Passed"), ("0.4.15/failing-an-item", "Pending")); + } + + [Test] + public async Task an_item_no_longer_on_the_checklist_cant_be_marked() + { + await Subject.MarkPassedAsync("0.3.0/old", 1, "Alex"); + + _records.Should().NotContainKey("0.3.0/old"); + } + + private void OnQa(string staged = "") + { + var environment = Environment("qa", staged); + Mocker.GetMock<IReleaseEnvironment>().SetupGet(e => e.VersionPosts).Returns(environment.VersionPosts); + Mocker.GetMock<IReleaseEnvironment>().SetupGet(e => e.Staged).Returns(environment.Staged); + Mocker.GetMock<IReleaseEnvironment>().SetupGet(e => e.IconSet).Returns(environment.IconSet); + Mocker.GetMock<IGuildSetupService>().Setup(g => g.GetConfig()).Returns((DiscordGuildConfig)null); + } + + private static ReleaseEnvironment Environment(string name, string staged = "") => new() + { + Variable = v => v == ReleaseEnvironment.EnvironmentVariable ? name : v == ReleaseEnvironment.StagedVersionsVariable ? staged : null + }; + } +} diff --git a/src/NzbDrone.Core.Test/Requestarr/Qa/QaGroupFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Qa/QaGroupFixture.cs new file mode 100644 index 0000000..c72597b --- /dev/null +++ b/src/NzbDrone.Core.Test/Requestarr/Qa/QaGroupFixture.cs @@ -0,0 +1,69 @@ +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Requestarr.Core.Announcements; +using Requestarr.Core.Discord; +using Requestarr.Core.Discord.Interactions; +using Requestarr.Core.Qa; +using Requestarr.Core.Test.Discord.Harness; +using Requestarr.Core.Test.Welcome; + +namespace Requestarr.Core.Test.Qa +{ + // The QA checklist has a channel group of its own, Requestarr QA, admin-only like Requestarr Admin, on every server + // that has a checklist (QA and dev); beta and production get none. An existing checklist channel is moved there with + // its cards, on the next start. + [TestFixture] + public class QaGroupFixture : WelcomeScenario + { + private FakeDiscordGuild.FakeChannel Group(string name) => Discord.Channels.Values.SingleOrDefault(c => c.IsCategory && c.Name == name); + + [Test] + public void production_and_beta_have_no_qa_group() + { + Group(ChannelLayout.QaCategoryName).Should().BeNull(); + Discord.ChannelNamed(QaText.ChecklistChannelName).Should().BeNull(); + } + + [Test] + public async Task the_checklist_already_in_the_admin_group_moves_to_its_own_group_with_its_cards() + { + var admin = Group(ChannelLayout.AdminCategoryName); + var checklist = await Discord.CreateTextChannelAsync(QaText.ChecklistChannelName, admin.Id, QaText.ChecklistChannelTopic, admin.Overwrites); + var card = await Discord.SendMessageAsync(checklist, new InteractionReply { Cards = { new InteractionCard { Text = "## QA checklist" } } }); + var config = Config; + config.QaChecklistChannelId = checklist.ToString(); + config.QaChecklistSummaryMessageId = card.ToString(); + Mocker.Resolve<IDiscordGuildConfigRepository>().Upsert(config); + + await StartOnQa(); + await StartOnQa(); + + var qa = Group(ChannelLayout.QaCategoryName); + var channel = Discord.Channels[checklist]; + channel.CategoryId.Should().Be(qa.Id, "moved, not recreated"); + channel.MessageIds.Should().Equal(card); + Config.QaChecklistSummaryMessageId.Should().Be(card.ToString(), "its cards are kept"); + Config.QaCategoryId.Should().Be(qa.Id.ToString()); + qa.Overwrites.Should().BeEquivalentTo(admin.Overwrites, "admin-only, like Requestarr Admin"); + channel.Overwrites.Should().BeEquivalentTo(qa.Overwrites); + Discord.Channels.Values.Count(c => c.IsCategory && c.Name == ChannelLayout.QaCategoryName).Should().Be(1); + Discord.ChannelsIn(admin.Id).Select(c => c.Name).Should().BeEquivalentTo(ChannelLayout.AdminChannelName, ChannelLayout.FeedChannelName); + } + + [Test] + public async Task a_new_qa_server_gets_the_group_and_the_checklist_in_it() + { + await StartOnQa(); + + Discord.ChannelNamed(QaText.ChecklistChannelName).CategoryId.Should().Be(Group(ChannelLayout.QaCategoryName).Id); + } + + private Task StartOnQa() + { + Mocker.GetMock<IReleaseEnvironment>().SetupGet(e => e.VersionPosts).Returns(VersionPostMode.NotYetStaged); + return Mocker.Resolve<IGuildSetupService>().EnsureAsync(); + } + } +} diff --git a/src/NzbDrone.Core.Test/Requestarr/Qa/QaHandlerFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Qa/QaHandlerFixture.cs new file mode 100644 index 0000000..ea7c8ea --- /dev/null +++ b/src/NzbDrone.Core.Test/Requestarr/Qa/QaHandlerFixture.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using Requestarr.Core.Discord; +using Requestarr.Core.Discord.Handlers; +using Requestarr.Core.Discord.Interactions; +using Requestarr.Core.Qa; +using Requestarr.Core.Test.Discord.Harness; + +namespace Requestarr.Core.Test.Qa +{ + // The checklist's buttons and the bug report flow, answered the way Discord accepts: a card stays a card (pressing + // Passes on one used to fail every time), an admin failing items isn't held to the members' report limit, and a + // report from the bug reports channel leaves no private reply pointing at the panel that moves below it. + [TestFixture] + public class QaHandlerFixture : CoreTest<QaHandler> + { + private const ulong Channel = 700; + private readonly QaChecklistEntry _item = new(new Version(0, 4, 16), "0.4.16/one", "One", "Do it. You should see it."); + private FakeDiscordGuild _guild; + private ulong _card; + private bool _admin; + + [SetUp] + public void Setup() + { + _guild = new FakeDiscordGuild(); + _guild.Channels[Channel] = new FakeDiscordGuild.FakeChannel { Id = Channel, Name = QaText.ChecklistChannelName }; + _card = _guild.SendMessageAsync(Channel, new InteractionReply { Cards = { new InteractionCard { Text = "**One** · 0.4.16" } } }).Result; + _admin = true; + + Mocker.GetMock<ICallerResolver>().Setup(c => c.Resolve(It.IsAny<DiscordInteraction>())).Returns(() => new CallerContext { RightServer = true, IsAdmin = _admin }); + Mocker.GetMock<IQaChecklistService>().Setup(c => c.Find(It.IsAny<string>())).Returns<string>(k => k.StartsWith("0.4.16/") ? new QaChecklistEntry(_item.Version, k, "Item " + k, _item.Text) : null); + + // The member limit is reached: only an admin's checklist work goes through. + Mocker.GetMock<IBugReportService>().Setup(r => r.WaitLeft(It.IsAny<ulong>())).Returns(TimeSpan.FromMinutes(1)); + Mocker.GetMock<IBugReportService>() + .Setup(r => r.SubmitAsync(It.IsAny<ulong>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>())) + .ReturnsAsync(new BugReportResult(new BugReport { Id = 1 }, "filed")); + } + + [Test] + public async Task passes_on_a_card_records_the_pass_and_never_turns_the_card_into_text() + { + var responder = await Press($"{QaRoutes.Check}:pass:{_item.Key}"); + + responder.Responses.Select(r => r.Kind).Should().Equal(ResponseKind.Deferred); + Mocker.GetMock<IQaChecklistService>().Verify(c => c.MarkPassedAsync(_item.Key, It.IsAny<ulong>(), It.IsAny<string>()), Times.Once()); + _guild.Channels[Channel].Messages.Single().IsCards.Should().BeTrue(); + } + + [Test] + public async Task an_admin_fails_three_items_in_a_row_each_opening_its_own_form_despite_the_member_limit() + { + foreach (var key in new[] { "0.4.16/one", "0.4.16/two", "0.4.16/three" }) + { + var menu = (await Press($"{QaRoutes.Check}:fail:{key}")).LastReply; + menu.SelectMenu.CustomId.Should().Be($"{QaRoutes.Part}:{key}"); + + var form = await Send(FakeInteraction.ModalSubmit($"{QaRoutes.Form}:admin:{key}").WithField(QaRoutes.WhatField, "Broken " + key)); + form.LastReply.Content.Should().Be("filed"); + } + + Mocker.GetMock<IBugReportService>().Verify(r => r.SubmitAsync(It.IsAny<ulong>(), It.IsAny<string>(), "admin", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), false), Times.Exactly(3)); + Mocker.GetMock<IBugReportService>().Verify(r => r.SubmitAsync(It.IsAny<ulong>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), true), Times.Never()); + } + + [Test] + public async Task a_member_pressing_report_a_problem_still_waits_out_the_limit() + { + _admin = false; + + var responder = await Press(QaRoutes.Report); + + responder.LastReply.Content.Should().Contain("You can send another"); + responder.LastReply.SelectMenu.Should().BeNull(); + } + + [Test] + public async Task a_report_from_the_bug_reports_channel_replaces_its_private_menu_and_then_removes_it() + { + var responder = await Send(FakeInteraction.ModalSubmit($"{QaRoutes.Form}:requests").WithField(QaRoutes.WhatField, "Nothing happened")); + + responder.Responses[0].Should().BeEquivalentTo(new { Kind = ResponseKind.MessageUpdate, Reply = new { Content = QaText.Sending } }); + responder.OriginalDeleted.Should().BeTrue("the card in the channel is the answer, and the menu replied to the panel that moves"); + Mocker.GetMock<IBugReportService>().Verify(r => r.SubmitAsync(It.IsAny<ulong>(), It.IsAny<string>(), "requests", "Nothing happened", null, null, true), Times.Once()); + } + + [Test] + public async Task a_report_held_back_by_the_limit_says_how_long_to_wait_instead() + { + Mocker.GetMock<IBugReportService>().Setup(r => r.SubmitAsync(It.IsAny<ulong>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), null, true)) + .ReturnsAsync(new BugReportResult(null, "You've sent a report just now. You can send another in a moment.")); + + var responder = await Send(FakeInteraction.ModalSubmit($"{QaRoutes.Form}:requests").WithField(QaRoutes.WhatField, "Again")); + + responder.OriginalDeleted.Should().BeFalse(); + responder.LastReply.Content.Should().Contain("You can send another"); + } + + private Task<RecordingResponder> Press(string customId) => Send(FakeInteraction.Button(customId).OnMessage(_card)); + + private async Task<RecordingResponder> Send(FakeInteraction interaction) + { + var built = interaction.InChannel(Channel).Build(); + var responder = new RecordingResponder(_guild, Channel, built.MessageId, isCommand: false); + await Subject.HandleAsync(built, responder); + return responder; + } + } +} diff --git a/src/NzbDrone.Core.Test/Requestarr/Releases/Item4cReleaseFlowFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Releases/Item4cReleaseFlowFixture.cs index 5c865ff..397f800 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Releases/Item4cReleaseFlowFixture.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Releases/Item4cReleaseFlowFixture.cs @@ -33,7 +33,9 @@ public void the_two_stacks_never_share_a_project_port_or_bot() foreach (var script in new[] { Read("docker", "dev.ps1"), Read("docker", "dev.sh") }) { script.Should().Contain("DEV_DISCORD_BOT_TOKEN").And.Contain("DEV_DISCORD_GUILD_ID"); - script.Should().Contain("19797").And.Contain("28989").And.Contain("27878", "the dev stack's own ports"); + script.Should().Contain("39797").And.Contain("38989").And.Contain("37878", "the dev stack's own ports"); + script.Should().Contain("29797").And.Contain("28989").And.Contain("27878", "the QA stack's own ports"); + script.Should().NotContain("'9797'").And.NotContain(":-9797}").And.NotContain("19797").And.NotContain("18989").And.NotContain("17878", "no stack keeps an old port"); script.Should().Contain("reset-uat", "the old name still works"); script.Should().Contain("archive --format=tar", "a stack runs exactly its branch's commit, never uncommitted files"); } diff --git a/src/NzbDrone.Core.Test/Requestarr/Welcome/Item1GroupsFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Welcome/Item1GroupsFixture.cs index 95dc2c3..ecf2d59 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Welcome/Item1GroupsFixture.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Welcome/Item1GroupsFixture.cs @@ -149,12 +149,12 @@ public async Task a_leftover_empty_request_group_is_removed() } [Test] - public async Task a_full_users_group_spills_into_the_next_and_keeps_its_three_channels() + public async Task a_full_users_group_spills_into_the_next_and_keeps_its_four_channels() { var first = ulong.Parse(Config.RequestCategoryIds.Single()); - Discord.ChannelsIn(first).Should().HaveCount(3, "welcome, announcements and blocked"); + Discord.ChannelsIn(first).Should().HaveCount(4, "welcome, announcements, blocked and bug reports"); - for (var i = 3; i < 50; i++) + for (var i = 4; i < 50; i++) { await Discord.CreateTextChannelAsync($"filler-{i}", first, null, System.Array.Empty<PermissionOverwrite>()); } diff --git a/src/NzbDrone.Core.Test/Requestarr/Welcome/Item6PanelsLastFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Welcome/Item6PanelsLastFixture.cs index 4796ac1..dd8b71f 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Welcome/Item6PanelsLastFixture.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Welcome/Item6PanelsLastFixture.cs @@ -54,6 +54,39 @@ public async Task a_panel_already_last_stays_put() AdminRoom.MessageIds.Should().Equal(ids); } + [Test] + public async Task moving_a_panel_edits_nothing_so_no_message_is_marked_edited() + { + await Discord.SendMessageAsync(AdminChannel, new InteractionReply { Content = "an approval card" }); + AdminRoom.Edited.Clear(); + + // The move, then the notice of the bot's own new panel: it's already last, so it's left alone. + await Panels.KeepLastAsync(AdminChannel); + await Panels.KeepLastAsync(AdminChannel); + + AdminRoom.Edited.Should().BeEmpty(); + AdminRoom.MessageIds.Last().ToString().Should().Be(Config.AdminPanelMessageId); + } + + [Test] + public async Task a_start_leaves_a_panel_showing_the_right_thing_alone_and_brings_an_outdated_one_up_to_date() + { + var welcome = Discord.Channels[WelcomeChannel]; + welcome.Edited.Clear(); + AdminRoom.Edited.Clear(); + + // The welcome message as an older version wrote it: same buttons, other words. + var index = welcome.MessageIds.IndexOf(ulong.Parse(Config.WelcomeMessageId)); + var current = welcome.Messages[index]; + welcome.Messages[index] = new InteractionReply { Cards = { new InteractionCard { Text = "Older welcome words", Buttons = current.Cards[0].Buttons } } }; + + await Panels.EnsureAllAsync(); + + AdminRoom.Edited.Should().BeEmpty("the admin panel already shows what it should"); + welcome.Edited.Should().Equal(ulong.Parse(Config.WelcomeMessageId)); + welcome.Messages[index].Cards[0].Text.Should().Be(current.Cards[0].Text); + } + [Test] public async Task the_welcome_and_blocked_messages_stay_last_too() { diff --git a/src/NzbDrone.Core.Test/Requestarr/Welcome/Item9WebIconsFixture.cs b/src/NzbDrone.Core.Test/Requestarr/Welcome/Item9WebIconsFixture.cs index 859fd3d..cb00d44 100644 --- a/src/NzbDrone.Core.Test/Requestarr/Welcome/Item9WebIconsFixture.cs +++ b/src/NzbDrone.Core.Test/Requestarr/Welcome/Item9WebIconsFixture.cs @@ -17,7 +17,7 @@ public class Item9WebIconsFixture private static readonly string[] Files = { "android-chrome-192x192.png", "android-chrome-512x512.png", "apple-touch-icon.png", "favicon-16x16.png", "favicon-32x32.png", "favicon.ico", - "logo.png", "mstile-144x144.png", "mstile-150x150.png", "mstile-310x150.png", "mstile-310x310.png", "mstile-70x70.png" + "logo.png", "logo-header.png", "mstile-144x144.png", "mstile-150x150.png", "mstile-310x150.png", "mstile-310x310.png", "mstile-70x70.png" }; private static string Icons(params string[] parts) => Item4To6Fixture.RepoFile(new[] { "frontend", "src", "Content", "Images", "Icons" }.Concat(parts).ToArray()); @@ -46,6 +46,12 @@ public void every_environment_has_a_full_light_and_dark_set() } } + // The light theme's header is purple, so it has its own header logos (beta's bar is white there). + foreach (var set in Sets) + { + Directory.GetFiles(Icons("light-purple", set)).Select(Path.GetFileName).Should().BeEquivalentTo(new[] { "logo-header.png" }, $"light-purple/{set}"); + } + File.Exists(Icons("safari-pinned-tab.svg")).Should().BeTrue(); } @@ -65,7 +71,13 @@ public void the_pages_and_the_manifests_use_the_themed_sets_and_nothing_old_is_l .And.NotContain("#7048e8", "the placeholder purple is gone"); } - login.Should().Contain("/Content/Images/Icons/__ICON_THEME__/__ICON_SET__/logo.png"); + // The sign-in panel header is dark gray in both themes, so its logo is always the dark set's. + login.Should().Contain("src=\"/Content/Images/Icons/dark/__ICON_SET__/__HEADER_LOGO__\"") + .And.NotContain("Icons/__ICON_THEME__/__ICON_SET__/__HEADER_LOGO__"); + + // While the app loads it shows the header's logo; nothing of the old placeholder is left. + var loading = File.ReadAllText(Item4To6Fixture.RepoFile("frontend", "src", "Components", "Page", "LoadingPage.js")); + loading.Should().Contain("<HeaderLogo").And.NotContain("data:image"); foreach (var theme in new[] { "light", "dark" }) { diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 2dac27b..1ede626 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -147,6 +147,54 @@ public int AccessBlockWithinHours set { SetValue("AccessBlockWithinHours", value); } } + public bool BotProfileKeepUpToDate + { + get { return GetValueBoolean("BotProfileKeepUpToDate", true); } + set { SetValue("BotProfileKeepUpToDate", value); } + } + + public bool BotProfileTakeOver + { + get { return GetValueBoolean("BotProfileTakeOver", false); } + set { SetValue("BotProfileTakeOver", value); } + } + + public string BotProfileAvatarSet + { + get { return GetValue("BotProfileAvatarSet"); } + set { SetValue("BotProfileAvatarSet", value ?? string.Empty); } + } + + public string BotProfileBannerSet + { + get { return GetValue("BotProfileBannerSet"); } + set { SetValue("BotProfileBannerSet", value ?? string.Empty); } + } + + public bool BugReportsFileIssues + { + get { return GetValueBoolean("BugReportsFileIssues", false); } + set { SetValue("BugReportsFileIssues", value); } + } + + public string BugReportsRepository + { + get { return GetValue("BugReportsRepository"); } + set { SetValue("BugReportsRepository", value ?? string.Empty); } + } + + public int BugReportsPerDay + { + get { return GetValueInt("BugReportsPerDay", Requestarr.Core.Qa.BugReportSettings.DefaultReportsPerDay); } + set { SetValue("BugReportsPerDay", value); } + } + + public int BugReportsMinutesBetween + { + get { return GetValueInt("BugReportsMinutesBetween", Requestarr.Core.Qa.BugReportSettings.DefaultMinutesBetween); } + set { SetValue("BugReportsMinutesBetween", value); } + } + public bool LogIndexerResponse { get { return GetValueBoolean("LogIndexerResponse", false); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 3052dab..5f3deb1 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -26,6 +26,18 @@ public interface IConfigService int AccessBlockClicks { get; set; } int AccessBlockWithinHours { get; set; } + // Requestarr's bot picture and banner; read through Requestarr.Core.Discord.BotProfileSettings + bool BotProfileKeepUpToDate { get; set; } + bool BotProfileTakeOver { get; set; } + string BotProfileAvatarSet { get; set; } + string BotProfileBannerSet { get; set; } + + // Requestarr's bug reports; read through Requestarr.Core.Qa.BugReportSettings + bool BugReportsFileIssues { get; set; } + string BugReportsRepository { get; set; } + int BugReportsPerDay { get; set; } + int BugReportsMinutesBetween { get; set; } + //UI int FirstDayOfWeek { get; set; } string CalendarWeekColumnHeader { get; set; } diff --git a/src/NzbDrone.Core/Datastore/Migration/011_qa_checklist_bug_reports.cs b/src/NzbDrone.Core/Datastore/Migration/011_qa_checklist_bug_reports.cs new file mode 100644 index 0000000..8e7bcf9 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/011_qa_checklist_bug_reports.cs @@ -0,0 +1,51 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + // Requestarr: the QA test checklist (its channel and summary card, and who passed or failed each item and when), and + // bug reports from the Report a problem button (its channel and message, each report and the issue it became, and + // one row per person for the report limit). + [Migration(11)] + public class qa_checklist_bug_reports : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("DiscordGuildConfigs").AddColumn("QaChecklistChannelId").AsString().Nullable(); + Alter.Table("DiscordGuildConfigs").AddColumn("QaChecklistSummaryMessageId").AsString().Nullable(); + Alter.Table("DiscordGuildConfigs").AddColumn("BugReportsChannelId").AsString().Nullable(); + Alter.Table("DiscordGuildConfigs").AddColumn("BugReportsMessageId").AsString().Nullable(); + + Create.TableForModel("QaChecklistItems") + .WithColumn("ItemKey").AsString().NotNullable().Unique() + .WithColumn("Version").AsString().NotNullable() + .WithColumn("Name").AsString().Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ByDiscordUserId").AsString().Nullable() + .WithColumn("ByName").AsString().Nullable() + .WithColumn("At").AsDateTimeOffset().Nullable() + .WithColumn("BugReportId").AsInt32().Nullable() + .WithColumn("MessageId").AsString().Nullable(); + + Create.TableForModel("BugReports") + .WithColumn("Component").AsString().NotNullable() + .WithColumn("WhatHappened").AsString().NotNullable() + .WithColumn("Expected").AsString().Nullable() + .WithColumn("Environment").AsString().NotNullable() + .WithColumn("Version").AsString().NotNullable() + .WithColumn("ReporterDiscordId").AsString().NotNullable().Indexed() + .WithColumn("ReporterName").AsString().Nullable() + .WithColumn("Created").AsDateTimeOffset().NotNullable() + .WithColumn("ChecklistItemKey").AsString().Nullable() + .WithColumn("Filing").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IssueNumber").AsInt32().Nullable() + .WithColumn("IssueUrl").AsString().Nullable() + .WithColumn("ChannelId").AsString().Nullable() + .WithColumn("MessageId").AsString().Nullable(); + + Create.TableForModel("BugReportGuards") + .WithColumn("DiscordUserId").AsString().NotNullable().Unique() + .WithColumn("RecentReports").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/012_qa_group_report_filing.cs b/src/NzbDrone.Core/Datastore/Migration/012_qa_group_report_filing.cs new file mode 100644 index 0000000..24e80a3 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/012_qa_group_report_filing.cs @@ -0,0 +1,17 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + // Requestarr: the QA checklist's own channel group, and why a bug report isn't on GitHub yet, in words an admin can + // act on. + [Migration(12)] + public class qa_group_report_filing : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("DiscordGuildConfigs").AddColumn("QaCategoryId").AsString().Nullable(); + Alter.Table("BugReports").AddColumn("FilingProblem").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index f6d32d9..a295f43 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -70,6 +70,9 @@ public static void Map() Mapper.Entity<Requestarr.Core.Access.AccessRequest>("AccessRequests").RegisterModel(); Mapper.Entity<Requestarr.Core.Access.WelcomeGuard>("WelcomeGuards").RegisterModel(); Mapper.Entity<Outage>("Outages").RegisterModel(); + Mapper.Entity<Requestarr.Core.Qa.QaChecklistItem>("QaChecklistItems").RegisterModel(); + Mapper.Entity<Requestarr.Core.Qa.BugReport>("BugReports").RegisterModel(); + Mapper.Entity<Requestarr.Core.Qa.BugReportGuard>("BugReportGuards").RegisterModel(); } private static void RegisterMappers() diff --git a/src/NzbDrone.Core/Requestarr.Core.csproj b/src/NzbDrone.Core/Requestarr.Core.csproj index 582a6f4..9cf1a05 100644 --- a/src/NzbDrone.Core/Requestarr.Core.csproj +++ b/src/NzbDrone.Core/Requestarr.Core.csproj @@ -27,6 +27,8 @@ <ItemGroup> <!-- What's new on upgrade (Requestarr.Core.Announcements.Changelog). --> <EmbeddedResource Include="..\..\CHANGELOG.md" LogicalName="Requestarr.CHANGELOG.md" /> + <!-- What QA tests before staging (Requestarr.Core.Qa.QaChecklist). --> + <EmbeddedResource Include="..\..\QA-CHECKLIST.md" LogicalName="Requestarr.QA-CHECKLIST.md" /> <!-- Images that travel with Discord messages as attachments (Requestarr.Core.Discord.DiscordAssets). --> <EmbeddedResource Include="Requestarr\Discord\Assets\*.png" LogicalName="Requestarr.Assets.%(Filename)%(Extension)" /> </ItemGroup> diff --git a/src/NzbDrone.Core/Requestarr/Availability/AvailabilityService.cs b/src/NzbDrone.Core/Requestarr/Availability/AvailabilityService.cs index 37fb7fd..c44047a 100644 --- a/src/NzbDrone.Core/Requestarr/Availability/AvailabilityService.cs +++ b/src/NzbDrone.Core/Requestarr/Availability/AvailabilityService.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using NLog; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation; using NzbDrone.Common.Messaging; using NzbDrone.Core.Configuration; @@ -135,7 +136,7 @@ public async Task<WebhookOutcome> HandleWebhookAsync(int backendId, string token return WebhookOutcome.Ignored; } - _logger.Debug("Webhook {0} from {1}: checking {2} request(s)", eventType, instance.Name, tracked.Count); + _logger.Debug("Webhook {0} from {1}: checking {2} request(s)", eventType.ForLog(), instance.Name, tracked.Count); foreach (var request in tracked) { diff --git a/src/NzbDrone.Core/Requestarr/Discord/Assets/README.md b/src/NzbDrone.Core/Requestarr/Discord/Assets/README.md index fbc97c8..bab3f97 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/Assets/README.md +++ b/src/NzbDrone.Core/Requestarr/Discord/Assets/README.md @@ -15,6 +15,10 @@ It is never more prominent than Requestarr's own branding. Requestarr uses TMDB and the TMDB APIs but is not endorsed, certified, or otherwise approved by TMDB. The logo is TMDB's trademark and is used under TMDB's API terms of use; it is not covered by this repository's GPL-3.0 licence. +## requestarr-avatar.png and requestarr-banner-*.png + +The bot's own Discord picture (the plain R, the same in every environment) and banner (`teal` for production and anything that isn't a test environment, `dev`, `qa`, and `beta` for staging). Requestarr sets them on the bot when it starts, only when they differ, and never over a picture someone chose by hand (`BotProfileService`, setup guide 5.13). A new picture or banner reaches Discord with the release that contains it. + ## feed-card-width.png A fully transparent 800×1 image. Every card in the admin feed (`#requestarr-feed`) uses it as its embed image, because Discord sizes an embed to its content: with it, all cards are drawn the same width, whatever their text. It shows nothing. diff --git a/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-avatar.png b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-avatar.png new file mode 100644 index 0000000..e651ad1 Binary files /dev/null and b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-avatar.png differ diff --git a/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-beta.png b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-beta.png new file mode 100644 index 0000000..81d0488 Binary files /dev/null and b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-beta.png differ diff --git a/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-dev.png b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-dev.png new file mode 100644 index 0000000..8bd7899 Binary files /dev/null and b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-dev.png differ diff --git a/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-qa.png b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-qa.png new file mode 100644 index 0000000..d4ead5d Binary files /dev/null and b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-qa.png differ diff --git a/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-teal.png b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-teal.png new file mode 100644 index 0000000..e64e3e2 Binary files /dev/null and b/src/NzbDrone.Core/Requestarr/Discord/Assets/requestarr-banner-teal.png differ diff --git a/src/NzbDrone.Core/Requestarr/Discord/BotProfile.cs b/src/NzbDrone.Core/Requestarr/Discord/BotProfile.cs new file mode 100644 index 0000000..690ebdb --- /dev/null +++ b/src/NzbDrone.Core/Requestarr/Discord/BotProfile.cs @@ -0,0 +1,205 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Discord; +using Discord.Net; +using NLog; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Messaging.Events; +using Requestarr.Core.Announcements; +using Requestarr.Core.Discord.Gateway; +using Requestarr.Core.Provisioning; + +namespace Requestarr.Core.Discord +{ + // Whether Requestarr keeps the bot's own Discord picture and banner up to date. Kept in Requestarr's settings (the + // admin web portal reads and changes them there); provisioning sets the ones it names on every start. + public interface IBotProfileSettings + { + bool KeepUpToDate { get; } + + // Requestarr's picture and banner always win: one someone else chose is replaced too. + bool TakeOver { get; } + + void Apply(BotProfileProvisioning provisioning); + } + + public class BotProfileSettings : IBotProfileSettings + { + private readonly IConfigService _config; + + public BotProfileSettings(IConfigService config) + { + _config = config; + } + + public bool KeepUpToDate => _config.BotProfileKeepUpToDate; + + public bool TakeOver => _config.BotProfileTakeOver; + + public void Apply(BotProfileProvisioning provisioning) + { + if (provisioning?.KeepUpToDate is { } keep) + { + _config.BotProfileKeepUpToDate = keep; + } + + if (provisioning?.TakeOver is { } takeOver) + { + _config.BotProfileTakeOver = takeOver; + } + } + } + + public enum BotImageAction + { + // Already Requestarr's current picture. + UpToDate, + + // Set Requestarr's picture: the bot has none, Requestarr's own is out of date, or taking over. + Set, + + // Someone chose this picture (changed by hand, or there before Requestarr managed it): left as it is. + LeftAlone + } + + // What Requestarr set last for one image: Discord's id for the picture it then had, and the SHA-256 of the file it + // uploaded. Discord gives a picture a new id whenever it changes, so a different id now means someone changed it. + public record BotImageRecord(string DiscordId, string File) + { + public static BotImageRecord Parse(string stored) => + stored?.Split('|') is [var id, var file] && id.Length > 0 && file.Length > 0 ? new BotImageRecord(id, file) : null; + + public override string ToString() => $"{DiscordId}|{File}"; + } + + // Keeps the bot's picture (the plain R, the same everywhere) and banner (the environment's) current. Runs when the + // bot is ready, changes only what differs, and never touches a picture someone else chose. Discord limits how often + // a picture can change; Requestarr changes each at most once per release that brings a new one, and if Discord + // refuses anyway it logs that and tries again on a later start. Nothing here can stop the bot. + public class BotProfileService : IHandleAsync<DiscordReadyEvent> + { + public const string AvatarFile = "requestarr-avatar.png"; + + private readonly IDiscordClientHost _host; + private readonly IBotProfileSettings _settings; + private readonly IConfigService _config; + private readonly IReleaseEnvironment _environment; + private readonly Logger _logger; + + public BotProfileService(IDiscordClientHost host, IBotProfileSettings settings, IConfigService config, IReleaseEnvironment environment, Logger logger) + { + _host = host; + _settings = settings; + _config = config; + _environment = environment; + _logger = logger; + } + + // The environment's banner: dev, QA, beta for staging, and the teal one for production and anything else. + public static string BannerFile(string iconSet) => $"requestarr-banner-{(iconSet is "dev" or "qa" or "beta" ? iconSet : "teal")}.png"; + + public static string Digest(byte[] file) => Convert.ToHexString(SHA256.HashData(file)).ToLowerInvariant(); + + public static BotImageAction Decide(string current, BotImageRecord record, string file, bool takeOver) + { + if (current == null) + { + return BotImageAction.Set; + } + + if (record == null || record.DiscordId != current) + { + return takeOver ? BotImageAction.Set : BotImageAction.LeftAlone; + } + + return record.File == file ? BotImageAction.UpToDate : BotImageAction.Set; + } + + public void HandleAsync(DiscordReadyEvent message) + { + SyncAsync().GetAwaiter().GetResult(); + } + + public async Task SyncAsync() + { + if (!_settings.KeepUpToDate) + { + _logger.Info("Bot picture and banner: not kept up to date (botProfile.keepUpToDate is off)"); + return; + } + + try + { + var client = _host.Client; + + if (client?.CurrentUser == null) + { + return; + } + + var avatar = DiscordAssets.Bytes(AvatarFile); + var banner = DiscordAssets.Bytes(BannerFile(_environment.IconSet)); + var me = await client.Rest.GetUserAsync(client.CurrentUser.Id); + + var setAvatar = Decide(me.AvatarId, BotImageRecord.Parse(_config.BotProfileAvatarSet), Digest(avatar), _settings.TakeOver); + var setBanner = Decide(me.BannerId, BotImageRecord.Parse(_config.BotProfileBannerSet), Digest(banner), _settings.TakeOver); + + _logger.Info("Bot picture: {0}; banner ({1}): {2}", Describe(setAvatar), BannerFile(_environment.IconSet), Describe(setBanner)); + + if (setAvatar != BotImageAction.Set && setBanner != BotImageAction.Set) + { + return; + } + + using var avatarStream = new MemoryStream(avatar); + using var bannerStream = new MemoryStream(banner); + + await client.CurrentUser.ModifyAsync(p => + { + if (setAvatar == BotImageAction.Set) + { + p.Avatar = new Image(avatarStream); + } + + if (setBanner == BotImageAction.Set) + { + p.Banner = new Image(bannerStream); + } + }); + + // Remember Discord's ids for what was just set, so a later change by hand can be told apart. + var now = await client.Rest.GetUserAsync(client.CurrentUser.Id); + + if (setAvatar == BotImageAction.Set) + { + _config.BotProfileAvatarSet = new BotImageRecord(now.AvatarId, Digest(avatar)).ToString(); + } + + if (setBanner == BotImageAction.Set) + { + _config.BotProfileBannerSet = new BotImageRecord(now.BannerId, Digest(banner)).ToString(); + } + + _logger.Info("Bot picture and banner updated in Discord"); + } + catch (HttpException ex) + { + _logger.Warn("Discord refused to update the bot's picture or banner ({0}: {1}); Requestarr tries again on a later start", ex.HttpCode, string.Join(" ", new[] { ex.Reason }.Concat(ex.Errors?.SelectMany(e => e.Errors.Select(x => x.Message)) ?? Enumerable.Empty<string>()).Where(s => !string.IsNullOrWhiteSpace(s)))); + } + catch (Exception ex) + { + _logger.Warn(ex, "Couldn't update the bot's picture or banner; Requestarr tries again on a later start"); + } + } + + private static string Describe(BotImageAction action) => action switch + { + BotImageAction.UpToDate => "up to date", + BotImageAction.Set => "setting Requestarr's", + _ => "chosen by someone else, left as it is (botProfile.takeOver replaces it)" + }; + } +} diff --git a/src/NzbDrone.Core/Requestarr/Discord/ChannelLayout.cs b/src/NzbDrone.Core/Requestarr/Discord/ChannelLayout.cs index 1aae4a3..6b09060 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/ChannelLayout.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/ChannelLayout.cs @@ -25,6 +25,9 @@ public static class ChannelLayout public const string AdminCategoryName = "Requestarr Admin"; public const string UsersCategoryName = "Requestarr Users"; public const string OldRequestCategoryName = "Requestarr Requests"; + + // QA and dev only: the QA checklist's own admin-only group. + public const string QaCategoryName = "Requestarr QA"; public const string UserChannelPrefix = "requests-"; public const string UserRolePrefix = "requestarr-"; public const string ArchivedPrefix = "archived-"; diff --git a/src/NzbDrone.Core/Requestarr/Discord/DiscordGuildConfig.cs b/src/NzbDrone.Core/Requestarr/Discord/DiscordGuildConfig.cs index 0ded34e..4867d68 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/DiscordGuildConfig.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/DiscordGuildConfig.cs @@ -39,6 +39,16 @@ public class DiscordGuildConfig : ModelBase public string MembersRoleId { get; set; } public string AnnouncementsChannelId { get; set; } public string AnnouncedVersion { get; set; } + + // QA and dev only: the admin-only Requestarr QA group, its QA checklist channel and the checklist's summary + // card (the items keep their own). + public string QaCategoryId { get; set; } + public string QaChecklistChannelId { get; set; } + public string QaChecklistSummaryMessageId { get; set; } + + // The bug reports channel members and admins read, and its one message with Report a problem. + public string BugReportsChannelId { get; set; } + public string BugReportsMessageId { get; set; } } public interface IDiscordGuildConfigRepository : IBasicRepository<DiscordGuildConfig> diff --git a/src/NzbDrone.Core/Requestarr/Discord/Gateway/DiscordNetGateway.cs b/src/NzbDrone.Core/Requestarr/Discord/Gateway/DiscordNetGateway.cs index f255f75..14733f5 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/Gateway/DiscordNetGateway.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/Gateway/DiscordNetGateway.cs @@ -270,10 +270,19 @@ public async Task<IReadOnlyList<ChannelMessage>> GetOwnMessagesAsync(ulong chann return messages.Where(m => m.Author.Id == BotUserId && m.Type != MessageType.ChannelPinnedMessage) .OrderBy(m => m.Id) - .Select(m => new ChannelMessage(m.Id, string.IsNullOrEmpty(m.Content) ? CardText(m) : m.Content, m.Embeds.FirstOrDefault()?.Title, m.Embeds.FirstOrDefault()?.Description, CustomIds(m))) + .Select(Read) .ToList(); } + private static ChannelMessage Read(IMessage m) => new( + m.Id, + string.IsNullOrEmpty(m.Content) ? CardText(m) : m.Content, + m.Embeds.FirstOrDefault()?.Title, + m.Embeds.FirstOrDefault()?.Description, + CustomIds(m), + All(m.Components).OfType<ButtonComponent>().Select(b => b.Label).ToList(), + m.Flags.GetValueOrDefault().HasFlag(MessageFlags.ComponentsV2)); + // The custom IDs of a message's buttons and menus, inside cards too. private static List<string> CustomIds(IMessage message) => All(message.Components).OfType<IInteractableComponent>().Select(c => c.CustomId).Where(id => id != null).ToList(); diff --git a/src/NzbDrone.Core/Requestarr/Discord/Gateway/DiscordNetResponder.cs b/src/NzbDrone.Core/Requestarr/Discord/Gateway/DiscordNetResponder.cs index a0a85ac..c3255b4 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/Gateway/DiscordNetResponder.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/Gateway/DiscordNetResponder.cs @@ -85,6 +85,8 @@ public async Task DeletePrivateRepliesAsync() _privateReplies.Clear(); } + public Task DeleteOriginalAsync() => _interaction.DeleteOriginalResponseAsync(); + // The images the reply's embeds point to (attachment://...), sent with it (DiscordAssets). public static List<FileAttachment> Files(InteractionReply reply) => DiscordAssets.For(reply).Select(f => new FileAttachment(new System.IO.MemoryStream(f.Bytes), f.Name)).ToList(); diff --git a/src/NzbDrone.Core/Requestarr/Discord/Gateway/IDiscordGuildGateway.cs b/src/NzbDrone.Core/Requestarr/Discord/Gateway/IDiscordGuildGateway.cs index 9f9befd..31e8c8e 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/Gateway/IDiscordGuildGateway.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/Gateway/IDiscordGuildGateway.cs @@ -34,7 +34,8 @@ public record PermissionOverwrite(ulong TargetId, OverwriteTarget TargetType, Ch // A message as Requestarr reads it back: its text, its first embed's title and description (version cards), and // its buttons' and menus' custom IDs (which panel it is). - public record ChannelMessage(ulong Id, string Content, string EmbedTitle = null, string EmbedDescription = null, IReadOnlyList<string> CustomIds = null) + // Labels: its buttons' labels, in order. IsCards: it uses the newer layout (cards). + public record ChannelMessage(ulong Id, string Content, string EmbedTitle = null, string EmbedDescription = null, IReadOnlyList<string> CustomIds = null, IReadOnlyList<string> Labels = null, bool IsCards = false) { public bool Has(string route) => CustomIds != null && CustomIds.Any(id => id == route || id.StartsWith(route + ":")); } diff --git a/src/NzbDrone.Core/Requestarr/Discord/GuildSetupService.cs b/src/NzbDrone.Core/Requestarr/Discord/GuildSetupService.cs index 2c09125..53f9a62 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/GuildSetupService.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/GuildSetupService.cs @@ -34,10 +34,12 @@ public class GuildSetupService : IGuildSetupService private readonly IDiscordGuildConfigRepository _repository; private readonly Users.IRequestarrUserRepository _users; private readonly IAuditService _audit; + private readonly Announcements.IReleaseEnvironment _environment; private readonly Logger _logger; - public GuildSetupService(IDiscordGuildGateway gateway, IDiscordSettingsStore settings, IDiscordGuildConfigRepository repository, Users.IRequestarrUserRepository users, IAuditService audit, Logger logger) + public GuildSetupService(IDiscordGuildGateway gateway, IDiscordSettingsStore settings, IDiscordGuildConfigRepository repository, Users.IRequestarrUserRepository users, IAuditService audit, Announcements.IReleaseEnvironment environment, Logger logger) { + _environment = environment; _gateway = gateway; _settings = settings; _repository = repository; @@ -116,6 +118,41 @@ await ExistingOrNull(settings.AdminChannelId) ?? config.FeedSeeded = false; } + // QA and dev only: the QA checklist, admin-only, in a Requestarr QA group of its own, so testing stays apart + // from running the server. An existing checklist channel moves there with its cards; a new (or different) + // one is posted again. + if (Qa.QaChecklist.HasChecklist(_environment)) + { + var qaGroup = await ExistingOrNull(Parse(config.QaCategoryId)) ?? await _gateway.FindCategoryAsync(ChannelLayout.QaCategoryName); + + if (qaGroup == null) + { + qaGroup = await _gateway.CreateCategoryAsync(ChannelLayout.QaCategoryName, group); + _logger.Info("Created Discord group '{0}' ({1})", ChannelLayout.QaCategoryName, qaGroup); + _audit.Record(Actor.System, "discord.category.create", "channel", qaGroup.ToString(), ChannelLayout.QaCategoryName); + } + + await SyncOverwrites(qaGroup.Value, group); + config.QaCategoryId = qaGroup.ToString(); + + var checklist = await ExistingOrNull(Parse(config.QaChecklistChannelId)) ?? await _gateway.FindTextChannelAsync(Qa.QaText.ChecklistChannelName); + + if (checklist == null) + { + checklist = await _gateway.CreateTextChannelAsync(Qa.QaText.ChecklistChannelName, qaGroup, Qa.QaText.ChecklistChannelTopic, group); + _logger.Info("Created Discord QA checklist channel {0}", checklist); + _audit.Record(Actor.System, "discord.qa-checklist-channel.create", "channel", checklist.ToString()); + } + + await PlaceAsync(checklist.Value, qaGroup.Value, group); + + if (config.QaChecklistChannelId != checklist.ToString()) + { + config.QaChecklistChannelId = checklist.ToString(); + config.QaChecklistSummaryMessageId = null; + } + } + // One shared role for every member (it costs one of the server's 250 roles, not one each) reads the // announcements channel; only admins and the bot post there. Another hides the welcome channel from // someone blocked for spamming its buttons. @@ -137,6 +174,9 @@ await ExistingOrNull(settings.AdminChannelId) ?? config.AnnouncementsChannelId = (await EnsureUsersChannelAsync(usersGroup, config.AnnouncementsChannelId, ChannelLayout.AnnouncementsChannelName, DiscordText.AnnouncementsChannelTopic, announcements, "discord.announcements-channel.create")).ToString(); config.BlockedChannelId = (await EnsureUsersChannelAsync(usersGroup, config.BlockedChannelId, ChannelLayout.BlockedChannelName, DiscordText.BlockedChannelTopic, blocked, "discord.blocked-channel.create")).ToString(); + // Bug reports: members and admins read it and press Report a problem; nobody types in it. + config.BugReportsChannelId = (await EnsureUsersChannelAsync(usersGroup, config.BugReportsChannelId, Qa.QaText.BugReportsChannelName, Qa.QaText.BugReportsChannelTopic, announcements, "discord.bug-reports-channel.create")).ToString(); + config.RequestCategoryIds = GetConfig()?.RequestCategoryIds ?? config.RequestCategoryIds; _repository.Upsert(config); diff --git a/src/NzbDrone.Core/Requestarr/Discord/Handlers/QaHandler.cs b/src/NzbDrone.Core/Requestarr/Discord/Handlers/QaHandler.cs new file mode 100644 index 0000000..e5b9d84 --- /dev/null +++ b/src/NzbDrone.Core/Requestarr/Discord/Handlers/QaHandler.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Requestarr.Core.Discord.Interactions; +using Requestarr.Core.Qa; + +namespace Requestarr.Core.Discord.Handlers +{ + // The QA checklist's Passes and Failing buttons (admins only), and the bug report flow anyone in the server can use: + // the Report a problem button (or a failing checklist item), a menu of Requestarr's parts, then a short form. + public class QaHandler : IInteractionHandler + { + private readonly ICallerResolver _callers; + private readonly IQaChecklistService _checklist; + private readonly IBugReportService _reports; + + public QaHandler(ICallerResolver callers, IQaChecklistService checklist, IBugReportService reports) + { + _callers = callers; + _checklist = checklist; + _reports = reports; + } + + public IEnumerable<(InteractionKind Kind, string Route)> Routes => new[] + { + (InteractionKind.Button, QaRoutes.Check), + (InteractionKind.Button, QaRoutes.Report), + (InteractionKind.SelectMenu, QaRoutes.Part), + (InteractionKind.ModalSubmit, QaRoutes.Form) + }; + + public async Task HandleAsync(DiscordInteraction interaction, IInteractionResponder responder) + { + var caller = _callers.Resolve(interaction); + + if (!caller.RightServer) + { + await responder.Ephemeral(DiscordText.WrongServer); + return; + } + + var args = interaction.CustomIdArgs; + + switch (interaction.Route) + { + case QaRoutes.Check: + { + var key = args.ElementAtOrDefault(1); + + if (!caller.IsAdmin) + { + await responder.Ephemeral(QaText.AdminsOnly); + return; + } + + if (key == null || _checklist.Find(key) is not { } entry) + { + await responder.Ephemeral(QaText.ItemGone); + return; + } + + // An admin working through the checklist isn't held to the members' report limit. + if (args.ElementAtOrDefault(0) == "fail") + { + await OpenReportAsync(interaction, responder, entry, limited: false); + return; + } + + // The card itself shows the pass (who and when), so the press needs no reply of its own. The card + // can't be edited into a plain message anyway: Discord keeps a card a card. + await responder.DeferAsync(); + await _checklist.MarkPassedAsync(key, interaction.UserId, interaction.DisplayName); + return; + } + + case QaRoutes.Report: + await OpenReportAsync(interaction, responder, null, limited: true); + return; + + case QaRoutes.Part: + { + var component = interaction.Values.Count > 0 ? interaction.Values[0] : "other"; + var key = args.ElementAtOrDefault(0); + + await responder.ShowModalAsync(new InteractionModal + { + CustomId = $"{QaRoutes.Form}:{component}{(string.IsNullOrEmpty(key) ? string.Empty : ":" + key)}", + Title = QaText.FormTitle, + Fields = new List<InteractionTextField> + { + new(QaRoutes.WhatField, QaText.WhatLabel, Required: true, MaxLength: BugReportService.FieldLength), + new(QaRoutes.ExpectedField, QaText.ExpectedLabel, Required: false, MaxLength: BugReportService.FieldLength) + } + }); + return; + } + + case QaRoutes.Form: + { + interaction.Fields.TryGetValue(QaRoutes.WhatField, out var what); + interaction.Fields.TryGetValue(QaRoutes.ExpectedField, out var expected); + + var key = args.ElementAtOrDefault(1); + var fromChecklist = key != null && caller.IsAdmin; + + // The private menu becomes the answer, so no new private message lands above the report's card. + await responder.UpdateMessageAsync(new InteractionReply { Content = QaText.Sending, Ephemeral = true }); + var result = await _reports.SubmitAsync(interaction.UserId, interaction.DisplayName, args.ElementAtOrDefault(0), what, expected, key, limited: !fromChecklist); + + // From the bug reports channel the new card is the answer, and the private menu goes: it replies to + // the Report a problem panel, which moves below the card and would leave it pointing at nothing. + if (result.Report != null && key == null) + { + await responder.DeleteOriginalAsync(); + return; + } + + await responder.EditAsync(new InteractionReply { Content = result.Message, Ephemeral = true }); + return; + } + } + } + + // The part menu, privately, saying first whether the report becomes public; or how long to wait. + private async Task OpenReportAsync(DiscordInteraction interaction, IInteractionResponder responder, QaChecklistEntry item, bool limited) + { + if (limited && _reports.WaitLeft(interaction.UserId) is { } wait) + { + await responder.Ephemeral(QaText.Wait(wait)); + return; + } + + await responder.RespondAsync(new InteractionReply + { + Ephemeral = true, + Content = QaText.PartPrompt(_reports.FilesIssues, item?.Name), + SelectMenu = new InteractionSelectMenu + { + CustomId = $"{QaRoutes.Part}{(item != null ? ":" + item.Key : string.Empty)}", + Placeholder = QaText.PartPlaceholder, + Options = BugReportComponents.All.Select(c => new InteractionSelectOption(c.Name, c.Key)).ToList() + } + }); + } + } +} diff --git a/src/NzbDrone.Core/Requestarr/Discord/Interactions/IInteractionResponder.cs b/src/NzbDrone.Core/Requestarr/Discord/Interactions/IInteractionResponder.cs index 3a904b4..e72bd9e 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/Interactions/IInteractionResponder.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/Interactions/IInteractionResponder.cs @@ -38,5 +38,9 @@ public interface IInteractionResponder // Removes those private messages. Discord allows it for 15 minutes after the interaction. Task DeletePrivateRepliesAsync(); + + // Components and forms opened from one: removes the private message the component belongs to (a private menu + // this flow posted earlier), once it has served its purpose. + Task DeleteOriginalAsync(); } } diff --git a/src/NzbDrone.Core/Requestarr/Discord/PanelService.cs b/src/NzbDrone.Core/Requestarr/Discord/PanelService.cs index 176d1e1..ccb1020 100644 --- a/src/NzbDrone.Core/Requestarr/Discord/PanelService.cs +++ b/src/NzbDrone.Core/Requestarr/Discord/PanelService.cs @@ -94,6 +94,7 @@ public class PanelService : IPanelService, NzbDrone.Core.Messaging.Events.IHandl private readonly IMediaServerConnections _mediaServers; private readonly Backends.IBackendService _backends; private readonly Admin.IAdminQueue _queue; + private readonly Qa.IBugReportSettings _bugReports; private readonly Logger _logger; private readonly ConcurrentDictionary<ulong, CancellationTokenSource> _pendingMoves = new(); private readonly ConcurrentDictionary<ulong, SemaphoreSlim> _channelLocks = new(); @@ -108,8 +109,10 @@ public PanelService(IDiscordGuildGateway gateway, IMediaServerConnections mediaServers, Backends.IBackendService backends, Admin.IAdminQueue queue, + Qa.IBugReportSettings bugReports, Logger logger) { + _bugReports = bugReports; _gateway = gateway; _guildSetup = guildSetup; _configs = configs; @@ -128,7 +131,7 @@ public async Task EnsureAllAsync() var channels = new List<ulong>(); var config = _guildSetup.GetConfig(); - foreach (var id in new[] { config?.AdminChannelId, config?.WelcomeChannelId, config?.BlockedChannelId } + foreach (var id in new[] { config?.AdminChannelId, config?.WelcomeChannelId, config?.BlockedChannelId, config?.BugReportsChannelId } .Concat(_users.All().Where(u => u.Status != UserStatus.Removed).Select(u => u.PrivateChannelId))) { if (ulong.TryParse(id, out var channel)) @@ -209,6 +212,20 @@ public InteractionReply WelcomePanel() return new InteractionReply { Cards = { new InteractionCard { Color = DiscordText.WelcomeColor, Text = DiscordText.WelcomeMessage(types, media), Buttons = buttons } } }; } + // Report a problem, always the newest message, below the reports; it says reports are public when they're filed. + public InteractionReply BugReportsPanel() => new() + { + Cards = + { + new InteractionCard + { + Color = Qa.QaText.ReportColor, + Text = Qa.QaText.BugReportsPanel(_bugReports.FileIssues), + Buttons = new List<InteractionButton> { new() { CustomId = Qa.QaRoutes.Report, Label = Qa.QaText.ReportLabel, Style = ButtonStyle.Primary } } + } + } + }; + public static InteractionReply BlockedPanel() => new() { Cards = { new InteractionCard { Color = DiscordText.BlockedColor, Text = DiscordText.BlockedMessage } } }; // Pending queue shows how many things wait on the admin ("Pending queue (3)"). @@ -284,13 +301,15 @@ public void NoticeBotMessage(ulong channelId, ulong messageId) }); } - public Task KeepLastAsync(ulong channelId) => EnsurePanelAsync(channelId, moveLast: true, unpin: false); + // Only a move: a panel already last is left as it is. The bot's own new panel also arrives as a "bot posted + // something" notice, and editing it then would mark it "edited" for nothing. + public Task KeepLastAsync(ulong channelId) => EnsurePanelAsync(channelId, moveLast: true, unpin: false, moveOnly: true); // The one place a panel is posted, edited, moved or removed, one channel at a time. The panel is found by its // saved ID, or else by what it is among the bot's newest messages (after a restart with a new database, or // a reset); it's edited in place, or posted if there's none; with moveLast it's posted again at the bottom if // something else is newer (quietly); and any other copy of it in the channel is removed, so there's exactly one. - private async Task EnsurePanelAsync(ulong channelId, bool moveLast, bool unpin) + private async Task EnsurePanelAsync(ulong channelId, bool moveLast, bool unpin, bool moveOnly = false) { var gate = Gate(channelId); await gate.WaitAsync(); @@ -308,7 +327,14 @@ private async Task EnsurePanelAsync(ulong channelId, bool moveLast, bool unpin) var keep = ulong.TryParse(panel.Id, out var stored) && own.Any(m => m.Id == stored) ? stored : copies.Count > 0 ? copies[^1] : (ulong?)null; var reply = panel.Build(); - if (keep.HasValue && !await _gateway.EditMessageAsync(channelId, keep.Value, reply)) + if (keep.HasValue && moveLast && await _gateway.GetLatestMessageIdAsync(channelId) != keep) + { + // The new one first, so the channel is never without its panel; it doesn't ping or notify anyone. + // The old one is removed without an edit first, which would only mark it "edited" on the way out. + copies.Add(keep.Value); + keep = await _gateway.SendMessageAsync(channelId, new InteractionReply { Content = reply.Content, Embeds = reply.Embeds, Buttons = reply.Buttons, SelectMenu = reply.SelectMenu, Cards = reply.Cards, Quiet = true }); + } + else if (keep.HasValue && !moveOnly && !Shows(own.FirstOrDefault(m => m.Id == keep.Value), reply) && !await _gateway.EditMessageAsync(channelId, keep.Value, reply)) { // Gone, or an older message that can't take the new layout: a new one replaces it. copies.Add(keep.Value); @@ -319,13 +345,6 @@ private async Task EnsurePanelAsync(ulong channelId, bool moveLast, bool unpin) { keep = await _gateway.SendMessageAsync(channelId, reply); } - else if (moveLast && await _gateway.GetLatestMessageIdAsync(channelId) != keep) - { - // The new one first, so the channel is never without its panel; it doesn't ping or notify anyone. - var old = keep.Value; - keep = await _gateway.SendMessageAsync(channelId, new InteractionReply { Content = reply.Content, Embeds = reply.Embeds, Buttons = reply.Buttons, SelectMenu = reply.SelectMenu, Cards = reply.Cards, Quiet = true }); - copies.Add(old); - } if (panel.Id != keep.ToString()) { @@ -348,6 +367,23 @@ private async Task EnsurePanelAsync(ulong channelId, bool moveLast, bool unpin) } } + // Whether a panel already shows exactly this (its text, layout, buttons and their labels), so it's left as it is: + // an edit would mark it "edited" (on every start) for nothing. Anything else (a menu, a disabled button, an + // embed) is edited, as before. + public static bool Shows(ChannelMessage message, InteractionReply reply) + { + if (message?.Labels == null || reply.SelectMenu != null || reply.MoreMenus?.Count > 0 || reply.Embeds?.Count > 0 || message.EmbedTitle != null) + { + return false; + } + + var buttons = reply.IsCards ? reply.Cards.SelectMany(c => c.Buttons).ToList() : reply.Buttons ?? new List<InteractionButton>(); + var text = reply.IsCards ? string.Join("\n", reply.Cards.Select(c => c.Text)) : reply.Content; + + return message.IsCards == reply.IsCards && message.Content == text && !buttons.Any(b => b.Disabled) && + (message.CustomIds ?? new List<string>()).SequenceEqual(buttons.Select(b => b.CustomId)) && message.Labels.SequenceEqual(buttons.Select(b => b.Label)); + } + private async Task TryEnsureAsync(ulong channelId) { try @@ -391,6 +427,11 @@ private Panel PanelIn(ulong channelId) return new Panel(config.BlockedMessageId, BlockedPanel, m => m.Content?.Contains(DiscordText.BlockedTitle) == true, saved => Save(c => c.BlockedMessageId = saved, c => c.BlockedMessageId)); } + if (config.BugReportsChannelId == id) + { + return new Panel(config.BugReportsMessageId, BugReportsPanel, m => m.Has(Qa.QaRoutes.Report), saved => Save(c => c.BugReportsMessageId = saved, c => c.BugReportsMessageId)); + } + var user = _users.FindByChannel(id); return user == null || user.Status == UserStatus.Removed diff --git a/src/NzbDrone.Core/Requestarr/Provisioning/ProvisioningDocument.cs b/src/NzbDrone.Core/Requestarr/Provisioning/ProvisioningDocument.cs index f370757..4b47fbe 100644 --- a/src/NzbDrone.Core/Requestarr/Provisioning/ProvisioningDocument.cs +++ b/src/NzbDrone.Core/Requestarr/Provisioning/ProvisioningDocument.cs @@ -26,6 +26,38 @@ public class ProvisioningDocument // Optional: access requests from the welcome channel. Stored as settings the admin portal can change. public AccessRequestsProvisioning AccessRequests { get; set; } + + // Optional: the bot's own Discord picture and banner. Stored as settings the admin portal can change. + public BotProfileProvisioning BotProfile { get; set; } + + // Optional: bug reports from the Report a problem button. Stored as settings the admin portal can change; the + // GitHub access key is kept in memory only. + public BugReportsProvisioning BugReports { get; set; } + } + + public class BugReportsProvisioning + { + // File each report as a public GitHub issue (default false; reports are only posted and kept then). + public bool? FileIssues { get; set; } + + // A GitHub access key that can only read and write issues on the repository; use a ${VAR} reference. + public string GithubToken { get; set; } + + // owner/name (default RunemarkLabs/Requestarr). + public string Repository { get; set; } + + // At most this many reports per person in a day (default 5), and this many minutes between two (default 2). + public int? ReportsPerDay { get; set; } + public int? MinutesBetween { get; set; } + } + + public class BotProfileProvisioning + { + // Requestarr sets the bot's picture and banner and updates them when a release changes them (default true). + public bool? KeepUpToDate { get; set; } + + // Replace a picture or banner someone else chose too, instead of leaving it (default false). + public bool? TakeOver { get; set; } } public class AccessRequestsProvisioning diff --git a/src/NzbDrone.Core/Requestarr/Provisioning/ProvisioningService.cs b/src/NzbDrone.Core/Requestarr/Provisioning/ProvisioningService.cs index 15c7b66..01927a1 100644 --- a/src/NzbDrone.Core/Requestarr/Provisioning/ProvisioningService.cs +++ b/src/NzbDrone.Core/Requestarr/Provisioning/ProvisioningService.cs @@ -32,6 +32,8 @@ public class ProvisioningService : IProvisioningService, IHandle<ApplicationStar private readonly Requestarr.Core.Availability.IAvailabilitySettingsStore _availability; private readonly Requestarr.Core.Removals.IRemovalSettings _removal; private readonly Requestarr.Core.Access.IAccessSettings _access; + private readonly Requestarr.Core.Discord.IBotProfileSettings _botProfile; + private readonly Requestarr.Core.Qa.IBugReportSettings _bugReports; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; @@ -44,6 +46,8 @@ public ProvisioningService(IProvisioningLoader loader, Requestarr.Core.Availability.IAvailabilitySettingsStore availability, Requestarr.Core.Removals.IRemovalSettings removal, Requestarr.Core.Access.IAccessSettings access, + Requestarr.Core.Discord.IBotProfileSettings botProfile, + Requestarr.Core.Qa.IBugReportSettings bugReports, IEventAggregator eventAggregator, Logger logger) { @@ -56,6 +60,8 @@ public ProvisioningService(IProvisioningLoader loader, _availability = availability; _removal = removal; _access = access; + _botProfile = botProfile; + _bugReports = bugReports; _eventAggregator = eventAggregator; _logger = logger; } @@ -200,6 +206,16 @@ private ProvisioningResult Apply(string path, ProvisioningDocument document) applied.Add($"access requests (ask again {_access.DenialCooldownDays} days after a denial; spam guard: {_access.ShortCooldownClicks} clicks in {_access.SpamWindowMinutes} min wait {_access.ShortCooldownMinutes} min, " + $"{_access.LongCooldownClicks} wait {_access.LongCooldownHours} h, {_access.BlockClicks} more within {_access.BlockWithinHours} h block)"); + _botProfile.Apply(document.BotProfile); + applied.Add(_botProfile.KeepUpToDate + ? $"bot picture and banner kept up to date{(_botProfile.TakeOver ? " (replacing ones chosen by hand)" : string.Empty)}" + : "bot picture and banner left as they are"); + + _bugReports.Apply(document.BugReports); + applied.Add(!_bugReports.FileIssues + ? $"bug reports (posted and kept; not filed on GitHub; {_bugReports.ReportsPerDay} a day, {_bugReports.MinutesBetween} min apart per person)" + : $"bug reports (filed on GitHub in {_bugReports.Repository}{(_bugReports.Token == null ? ", but there's no githubToken, so none are filed" : string.Empty)}; {_bugReports.ReportsPerDay} a day, {_bugReports.MinutesBetween} min apart per person)"); + _backends.SyncFromProvisioning(document.Backends); if (document.Backends != null) diff --git a/src/NzbDrone.Core/Requestarr/Qa/BugReport.cs b/src/NzbDrone.Core/Requestarr/Qa/BugReport.cs new file mode 100644 index 0000000..f27ef4e --- /dev/null +++ b/src/NzbDrone.Core/Requestarr/Qa/BugReport.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using Requestarr.Core.Announcements; +using Requestarr.Core.Provisioning; + +namespace Requestarr.Core.Qa +{ + // The parts of Requestarr a report can be about. One list, so it grows with the app: the form's choices, the issue's + // "component:" label and the card all come from here. + public static class BugReportComponents + { + public static readonly IReadOnlyList<(string Key, string Name)> All = new List<(string, string)> + { + ("requests", "Requests"), + ("approvals", "Approvals"), + ("availability", "Availability and notifications"), + ("removals", "Removal votes"), + ("access", "Access and the welcome channel"), + ("admin", "Admin panel and commands"), + ("web-app", "Web app"), + ("other", "Other") + }; + + public static string NameOf(string key) => All.FirstOrDefault(c => c.Key == key).Name ?? "Other"; + + public static bool IsKnown(string key) => All.Any(c => c.Key == key); + } + + // Anything but Filed is a report not on GitHub yet; Requestarr tries again each time it starts. + public enum BugReportFiling + { + // Filing is off on this install (the default for other people's installs; always on for the test servers). + Off = 0, + Filed = 1, + + // GitHub refused it or couldn't be reached: FilingProblem says which. + Failed = 2, + + // On, but this install has no access key for GitHub. + NoToken = 3, + + // Posted, and being filed now. + Pending = 4 + } + + // One report: what the reporter typed, what Requestarr added (environment, version, time, their Discord name), and + // the GitHub issue it became. Nothing else from the server goes anywhere. + public class BugReport : ModelBase + { + public string Component { get; set; } + public string WhatHappened { get; set; } + public string Expected { get; set; } + public string Environment { get; set; } + public string Version { get; set; } + public string ReporterDiscordId { get; set; } + public string ReporterName { get; set; } + public DateTime Created { get; set; } + public string ChecklistItemKey { get; set; } + public BugReportFiling Filing { get; set; } + + // Why the last try to file it failed, in words an admin can act on. + public string FilingProblem { get; set; } + public int? IssueNumber { get; set; } + public string IssueUrl { get; set; } + public string ChannelId { get; set; } + public string MessageId { get; set; } + } + + public interface IBugReportRepository : IBasicRepository<BugReport> + { + } + + public class BugReportRepository : BasicRepository<BugReport>, IBugReportRepository + { + public BugReportRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } + + // One row per person: when they last sent reports, so the button can't flood the issue tracker. + public class BugReportGuard : ModelBase + { + public string DiscordUserId { get; set; } + public string RecentReports { get; set; } + + public List<DateTimeOffset> Reports() => (RecentReports ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(t => long.TryParse(t, out var ticks) ? new DateTimeOffset(ticks, TimeSpan.Zero) : (DateTimeOffset?)null) + .Where(t => t != null) + .Select(t => t.Value) + .ToList(); + + public void SetReports(IEnumerable<DateTimeOffset> reports) => RecentReports = string.Join(",", reports.Select(r => r.UtcTicks)); + } + + public interface IBugReportGuardRepository : IBasicRepository<BugReportGuard> + { + BugReportGuard Find(string discordUserId); + } + + public class BugReportGuardRepository : BasicRepository<BugReportGuard>, IBugReportGuardRepository + { + public BugReportGuardRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public BugReportGuard Find(string discordUserId) => Query(g => g.DiscordUserId == discordUserId).SingleOrDefault(); + } + + // The report limit, modelled on the welcome buttons' spam guard: a short wait between reports, and a ceiling per day. + public static class BugReportLimit + { + // How long the person waits before they can send another report; null when they can send one now. + public static TimeSpan? WaitLeft(IReadOnlyList<DateTimeOffset> reports, DateTimeOffset now, IBugReportSettings settings) + { + var recent = reports.Where(r => r > now.AddDays(-1)).OrderBy(r => r).ToList(); + + if (recent.Count == 0) + { + return null; + } + + var gap = recent[^1].AddMinutes(settings.MinutesBetween) - now; + var day = recent.Count >= settings.ReportsPerDay ? recent[^settings.ReportsPerDay].AddDays(1) - now : TimeSpan.Zero; + var wait = gap > day ? gap : day; + + return wait > TimeSpan.Zero ? wait : null; + } + } + + // Bug report settings: kept in Requestarr's settings (the admin web portal reads and changes them there), set by + // provisioning when it names them. The GitHub access key is in memory only, never in the database or config: from + // provisioning, or else from REQUESTARR_GITHUB_ISSUES_TOKEN. + public interface IBugReportSettings + { + // File each report as a GitHub issue. Off unless the admin turns it on (reports become public). Always on for + // the dev, QA and beta servers: every report from them reaches GitHub, where the gate before staging sees it. + bool FileIssues { get; } + string Repository { get; } + string Token { get; } + int ReportsPerDay { get; } + int MinutesBetween { get; } + + void Apply(BugReportsProvisioning provisioning); + } + + public class BugReportSettings : IBugReportSettings + { + public const string DefaultRepository = "RunemarkLabs/Requestarr"; + public const int DefaultReportsPerDay = 5; + public const int DefaultMinutesBetween = 2; + public const string TokenVariable = "REQUESTARR_GITHUB_ISSUES_TOKEN"; + + private readonly IConfigService _config; + private readonly IReleaseEnvironment _environment; + private string _token; + + public BugReportSettings(IConfigService config, IReleaseEnvironment environment) + { + _config = config; + _environment = environment; + } + + public Func<string, string> Variable { get; set; } = System.Environment.GetEnvironmentVariable; + + public bool FileIssues => _environment.IconSet != "production" || _config.BugReportsFileIssues; + + public string Repository => string.IsNullOrWhiteSpace(_config.BugReportsRepository) ? DefaultRepository : _config.BugReportsRepository; + + public string Token => _token ?? (string.IsNullOrWhiteSpace(Variable(TokenVariable)) ? null : Variable(TokenVariable).Trim()); + + public int ReportsPerDay => Math.Max(1, _config.BugReportsPerDay); + + public int MinutesBetween => Math.Max(0, _config.BugReportsMinutesBetween); + + public void Apply(BugReportsProvisioning provisioning) + { + _token = string.IsNullOrWhiteSpace(provisioning?.GithubToken) ? null : provisioning.GithubToken.Trim(); + + if (provisioning?.FileIssues is { } file) + { + _config.BugReportsFileIssues = file; + } + + if (!string.IsNullOrWhiteSpace(provisioning?.Repository)) + { + _config.BugReportsRepository = provisioning.Repository.Trim(); + } + + if (provisioning?.ReportsPerDay is { } perDay) + { + _config.BugReportsPerDay = Math.Max(1, perDay); + } + + if (provisioning?.MinutesBetween is { } minutes) + { + _config.BugReportsMinutesBetween = Math.Max(0, minutes); + } + } + } +} diff --git a/src/NzbDrone.Core/Requestarr/Qa/BugReportService.cs b/src/NzbDrone.Core/Requestarr/Qa/BugReportService.cs new file mode 100644 index 0000000..f7b88bc --- /dev/null +++ b/src/NzbDrone.Core/Requestarr/Qa/BugReportService.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Messaging.Events; +using Requestarr.Core.Announcements; +using Requestarr.Core.Audit; +using Requestarr.Core.Discord; +using Requestarr.Core.Discord.Gateway; +using Requestarr.Core.Discord.Interactions; + +namespace Requestarr.Core.Qa +{ + public record BugReportResult(BugReport Report, string Message); + + public interface IBugReportService + { + // How long the person waits before they may send a report; null when they can send one now. + TimeSpan? WaitLeft(ulong discordUserId); + + bool FilesIssues { get; } + + // limited: held to the report limit (members' Report a problem); an admin failing checklist items isn't. + Task<BugReportResult> SubmitAsync(ulong discordUserId, string reporterName, string component, string whatHappened, string expected, string checklistItemKey, bool limited = true); + + // Every report not on GitHub yet is filed now if it can be, without its reporter sending it again; each card + // (and its checklist item) then says where it is, or exactly why it isn't. + Task RetryUnfiledAsync(); + } + + // Bug reports from the Report a problem button: kept in Requestarr's record, posted as a card in the bug reports + // channel, and filed as a GitHub issue when this install files them (its own access key; off by default, always on + // for the test servers). A report holds only what the reporter typed plus the environment, version, time, part and + // their Discord name. One that couldn't be filed is tried again each time the bot connects. + public class BugReportService : IBugReportService, IHandleAsync<DiscordReadyEvent> + { + public const int FieldLength = 1000; + + private static readonly ConcurrentDictionary<ulong, SemaphoreSlim> Locks = new(); + + // One filing at a time, so a report is never filed twice (a retry while it's being sent, say). + private static readonly SemaphoreSlim Filing = new(1, 1); + + private readonly IBugReportRepository _reports; + private readonly IBugReportGuardRepository _guards; + private readonly IBugReportSettings _settings; + private readonly IGitHubIssues _github; + private readonly IQaChecklistService _checklist; + private readonly IDiscordGuildGateway _gateway; + private readonly IGuildSetupService _guildSetup; + private readonly IReleaseEnvironment _environment; + private readonly IAuditService _audit; + private readonly Logger _logger; + + public BugReportService(IBugReportRepository reports, IBugReportGuardRepository guards, IBugReportSettings settings, IGitHubIssues github, IQaChecklistService checklist, IDiscordGuildGateway gateway, IGuildSetupService guildSetup, IReleaseEnvironment environment, IAuditService audit, Logger logger) + { + _reports = reports; + _guards = guards; + _settings = settings; + _github = github; + _checklist = checklist; + _gateway = gateway; + _guildSetup = guildSetup; + _environment = environment; + _audit = audit; + _logger = logger; + } + + public Func<DateTimeOffset> Now { get; set; } = () => DateTimeOffset.UtcNow; + + public bool FilesIssues => _settings.FileIssues; + + public TimeSpan? WaitLeft(ulong discordUserId) => + BugReportLimit.WaitLeft(_guards.Find(discordUserId.ToString())?.Reports() ?? new(), Now(), _settings); + + public void HandleAsync(DiscordReadyEvent message) + { + try + { + RetryUnfiledAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.Warn(ex, "Could not file the bug reports that aren't on GitHub yet"); + } + } + + public async Task<BugReportResult> SubmitAsync(ulong discordUserId, string reporterName, string component, string whatHappened, string expected, string checklistItemKey, bool limited = true) + { + var gate = Locks.GetOrAdd(discordUserId, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(); + + try + { + if (limited && WaitLeft(discordUserId) is { } wait) + { + return new BugReportResult(null, QaText.Wait(wait)); + } + + if (!ulong.TryParse(_guildSetup.GetConfig()?.BugReportsChannelId, out var channel)) + { + return new BugReportResult(null, QaText.NoChannel); + } + + var now = Now(); + + if (limited) + { + var guard = _guards.Find(discordUserId.ToString()) ?? new BugReportGuard { DiscordUserId = discordUserId.ToString() }; + guard.SetReports(guard.Reports().Where(r => r > now.AddDays(-1)).Append(now)); + _guards.Upsert(guard); + } + + var item = checklistItemKey != null ? _checklist.Find(checklistItemKey) : null; + var report = _reports.Insert(new BugReport + { + Component = BugReportComponents.IsKnown(component) ? component : "other", + WhatHappened = Trim(whatHappened), + Expected = Trim(expected), + Environment = _environment.IconSet, + Version = Changelog.Release(BuildInfo.Version).ToString(), + ReporterDiscordId = discordUserId.ToString(), + ReporterName = reporterName, + Created = now.UtcDateTime, + ChecklistItemKey = item?.Key, + Filing = CanFile() ?? BugReportFiling.Pending, + ChannelId = channel.ToString() + }); + + _audit.Record(Actor.Discord(discordUserId, reporterName), "bugreport.send", "bug-report", report.Id.ToString(), report.Component); + + // Posted first, so the report is never lost to GitHub being slow or down. + report.MessageId = (await _gateway.SendMessageAsync(channel, Card(report))).ToString(); + _reports.SetFields(report, r => r.MessageId); + + if (item != null) + { + await _checklist.MarkFailingAsync(item.Key, discordUserId, reporterName, report); + } + + if (report.Filing == BugReportFiling.Pending) + { + report = await FileAsync(report.Id) ?? report; + } + + _logger.Info("Bug report {0} ({1}) from {2}: {3}", report.Id, report.Component, reporterName, QaText.Filing(report)); + + return new BugReportResult(report, QaText.Thanks(report, channel)); + } + finally + { + gate.Release(); + } + } + + public async Task RetryUnfiledAsync() + { + foreach (var id in _reports.All().Where(r => r.Filing != BugReportFiling.Filed).OrderBy(r => r.Id).Select(r => r.Id).ToList()) + { + var report = await FileAsync(id); + + if (report?.Filing == BugReportFiling.Filed) + { + _logger.Info("Bug report {0} is on GitHub now: {1}", report.Id, report.IssueUrl); + } + } + } + + // Why a report can't be filed now (filing off, or no access key), or null when it can. + private BugReportFiling? CanFile() => !_settings.FileIssues ? BugReportFiling.Off : _settings.Token == null ? BugReportFiling.NoToken : null; + + // Files one report if it isn't on GitHub yet, or records why it can't be; its card (and its checklist item) + // follow when that changed. Returns the report as it is now. + private async Task<BugReport> FileAsync(int id) + { + await Filing.WaitAsync(); + + try + { + var report = _reports.Find(id); + + if (report == null || report.Filing == BugReportFiling.Filed) + { + return report; + } + + var before = (report.Filing, report.FilingProblem); + + if (CanFile() is { } cannot) + { + report.Filing = cannot; + report.FilingProblem = null; + } + else + { + var item = report.ChecklistItemKey != null ? _checklist.Find(report.ChecklistItemKey) : null; + var result = await _github.CreateAsync(_settings.Repository, _settings.Token, QaText.IssueTitle(report), QaText.IssueBody(report, item), QaText.IssueLabels(report), CancellationToken.None); + + if (result?.Issue != null) + { + report.Filing = BugReportFiling.Filed; + report.FilingProblem = null; + report.IssueNumber = result.Issue.Number; + report.IssueUrl = result.Issue.Url; + _audit.Record(Actor.System, "bugreport.file", "bug-report", report.Id.ToString(), result.Issue.Url); + } + else + { + report.Filing = BugReportFiling.Failed; + report.FilingProblem = result?.Problem ?? "GitHub gave no answer"; + } + } + + if ((report.Filing, report.FilingProblem) == before) + { + return report; + } + + _reports.SetFields(report, r => r.Filing, r => r.FilingProblem, r => r.IssueNumber, r => r.IssueUrl); + + if (ulong.TryParse(report.ChannelId, out var channel) && ulong.TryParse(report.MessageId, out var message)) + { + await _gateway.EditMessageAsync(channel, message, Card(report)); + } + + if (report.ChecklistItemKey != null) + { + await _checklist.RefreshItemAsync(report.ChecklistItemKey); + } + + return report; + } + finally + { + Filing.Release(); + } + } + + private static InteractionReply Card(BugReport report) => new() + { + Quiet = true, + Cards = { new InteractionCard { Color = QaText.ReportColor, Text = QaText.ReportCard(report) } } + }; + + private static string Trim(string text) + { + text = (text ?? string.Empty).Trim(); + return text.Length > FieldLength ? text.Substring(0, FieldLength) : text; + } + } +} diff --git a/src/NzbDrone.Core/Requestarr/Qa/GitHubIssues.cs b/src/NzbDrone.Core/Requestarr/Qa/GitHubIssues.cs new file mode 100644 index 0000000..f520597 --- /dev/null +++ b/src/NzbDrone.Core/Requestarr/Qa/GitHubIssues.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NLog; + +namespace Requestarr.Core.Qa +{ + public record FiledIssue(int Number, string Url); + + // The new issue, or why there's none, in words an admin can act on (never the key). + public record IssueResult(FiledIssue Issue, string Problem); + + public interface IGitHubIssues + { + // The new issue, or why GitHub refused it or couldn't be reached (logged without the key). + Task<IssueResult> CreateAsync(string repository, string token, string title, string body, IReadOnlyList<string> labels, CancellationToken cancellationToken); + } + + // Files issues on GitHub with the install's own access key. Uses its own HttpClient so requests, which carry the key, + // never reach the logs; only the HTTP status or the exception type is logged. + public class GitHubIssues : IGitHubIssues + { + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) }; + private static readonly Uri BaseUri = new("https://api.github.com/"); + + private readonly Logger _logger; + + public GitHubIssues(Logger logger) + { + _logger = logger; + } + + // Swapped in tests; the live transport is the shared HttpClient. + public Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> Transport { get; set; } = (request, token) => Http.SendAsync(request, token); + + public async Task<IssueResult> CreateAsync(string repository, string token, string title, string body, IReadOnlyList<string> labels, CancellationToken cancellationToken) + { + try + { + // A label GitHub doesn't know yet is created first, so issues can be filtered by environment, version + // and component from the start. "Already exists" (422) is fine. + foreach (var label in labels) + { + using var create = Build(HttpMethod.Post, $"repos/{repository}/labels", token, new { name = label, color = LabelColor(label) }); + using var created = await Transport(create, cancellationToken); + + if (!created.IsSuccessStatusCode && created.StatusCode != HttpStatusCode.UnprocessableEntity) + { + _logger.Warn("GitHub refused to create the issue label '{0}' (HTTP {1})", label, (int)created.StatusCode); + } + } + + using var request = Build(HttpMethod.Post, $"repos/{repository}/issues", token, new { title, body, labels }); + using var response = await Transport(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.Warn("GitHub refused the bug report issue (HTTP {0})", (int)response.StatusCode); + return new IssueResult(null, Refused(response.StatusCode, repository)); + } + + var json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + + return new IssueResult(new FiledIssue((int)json["number"], (string)json["html_url"]), null); + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + _logger.Warn("Filing the bug report on GitHub failed ({0})", ex.GetType().Name); + return new IssueResult(null, $"GitHub couldn't be reached ({ex.GetType().Name})"); + } + } + + // What GitHub's answer means for whoever fixes it. + public static string Refused(HttpStatusCode status, string repository) => (int)status switch + { + 401 => "GitHub refused the access key (HTTP 401): it's wrong, revoked or expired", + 403 => $"the access key isn't allowed to create issues on {repository} (HTTP 403)", + 404 => $"GitHub can't find {repository}, or the access key can't see it (HTTP 404)", + 410 => $"issues are turned off on {repository} (HTTP 410)", + 422 => "GitHub rejected the issue's contents (HTTP 422)", + _ => $"GitHub answered HTTP {(int)status}" + }; + + public static HttpRequestMessage Build(HttpMethod method, string path, string token, object payload) + { + var request = new HttpRequestMessage(method, new Uri(BaseUri, path)) + { + Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json") + }; + + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("Requestarr", "1")); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + + return request; + } + + private static string LabelColor(string label) => label.Split(':')[0] switch + { + "env" => "5319e7", + "version" => "0e8a16", + "component" => "1d76db", + _ => "d73a4a" + }; + } +} diff --git a/src/NzbDrone.Core/Requestarr/Qa/QaChecklist.cs b/src/NzbDrone.Core/Requestarr/Qa/QaChecklist.cs new file mode 100644 index 0000000..9d5381d --- /dev/null +++ b/src/NzbDrone.Core/Requestarr/Qa/QaChecklist.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using Requestarr.Core.Announcements; + +namespace Requestarr.Core.Qa +{ + // One thing to test in a version: "- **Name:** what to do. You should see what happens." Key: "0.4.15/name-in-words", + // stable across releases, so a pass is kept when the checklist is posted again. + public record QaChecklistEntry(Version Version, string Key, string Name, string Text); + + public record QaChecklistVersion(Version Version, IReadOnlyList<QaChecklistEntry> Items); + + // QA-CHECKLIST.md at the repo root is what admins on the QA server test before a version goes to staging; every pull + // request adds its version's items (CI's checklist check enforces the format). It is embedded in the build. + public static class QaChecklist + { + public const string ResourceName = "Requestarr.QA-CHECKLIST.md"; + + private static readonly Regex Heading = new(@"^## (?<version>\d+\.\d+\.\d+)(?: \(\d{4}-\d{2}-\d{2}\))?\s*$"); + private static readonly Regex Item = new(@"^- \*\*(?<name>[^*:]+):\*\* (?<text>.+)$"); + + public static string Embedded() + { + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ResourceName); + + if (stream == null) + { + return string.Empty; + } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + public static IReadOnlyList<QaChecklistVersion> Parse(string text) + { + var versions = new List<QaChecklistVersion>(); + Version current = null; + List<QaChecklistEntry> items = null; + + foreach (var raw in (text ?? string.Empty).Split('\n')) + { + var line = raw.Trim(); + var heading = Heading.Match(line); + + if (heading.Success) + { + current = Version.Parse(heading.Groups["version"].Value); + items = new List<QaChecklistEntry>(); + versions.Add(new QaChecklistVersion(current, items)); + continue; + } + + var item = Item.Match(line); + + if (current != null && item.Success) + { + var name = item.Groups["name"].Value.Trim(); + items.Add(new QaChecklistEntry(current, $"{current}/{Slug(name)}", name, item.Groups["text"].Value.Trim())); + } + } + + return versions; + } + + // The versions this server tests: QA every version staging doesn't have yet; dev only the one running (where the + // checklist is tried out). Other servers have no checklist. + public static IReadOnlyList<QaChecklistVersion> ForServer(IReadOnlyList<QaChecklistVersion> versions, IReleaseEnvironment environment, Version running) + { + var current = Changelog.Release(running); + + return environment.VersionPosts switch + { + VersionPostMode.NotYetStaged => versions.Where(v => v.Version <= current && !environment.Staged.Contains(v.Version)).ToList(), + VersionPostMode.CurrentOnly => versions.Where(v => v.Version == current).ToList(), + _ => new List<QaChecklistVersion>() + }; + } + + public static bool HasChecklist(IReleaseEnvironment environment) => environment.VersionPosts != VersionPostMode.Standing; + + private static string Slug(string name) => Regex.Replace(name.ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-'); + } + + public enum QaItemState + { + Pending = 0, + Passed = 1, + Failing = 2 + } + + // Requestarr's record of each checklist item on this server: who passed or failed it and when, and the bug report + // a failing one opened. Discord shows it; the portal will too. + public class QaChecklistItem : ModelBase + { + public string ItemKey { get; set; } + public string Version { get; set; } + public string Name { get; set; } + public QaItemState State { get; set; } + public string ByDiscordUserId { get; set; } + public string ByName { get; set; } + public DateTime? At { get; set; } + public int? BugReportId { get; set; } + public string MessageId { get; set; } + } + + public interface IQaChecklistItemRepository : IBasicRepository<QaChecklistItem> + { + QaChecklistItem FindByKey(string itemKey); + } + + public class QaChecklistItemRepository : BasicRepository<QaChecklistItem>, IQaChecklistItemRepository + { + public QaChecklistItemRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public QaChecklistItem FindByKey(string itemKey) => Query(i => i.ItemKey == itemKey).SingleOrDefault(); + } +} diff --git a/src/NzbDrone.Core/Requestarr/Qa/QaChecklistService.cs b/src/NzbDrone.Core/Requestarr/Qa/QaChecklistService.cs new file mode 100644 index 0000000..a8338b5 --- /dev/null +++ b/src/NzbDrone.Core/Requestarr/Qa/QaChecklistService.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Messaging.Events; +using Requestarr.Core.Announcements; +using Requestarr.Core.Audit; +using Requestarr.Core.Discord; +using Requestarr.Core.Discord.Gateway; +using Requestarr.Core.Discord.Interactions; + +namespace Requestarr.Core.Qa +{ + public record QaChecklistItemState(string Key, string Version, string Name, string State, string By, DateTime? At, int? BugReportId, string IssueUrl); + + // What Claude Code reads from the QA stack before promoting to staging, and what the portal will show. + public record QaChecklistState(string Environment, IReadOnlyList<string> Versions, int Total, int Passed, int Failing, bool Complete, string CompletedBy, DateTime? CompletedAt, IReadOnlyList<QaChecklistItemState> Items); + + public interface IQaChecklistService + { + // Posts the checklist in its channel, or brings it in line with QA-CHECKLIST.md and the record. + Task EnsureAsync(); + + QaChecklistEntry Find(string key); + QaChecklistState State(); + + Task MarkPassedAsync(string key, ulong discordUserId, string name); + Task MarkFailingAsync(string key, ulong discordUserId, string name, BugReport report); + + // A bug report's issue link arrived: the failing item's card shows it. + Task RefreshItemAsync(string key); + } + + // The QA test checklist in Discord: a summary card, then one card per item with Passes and Failing, for every version + // this server has that staging doesn't (QA), or the running one (dev, where it's tried out). The record is the + // truth; the cards show it. A new promotion to QA adds the new versions' items (as not tested yet) and drops the + // versions staging now has; every pass or failure already recorded stays. + public class QaChecklistService : IQaChecklistService, IHandleAsync<DiscordReadyEvent> + { + private static readonly SemaphoreSlim Gate = new(1, 1); + + private readonly IDiscordGuildGateway _gateway; + private readonly IGuildSetupService _guildSetup; + private readonly IDiscordGuildConfigRepository _configs; + private readonly IQaChecklistItemRepository _items; + private readonly IBugReportRepository _reports; + private readonly IReleaseEnvironment _environment; + private readonly IAuditService _audit; + private readonly Logger _logger; + + public QaChecklistService(IDiscordGuildGateway gateway, IGuildSetupService guildSetup, IDiscordGuildConfigRepository configs, IQaChecklistItemRepository items, IBugReportRepository reports, IReleaseEnvironment environment, IAuditService audit, Logger logger) + { + _gateway = gateway; + _guildSetup = guildSetup; + _configs = configs; + _items = items; + _reports = reports; + _environment = environment; + _audit = audit; + _logger = logger; + } + + // Settable so tests can use their own checklist and version. + public Func<string> ChecklistText { get; set; } = QaChecklist.Embedded; + public Func<Version> CurrentVersion { get; set; } = () => BuildInfo.Version; + + public void HandleAsync(DiscordReadyEvent message) + { + EnsureAsync().GetAwaiter().GetResult(); + } + + public IReadOnlyList<QaChecklistVersion> Versions() => + QaChecklist.ForServer(QaChecklist.Parse(ChecklistText()), _environment, CurrentVersion()); + + public IReadOnlyList<QaChecklistEntry> Entries() => Versions().SelectMany(v => v.Items).ToList(); + + public QaChecklistEntry Find(string key) => Entries().FirstOrDefault(e => e.Key == key); + + public QaChecklistState State() + { + var entries = Entries(); + var records = Records(entries); + var reports = records.Values.Where(r => r.BugReportId.HasValue).Select(r => _reports.Find(r.BugReportId.Value)).Where(r => r != null).ToDictionary(r => r.Id); + var passed = records.Values.Count(r => r.State == QaItemState.Passed); + var failing = records.Values.Count(r => r.State == QaItemState.Failing); + var complete = entries.Count > 0 && passed == entries.Count; + var last = complete ? records.Values.OrderByDescending(r => r.At).First() : null; + + var items = entries.Select(e => + { + var r = records[e.Key]; + var report = r.BugReportId.HasValue && reports.TryGetValue(r.BugReportId.Value, out var found) ? found : null; + return new QaChecklistItemState(e.Key, e.Version.ToString(), e.Name, r.State.ToString(), r.ByName, r.At, r.BugReportId, report?.IssueUrl); + }).ToList(); + + return new QaChecklistState(_environment.IconSet, Versions().Select(v => v.Version.ToString()).ToList(), entries.Count, passed, failing, complete, last?.ByName, last?.At, items); + } + + public async Task EnsureAsync() + { + if (!QaChecklist.HasChecklist(_environment) || !ulong.TryParse(_guildSetup.GetConfig()?.QaChecklistChannelId, out var channel)) + { + return; + } + + await Gate.WaitAsync(); + + try + { + var entries = Entries(); + var records = Records(entries); + var config = _guildSetup.GetConfig(); + + // The cards, in order: the summary, then each item. Edited in place when they're all still there in + // that order; otherwise the channel is cleared and posted again (nothing is lost: the record has it). + var wanted = new List<string> { config.QaChecklistSummaryMessageId }.Concat(entries.Select(e => records[e.Key].MessageId)).ToList(); + var have = (await _gateway.GetOwnMessagesAsync(channel, 500)).Select(m => m.Id.ToString()).ToList(); + + if (have.SequenceEqual(wanted)) + { + await _gateway.EditMessageAsync(channel, ulong.Parse(wanted[0]), SummaryCard(entries, records)); + + foreach (var entry in entries) + { + await _gateway.EditMessageAsync(channel, ulong.Parse(records[entry.Key].MessageId), ItemCard(entry, records[entry.Key])); + } + + return; + } + + foreach (var id in have) + { + await _gateway.DeleteMessageAsync(channel, ulong.Parse(id)); + } + + config.QaChecklistSummaryMessageId = (await _gateway.SendMessageAsync(channel, SummaryCard(entries, records))).ToString(); + _configs.SetFields(config, c => c.QaChecklistSummaryMessageId); + + foreach (var entry in entries) + { + var record = records[entry.Key]; + record.MessageId = (await _gateway.SendMessageAsync(channel, ItemCard(entry, record))).ToString(); + _items.SetFields(record, r => r.MessageId); + } + + _logger.Info("QA checklist posted: {0} item(s) for {1}", entries.Count, string.Join(", ", Versions().Select(v => v.Version))); + } + catch (Exception ex) + { + _logger.Warn(ex, "Could not post the QA checklist"); + } + finally + { + Gate.Release(); + } + } + + public async Task MarkPassedAsync(string key, ulong discordUserId, string name) + { + if (Find(key) == null) + { + return; + } + + var wasComplete = State().Complete; + var record = _items.FindByKey(key); + + record.State = QaItemState.Passed; + record.ByDiscordUserId = discordUserId.ToString(); + record.ByName = name; + record.At = DateTime.UtcNow; + _items.Update(record); + _audit.Record(Actor.Discord(discordUserId, name), "qa.checklist.pass", "qa-item", key); + + await RefreshItemAsync(key); + + var state = State(); + + if (state.Complete && !wasComplete) + { + _audit.Record(Actor.Discord(discordUserId, name), "qa.checklist.complete", "qa-checklist", string.Join(",", state.Versions)); + _logger.Info("QA checklist complete for {0}, the last item passed by {1}", string.Join(", ", state.Versions), name); + } + } + + public async Task MarkFailingAsync(string key, ulong discordUserId, string name, BugReport report) + { + if (Find(key) == null) + { + return; + } + + // Every item on the checklist has a record, even before the cards were posted. + var record = Records(Entries())[key]; + + record.State = QaItemState.Failing; + record.ByDiscordUserId = discordUserId.ToString(); + record.ByName = name; + record.At = DateTime.UtcNow; + record.BugReportId = report?.Id; + _items.Update(record); + _audit.Record(Actor.Discord(discordUserId, name), "qa.checklist.fail", "qa-item", key, report != null ? $"bug report {report.Id}" : null); + + await RefreshItemAsync(key); + } + + public async Task RefreshItemAsync(string key) + { + var config = _guildSetup.GetConfig(); + var entry = Find(key); + var record = _items.FindByKey(key); + + if (entry == null || record?.MessageId == null || !ulong.TryParse(config?.QaChecklistChannelId, out var channel)) + { + return; + } + + var entries = Entries(); + await _gateway.EditMessageAsync(channel, ulong.Parse(record.MessageId), ItemCard(entry, record)); + + if (ulong.TryParse(config.QaChecklistSummaryMessageId, out var summary)) + { + await _gateway.EditMessageAsync(channel, summary, SummaryCard(entries, Records(entries))); + } + } + + // Every item has a record; new ones start as not tested yet. + private Dictionary<string, QaChecklistItem> Records(IReadOnlyList<QaChecklistEntry> entries) + { + var records = new Dictionary<string, QaChecklistItem>(); + + foreach (var entry in entries) + { + var record = _items.FindByKey(entry.Key); + + if (record == null) + { + record = _items.Insert(new QaChecklistItem { ItemKey = entry.Key, Version = entry.Version.ToString(), Name = entry.Name, State = QaItemState.Pending }); + } + + records[entry.Key] = record; + } + + return records; + } + + private InteractionReply SummaryCard(IReadOnlyList<QaChecklistEntry> entries, Dictionary<string, QaChecklistItem> records) + { + var passed = records.Values.Count(r => r.State == QaItemState.Passed); + var failing = records.Values.Count(r => r.State == QaItemState.Failing); + var last = entries.Count > 0 && passed == entries.Count ? records.Values.OrderByDescending(r => r.At).First() : null; + + return new InteractionReply + { + Quiet = true, + Cards = { new InteractionCard { Color = failing > 0 ? QaText.FailingColor : last != null ? QaText.PassedColor : QaText.ChecklistColor, Text = QaText.Summary(Versions().Select(v => v.Version).ToList(), entries.Count, passed, failing, last?.ByName, last?.At) } } + }; + } + + private InteractionReply ItemCard(QaChecklistEntry entry, QaChecklistItem record) + { + var report = record.BugReportId.HasValue ? _reports.Find(record.BugReportId.Value) : null; + + return new InteractionReply + { + Quiet = true, + Cards = + { + new InteractionCard + { + Color = record.State switch { QaItemState.Passed => QaText.PassedColor, QaItemState.Failing => QaText.FailingColor, _ => QaText.ChecklistColor }, + Text = QaText.Item(entry, record, report), + Buttons = new List<InteractionButton> + { + new() { CustomId = $"{QaRoutes.Check}:pass:{entry.Key}", Label = QaText.PassLabel, Style = ButtonStyle.Success }, + new() { CustomId = $"{QaRoutes.Check}:fail:{entry.Key}", Label = QaText.FailLabel, Style = ButtonStyle.Danger } + } + } + } + }; + } + } +} diff --git a/src/NzbDrone.Core/Requestarr/Qa/QaText.cs b/src/NzbDrone.Core/Requestarr/Qa/QaText.cs new file mode 100644 index 0000000..d746995 --- /dev/null +++ b/src/NzbDrone.Core/Requestarr/Qa/QaText.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; + +namespace Requestarr.Core.Qa +{ + public static class QaRoutes + { + // Checklist card buttons: "qacheck:pass:{item key}", "qacheck:fail:{item key}". + public const string Check = "qacheck"; + + // Bug reports: the panel's button (and a failing checklist item) -> the part menu -> the form. + public const string Report = "bugreport"; + public const string Part = "bugpart"; + public const string Form = "bugform"; + public const string WhatField = "what"; + public const string ExpectedField = "expected"; + } + + public static class QaText + { + public const uint ChecklistColor = 0xE0A030; + public const uint PassedColor = 0x2EA043; + public const uint FailingColor = 0xDA3633; + public const uint ReportColor = 0x5865F2; + + public const string ChecklistChannelName = "requestarr-qa-checklist"; + public const string ChecklistChannelTopic = "What to test on this server before the versions here go to staging. Press Passes or Failing on each item."; + public const string BugReportsChannelName = "requestarr-bug-reports"; + public const string BugReportsChannelTopic = "Found a problem? Press Report a problem. Reports reach the people who build Requestarr."; + public const string ChecklistTitle = "## QA checklist"; + + public const string PassLabel = "Passes"; + public const string FailLabel = "Failing"; + public const string ReportLabel = "Report a problem"; + public const string PartPlaceholder = "Which part of Requestarr?"; + public const string FormTitle = "Report a problem"; + public const string WhatLabel = "What happened?"; + public const string ExpectedLabel = "What did you expect instead?"; + + public const string AdminsOnly = "Only admins can mark checklist items."; + public const string ItemGone = "That item isn't on the checklist any more."; + public const string NoChannel = "The bug reports channel isn't set up yet. Try again in a minute."; + public const string Sending = "Sending your report…"; + + public static string Summary(IReadOnlyList<Version> versions, int total, int passed, int failing, string completedBy, DateTime? completedAt) + { + var which = versions.Count == 0 ? "nothing to test" : versions.Count == 1 ? $"version {versions[0]}" : $"versions {string.Join(", ", versions)}"; + var status = total == 0 + ? "Nothing to test by hand in these versions." + : failing > 0 + ? $"❌ **Can't complete:** {failing} item{(failing == 1 ? " is" : "s are")} failing. Fix, then press Passes once it works." + : passed == total + ? $"✅ **Complete:** every item passed{(completedBy != null ? $", the last by {completedBy} {Stamp(completedAt)}" : string.Empty)}. Staging can have these versions once no bug report from QA is still open." + : $"⏳ **{total - passed} left to test.**"; + + return $"{ChecklistTitle}\nWhat to test here before {which} go{(versions.Count == 1 ? "es" : string.Empty)} to staging: **{passed} of {total} passed**{(failing > 0 ? $", {failing} failing" : string.Empty)}.\n{status}"; + } + + public static string Item(QaChecklistEntry entry, QaChecklistItem record, BugReport report) + { + var state = record?.State switch + { + QaItemState.Passed => $"✅ Passed by {record.ByName} {Stamp(record.At)}", + QaItemState.Failing => $"❌ Failing, reported by {record.ByName} {Stamp(record.At)}{(report != null ? $": {ReportLink(report)}" : string.Empty)}", + _ => "⏳ Not tested yet" + }; + + return $"**{entry.Name}** · {entry.Version}\n{entry.Text}\n{state}"; + } + + public static string BugReportsPanel(bool filed) => + "## Report a problem\nSomething not working the way it should? Press **Report a problem**, pick the part of Requestarr, and say what " + + "happened and what you expected. It goes to the people who build Requestarr, with this server's version." + + (filed ? "\n\n**Reports are public:** each one becomes an issue on GitHub that anyone can read. Don't include anything private." : string.Empty); + + public static string PartPrompt(bool filed, string itemName) => + (itemName != null ? $"Reporting the failing checklist item **{itemName}**. " : string.Empty) + + "Pick the part of Requestarr it's about, then describe it." + + (filed + ? "\n\n**This report will be public:** it becomes an issue on GitHub that anyone can read, with your Discord name. Don't include anything private." + : "\n\nYour report is posted in the bug reports channel with your Discord name."); + + public static string Wait(TimeSpan wait) => $"You've sent a report just now. You can send another {In(wait)}."; + + public static string Thanks(BugReport report, ulong channelId) => + $"Thanks, your report is in <#{channelId}>{(report.IssueUrl != null ? $" and on GitHub: {report.IssueUrl}" : ".")}"; + + public static string ReportCard(BugReport report) => + $"**Bug report #{report.Id}** · {BugReportComponents.NameOf(report.Component)}\n" + + $"**What happened:** {Plain(report.WhatHappened)}\n" + + (string.IsNullOrWhiteSpace(report.Expected) ? string.Empty : $"**Expected:** {Plain(report.Expected)}\n") + + $"-# {EnvironmentName(report.Environment)} · Requestarr {report.Version} · <t:{Unix(report.Created)}:f> · reported by {Plain(report.ReporterName)}\n" + + Filing(report); + + // Where the report is, or exactly why it isn't on GitHub, so an admin can act on it. + public static string Filing(BugReport report) => report.Filing switch + { + BugReportFiling.Filed => $"🔗 On GitHub as issue #{report.IssueNumber}: {report.IssueUrl}", + BugReportFiling.Pending => "⏳ Being filed on GitHub…", + BugReportFiling.Failed => $"⚠️ Not on GitHub yet: {report.FilingProblem ?? "GitHub refused it or couldn't be reached"}. Requestarr tries again each time it starts.", + BugReportFiling.NoToken => "⚠️ Not on GitHub yet: this server has no GitHub access key. It's filed once the key is added and Requestarr restarts.", + _ => "Not filed on GitHub: filing reports is turned off in this server's settings. The admins have it here." + }; + + public static string IssueTitle(BugReport report) + { + var what = report.WhatHappened.Replace('\n', ' ').Trim(); + return $"[{EnvironmentName(report.Environment)}] {BugReportComponents.NameOf(report.Component)}: {(what.Length > 70 ? what.Substring(0, 67) + "..." : what)}"; + } + + // Only what the reporter typed and what Requestarr adds: the environment, version, time, part and their Discord name. + public static string IssueBody(BugReport report, QaChecklistEntry item) => + $"**What happened**\n\n{NoPings(report.WhatHappened)}\n\n" + + (string.IsNullOrWhiteSpace(report.Expected) ? string.Empty : $"**What they expected**\n\n{NoPings(report.Expected)}\n\n") + + (item != null ? $"**From the QA checklist:** {item.Name} ({item.Version})\n\n" : string.Empty) + + "| | |\n|---|---|\n" + + $"| Part | {BugReportComponents.NameOf(report.Component)} |\n" + + $"| Environment | {EnvironmentName(report.Environment)} |\n" + + $"| Version | {report.Version} |\n" + + $"| Reported | {report.Created:yyyy-MM-dd HH:mm} UTC |\n" + + $"| Reporter (Discord) | {NoPings(report.ReporterName)} |\n\n" + + $"_Sent with Requestarr's Report a problem button (report #{report.Id} on that server)._"; + + public static IReadOnlyList<string> IssueLabels(BugReport report) + { + var labels = new List<string> { "bug report", $"env: {report.Environment}", $"version: {report.Version}", $"component: {report.Component}" }; + + // The dev server tries the feature out: its issues are marked as tests. + if (report.Environment == "dev") + { + labels.Add("test"); + } + + return labels; + } + + public static string EnvironmentName(string iconSet) => iconSet switch + { + "dev" => "Dev", + "qa" => "QA", + "beta" => "Beta", + _ => "Production" + }; + + private static string Stamp(DateTime? at) => at.HasValue ? $"<t:{Unix(at.Value)}:R>" : string.Empty; + + // Stored times are UTC. + private static long Unix(DateTime at) => new DateTimeOffset(DateTime.SpecifyKind(at, DateTimeKind.Utc)).ToUnixTimeSeconds(); + + private static string ReportLink(BugReport report) => report.IssueUrl != null ? $"[bug report #{report.Id}]({report.IssueUrl})" : $"bug report #{report.Id}"; + + // What someone typed, shown in a card: no mentions (no pings) and no headings. + private static string Plain(string text) => NoPings(text).Replace("\n#", "\n\\#"); + + // An @ followed by an invisible space: no Discord mention, and nobody on GitHub is notified. + private static string NoPings(string text) => (text ?? string.Empty).Replace("@", "@​"); + + private static string In(TimeSpan wait) => wait.TotalMinutes < 1 ? "in a moment" : wait.TotalHours < 1 ? $"in {Math.Ceiling(wait.TotalMinutes)} minutes" : $"in {Math.Ceiling(wait.TotalHours)} hours"; + } +} diff --git a/src/Requestarr.Api.V1.Test/Frontend/VersionLogoFixture.cs b/src/Requestarr.Api.V1.Test/Frontend/VersionLogoFixture.cs new file mode 100644 index 0000000..ec5dfc7 --- /dev/null +++ b/src/Requestarr.Api.V1.Test/Frontend/VersionLogoFixture.cs @@ -0,0 +1,146 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NLog; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; +using Requestarr.Http.Frontend.Mappers; + +namespace Requestarr.Api.V1.Test.Frontend +{ + // The production header and login logo: the running version on a green bar, laid out like the branch logos. + [TestFixture] + public class VersionLogoFixture + { + private static double Px(double units, double size) => units * size / VersionLogoGlyphs.UnitsPerEm; + + [Test] + public void the_lettering_is_the_branch_labels_size_and_matches_the_delivered_logos() + { + var size = VersionLogo.FontSize("v0.4.9"); + + size.Should().Be(117); + + // Measured from the delivered beta logo: 85 px across the bar and 299 px along it. + var beta = VersionLogo.Ink("BETA"); + Px(beta.YMax - beta.YMin, size).Should().BeApproximately(85, 1); + Px(beta.XMax - beta.XMin, size).Should().BeApproximately(299, 2); + } + + [TestCase("v0.4.9")] + [TestCase("v1.12.10")] + public void a_usual_version_keeps_the_branch_size(string text) + { + VersionLogo.FontSize(text).Should().Be(117); + Px(VersionLogo.Ink(text).XMax - VersionLogo.Ink(text).XMin, 117).Should().BeLessThan(0.86 * VersionLogo.Height); + } + + [Test] + public void a_long_version_shrinks_to_fit_the_bar_instead_of_being_cut_off() + { + var ink = VersionLogo.Ink("v10.100.1000"); + var size = VersionLogo.FontSize("v10.100.1000"); + + size.Should().BeLessThan(117); + Px(ink.XMax - ink.XMin, size).Should().BeApproximately(0.86 * VersionLogo.Height, 0.01); + } + + [Test] + public void the_text_is_the_version_to_three_parts() + { + VersionLogo.Text(new Version(0, 4, 9, 30588)).Should().Be("v0.4.9"); + VersionLogo.Text(new Version(1, 12, 10)).Should().Be("v1.12.10"); + VersionLogo.Text(new Version(2, 0)).Should().Be("v2.0"); + } + + [Test] + public void the_logo_is_the_bar_the_version_and_the_delivered_r_with_the_page_showing_through() + { + var r = new byte[] { 137, 80, 78, 71, 1, 2, 3 }; + var svg = VersionLogo.Svg(new Version(0, 4, 9, 30588), r); + + svg.Should().StartWith("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"662\" height=\"512\" viewBox=\"0 0 662 512\">") + .And.Contain("<title>v0.4.9") + .And.Contain("") + .And.Contain($""); + Regex.Matches(svg, " double.Parse(m.Groups[i].Value, CultureInfo.InvariantCulture)).ToArray(); + var ink = VersionLogo.Ink("v1.12.10"); + + // The text's direction (font x) points up the bar and the letters' tops (font y) face left. + n[0].Should().BeNegative(); + n[1].Should().BeNegative(); + + // The middle of the ink lands in the middle of the bar. + ((n[1] * (ink.YMin + ink.YMax) / 2) + n[2]).Should().BeApproximately(VersionLogo.BarWidth / 2.0, 0.01); + ((n[0] * (ink.XMin + ink.XMax) / 2) + n[3]).Should().BeApproximately(VersionLogo.Height / 2.0, 0.01); + } + + [Test] + public void production_uses_the_drawn_logo_and_the_others_their_delivered_one() + { + WebIcons.HeaderLogo("production").Should().Be("logo-version.svg"); + + foreach (var set in new[] { "dev", "qa", "beta" }) + { + WebIcons.HeaderLogo(set).Should().Be("logo-header.png"); + } + } + + [TestCase("/Content/Images/Icons/light/production/logo-version.svg", true)] + [TestCase("/content/images/icons/dark/production/logo-version.svg", true)] + [TestCase("/Content/Images/Icons/light-purple/production/logo-version.svg", true)] + [TestCase("/Content/Images/Icons/dark/dev/logo-version.svg", false)] + [TestCase("/Content/Images/Icons/dark/production/logo-header.png", false)] + public void only_the_version_logo_is_drawn_and_everything_else_is_a_file(string url, bool drawn) + { + VersionLogoMapper.Handles(url).Should().Be(drawn); + new StaticResourceMapper(null, null, null, null).CanHandle(url).Should().Be(!drawn); + } + + [Test] + public void it_is_drawn_with_the_running_version_beside_the_delivered_r() + { + var folder = Path.Combine(Path.GetTempPath(), "requestarr-version-logo-" + Guid.NewGuid().ToString("N")); + var r = Path.Combine(folder, "UI", "Content", "Images", "Icons", "light-purple", "production", "logo-header.png"); + Directory.CreateDirectory(Path.GetDirectoryName(r)!); + File.WriteAllBytes(r, new byte[] { 137, 80, 78, 71, 9, 9 }); + + try + { + var appFolder = new Mock(); + appFolder.SetupGet(a => a.StartUpFolder).Returns(folder); + var config = new Mock(); + config.SetupGet(c => c.UiFolder).Returns("UI"); + var disk = new Mock(); + disk.Setup(d => d.FileExists(It.IsAny())).Returns(File.Exists); + + var mapper = new VersionLogoMapper(appFolder.Object, disk.Object, config.Object, LogManager.GetCurrentClassLogger()); + var result = (ContentResult)mapper.GetResponse("/Content/Images/Icons/light-purple/production/logo-version.svg").Result; + + result.ContentType.Should().Be("image/svg+xml"); + result.Content.Should().Contain($"{VersionLogo.Text(BuildInfo.Version)}") + .And.Contain(Convert.ToBase64String(File.ReadAllBytes(r))); + } + finally + { + Directory.Delete(folder, true); + } + } + } +} diff --git a/src/Requestarr.Http/Authentication/AuthenticationService.cs b/src/Requestarr.Http/Authentication/AuthenticationService.cs index bfba25b..380deb4 100644 --- a/src/Requestarr.Http/Authentication/AuthenticationService.cs +++ b/src/Requestarr.Http/Authentication/AuthenticationService.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using NLog; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using Requestarr.Http.Extensions; @@ -62,7 +63,7 @@ public void Logout(HttpContext context) public void LogUnauthorized(HttpRequest context) { - _authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.GetRemoteIP(), context.Path); + _authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.GetRemoteIP(), context.Path.Value.ForLog()); } private void LogInvalidated(HttpRequest context) @@ -72,12 +73,12 @@ private void LogInvalidated(HttpRequest context) private void LogFailure(HttpRequest context, string username) { - _authLogger.Warn("Auth-Failure ip {0} username '{1}'", context.GetRemoteIP(), username); + _authLogger.Warn("Auth-Failure ip {0} username '{1}'", context.GetRemoteIP(), username.ForLog()); } private void LogSuccess(HttpRequest context, string username) { - _authLogger.Debug("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username); + _authLogger.Debug("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username.ForLog()); } private void LogLogout(HttpRequest context, string username) diff --git a/src/Requestarr.Http/ErrorManagement/RequestarrErrorPipeline.cs b/src/Requestarr.Http/ErrorManagement/RequestarrErrorPipeline.cs index f2d62aa..f6c4493 100644 --- a/src/Requestarr.Http/ErrorManagement/RequestarrErrorPipeline.cs +++ b/src/Requestarr.Http/ErrorManagement/RequestarrErrorPipeline.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using NLog; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; using NzbDrone.Core.Datastore; using NzbDrone.Core.Exceptions; @@ -75,11 +76,11 @@ public async Task HandleException(HttpContext context) } } - _logger.Error(sqLiteException, "[{0} {1}]", context.Request.Method, context.Request.Path); + _logger.Error(sqLiteException, "[{0} {1}]", context.Request.Method.ForLog(), context.Request.Path.Value.ForLog()); } else { - _logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method, context.Request.Path); + _logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method.ForLog(), context.Request.Path.Value.ForLog()); } await errorModel.WriteToResponse(response, statusCode); diff --git a/src/Requestarr.Http/Frontend/Mappers/StaticResourceMapper.cs b/src/Requestarr.Http/Frontend/Mappers/StaticResourceMapper.cs index df25452..264f61c 100644 --- a/src/Requestarr.Http/Frontend/Mappers/StaticResourceMapper.cs +++ b/src/Requestarr.Http/Frontend/Mappers/StaticResourceMapper.cs @@ -33,7 +33,8 @@ public override bool CanHandle(string resourceUrl) resourceUrl = resourceUrl.ToLowerInvariant(); if (resourceUrl.StartsWith("/content/manifest") || - resourceUrl.StartsWith("/content/browserconfig")) + resourceUrl.StartsWith("/content/browserconfig") || + VersionLogoMapper.Handles(resourceUrl)) { return false; } diff --git a/src/Requestarr.Http/Frontend/Mappers/VersionLogo.cs b/src/Requestarr.Http/Frontend/Mappers/VersionLogo.cs new file mode 100644 index 0000000..e16a236 --- /dev/null +++ b/src/Requestarr.Http/Frontend/Mappers/VersionLogo.cs @@ -0,0 +1,90 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace Requestarr.Http.Frontend.Mappers +{ + // The production header and login logo: a green bar with the running version beside the delivered R + // (Icons/{theme}/production/logo-header.png), laid out like the dev, QA and beta logos so it always shows the + // version actually running. Like them it has no background: the page shows through around the bar and the R. The rules are the ones the branch logos were made with, at 512 px tall: a 138 px bar, + // a 38 px gap, white Inter Display Black reading bottom to top and centred in the bar, at the largest size where + // "BETA" is no taller than 62% of the bar's width and no longer than 80% of the height; the version uses that size + // and shrinks only if it would be longer than 86% of the height. + public static class VersionLogo + { + public const int Height = 512; + public const int BarWidth = 138; + public const int LogoX = 176; + public const int LogoWidth = 486; + public const int Width = LogoX + LogoWidth; + public const string Green = "#2EA043"; + + public static string Text(Version version) => "v" + version.ToString(version.Build >= 0 ? 3 : 2); + + // The font size in pixels: the branch labels' size, smaller only when the text would be too long. + public static double FontSize(string text) + { + var beta = Ink("BETA"); + var size = Math.Floor(Math.Min(0.62 * BarWidth * VersionLogoGlyphs.UnitsPerEm / (beta.YMax - beta.YMin), + 0.80 * Height * VersionLogoGlyphs.UnitsPerEm / (beta.XMax - beta.XMin))); + var ink = Ink(text); + var longest = 0.86 * Height * VersionLogoGlyphs.UnitsPerEm / (ink.XMax - ink.XMin); + + return Math.Min(size, longest); + } + + public static string Svg(Version version, byte[] logo) + { + var text = Text(version); + var ink = Ink(text); + var scale = FontSize(text) / VersionLogoGlyphs.UnitsPerEm; + + // Font units (y up) to the bar, turned to read bottom to top with the ink centred on the bar. + var e = (BarWidth / 2.0) + (scale * (ink.YMin + ink.YMax) / 2); + var f = (Height / 2.0) + (scale * (ink.XMin + ink.XMax) / 2); + + var svg = new StringBuilder(); + svg.Append($""); + svg.Append($"{text}"); + svg.Append($""); + svg.Append($""); + + foreach (var (letter, x) in Layout(text)) + { + svg.Append($""); + } + + svg.Append(""); + svg.Append($""); + svg.Append(""); + + return svg.ToString(); + } + + // Where the text's ink sits, in font units from the start of the first letter. + public static (double XMin, double YMin, double XMax, double YMax) Ink(string text) + { + var letters = Layout(text).Select(l => (Glyph: VersionLogoGlyphs.Letters[l.Letter], l.X)).ToList(); + + return (letters.Min(l => l.X + l.Glyph.XMin), letters.Min(l => l.Glyph.YMin), + letters.Max(l => l.X + l.Glyph.XMax), letters.Max(l => l.Glyph.YMax)); + } + + private static (char Letter, double X)[] Layout(string text) + { + var x = 0.0; + + return text.Select((letter, i) => + { + var at = x; + var next = i + 1 < text.Length ? text.Substring(i, 2) : null; + x += VersionLogoGlyphs.Letters[letter].Advance + (next != null && VersionLogoGlyphs.Kerning.TryGetValue(next, out var kern) ? kern : 0); + + return (letter, at); + }).ToArray(); + } + + private static string N(double value) => Math.Round(value, 6).ToString(CultureInfo.InvariantCulture); + } +} diff --git a/src/Requestarr.Http/Frontend/Mappers/VersionLogoGlyphs.cs b/src/Requestarr.Http/Frontend/Mappers/VersionLogoGlyphs.cs new file mode 100644 index 0000000..833b182 --- /dev/null +++ b/src/Requestarr.Http/Frontend/Mappers/VersionLogoGlyphs.cs @@ -0,0 +1,91 @@ +// Generated by .github/version_logo_glyphs.py from Inter Display Black (Version 4.001;git-9221beed3). Do not edit by hand. +// Inter: Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter), SIL Open Font License 1.1. +using System.Collections.Generic; + +namespace Requestarr.Http.Frontend.Mappers +{ + public static class VersionLogoGlyphs + { + public const int UnitsPerEm = 2048; + + public static readonly IReadOnlyDictionary Letters = new Dictionary + { + { 'v', new Glyph(1208, -4, 0, 1212, 1056, "M374 0H834L1212 1056H800L674 608C651 526 630 444 609 361C587 444 564 526 541 608L412 1056H-4Z") }, + { '0', new Glyph(1396, 40, -24, 1356, 1514, "M698 -24C1111 -24 1356 267 1356 744C1356 1222 1111 1514 698 1514C285 1514 40 1221 40 744C40 268 285 -24 698 -24ZM698 296C533 296 432 466 432 744C432 1023 533 1194 698 1194C863 1194 964 1023 964 744C964 466 863 296 698 296Z") }, + { '1', new Glyph(846, 32, 0, 766, 1490, "M766 1490H398L32 1266V922L364 1125H366V0H766Z") }, + { '2', new Glyph(1291, 60, 0, 1219, 1514, "M75 0H1219V322H639V324L880 520C1078 681 1215 816 1215 1039C1215 1335 975 1514 645 1514C284 1514 60 1299 60 952H441C441 1107 523 1194 644 1194C755 1194 829 1122 829 1016C829 887 718 809 635 742L75 290Z") }, + { '3', new Glyph(1306, 50, -24, 1266, 1514, "M654 -24C1019 -24 1266 165 1266 420C1266 598 1146 756 922 779V782C1091 802 1220 918 1220 1100C1220 1343 988 1514 658 1514C294 1514 78 1305 78 1032H466C466 1139 548 1214 658 1214C763 1214 839 1145 839 1050C839 953 760 896 626 896H482V616H626C765 616 854 551 854 451C854 354 771 286 652 286C520 286 440 371 440 512H50C50 234 228 -24 654 -24Z") }, + { '4', new Glyph(1414, 64, 0, 1348, 1490, "M64 288H792V0H1182V288H1348V594H1182V1490H671L64 602ZM792 594H439V596L791 1118H792Z") }, + { '5', new Glyph(1270, 40, -24, 1230, 1490, "M632 -24C994 -24 1230 200 1230 512C1230 795 980 1017 715 1017C605 1017 512 985 451 925H449L468 1166H1138V1490H104L62 600H434C457 674 532 724 635 724C755 724 840 631 840 500C840 370 757 282 635 282C515 282 425 366 425 480H40C60 166 288 -24 632 -24Z") }, + { '6', new Glyph(1322, 40, -24, 1282, 1514, "M686 -24C1034 -24 1282 186 1282 498C1282 789 1062 1000 758 1000C609 1000 498 950 420 838H418C418 1069 498 1210 673 1210C786 1210 863 1150 880 1056H1262C1237 1311 1017 1514 688 1514C282 1514 40 1210 40 728C40 254 279 -24 686 -24ZM682 288C563 288 476 376 476 498C476 620 563 708 682 708C803 708 890 620 890 498C890 376 803 288 682 288Z") }, + { '7', new Glyph(1186, 40, 0, 1146, 1490, "M142 0H558L1146 1166V1490H40V1152H726V1150Z") }, + { '8', new Glyph(1314, 40, -24, 1274, 1514, "M656 -24C1029 -24 1274 146 1274 404C1274 608 1125 739 884 772C1099 804 1228 920 1228 1106C1228 1355 1005 1514 656 1514C315 1514 86 1350 86 1106C86 920 216 803 430 772C189 739 40 608 40 404C40 146 284 -24 656 -24ZM656 254C542 254 460 330 460 436C460 541 542 616 656 616C771 616 854 541 854 436C854 331 770 254 656 254ZM656 907C550 907 482 969 482 1066C482 1163 550 1225 656 1225C763 1225 832 1163 832 1066C832 969 763 907 656 907Z") }, + { '9', new Glyph(1322, 40, -24, 1282, 1514, "M634 -24C1040 -24 1282 280 1282 762C1282 1236 1043 1514 636 1514C288 1514 40 1304 40 992C40 701 260 490 564 490C713 490 824 540 902 652H904C904 421 824 280 649 280C536 280 459 340 442 434H60C85 179 305 -24 634 -24ZM640 782C519 782 432 870 432 992C432 1114 519 1202 640 1202C759 1202 846 1114 846 992C846 870 759 782 640 782Z") }, + { '.', new Glyph(544, 64, -19, 480, 397, "M272 -19C387 -19 480 74 480 189C480 304 387 397 272 397C157 397 64 304 64 189C64 74 157 -19 272 -19Z") }, + { 'B', new Glyph(1331, 80, 0, 1296, 1490, "M80 0H782C1105 0 1296 178 1296 422C1296 632 1150 776 958 783V785C1131 818 1246 945 1246 1118C1246 1335 1066 1490 726 1490H80ZM484 312V606H704C805 606 870 548 870 459C870 370 805 312 704 312ZM484 900V1178H679C771 1178 830 1123 830 1039C830 955 771 900 679 900Z") }, + { 'E', new Glyph(1294, 80, 0, 1234, 1490, "M80 0H1234V352H496V574H1177V914H496V1138H1234V1490H80Z") }, + { 'T', new Glyph(1344, 40, 0, 1304, 1490, "M40 1138H464V0H880V1138H1304V1490H40Z") }, + { 'A', new Glyph(1565, 0, 0, 1565, 1490, "M0 0H455L528 245H1026L1097 0H1565L1060 1490H500ZM624 565 628 578C682 758 732 932 781 1113C829 932 878 758 930 578L934 565Z") }, + }; + + public static readonly IReadOnlyDictionary Kerning = new Dictionary + { + { "v.", -160 }, + { "vB", 20 }, + { "vE", 20 }, + { "vT", -69 }, + { "vA", -139 }, + { "07", -40 }, + { "0.", -66 }, + { "0A", -80 }, + { "1v", 20 }, + { "24", -32 }, + { "3.", -20 }, + { "41", -130 }, + { "47", -120 }, + { "4.", -69 }, + { "4A", -20 }, + { "51", -40 }, + { "5.", -30 }, + { "6.", -55 }, + { "70", -32 }, + { "73", -34 }, + { "74", -119 }, + { "75", -20 }, + { "76", -32 }, + { "77", 40 }, + { "78", -29 }, + { "79", -20 }, + { "7.", -256 }, + { "7A", -221 }, + { "8.", -20 }, + { "97", -40 }, + { "9.", -66 }, + { "9A", -80 }, + { ".0", -66 }, + { ".1", -240 }, + { ".3", -40 }, + { ".6", -66 }, + { ".7", -90 }, + { ".8", -20 }, + { ".9", -36 }, + { ".T", -69 }, + { "BT", -40 }, + { "Tv", -128 }, + { "T0", -69 }, + { "T4", -139 }, + { "T6", -69 }, + { "T7", -162 }, + { "T.", -69 }, + { "TA", -200 }, + { "Av", -139 }, + { "A0", -80 }, + { "A1", -240 }, + { "A6", -80 }, + { "AT", -200 }, + { "AA", 30 }, + }; + + public record Glyph(double Advance, double XMin, double YMin, double XMax, double YMax, string Path); + } +} diff --git a/src/Requestarr.Http/Frontend/Mappers/VersionLogoMapper.cs b/src/Requestarr.Http/Frontend/Mappers/VersionLogoMapper.cs new file mode 100644 index 0000000..ce6112d --- /dev/null +++ b/src/Requestarr.Http/Frontend/Mappers/VersionLogoMapper.cs @@ -0,0 +1,65 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; + +namespace Requestarr.Http.Frontend.Mappers +{ + // /Content/Images/Icons/{light|light-purple|dark}/production/logo-version.svg: the production header and login logo + // with the running version on its bar (VersionLogo); light-purple is the light theme's purple header. Made from the + // delivered R the first time it's asked for; never cached by the browser, so an upgrade shows the new version + // straight away. + public class VersionLogoMapper : IMapHttpRequestsToDisk + { + public const string FileName = "logo-version.svg"; + + private static readonly Regex Url = new(@"^/content/images/icons/(light|light-purple|dark)/production/logo-version\.svg$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly ConcurrentDictionary Made = new(); + + private readonly IAppFolderInfo _appFolderInfo; + private readonly IDiskProvider _diskProvider; + private readonly IConfigFileProvider _configFileProvider; + private readonly Logger _logger; + + public VersionLogoMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger) + { + _appFolderInfo = appFolderInfo; + _diskProvider = diskProvider; + _configFileProvider = configFileProvider; + _logger = logger; + } + + public static bool Handles(string resourceUrl) => Url.IsMatch(resourceUrl); + + public bool CanHandle(string resourceUrl) => Handles(resourceUrl); + + // The delivered R it's drawn beside. + public string Map(string resourceUrl) + { + var theme = Url.Match(resourceUrl).Groups[1].Value.ToLowerInvariant(); + + return Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, "Content", "Images", "Icons", theme, "production", "logo-header.png"); + } + + public Task GetResponse(string resourceUrl) + { + var logo = Map(resourceUrl); + + if (!_diskProvider.FileExists(logo)) + { + _logger.Warn("File {0} not found", logo); + + return Task.FromResult(null); + } + + var svg = Made.GetOrAdd(logo, path => VersionLogo.Svg(BuildInfo.Version, File.ReadAllBytes(path))); + + return Task.FromResult(new ContentResult { Content = svg, ContentType = "image/svg+xml" }); + } + } +} diff --git a/src/Requestarr.Http/Frontend/Mappers/WebIcons.cs b/src/Requestarr.Http/Frontend/Mappers/WebIcons.cs index d151301..10ca580 100644 --- a/src/Requestarr.Http/Frontend/Mappers/WebIcons.cs +++ b/src/Requestarr.Http/Frontend/Mappers/WebIcons.cs @@ -13,6 +13,11 @@ public static class WebIcons public const string SetToken = "__ICON_SET__"; public const string ThemeToken = "__ICON_THEME__"; public const string UiThemeToken = "__UI_THEME__"; + public const string HeaderLogoToken = "__HEADER_LOGO__"; + + // The header and login logo: the delivered one for dev, QA and beta, and for production the one Requestarr draws + // with the running version on its bar. + public static string HeaderLogo(string set) => set == "production" ? VersionLogoMapper.FileName : "logo-header.png"; public static string Theme(string configured) => string.Equals(configured, "dark", StringComparison.OrdinalIgnoreCase) ? "dark" : "light"; @@ -21,7 +26,8 @@ public static class WebIcons public static string Apply(string text, IReleaseEnvironment environment, IConfigFileProvider config, string theme = null) { - return text.Replace(SetToken, environment.IconSet) + return text.Replace(HeaderLogoToken, HeaderLogo(environment.IconSet)) + .Replace(SetToken, environment.IconSet) .Replace(ThemeToken, theme ?? Theme(config.Theme)) .Replace(UiThemeToken, config.Theme); } diff --git a/src/Requestarr.Http/Frontend/StaticResourceController.cs b/src/Requestarr.Http/Frontend/StaticResourceController.cs index c9da88f..31c6680 100644 --- a/src/Requestarr.Http/Frontend/StaticResourceController.cs +++ b/src/Requestarr.Http/Frontend/StaticResourceController.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using NLog; +using NzbDrone.Common.Extensions; using Requestarr.Http.Extensions; using Requestarr.Http.Frontend.Mappers; @@ -76,7 +77,7 @@ private async Task MapResource(string path) return NotFound(); } - _logger.Warn("Couldn't find handler for {0}", path); + _logger.Warn("Couldn't find handler for {0}", path.ForLog()); return NotFound(); } diff --git a/src/Requestarr.Http/Middleware/CacheableSpecification.cs b/src/Requestarr.Http/Middleware/CacheableSpecification.cs index 36535cb..506cd9e 100644 --- a/src/Requestarr.Http/Middleware/CacheableSpecification.cs +++ b/src/Requestarr.Http/Middleware/CacheableSpecification.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using Requestarr.Http.Frontend.Mappers; namespace Requestarr.Http.Middleware { @@ -51,6 +52,12 @@ public bool IsCacheable(HttpRequest request) return false; } + // The production logo carries the running version, so it must change on upgrade. + if (path.EndsWith("/" + VersionLogoMapper.FileName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + if (path.StartsWith("/feed", StringComparison.CurrentCultureIgnoreCase)) { return false; diff --git a/src/Requestarr.Http/Middleware/LoggingMiddleware.cs b/src/Requestarr.Http/Middleware/LoggingMiddleware.cs index 3dcbaee..cd0bebf 100644 --- a/src/Requestarr.Http/Middleware/LoggingMiddleware.cs +++ b/src/Requestarr.Http/Middleware/LoggingMiddleware.cs @@ -44,7 +44,7 @@ private void LogStart(HttpContext context) var reqPath = GetRequestPathAndQuery(context.Request); - _loggerHttp.Trace("Req: {0} [{1}] {2} (from {3})", id, context.Request.Method, reqPath, GetOrigin(context)); + _loggerHttp.Trace("Req: {0} [{1}] {2} (from {3})", id, context.Request.Method.ForLog(), reqPath.ForLog(), GetOrigin(context).ForLog()); } private void LogEnd(HttpContext context) @@ -57,11 +57,11 @@ private void LogEnd(HttpContext context) var reqPath = GetRequestPathAndQuery(context.Request); - _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, context.Response.StatusCode, (HttpStatusCode)context.Response.StatusCode, (int)duration.TotalMilliseconds); + _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method.ForLog(), reqPath.ForLog(), context.Response.StatusCode, (HttpStatusCode)context.Response.StatusCode, (int)duration.TotalMilliseconds); if (context.Request.IsApiRequest()) { - _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, context.Response.StatusCode, (HttpStatusCode)context.Response.StatusCode, (int)duration.TotalMilliseconds); + _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method.ForLog(), reqPath.ForLog(), context.Response.StatusCode, (HttpStatusCode)context.Response.StatusCode, (int)duration.TotalMilliseconds); } } diff --git a/src/Requestarr.Http/Qa/QaChecklistController.cs b/src/Requestarr.Http/Qa/QaChecklistController.cs new file mode 100644 index 0000000..0f33f6d --- /dev/null +++ b/src/Requestarr.Http/Qa/QaChecklistController.cs @@ -0,0 +1,41 @@ +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Requestarr.Core.Qa; + +namespace Requestarr.Http.Qa +{ + // What Claude Code reads from the QA stack at the start of every goal (docker/qa-gate.py): the QA checklist's state + // and the bug reports this server sent. Needs the API key, like the rest of the API. + public class QaChecklistController : Controller + { + private readonly IQaChecklistService _checklist; + private readonly IBugReportRepository _reports; + + public QaChecklistController(IQaChecklistService checklist, IBugReportRepository reports) + { + _checklist = checklist; + _reports = reports; + } + + [HttpGet("/api/v1/qa/checklist")] + [Produces("application/json")] + public IActionResult Get() + { + var state = _checklist.State(); + var all = _reports.All().OrderBy(r => r.Id).ToList(); + var reports = all + .Where(r => state.Versions.Contains(r.Version)) + .Select(r => new { r.Id, r.Component, r.Version, r.Environment, r.Created, r.ChecklistItemKey, Filing = r.Filing.ToString(), r.FilingProblem, r.IssueNumber, r.IssueUrl }) + .ToList(); + + // Every report from this server that isn't on GitHub, whatever its version: each is an open bug the gate + // can't see on GitHub, so it blocks until it's filed (and then closed there). + var unfiled = all + .Where(r => r.Filing != BugReportFiling.Filed) + .Select(r => new { r.Id, r.Component, r.Version, r.Created, Filing = r.Filing.ToString(), r.FilingProblem, What = r.WhatHappened }) + .ToList(); + + return Json(new { checklist = state, bugReports = reports, unfiledReports = unfiled }); + } + } +} diff --git a/yarn.lock b/yarn.lock index 66a558e..1a79b80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1288,6 +1288,14 @@ "@sentry/types" "7.119.1" "@sentry/utils" "7.119.1" +"@sentry/core@7.120.4": + version "7.120.4" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.120.4.tgz#b90780621ed8f5a4826c827f0843dc86b3ba4cd4" + integrity sha512-TXu3Q5kKiq8db9OXGkWyXUbIxMMuttB5vJ031yolOl5T/B69JRyAoKuojLBjRv1XX583gS1rSSoX8YXX7ATFGA== + dependencies: + "@sentry/types" "7.120.4" + "@sentry/utils" "7.120.4" + "@sentry/integrations@7.119.1": version "7.119.1" resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.119.1.tgz#9fc17aa9fcb942fbd2fc12eecd77a0f316897960" @@ -1298,6 +1306,16 @@ "@sentry/utils" "7.119.1" localforage "^1.8.1" +"@sentry/integrations@7.120.4": + version "7.120.4" + resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.120.4.tgz#bcd21b4981890282dfb38f58e07e6bbfd8954d5b" + integrity sha512-kkBTLk053XlhDCg7OkBQTIMF4puqFibeRO3E3YiVc4PGLnocXMaVpOSCkMqAc1k1kZ09UgGi8DxfQhnFEjUkpA== + dependencies: + "@sentry/core" "7.120.4" + "@sentry/types" "7.120.4" + "@sentry/utils" "7.120.4" + localforage "^1.8.1" + "@sentry/replay@7.119.1": version "7.119.1" resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.119.1.tgz#117cf493a3008a39943b7d571d451c6218542847" @@ -1313,6 +1331,11 @@ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.119.1.tgz#f9c3c12e217c9078a6d556c92590e42a39b750dd" integrity sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ== +"@sentry/types@7.120.4": + version "7.120.4" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.120.4.tgz#8fab8dceeec4bda079fc6e8e380b982f766de354" + integrity sha512-cUq2hSSe6/qrU6oZsEP4InMI5VVdD86aypE+ENrQ6eZEVLTCYm1w6XhW1NvIu3UuWh7gZec4a9J7AFpYxki88Q== + "@sentry/utils@7.119.1": version "7.119.1" resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.119.1.tgz#08b28fa8170987a60e149e2102e83395a95e9a89" @@ -1320,6 +1343,13 @@ dependencies: "@sentry/types" "7.119.1" +"@sentry/utils@7.120.4": + version "7.120.4" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.120.4.tgz#8995637fc4742ee75df347dcd0f08ee137968c78" + integrity sha512-zCKpyDIWKHwtervNK2ZlaK8mMV7gVUijAgFeJStH+CU/imcdquizV3pFLlSQYRswG+Lbyd6CT/LGRh3IbtkCFw== + dependencies: + "@sentry/types" "7.120.4" + "@sindresorhus/merge-streams@^2.1.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" @@ -1384,10 +1414,10 @@ dependencies: "@types/node" "*" -"@types/lodash@4.17.26": - version "4.17.26" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.26.tgz#7f83594d70359fc4b2707e344aed9a59138d024c" - integrity sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA== +"@types/lodash@4.17.27": + version "4.17.27" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.27.tgz#15fb73275b64dbfe49e2ae8376bb469b352a47a7" + integrity sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew== "@types/minimist@^1.2.2": version "1.2.5" @@ -1560,100 +1590,100 @@ anymatch "^3.0.0" source-map "^0.6.0" -"@typescript-eslint/eslint-plugin@8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz#fdaac18e9fe84b78652ae4befd2693bdd815fe04" - integrity sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ== +"@typescript-eslint/eslint-plugin@8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz#9855d73bcc4e9d074cac3a0b8c244a4966e519ba" + integrity sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg== dependencies: "@eslint-community/regexpp" "^4.12.2" - "@typescript-eslint/scope-manager" "8.71.2" - "@typescript-eslint/type-utils" "8.71.2" - "@typescript-eslint/utils" "8.71.2" - "@typescript-eslint/visitor-keys" "8.71.2" + "@typescript-eslint/scope-manager" "8.72.0" + "@typescript-eslint/type-utils" "8.72.0" + "@typescript-eslint/utils" "8.72.0" + "@typescript-eslint/visitor-keys" "8.72.0" ignore "^7.0.5" natural-compare "^1.4.0" ts-api-utils "^2.5.0" -"@typescript-eslint/parser@8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.71.2.tgz#f12dc1d3b26432144319d384273b9c4b6f5c1e99" - integrity sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw== +"@typescript-eslint/parser@8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.72.0.tgz#e90d951ba97c6e2d8f1032f048292b7e39d8a019" + integrity sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw== dependencies: - "@typescript-eslint/scope-manager" "8.71.2" - "@typescript-eslint/types" "8.71.2" - "@typescript-eslint/typescript-estree" "8.71.2" - "@typescript-eslint/visitor-keys" "8.71.2" + "@typescript-eslint/scope-manager" "8.72.0" + "@typescript-eslint/types" "8.72.0" + "@typescript-eslint/typescript-estree" "8.72.0" + "@typescript-eslint/visitor-keys" "8.72.0" debug "^4.4.3" -"@typescript-eslint/project-service@8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.71.2.tgz#b0f89f4eec94bbdde598b8d27a446553172ada84" - integrity sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw== +"@typescript-eslint/project-service@8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.72.0.tgz#7667745dee47f92eaf12082b0e33b76db4338423" + integrity sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.71.2" - "@typescript-eslint/types" "^8.71.2" + "@typescript-eslint/tsconfig-utils" "^8.72.0" + "@typescript-eslint/types" "^8.72.0" debug "^4.4.3" -"@typescript-eslint/scope-manager@8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz#ddb35569150c9aab76d1324d7f6c1a1b68ac60d9" - integrity sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg== +"@typescript-eslint/scope-manager@8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz#673cf580ef6e2377a5ef42a7bee7c7808e0fb886" + integrity sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA== dependencies: - "@typescript-eslint/types" "8.71.2" - "@typescript-eslint/visitor-keys" "8.71.2" + "@typescript-eslint/types" "8.72.0" + "@typescript-eslint/visitor-keys" "8.72.0" -"@typescript-eslint/tsconfig-utils@8.71.2", "@typescript-eslint/tsconfig-utils@^8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz#0f12d2abbc8aca1a7cf2d0898a7c81b950622f61" - integrity sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg== +"@typescript-eslint/tsconfig-utils@8.72.0", "@typescript-eslint/tsconfig-utils@^8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz#a71e770c420ccbd7afc081f388bfa87d1958dc16" + integrity sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA== -"@typescript-eslint/type-utils@8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz#b7a615036ed59898ed1628ed74fe0b129ba0032f" - integrity sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang== +"@typescript-eslint/type-utils@8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz#b66543c7a5cb3c1447bc455222b8c02b7499a8ea" + integrity sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw== dependencies: - "@typescript-eslint/types" "8.71.2" - "@typescript-eslint/typescript-estree" "8.71.2" - "@typescript-eslint/utils" "8.71.2" + "@typescript-eslint/types" "8.72.0" + "@typescript-eslint/typescript-estree" "8.72.0" + "@typescript-eslint/utils" "8.72.0" debug "^4.4.3" ts-api-utils "^2.5.0" -"@typescript-eslint/types@8.71.2", "@typescript-eslint/types@^8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.71.2.tgz#c2629dcd4056c71b02cf172d109a59a757c2cce9" - integrity sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw== +"@typescript-eslint/types@8.72.0", "@typescript-eslint/types@^8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.72.0.tgz#d90bb9954fefa13acbcdb43bb7c0155f74d60fca" + integrity sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ== -"@typescript-eslint/typescript-estree@8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz#7ef01d376f6c1da20e173c3483f446127c88c24a" - integrity sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA== +"@typescript-eslint/typescript-estree@8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz#e20f08b852484a49af7fe621d264e8e73138308b" + integrity sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug== dependencies: - "@typescript-eslint/project-service" "8.71.2" - "@typescript-eslint/tsconfig-utils" "8.71.2" - "@typescript-eslint/types" "8.71.2" - "@typescript-eslint/visitor-keys" "8.71.2" + "@typescript-eslint/project-service" "8.72.0" + "@typescript-eslint/tsconfig-utils" "8.72.0" + "@typescript-eslint/types" "8.72.0" + "@typescript-eslint/visitor-keys" "8.72.0" debug "^4.4.3" minimatch "^10.2.2" semver "^7.7.3" tinyglobby "^0.2.15" ts-api-utils "^2.5.0" -"@typescript-eslint/utils@8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.71.2.tgz#7c01b239b7cea1310f1793bbb5a02b5f8b74a6b9" - integrity sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw== +"@typescript-eslint/utils@8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.72.0.tgz#12de2367030571a0b1d855999cdd5d614cfe7e8b" + integrity sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg== dependencies: "@eslint-community/eslint-utils" "^4.9.1" - "@typescript-eslint/scope-manager" "8.71.2" - "@typescript-eslint/types" "8.71.2" - "@typescript-eslint/typescript-estree" "8.71.2" + "@typescript-eslint/scope-manager" "8.72.0" + "@typescript-eslint/types" "8.72.0" + "@typescript-eslint/typescript-estree" "8.72.0" -"@typescript-eslint/visitor-keys@8.71.2": - version "8.71.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz#3a71f8e833aa812670a31107236b9077f4e08fe0" - integrity sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg== +"@typescript-eslint/visitor-keys@8.72.0": + version "8.72.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz#e96d7faafae685e0a0f276dc59b00e9c0ad95869" + integrity sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA== dependencies: - "@typescript-eslint/types" "8.71.2" + "@typescript-eslint/types" "8.72.0" eslint-visitor-keys "^5.0.0" "@ungap/structured-clone@^1.2.0": @@ -2546,10 +2576,10 @@ core-js-compat@^3.48.0: dependencies: browserslist "^4.28.7" -core-js@3.50.0: - version "3.50.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.50.0.tgz#a18646fcb0097650189b4504c803e887bcae4c0a" - integrity sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw== +core-js@3.51.0: + version "3.51.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.51.0.tgz#9a7b197159ab83173b50efbe0c4846bec94b3abb" + integrity sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw== core-util-is@~1.0.0: version "1.0.3" @@ -3418,9 +3448,9 @@ fast-levenshtein@^2.0.6: integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fast-uri@^3.0.1: - version "3.1.8" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.8.tgz#f7db8d942e20eead3cfe93b0beeb4eb0169e938b" - integrity sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg== + version "3.1.9" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.9.tgz#f1deba822b1d6ac79da2f860e872a63470cff203" + integrity sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ== fastest-levenshtein@^1.0.16: version "1.0.16" @@ -3556,10 +3586,10 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6" integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q== -focus-lock@^0.11.6: - version "0.11.6" - resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-0.11.6.tgz#e8821e21d218f03e100f7dc27b733f9c4f61e683" - integrity sha512-KSuV3ur4gf2KqMNoZx3nXNVhqCkn42GuTYCX4tXPEwf0MjpFQmNMiN6m7dXaUXgIoivL6/65agoUMg4RLS0Vbg== +focus-lock@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-1.3.6.tgz#955eec1e10591d56f679258edb94aedb11d691cd" + integrity sha512-Ik/6OCk9RQQ0T5Xw+hKNLWrjSMtv51dD4GRmJjbD5a58TIEpI5a5iXagKVl3Z5UuyslMCA8Xwnu76jQob62Yhg== dependencies: tslib "^2.0.3" @@ -5443,10 +5473,10 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.5.30, postcss@^8.0.0, postcss@^8.4.28, postcss@^8.4.32, postcss@^8.4.33, postcss@^8.4.35: - version "8.5.30" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.30.tgz#f76f192357d2b7026b651fd2c7baa60d05325a9e" - integrity sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA== +postcss@8.5.31, postcss@^8.0.0, postcss@^8.4.28, postcss@^8.4.32, postcss@^8.4.33, postcss@^8.4.35: + version "8.5.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.31.tgz#d44731fa02f8ae8371629ec7220e2ae0dbdf23ec" + integrity sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg== dependencies: nanoid "^3.3.20" picocolors "^1.1.1" @@ -5613,7 +5643,7 @@ react-autosuggest@10.1.0: section-iterator "^2.0.0" shallow-equal "^1.2.1" -react-clientside-effect@^1.2.6: +react-clientside-effect@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.8.tgz#0b90a9d7b2a1823a3a10ed1ea3f651f7e0301cb7" integrity sha512-ma2FePH0z3px2+WOu6h+YycZcEvFmmxIlAb62cF52bG86eMySciO/EQZeQMXd07kPCYB0a1dWDT5J+KE9mCDUw== @@ -5687,17 +5717,17 @@ react-dom@18.3.1: loose-envify "^1.1.0" scheduler "^0.23.2" -react-focus-lock@2.9.4: - version "2.9.4" - resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.9.4.tgz#4753f6dcd167c39050c9d84f9c63c71b3ff8462e" - integrity sha512-7pEdXyMseqm3kVjhdVH18sovparAzLg5h6WvIx7/Ck3ekjhrrDMEegHSa3swwC8wgfdd7DIdUVRGeiHT9/7Sgg== +react-focus-lock@2.13.7: + version "2.13.7" + resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.13.7.tgz#2f51ad417e4212a9f7bdd87da092aa2ad0536d5a" + integrity sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og== dependencies: "@babel/runtime" "^7.0.0" - focus-lock "^0.11.6" + focus-lock "^1.3.6" prop-types "^15.6.2" - react-clientside-effect "^1.2.6" - use-callback-ref "^1.3.0" - use-sidecar "^1.1.2" + react-clientside-effect "^1.2.7" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" react-google-recaptcha@2.1.0: version "2.1.0" @@ -7125,14 +7155,14 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" -use-callback-ref@^1.3.0: +use-callback-ref@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== dependencies: tslib "^2.0.0" -use-sidecar@^1.1.2: +use-sidecar@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==