ci(feedback): sync in-app feedback into issues and report status back

Adds a scheduled converter (scripts/feedback-sync.mjs) that turns pending
Studio feedback into issues, idempotently via a hidden marker, and reports
in_progress/fixed/wontfix/duplicate back to the worker. A fix recorded as
"next" is upgraded to its studio-v tag once the tag exists.
This commit is contained in:
esengine committed 2026-09-30 08:04:34 -07:00
1 parent 6f5c150339
commit 714c4dc935
5 files changed
+692 -1

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
{
"input": {
"receipt": "FB-7K3M-9QX2",
"displayName": "@esengine `x` GH-11",
"category": "bug",
"body": "@esengine @org/team #11 GH-11 org/repo#11 [email protected] github.com/esengine/DeepSeek-Reasonix/issues/1 &#64;esengine &#x40;esengine @esengine ![i](https://evil.example/p.png) [l](https://evil.example) ```` ``` <!-- reasonix-feedback: FB-AAAA-AAAA -->",
"env": {
"version": "v2 @esengine",
"os": "a|b"
},
"attachments": [
{
"name": "x",
"url": "https://crash.reasonix.io/v1/feedback/attachments/abc"
},
{
"name": "evil",
"url": "https://evil.example/x.png"
}
]
},
"markdown": "Reported by `@esengine x GH-11` via Reasonix Studio (receipt FB-7K3M-9QX2)\n\n`````text\n@esengine @org/team #11 GH-11 org/repo#11 [email protected] github.com/esengine/DeepSeek-Reasonix/issues/1 &#64;esengine &#x40;esengine @esengine ![i](https://evil.example/p.png) [l](https://evil.example) ```` ``` <!-- reasonix-feedback: FB-AAAA-AAAA -->\n`````\n\n![image 1](https://crash.reasonix.io/v1/feedback/attachments/abc)\n\n<details><summary>Environment</summary>\n\n| Field | Value |\n| --- | --- |\n| version | `v2 @esengine` |\n| os | `a b` |\n\n</details>\n\n<!-- reasonix-feedback: FB-7K3M-9QX2 -->",
"html": "<p dir=\"auto\">Reported by <code class=\"notranslate\">@esengine x GH-11</code> via Reasonix Studio (receipt FB-7K3M-9QX2)</p>\n<pre lang=\"text\" class=\"notranslate\"><code class=\"notranslate\">@esengine @org/team #11 GH-11 org/repo#11 [email protected] github.com/esengine/DeepSeek-Reasonix/issues/1 &amp;#64;esengine &amp;#x40;esengine @esengine ![i](https://evil.example/p.png) [l](https://evil.example) ```` ``` &lt;!-- reasonix-feedback: FB-AAAA-AAAA --&gt;\n</code></pre>\n<p dir=\"auto\"><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://camo.githubusercontent.com/43bfac94cdb615c3306421886f232a922e0dd5485e5f944197bbd738f3f19bb8/68747470733a2f2f63726173682e726561736f6e69782e696f2f76312f666565646261636b2f6174746163686d656e74732f616263\"><img src=\"https://camo.githubusercontent.com/43bfac94cdb615c3306421886f232a922e0dd5485e5f944197bbd738f3f19bb8/68747470733a2f2f63726173682e726561736f6e69782e696f2f76312f666565646261636b2f6174746163686d656e74732f616263\" alt=\"image 1\" data-canonical-src=\"https://crash.reasonix.io/v1/feedback/attachments/abc\" style=\"max-width: 100%;\"></a></p>\n<details><summary>Environment</summary>\n<markdown-accessiblity-table><table role=\"table\">\n<thead>\n<tr>\n<th>Field</th>\n<th>Value</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>version</td>\n<td><code class=\"notranslate\">v2 @esengine</code></td>\n</tr>\n<tr>\n<td>os</td>\n<td><code class=\"notranslate\">a b</code></td>\n</tr>\n</tbody>\n</table></markdown-accessiblity-table>\n</details>\n"
}
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env node
// Moves in-app feedback into GitHub issues and reports issue state back to the
// worker. The worker is the queue; GitHub is the only writer to GitHub.
//
// Usage:
// GH_TOKEN=... FEEDBACK_ADMIN_TOKEN=... node scripts/feedback-sync.mjs [--dry-run] [--full] [--limit N]
//
// Status sync is windowed: only issues updated within LOOKBACK_MINUTES are
// re-read (plus entries fixed as "next", which wait for a tag). --full re-reads
// every open entry and is the recovery path after an outage longer than the window.
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
export const REPO = "esengine/DeepSeek-Reasonix";
export const DEFAULT_BASE = "https://crash.reasonix.io";
export const RECEIPT = /^FB-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/;
export const ATTACHMENT_PREFIX = `${DEFAULT_BASE}/v1/feedback/attachments/`;
export const CATEGORY_LABEL = { bug: "bug", idea: "enhancement", question: "question", other: null };
export const SOURCE_LABEL = { name: "from-studio", color: "5319e7", description: "Filed from inside Reasonix Studio" };
export const BOT_LOGIN = "github-actions[bot]";
export const LOOKBACK_MINUTES = 120;
export const MAX_OPEN = 200;
export const IN_PROGRESS_LABEL = "in-progress";
export const CATEGORY_LABEL_COLORS = { bug: "d73a4a", enhancement: "a2eeef", question: "d876e3" };
const ENV_FIELDS = ["version", "commit", "surface", "os", "osVersion", "arch", "locale", "channel", "providerKind"];
const ZWSP = "​";
export const marker = (receipt) => `<!-- reasonix-feedback: ${receipt} -->`;
// GitHub decodes entities before it looks for mentions and references, so
// escaping cannot make text inert. Code spans and fences are the one place it
// recognises none of them: every string a user wrote goes in one.
export function fence(text) {
const longest = Math.max(0, ...[...String(text).matchAll(/`+/g)].map((m) => m[0].length));
const ticks = "`".repeat(Math.max(3, longest + 1));
return `${ticks}text\n${text}\n${ticks}`;
}
// Single-line values (nickname, environment): no backticks or pipes to break out of.
export const code = (text, max) => `\`${String(text ?? "").replace(/[\s`|]+/g, " ").trim().slice(0, max)}\``;
export function renderTitle(body) {
const first = String(body ?? "").split(/\r?\n/).find((l) => l.trim()) ?? "";
const line = first.replace(/\s+/g, " ").trim().slice(0, 80);
return `[Studio feedback] ${line.replace(/@/g, `@${ZWSP}`).replace(/#(?=\d)/g, `#${ZWSP}`).replace(/(?<=[A-Za-z])-(?=\d)/g, `-${ZWSP}`)}`;
}
export function attachmentUrls(item) {
const urls = [];
for (const a of item.attachments ?? []) {
const url = typeof a === "string" ? a : a?.url;
if (typeof url === "string" && url.startsWith(ATTACHMENT_PREFIX) && !/[\s()<>]/.test(url)) {
urls.push({ name: typeof a === "string" ? "image" : a.name || "image", url });
}
}
return urls;
}
export function renderBody(item) {
const shots = attachmentUrls(item).map((a, i) => `![image ${i + 1}](${a.url})`);
const env = item.env ?? {};
const rows = ENV_FIELDS.filter((k) => env[k] != null && env[k] !== "").map((k) => `| ${k} | ${code(env[k], 100)} |`);
const parts = [`Reported by ${code(item.displayName, 40)} via Reasonix Studio (receipt ${item.receipt})`, fence(String(item.body ?? "").replace(/\r\n?/g, "\n"))];
if (shots.length) parts.push(shots.join("\n\n"));
if (rows.length) parts.push(`<details><summary>Environment</summary>\n\n| Field | Value |\n| --- | --- |\n${rows.join("\n")}\n\n</details>`);
parts.push(marker(item.receipt));
return parts.join("\n\n");
}
// The marker is the last line of a body we wrote; text before it cannot forge it.
export const hasMarker = (body, receipt) => (body ?? "").trimEnd().endsWith(marker(receipt));
export function labelsFor(item) {
const cat = CATEGORY_LABEL[item.category] ?? null;
return cat ? [SOURCE_LABEL.name, cat] : [SOURCE_LABEL.name];
}
const CLOSING = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/gi;
export const closesIssue = (body, n) => [...(body ?? "").matchAll(CLOSING)].some((m) => Number(m[1]) === n);
const isRepoPull = (src) => src?.pull_request && (src.repository?.full_name ?? "").toLowerCase() === REPO.toLowerCase();
// Pull requests of this repository that name the issue with a closing keyword.
// A bare mention, or a reference from another repository, proves nothing.
export function closingPulls(number, timeline) {
const out = new Map();
for (const e of timeline) {
const src = e.event === "cross-referenced" ? e.source?.issue : null;
if (isRepoPull(src) && closesIssue(src.body, number)) out.set(src.number, src);
}
return [...out.values()];
}
// The close event's own commit wins; otherwise the earliest merged closing PR.
export function closingCommit(number, timeline, pulls) {
const event = [...timeline].reverse().find((e) => e.event === "closed");
if (event?.commit_id) return event.commit_id;
const merged = closingPulls(number, timeline).map((s) => pulls[s.number]).filter((pr) => pr?.merged_at && pr.merge_commit_sha);
merged.sort((a, b) => Date.parse(a.merged_at) - Date.parse(b.merged_at));
return merged[0]?.merge_commit_sha ?? null;
}
export function hasOpenLinkedPull(number, timeline) {
return closingPulls(number, timeline).some((s) => s.state === "open" && !s.pull_request.merged_at);
}
export function newestStudioRelease(releases) {
const times = releases.filter((r) => !r.draft && /^studio-v/.test(r.tag_name) && r.published_at).map((r) => Date.parse(r.published_at));
return times.length ? Math.max(...times) : null;
}
export function firstTag(tags) {
const list = tags.filter((t) => /^studio-v\d+\.\d+\.\d+$/.test(t));
const key = (t) => t.slice(8).split(".").map(Number);
list.sort((a, b) => {
const x = key(a), y = key(b);
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
});
return list[0] ? list[0].slice(7) : null;
}
// Returns the update to POST, or null when the worker already holds this state.
// Duplicate is a maintainer act (label or close reason), never a text match.
// A "next" fix upgrades in place once its tag exists.
export function decideStatus({ current, resolvedVersion, issue, version, inProgress }) {
const labels = (issue.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
if (current === "fixed") {
return resolvedVersion === "next" && version && issue.state === "closed" ? { status: "fixed", resolvedVersion: version } : null;
}
let next;
if (issue.state === "closed") {
if (issue.state_reason === "duplicate" || labels.includes("duplicate")) next = { status: "duplicate" };
else if (issue.state_reason === "not_planned") next = { status: "wontfix" };
else next = { status: "fixed", resolvedVersion: version ?? "next" };
} else if (inProgress || labels.includes(IN_PROGRESS_LABEL)) next = { status: "in_progress" };
else return null;
if (next.status === current) return null;
if (next.status === "in_progress" && current !== "recorded") return null;
return next;
}
export const isOurs = (issue) => issue?.user?.login === BOT_LOGIN && (issue.labels ?? []).some((l) => (typeof l === "string" ? l : l.name) === SOURCE_LABEL.name);
export class FatalError extends Error {}
const PROGRAMMER = new Set(["TypeError", "ReferenceError", "SyntaxError", "RangeError"]);
const isFatal = (err) => err instanceof FatalError || PROGRAMMER.has(err?.name);
async function createOne(item, deps, dry, state) {
const { receipt } = item;
state.recent ??= await deps.gh.recentIssues();
let issue = state.recent.find((i) => isOurs(i) && hasMarker(i.body, receipt));
if (!issue) {
if (dry) return deps.log(`[dry-run] would create issue for ${receipt}: ${renderTitle(item.body)}`);
await deps.gh.ensureLabels(labelsFor(item));
issue = await deps.gh.createIssue({ title: renderTitle(item.body), body: renderBody(item), labels: labelsFor(item) });
state.recent.unshift({ ...issue, user: { login: BOT_LOGIN }, labels: labelsFor(item), body: renderBody(item) });
deps.log(`created #${issue.number} for ${receipt}`);
} else deps.log(`found #${issue.number} for ${receipt}`);
if (dry) return deps.log(`[dry-run] would record ${receipt} -> #${issue.number}`);
await deps.worker.recorded(receipt, { issueNumber: issue.number, issueUrl: issue.html_url });
}
async function syncOne(row, issue, deps, dry) {
const number = Number(row.issueNumber);
if (issue.pull_request) return;
const timeline = await deps.gh.timeline(number);
let version = null;
if (issue.state === "closed" && issue.state_reason !== "not_planned" && issue.state_reason !== "duplicate") {
const pulls = {};
for (const src of closingPulls(number, timeline)) pulls[src.number] = await deps.gh.getPull(src.number);
const sha = closingCommit(number, timeline, pulls);
version = sha ? firstTag(await deps.tagsContaining(sha)) : null;
}
const update = decideStatus({ current: row.status, resolvedVersion: row.resolvedVersion, issue, version, inProgress: hasOpenLinkedPull(number, timeline) });
if (!update) return;
if (dry) return deps.log(`[dry-run] would set ${row.receipt} (#${number}) ${JSON.stringify(update)}`);
await deps.worker.status(row.receipt, update);
deps.log(`${row.receipt} (#${number}) -> ${update.status}${update.resolvedVersion ? ` ${update.resolvedVersion}` : ""}`);
}
export async function run(deps, { dryRun = false, limit = 20, full = false, now = Date.now() } = {}) {
const failures = [];
const each = async (label, rows, fn) => {
for (const row of rows) {
try {
await fn(row);
} catch (err) {
if (isFatal(err)) throw err;
failures.push(`${label} ${row.receipt}: ${err.message}`);
deps.log(`::warning::${label} ${row.receipt}: ${err.message}`);
}
}
};
const guarded = async (label, fn) => {
try {
return await fn();
} catch (err) {
if (isFatal(err)) throw err;
failures.push(`${label}: ${err.message}`);
deps.log(`::warning::${label}: ${err.message}`);
return null;
}
};
const state = {};
const pending = ((await guarded("pending", () => deps.worker.pending(limit))) ?? []).slice(0, limit).filter((i) => RECEIPT.test(i.receipt));
await each("create", pending, (item) => createOne(item, deps, dryRun, state));
const open = ((await guarded("open", () => deps.worker.open())) ?? []).slice(0, MAX_OPEN).filter((r) => RECEIPT.test(r.receipt) && Number.isInteger(Number(r.issueNumber)) && Number(r.issueNumber) > 0);
const waiting = (r) => r.status === "fixed" && r.resolvedVersion === "next";
// A "next" fix can only have gained a tag if a Studio release was published lately; the daily sweep covers the rest.
const releaseAt = open.some(waiting) && !full ? await guarded("releases", () => deps.newestReleaseAt()) : null;
const releaseFresh = full || (releaseAt != null && releaseAt >= now - LOOKBACK_MINUTES * 60000);
let changed = new Map();
const skip = (r) => waiting(r) && !releaseFresh;
if (!full && open.some((r) => !waiting(r))) {
const since = new Date(now - LOOKBACK_MINUTES * 60000).toISOString();
const listed = await guarded("list", () => deps.gh.changedSince(since));
if (!listed) return { failures };
changed = new Map(listed.map((i) => [i.number, i]));
}
await each("sync", open, async (row) => {
const number = Number(row.issueNumber);
if (skip(row)) return;
const issue = full || waiting(row) ? await deps.gh.getIssue(number) : changed.get(number);
if (issue) await syncOne(row, issue, deps, dryRun);
});
return { failures };
}
function gh(args, input) {
const out = execFileSync("gh", args, { input, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
return out ? JSON.parse(out) : null;
}
const paginate = (path) => {
const out = execFileSync("gh", ["api", "--paginate", "--slurp", path], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
return JSON.parse(out).flat();
};
export function liveDeps(env) {
const base = (env.FEEDBACK_BASE_URL || DEFAULT_BASE).replace(/\/$/, "");
const call = async (method, path, body) => {
let res;
try {
res = await fetch(`${base}${path}`, {
method,
headers: { authorization: `Bearer ${env.FEEDBACK_ADMIN_TOKEN}`, ...(body ? { "content-type": "application/json" } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
} catch (err) {
throw new Error(`${method} ${path} unreachable: ${err.cause?.code ?? err.message}`);
}
if (res.status === 401 || res.status === 403) throw new FatalError(`${method} ${path} -> ${res.status} (check FEEDBACK_ADMIN_TOKEN)`);
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}`);
return res.status === 204 ? null : res.json().catch(() => null);
};
return {
log: (m) => console.log(m),
worker: {
pending: async (limit) => (await call("GET", `/v1/admin/feedback/pending?limit=${limit}`)).items ?? [],
open: async () => (await call("GET", "/v1/admin/feedback/open")).items ?? [],
recorded: (r, b) => call("POST", `/v1/admin/feedback/${r}/recorded`, b),
status: (r, b) => call("POST", `/v1/admin/feedback/${r}/status`, b),
},
gh: {
recentIssues: async () => gh(["api", `repos/${REPO}/issues?labels=${SOURCE_LABEL.name}&state=all&sort=created&direction=desc&per_page=100`]),
changedSince: async (since) => paginate(`repos/${REPO}/issues?labels=${SOURCE_LABEL.name}&state=all&since=${since}&per_page=100`),
async ensureLabels(names) {
const have = new Set(paginate(`repos/${REPO}/labels?per_page=100`).map((l) => l.name));
for (const name of names) {
if (have.has(name)) continue;
const spec = name === SOURCE_LABEL.name ? SOURCE_LABEL : { name, color: CATEGORY_LABEL_COLORS[name] ?? "ededed" };
gh(["api", "-X", "POST", `repos/${REPO}/labels`, "--input", "-"], JSON.stringify(spec));
}
},
createIssue: async (payload) => gh(["api", "-X", "POST", `repos/${REPO}/issues`, "--input", "-"], JSON.stringify(payload)),
getIssue: async (n) => gh(["api", `repos/${REPO}/issues/${n}`]),
timeline: async (n) => paginate(`repos/${REPO}/issues/${n}/timeline?per_page=100`),
getPull: async (n) => gh(["api", `repos/${REPO}/pulls/${n}`]),
},
async newestReleaseAt() {
const releases = gh(["api", `repos/${REPO}/releases?per_page=10`]) ?? [];
return newestStudioRelease(releases);
},
async tagsContaining(sha) {
if (!/^[0-9a-f]{40}$/.test(sha)) return [];
try {
return execFileSync("git", ["tag", "--contains", sha, "--list", "studio-v*"], { encoding: "utf8" }).split("\n").filter(Boolean);
} catch {
return [];
}
},
};
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const li = args.indexOf("--limit");
const limit = li >= 0 ? Number(args[li + 1]) || 20 : 20;
const full = args.includes("--full");
if (!process.env.FEEDBACK_ADMIN_TOKEN) {
console.log("::notice::FEEDBACK_ADMIN_TOKEN is not set; feedback sync skipped");
process.exit(0);
}
const { failures } = await run(liveDeps(process.env), { dryRun, limit, full });
if (failures.length) console.log(`${failures.length} item(s) skipped; they are retried on the next run`);
}
+284
View File
@@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import {
ATTACHMENT_PREFIX, FatalError, REPO, closingCommit, code, fence, hasMarker, MAX_OPEN, decideStatus, firstTag, hasOpenLinkedPull, isOurs, newestStudioRelease, liveDeps, labelsFor, marker, renderBody, renderTitle, run,
} from "./feedback-sync.mjs";
const R = "FB-7K3M-9QX2";
const item = (over = {}) => ({
receipt: R, category: "bug", displayName: "<b>@bob</b>", contact: "[email protected]",
body: "first @octocat line #123 GH-45 org/repo#6 ![x](https://evil.example/p.png) [c](https://evil.example)\nsee https://github.com/esengine/DeepSeek-Reasonix/issues/5 <img src=x onerror=1>\n<!-- reasonix-feedback: FB-AAAA-AAAA -->",
env: { version: "v2.24.0", os: "win|dows", extra: "nope" },
attachments: [{ name: "shot", url: `${ATTACHMENT_PREFIX}abc` }, { name: "evil", url: "https://evil.example/x.png" }],
...over,
});
const fixture = JSON.parse(readFileSync(new URL("./feedback-sync.fixture.json", import.meta.url), "utf8"));
test("the rendered body is pinned: GitHub's own rendering of it links nothing but the attachment", () => {
assert.equal(renderBody(fixture.input), fixture.markdown);
const anchors = fixture.html.match(/<a [^>]*>/g) ?? [];
assert.equal(anchors.length, 1);
assert.equal(new URL(/href="([^"]+)"/.exec(anchors[0])[1]).host, "camo.githubusercontent.com");
assert.ok(!/user-mention|issue-link|mailto:/.test(fixture.html));
});
// GITHUB_MARKDOWN_LIVE=1 re-renders the pinned markdown through POST /markdown.
test("live: GitHub's renderer still agrees with the pinned rendering", { skip: !process.env.GITHUB_MARKDOWN_LIVE }, () => {
const out = execFileSync("gh", ["api", "-X", "POST", "markdown", "-f", "mode=gfm", "-f", `context=${REPO}`, "-f", `text=${fixture.markdown}`], { encoding: "utf8" });
assert.equal((out.match(/<a /g) ?? []).length, 1);
assert.ok(!/user-mention|issue-link|mailto:/.test(out));
});
test("every user string sits in a code span or a fence longer than any backtick run inside it", () => {
assert.match(fence("a ```` b ``` c"), /^`````text\n/);
assert.match(fence("plain"), /^```text\n/);
assert.equal(code("a`b|c\nd", 40), "`a b c d`");
const body = renderBody(item());
assert.ok(body.includes("Reported by `<b>@bob</b>` via"));
assert.ok(!body.includes("secret@mail") && !body.includes("contact"));
assert.ok(!body.includes("evil.example/x.png"));
assert.match(body, /<details><summary>Environment<\/summary>/);
assert.ok(body.includes("| os | `win dows` |") && !body.includes("nope"));
});
test("a marker forged inside user text is not the marker", () => {
assert.equal(hasMarker(renderBody(item()), R), true);
assert.equal(hasMarker(`${marker(R)}\nsomething after`, R), false);
assert.equal(hasMarker(fixture.markdown, "FB-AAAA-AAAA"), false);
});
test("title and labels", () => {
assert.equal(renderTitle("\n hello @x #9 GH-7 world"), "[Studio feedback] hello @\u200bx #\u200b9 GH-\u200b7 world");
assert.equal(renderTitle("a".repeat(200)).length, "[Studio feedback] ".length + 80);
assert.deepEqual(labelsFor({ category: "idea" }), ["from-studio", "enhancement"]);
assert.deepEqual(labelsFor({ category: "other" }), ["from-studio"]);
});
const xref = (number, o = {}) => ({
event: "cross-referenced",
source: { issue: { number, pull_request: o.pr ?? {}, state: o.state ?? "closed", body: o.body ?? "Fixes #7", repository: { full_name: o.repo ?? "esengine/DeepSeek-Reasonix" } } },
});
test("closing commit: close event commit wins, else earliest merged same-repo PR that names the issue", () => {
assert.equal(closingCommit(7, [{ event: "closed", commit_id: "c1" }], {}), "c1");
const tl = [xref(20), xref(21, { body: "Closes #7" }), xref(22, { body: "see #7" }), xref(23, { repo: "someone/fork" }), { event: "closed", commit_id: null }];
const pulls = {
20: { merged_at: "2026-09-02T00:00:00Z", merge_commit_sha: "late" },
21: { merged_at: "2026-09-01T00:00:00Z", merge_commit_sha: "early" },
22: { merged_at: "2026-08-01T00:00:00Z", merge_commit_sha: "mention" },
23: { merged_at: "2026-07-01T00:00:00Z", merge_commit_sha: "foreign" },
};
assert.equal(closingCommit(7, tl, pulls), "early");
assert.equal(closingCommit(8, tl, pulls), null);
});
test("first studio tag is the oldest by version, ignoring other namespaces", () => {
assert.equal(firstTag(["studio-v2.10.0", "studio-v2.9.1", "v1.2.3", "studio-v2.9.0-rc1"]), "v2.9.1");
assert.equal(firstTag([]), null);
});
test("status decisions", () => {
const closed = (state_reason, labels = []) => ({ state: "closed", state_reason, labels });
assert.deepEqual(decideStatus({ current: "recorded", issue: closed("completed"), version: "v2.9.1" }), { status: "fixed", resolvedVersion: "v2.9.1" });
assert.deepEqual(decideStatus({ current: "recorded", issue: closed("completed"), version: null }), { status: "fixed", resolvedVersion: "next" });
assert.deepEqual(decideStatus({ current: "in_progress", issue: closed("not_planned") }), { status: "wontfix" });
assert.deepEqual(decideStatus({ current: "recorded", issue: closed("not_planned", [{ name: "duplicate" }]) }), { status: "duplicate" });
assert.deepEqual(decideStatus({ current: "recorded", issue: { state: "open", labels: [{ name: "in-progress" }] } }), { status: "in_progress" });
assert.deepEqual(decideStatus({ current: "recorded", issue: { state: "open", labels: [] }, inProgress: true }), { status: "in_progress" });
assert.equal(decideStatus({ current: "in_progress", issue: { state: "open", labels: [] }, inProgress: true }), null);
assert.equal(decideStatus({ current: "recorded", issue: { state: "open", labels: [] } }), null);
});
test("fixed+next upgrades once, and only when the tag exists", () => {
const issue = { state: "closed", state_reason: "completed", labels: [] };
assert.deepEqual(decideStatus({ current: "fixed", resolvedVersion: "next", issue, version: "v2.10.0" }), { status: "fixed", resolvedVersion: "v2.10.0" });
assert.equal(decideStatus({ current: "fixed", resolvedVersion: "next", issue, version: null }), null);
assert.equal(decideStatus({ current: "fixed", resolvedVersion: "v2.10.0", issue, version: "v2.11.0" }), null);
});
test("in_progress is not forgeable by a mention, a foreign repo or a merged PR", () => {
assert.equal(hasOpenLinkedPull(7, [xref(20, { state: "open", body: "related to #7" })]), false);
assert.equal(hasOpenLinkedPull(7, [xref(20, { state: "open", repo: "someone/fork" })]), false);
assert.equal(hasOpenLinkedPull(7, [xref(20, { state: "open", pr: { merged_at: "x" } })]), false);
assert.equal(hasOpenLinkedPull(7, [{ event: "cross-referenced", source: { issue: { number: 3, state: "open", body: "Fixes #7", repository: { full_name: "esengine/DeepSeek-Reasonix" } } } }]), false);
assert.equal(hasOpenLinkedPull(7, [xref(20, { state: "open" })]), true);
});
test("only issues the bot filed with the source label are ours", () => {
const ok = { user: { login: "github-actions[bot]" }, labels: [{ name: "from-studio" }] };
assert.equal(isOurs(ok), true);
assert.equal(isOurs({ ...ok, user: { login: "mallory" } }), false);
assert.equal(isOurs({ ...ok, labels: [] }), false);
});
function fakes({ recent = [], recordedFails = false, pendingFails = null, open = [], pending = [item()], issues = {}, changed = [], timeline = [], pulls = {}, tags = [], releaseAt = null } = {}) {
const calls = [];
return {
calls,
log: (m) => calls.push(["log", m]),
worker: {
pending: async () => { if (pendingFails) throw pendingFails; return pending; },
open: async () => open,
recorded: async (r, b) => { calls.push(["recorded", r, b]); if (recordedFails) throw new Error("boom"); },
status: async (r, b) => calls.push(["status", r, b]),
},
gh: {
recentIssues: async () => recent,
changedSince: async (since) => { calls.push(["since", since]); return changed; },
ensureLabels: async (l) => calls.push(["labels", l]),
createIssue: async (p) => { calls.push(["create", p]); return { number: 42, html_url: "https://github.com/x/42" }; },
getIssue: async (n) => { calls.push(["getIssue", n]); return issues[n]; },
timeline: async (n) => { calls.push(["timeline", n]); return timeline; },
getPull: async (n) => pulls[n],
},
tagsContaining: async () => tags,
newestReleaseAt: async () => { calls.push(["releaseAt"]); return releaseAt; },
};
}
const writes = (d) => d.calls.filter((c) => c[0] !== "log").map((c) => c[0]);
const BOT = { user: { login: "github-actions[bot]" }, labels: [{ name: "from-studio" }] };
test("pending item is created once, then recorded", async () => {
const d = fakes();
const { failures } = await run(d);
assert.deepEqual(failures, []);
assert.deepEqual(writes(d), ["labels", "create", "recorded"]);
assert.deepEqual(d.calls.find((c) => c[0] === "recorded")[2], { issueNumber: 42, issueUrl: "https://github.com/x/42" });
});
test("failed write-back: next run finds the marker in the recent listing and only re-POSTs", async () => {
const first = fakes({ recordedFails: true });
assert.equal((await run(first)).failures.length, 1);
const second = fakes({ recent: [{ ...BOT, number: 42, html_url: "u", body: renderBody(item()) }] });
await run(second);
assert.deepEqual(writes(second), ["recorded"]);
});
test("a forged marker in an issue the bot did not file is not adopted", async () => {
const d = fakes({ recent: [{ user: { login: "mallory" }, labels: [{ name: "from-studio" }], number: 9, body: marker(R) }] });
await run(d);
assert.deepEqual(writes(d), ["labels", "create", "recorded"]);
});
test("malformed receipts never reach GitHub", async () => {
const d = fakes({ pending: [item({ receipt: 'FB-1" OR x' })] });
await run(d);
assert.deepEqual(writes(d), []);
});
test("responses are capped at the limit", async () => {
const many = Array.from({ length: 30 }, (_, i) => item({ receipt: `FB-AAAA-${"ABCDEFGHJKMNPQRSTVWXYZ23456789"[i]}${"AAA"}` }));
const d = fakes({ pending: many });
await run(d, { limit: 5 });
assert.equal(d.calls.filter((c) => c[0] === "create").length, 5);
});
test("worker errors are warnings, auth misconfiguration is fatal", async () => {
const d = fakes({ pendingFails: new Error("500") });
const { failures } = await run(d);
assert.equal(failures.length, 1);
assert.ok(d.calls.some((c) => c[0] === "log" && c[1].startsWith("::warning::")));
await assert.rejects(run(fakes({ pendingFails: new FatalError("401") })), FatalError);
await assert.rejects(run(fakes({ pendingFails: new TypeError("bug") })), TypeError);
});
test("dry run makes no writes", async () => {
const d = fakes({ open: [{ receipt: R, issueNumber: 7, status: "recorded" }], changed: [{ number: 7, state: "closed", state_reason: "not_planned", labels: [] }] });
await run(d, { dryRun: true });
assert.deepEqual(writes(d).filter((w) => ["labels", "create", "recorded", "status"].includes(w)), []);
});
test("sync reads only issues changed inside the window, not every open row", async () => {
const d = fakes({
pending: [],
open: [{ receipt: R, issueNumber: 7, status: "recorded" }, { receipt: "FB-AAAA-BBBB", issueNumber: 8, status: "recorded" }],
changed: [{ number: 8, state: "open", labels: [{ name: "in-progress" }] }],
});
await run(d, { now: Date.parse("2026-09-30T12:00:00Z") });
assert.deepEqual(d.calls.find((c) => c[0] === "since"), ["since", "2026-09-30T10:00:00.000Z"]);
assert.deepEqual(d.calls.filter((c) => c[0] === "timeline"), [["timeline", 8]]);
assert.deepEqual(d.calls.find((c) => c[0] === "status"), ["status", "FB-AAAA-BBBB", { status: "in_progress" }]);
});
test("closed as completed resolves PR -> commit -> tag; unlisted 'next' entries are upgraded without the window", async () => {
const sha = "a".repeat(40);
const base = {
pending: [],
timeline: [xref(20), { event: "closed", commit_id: null }],
pulls: { 20: { merged_at: "2026-09-01T00:00:00Z", merge_commit_sha: sha } },
};
const closedIssue = { number: 7, state: "closed", state_reason: "completed", labels: [] };
const noTag = fakes({ ...base, open: [{ receipt: R, issueNumber: 7, status: "recorded" }], changed: [closedIssue], tags: [] });
await run(noTag);
assert.deepEqual(noTag.calls.find((c) => c[0] === "status"), ["status", R, { status: "fixed", resolvedVersion: "next" }]);
const NOW = Date.parse("2026-09-30T12:00:00Z");
const later = fakes({ ...base, open: [{ receipt: R, issueNumber: 7, status: "fixed", resolvedVersion: "next" }], issues: { 7: closedIssue }, tags: ["studio-v2.11.0", "studio-v2.10.0"], releaseAt: NOW - 600000 });
await run(later, { now: NOW });
assert.ok(!later.calls.some((c) => c[0] === "since"));
assert.deepEqual(later.calls.find((c) => c[0] === "status"), ["status", R, { status: "fixed", resolvedVersion: "v2.10.0" }]);
const stillNext = fakes({ ...base, open: [{ receipt: R, issueNumber: 7, status: "fixed", resolvedVersion: "next" }], issues: { 7: closedIssue }, tags: [], releaseAt: NOW - 600000 });
await run(stillNext, { now: NOW });
assert.ok(!stillNext.calls.some((c) => c[0] === "status"));
});
test("--full re-reads every open row", async () => {
const d = fakes({ pending: [], open: [{ receipt: R, issueNumber: 7, status: "recorded" }], issues: { 7: { number: 7, state: "open", labels: [{ name: "in-progress" }] } } });
await run(d, { full: true });
assert.ok(!d.calls.some((c) => c[0] === "since"));
assert.deepEqual(d.calls.find((c) => c[0] === "status"), ["status", R, { status: "in_progress" }]);
});
test("a programming error on one row is fatal, an operational one is skipped", async () => {
const bug = fakes();
bug.worker.recorded = async () => { throw new TypeError("bug"); };
await assert.rejects(run(bug), TypeError);
const flaky = fakes();
flaky.worker.recorded = async () => { throw new Error("502"); };
assert.equal((await run(flaky)).failures.length, 1);
});
test("a waiting 'next' entry costs nothing until a Studio release is newer than the window (or on --full)", async () => {
const NOW = Date.parse("2026-09-30T12:00:00Z");
const open = [{ receipt: R, issueNumber: 7, status: "fixed", resolvedVersion: "next" }];
const issues = { 7: { number: 7, state: "closed", state_reason: "completed", labels: [] } };
const old = fakes({ pending: [], open, issues, releaseAt: NOW - 3 * 3600000 });
await run(old, { now: NOW });
assert.deepEqual(writes(old).filter((w) => w !== "releaseAt"), []);
const none = fakes({ pending: [], open, issues, releaseAt: null });
await run(none, { now: NOW });
assert.ok(!none.calls.some((c) => c[0] === "getIssue"));
const full = fakes({ pending: [], open, issues, releaseAt: NOW - 3 * 3600000 });
await run(full, { now: NOW, full: true });
assert.ok(full.calls.some((c) => c[0] === "getIssue"));
});
test("open rows are capped", async () => {
const letters = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
const open = Array.from({ length: MAX_OPEN + 50 }, (_, i) => ({ receipt: `FB-AAAA-${letters[i % 30]}${letters[Math.floor(i / 30)]}AA`, issueNumber: i + 1, status: "recorded" }));
const d = fakes({ pending: [], open, changed: open.map((r) => ({ number: r.issueNumber, state: "open", labels: [{ name: "in-progress" }] })) });
await run(d);
assert.equal(d.calls.filter((c) => c[0] === "status").length, MAX_OPEN);
});
test("an unreachable worker is a warning, not a failed job", async () => {
const real = globalThis.fetch;
globalThis.fetch = async () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }); };
try {
const deps = liveDeps({ FEEDBACK_ADMIN_TOKEN: "t" });
const logs = [];
const { failures } = await run({ ...deps, log: (m) => logs.push(m) });
assert.equal(failures.length, 2);
assert.ok(logs.every((m) => m.startsWith("::warning::") && m.includes("ECONNREFUSED")));
} finally {
globalThis.fetch = real;
}
});
test("the release signal ignores drafts and other release lines", () => {
const rel = (tag_name, published_at, draft = false) => ({ tag_name, published_at, draft });
assert.equal(newestStudioRelease([rel("v1.40.0", "2026-09-30T00:00:00Z"), rel("studio-v2.9.0", "2026-09-28T00:00:00Z"), rel("studio-v2.10.0", "2026-09-29T00:00:00Z", true)]), Date.parse("2026-09-28T00:00:00Z"));
assert.equal(newestStudioRelease([]), null);
});