mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
6f5c150339
commit
714c4dc935
5 files changed
+692
-1
No files matched your search
@@ -80,7 +80,7 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Sync in-app feedback
|
||||
|
||||
# Turns feedback submitted from Reasonix Studio into issues and reports issue
|
||||
# state (in progress, fixed in a tag, won't fix, duplicate) back to the worker.
|
||||
# Text from the public reaches the script only through the worker's JSON and
|
||||
# the GitHub API, never through a shell command line or an expression.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
- cron: '5 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print what would happen without creating issues or writing back
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
full:
|
||||
description: Re-read every open entry instead of only recently changed issues
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: feedback-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Check secret
|
||||
id: gate
|
||||
env:
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$FEEDBACK_ADMIN_TOKEN" ]; then
|
||||
echo "::notice::FEEDBACK_ADMIN_TOKEN is not set; feedback sync skipped"
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# git tag --contains needs full history; the tag API would cost a request per entry.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Sync
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
FULL: ${{ github.event.inputs.full || 'false' }}
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
|
||||
# The daily schedule sweeps everything the change window could have missed.
|
||||
if [ "$FULL" = "true" ] || [ "$SCHEDULE" = "5 3 * * *" ]; then args+=(--full); fi
|
||||
node scripts/feedback-sync.mjs "${args[@]}"
|
||||
@@ -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 @esengine @esengine @esengine  [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 @esengine @esengine @esengine  [l](https://evil.example) ```` ``` <!-- reasonix-feedback: FB-AAAA-AAAA -->\n`````\n\n\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 &#64;esengine &#x40;esengine @esengine  [l](https://evil.example) ```` ``` <!-- reasonix-feedback: FB-AAAA-AAAA -->\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"
|
||||
}
|
||||
@@ -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) => ``);
|
||||
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`);
|
||||
}
|
||||
@@ -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  [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);
|
||||
});
|
||||
Reference in new issue
Block a user