Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
428 changes: 428 additions & 0 deletions .github/scripts/issue-translation.cjs

Large diffs are not rendered by default.

458 changes: 458 additions & 0 deletions .github/scripts/issue-translation.test.cjs

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions .github/scripts/parse-issue-translation-response.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use strict";

const fs = require("fs");
const crypto = require("crypto");

function scrubLine(value, max) {
return String(value || "")
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, max);
}

function parseAiResponse(raw) {
const text = String(raw || "").trim();
if (!text) return null;

let parsed;
try {
parsed = JSON.parse(text);
} catch {
try {
parsed = JSON.parse(
text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim(),
);
} catch {
return null;
}
}

if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return parsed;
}

function writeOutput(key, value) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
}

function writeMultilineOutput(key, value) {
const delim = `${key.toUpperCase()}_${crypto.randomBytes(16).toString("hex")}`;
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`${key}<<${delim}\n${value}\n${delim}\n`,
);
}

function main() {
if (!process.env.GITHUB_OUTPUT) {
console.error("GITHUB_OUTPUT is not set");
process.exit(1);
}

const parsed = parseAiResponse(process.env.AI_RESPONSE);
if (!parsed) {
// Still emit outputs so the workflow can persist rate-limit state.
console.warn("::warning::Issue translation AI response was empty or not valid JSON.");
writeOutput("requires_translation", "false");
writeOutput("detected_language", "unknown");
return;
}

if (parsed.requires_translation !== true) {
const lang = scrubLine(parsed.detected_language || "English", 64) || "English";
writeOutput("requires_translation", "false");
writeOutput("detected_language", lang);
return;
}

const lang = scrubLine(parsed.detected_language || "non-English", 64) || "non-English";
const title = scrubLine(parsed.translated_title, 256);
const body = String(parsed.translated_body || "").replace(
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g,
"",
);

writeOutput("requires_translation", "true");
writeOutput("detected_language", lang);
writeOutput("translated_title", title);
writeMultilineOutput("translated_body", body);
}

if (require.main === module) {
main();
}

module.exports = {
scrubLine,
parseAiResponse,
main,
};
162 changes: 162 additions & 0 deletions .github/scripts/parse-issue-translation-response.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"use strict";

const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");

const SCRIPT = path.join(__dirname, "parse-issue-translation-response.cjs");
const { parseAiResponse, scrubLine } = require("./parse-issue-translation-response.cjs");

function runParser(aiResponse) {
const outFile = path.join(
os.tmpdir(),
`ocx-parse-out-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
);
fs.writeFileSync(outFile, "");
const result = spawnSync(process.execPath, [SCRIPT], {
env: {
...process.env,
AI_RESPONSE: aiResponse,
GITHUB_OUTPUT: outFile,
},
encoding: "utf8",
});
const output = fs.readFileSync(outFile, "utf8");
fs.unlinkSync(outFile);
return { status: result.status, stdout: result.stdout, stderr: result.stderr, output };
}

function parseOutputs(raw) {
const lines = String(raw || "").split("\n");
const out = {};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const delimMatch = line.match(/^([A-Za-z0-9_]+)<<(.+)$/);
if (delimMatch) {
const [, key, delim] = delimMatch;
const body = [];
i += 1;
while (i < lines.length && lines[i] !== delim) {
body.push(lines[i]);
i += 1;
}
out[key] = body.join("\n");
continue;
}
const eq = line.indexOf("=");
if (eq === -1) continue;
out[line.slice(0, eq)] = line.slice(eq + 1);
}
return out;
}

describe("parseAiResponse", () => {
it("parses plain JSON objects", () => {
assert.deepEqual(parseAiResponse('{"requires_translation":false}'), {
requires_translation: false,
});
});

it("parses fenced JSON", () => {
const raw = "```json\n{\"requires_translation\":true,\"translated_title\":\"T\"}\n```";
assert.equal(parseAiResponse(raw).translated_title, "T");
});

it("rejects invalid JSON, empty input, and arrays", () => {
assert.equal(parseAiResponse(""), null);
assert.equal(parseAiResponse("{"), null);
assert.equal(parseAiResponse("[1]"), null);
});
});

describe("parse-issue-translation-response process", () => {
it("writes translated outputs for requires_translation=true", () => {
const { status, output } = runParser(JSON.stringify({
requires_translation: true,
detected_language: "German",
translated_title: "Proxy does not start",
translated_body: "The proxy does not start.",
}));
assert.equal(status, 0);
const parsed = parseOutputs(output);
assert.equal(parsed.requires_translation, "true");
assert.equal(parsed.detected_language, "German");
assert.equal(parsed.translated_title, "Proxy does not start");
assert.equal(parsed.translated_body, "The proxy does not start.");
});

it("writes English path without translation fields", () => {
const { status, output } = runParser(JSON.stringify({
requires_translation: false,
detected_language: "English",
translated_title: "",
translated_body: "",
}));
assert.equal(status, 0);
const parsed = parseOutputs(output);
assert.equal(parsed.requires_translation, "false");
assert.equal(parsed.detected_language, "English");
assert.equal(parsed.translated_title, undefined);
});

it("treats string requires_translation as false path", () => {
const { status, output } = runParser(JSON.stringify({
requires_translation: "true",
detected_language: "German",
}));
assert.equal(status, 0);
assert.equal(parseOutputs(output).requires_translation, "false");
});

it("supports multiline bodies and shell-looking content", () => {
const body = "line1\n$(rm -rf /)\nBODY_abc\n\"quotes\"\n`ticks`";
const { status, output } = runParser(JSON.stringify({
requires_translation: true,
detected_language: "German",
translated_title: "T",
translated_body: body,
}));
assert.equal(status, 0);
assert.equal(parseOutputs(output).translated_body, body);
});

it("writes false outputs on invalid JSON and empty responses", () => {
for (const raw of ["{", "", "[]"]) {
const { status, output } = runParser(raw);
assert.equal(status, 0);
const parsed = parseOutputs(output);
assert.equal(parsed.requires_translation, "false");
assert.equal(parsed.detected_language, "unknown");
}
});

it("parses fenced responses in the process path", () => {
const raw = "```json\n" + JSON.stringify({
requires_translation: true,
detected_language: "Japanese",
translated_title: "Title",
translated_body: "Body",
}) + "\n```";
const { status, output } = runParser(raw);
assert.equal(status, 0);
assert.equal(parseOutputs(output).requires_translation, "true");
assert.equal(parseOutputs(output).translated_title, "Title");
});

it("scrubs control characters from language and title", () => {
assert.equal(scrubLine("German\u0000-->@x", 64), "German -->@x");
const { output } = runParser(JSON.stringify({
requires_translation: true,
detected_language: "German\u0007",
translated_title: "A".repeat(300),
translated_body: "ok",
}));
const parsed = parseOutputs(output);
assert.equal(parsed.detected_language, "German");
assert.equal(parsed.translated_title.length, 256);
});
});
Loading
Loading