mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
chore(release): reconcile v3.8.50 provenance and notes
This commit is contained in:
@@ -63,7 +63,7 @@ export function verifyFrozenMap(before = {}, after = {}, label = "frozen") {
|
||||
if (typeof prev !== "number") {
|
||||
// note key
|
||||
if (!has) problems.push(`${label}: note "${key}" was deleted — notes must be preserved`);
|
||||
else if (after[key] !== prev)
|
||||
else if (!jsonEqual(after[key], prev))
|
||||
problems.push(`${label}: note "${key}" was rewritten — notes must be preserved verbatim`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file extract-credentials.mjs
|
||||
* @description Print Raycast Pro credentials from local macOS install (redacted preview).
|
||||
*
|
||||
* Usage: node scripts/raycast/extract-credentials.mjs
|
||||
*
|
||||
* @changes
|
||||
* - [2026-07-27] [Composer] - CLI credential extractor for local Raycast
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const RAYCAST_SALT = "yvkwWXzxPPBAqY2tmaKrB*DvYjjMaeEf";
|
||||
const RAYCAST_SUPPORT = join(homedir(), "Library", "Application Support", "com.raycast.macos");
|
||||
const RAYCAST_DB = join(RAYCAST_SUPPORT, "raycast-enc.sqlite");
|
||||
|
||||
function redact(s, keep = 8) {
|
||||
if (!s || s.length <= keep * 2) return "***";
|
||||
return `${s.slice(0, keep)}…${s.slice(-4)}`;
|
||||
}
|
||||
|
||||
function readKeychain(account) {
|
||||
return JSON.parse(
|
||||
execFileSync("security", ["find-generic-password", "-s", "Raycast", "-a", account, "-w"], {
|
||||
encoding: "utf-8",
|
||||
}).trim()
|
||||
);
|
||||
}
|
||||
|
||||
function dbPassphrase() {
|
||||
const keyHex = execFileSync(
|
||||
"security",
|
||||
["find-generic-password", "-s", "Raycast", "-a", "database_key", "-w"],
|
||||
{ encoding: "utf-8" }
|
||||
).trim();
|
||||
return createHash("sha256")
|
||||
.update(keyHex + RAYCAST_SALT)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function queryDb(sql) {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "raycast-extract-"));
|
||||
const tmpDb = join(tmpDir, "db.sqlite");
|
||||
copyFileSync(RAYCAST_DB, tmpDb);
|
||||
for (const ext of ["-wal", "-shm"]) {
|
||||
const src = RAYCAST_DB + ext;
|
||||
if (existsSync(src)) copyFileSync(src, tmpDb + ext);
|
||||
}
|
||||
const passphrase = dbPassphrase();
|
||||
const input = `PRAGMA key = '${passphrase}';\n.mode json\n${sql}`;
|
||||
const out = execFileSync("sqlcipher", [tmpDb], { input, encoding: "utf-8" });
|
||||
for (const ext of ["", "-wal", "-shm"]) {
|
||||
try {
|
||||
unlinkSync(tmpDb + ext);
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
rmdirSync(tmpDir);
|
||||
} catch {}
|
||||
const jsonStr = out.startsWith("ok\n") ? out.slice(3) : out;
|
||||
return JSON.parse(jsonStr.trim() || "[]");
|
||||
}
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
console.error("macOS only");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = readKeychain("raycast-store_credentials");
|
||||
const token = store?.oauth?.access_token;
|
||||
if (!token) {
|
||||
console.error("No Raycast bearer token in Keychain — open Raycast and sign in");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const users = queryDb("SELECT analyticsId, email, username, hasProFeatures, hasBetterAI FROM user LIMIT 1;");
|
||||
const user = users[0] || {};
|
||||
const deviceId =
|
||||
user.analyticsId ||
|
||||
JSON.parse(readFileSync(join(RAYCAST_SUPPORT, "posthog.distinctId"), "utf-8"))["posthog.distinctId"];
|
||||
|
||||
console.log(JSON.stringify({
|
||||
accessTokenPreview: redact(token),
|
||||
accessToken: token,
|
||||
deviceId,
|
||||
aid: deviceId,
|
||||
email: user.email || store?.user?.email,
|
||||
username: user.username || store?.user?.username,
|
||||
hasProFeatures: !!user.hasProFeatures,
|
||||
hasBetterAI: !!user.hasBetterAI,
|
||||
sources: {
|
||||
bearer: "Keychain Raycast / raycast-store_credentials",
|
||||
deviceId: "raycast-enc.sqlite user.analyticsId",
|
||||
},
|
||||
}, null, 2));
|
||||
@@ -1,165 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file usage-benchmark.mjs
|
||||
* @description Battle-test Raycast Pro usage via OmniRoute local endpoint.
|
||||
*
|
||||
* Env (required):
|
||||
* OMNIROUTE_URL default http://127.0.0.1:20128/v1
|
||||
* OMNIROUTE_API_KEY OmniRoute API key (if REQUIRE_API_KEY)
|
||||
*
|
||||
* Env (optional — direct Raycast probe without OmniRoute):
|
||||
* RAYCAST_BEARER_TOKEN
|
||||
* RAYCAST_DEVICE_ID
|
||||
* RAYCAST_AID
|
||||
* RAYCAST_SIG_SECRET
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/raycast/usage-benchmark.mjs --models 5 --rounds 3
|
||||
* node scripts/raycast/usage-benchmark.mjs --model openai-gpt-5-mini --rounds 10
|
||||
*
|
||||
* @changes
|
||||
* - [2026-07-27] [Composer] - Initial Raycast Pro usage benchmark script
|
||||
*/
|
||||
|
||||
import { createHmac, createHash } from "node:crypto";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function arg(name, fallback) {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||
}
|
||||
|
||||
const rounds = Number(arg("rounds", "3"));
|
||||
const model = arg("model", "");
|
||||
const modelCount = Number(arg("models", "5"));
|
||||
const omnirouteUrl = (process.env.OMNIROUTE_URL || "http://127.0.0.1:20128/v1").replace(/\/$/, "");
|
||||
const apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
|
||||
const RAYCAST_CHAT_URL = "https://backend.raycast.com/api/v1/ai/chat_completions";
|
||||
const RAYCAST_MODELS_URL = "https://backend.raycast.com/api/v1/ai/models";
|
||||
const SIG_SECRET =
|
||||
process.env.RAYCAST_SIG_SECRET ||
|
||||
"6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408";
|
||||
|
||||
function rot13rot5(input) {
|
||||
return input.replace(/[A-Za-z0-9]/g, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code >= 65 && code <= 90) return String.fromCharCode(((code - 65 + 13) % 26) + 65);
|
||||
if (code >= 97 && code <= 122) return String.fromCharCode(((code - 97 + 13) % 26) + 97);
|
||||
return String.fromCharCode(((code - 48 + 5) % 10) + 48);
|
||||
});
|
||||
}
|
||||
|
||||
function signatureV2(timestamp, deviceId, payload, secret) {
|
||||
const bodyHash = createHash("sha256").update(payload).digest("hex");
|
||||
const message = [timestamp, deviceId, bodyHash].map(rot13rot5).join(".");
|
||||
return createHmac("sha256", secret).update(message).digest("hex");
|
||||
}
|
||||
|
||||
function raycastJwt(aid, secret) {
|
||||
const iat = Date.now() / 1000;
|
||||
const header = Buffer.from(JSON.stringify({ typ: "JWT", alg: "HS256" })).toString("base64url");
|
||||
const payload = Buffer.from(JSON.stringify({ aid, exp: iat + 60, iat })).toString("base64url");
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
function raycastHeaders(payload) {
|
||||
const bearerToken = process.env.RAYCAST_BEARER_TOKEN;
|
||||
const deviceId = process.env.RAYCAST_DEVICE_ID;
|
||||
const aid = process.env.RAYCAST_AID;
|
||||
if (!bearerToken || !deviceId || !aid) {
|
||||
throw new Error("Set RAYCAST_BEARER_TOKEN, RAYCAST_DEVICE_ID, RAYCAST_AID for direct probe");
|
||||
}
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
return {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
"X-Raycast-Timestamp": timestamp,
|
||||
"X-Raycast-DeviceId": deviceId,
|
||||
"Content-Type": "application/json",
|
||||
"X-Raycast-Signature-v2": signatureV2(timestamp, deviceId, payload, SIG_SECRET),
|
||||
"X-Raycast-Signature": raycastJwt(aid, SIG_SECRET),
|
||||
"X-Raycast-Experimental": "chatBranching, mcpHTTPServer",
|
||||
"User-Agent": "Raycast/1.104.20 (macOS Version 26.5.1 (Build 25F80))",
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRaycastModels() {
|
||||
const payload = "{}";
|
||||
const res = await fetch(RAYCAST_MODELS_URL, { method: "GET", headers: raycastHeaders(payload) });
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`models [${res.status}]: ${text.slice(0, 200)}`);
|
||||
const data = JSON.parse(text);
|
||||
return (data.models || []).map((m) => m.id);
|
||||
}
|
||||
|
||||
async function chatOmniroute(modelId, prompt) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
const started = Date.now();
|
||||
const res = await fetch(`${omnirouteUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: `raycast/${modelId}`,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
max_tokens: 32,
|
||||
}),
|
||||
});
|
||||
const ms = Date.now() - started;
|
||||
const body = await res.text();
|
||||
return { ok: res.ok, status: res.status, ms, body: body.slice(0, 300) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`OmniRoute: ${omnirouteUrl}`);
|
||||
console.log(`Rounds per model: ${rounds}`);
|
||||
|
||||
let models = [];
|
||||
if (model) {
|
||||
models = [model];
|
||||
} else if (process.env.RAYCAST_BEARER_TOKEN) {
|
||||
models = (await fetchRaycastModels()).slice(0, modelCount);
|
||||
console.log(`Direct Raycast model probe — testing ${models.length} models via OmniRoute`);
|
||||
} else {
|
||||
models = ["openai-gpt-5-mini"];
|
||||
console.log("No RAYCAST_* env — using default model openai-gpt-5-mini via OmniRoute combo id");
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const modelId of models) {
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
const latencies = [];
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const prompt = `Raycast benchmark round ${i + 1} — reply with exactly: pong`;
|
||||
try {
|
||||
const r = await chatOmniroute(modelId, prompt);
|
||||
latencies.push(r.ms);
|
||||
if (r.ok) ok++;
|
||||
else {
|
||||
fail++;
|
||||
console.error(` FAIL ${modelId} #${i + 1} [${r.status}]: ${r.body}`);
|
||||
}
|
||||
} catch (err) {
|
||||
fail++;
|
||||
console.error(` ERR ${modelId} #${i + 1}:`, err.message);
|
||||
}
|
||||
}
|
||||
const avg = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : 0;
|
||||
results.push({ modelId, ok, fail, avgMs: avg });
|
||||
console.log(`${modelId}: ${ok}/${rounds} ok, avg ${avg}ms`);
|
||||
}
|
||||
|
||||
console.log("\nSummary:");
|
||||
console.table(results);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -20,7 +20,8 @@
|
||||
// allowed). Credit format stays the repo norm: "(#PR — thanks @user)".
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/release/aggregate-changelog.mjs [--dry-run]
|
||||
// node scripts/release/aggregate-changelog.mjs --version <version> [--dry-run]
|
||||
// --version exact CHANGELOG release section to update (for example, 3.8.50);
|
||||
// --dry-run print the would-be CHANGELOG.md to stdout and list fragments;
|
||||
// touch nothing.
|
||||
//
|
||||
@@ -44,6 +45,12 @@ export const SECTIONS = Object.freeze({
|
||||
|
||||
const SKIP_FILES = new Set(["README.md", ".gitkeep"]);
|
||||
|
||||
function assertTargetVersion(version) {
|
||||
if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error('target version is required (for example, { version: "3.8.50" })');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one fragment's text. Returns null when OK, or a human-readable error.
|
||||
* Pure — unit-tested.
|
||||
@@ -57,6 +64,15 @@ export function validateFragmentText(text) {
|
||||
return 'fragment must start with a markdown bullet ("- ")';
|
||||
}
|
||||
if (/^(<{7}|={7}|>{7})/m.test(body)) return "fragment contains merge-conflict markers";
|
||||
if (/#(?:PRNUM|PENDING)\b|\/pull\/(?:PRNUM|PENDING)(?:[/?#)]|$)/i.test(body)) {
|
||||
return "fragment contains an unresolved PR placeholder";
|
||||
}
|
||||
for (const match of body.matchAll(
|
||||
/\[([^\]\n]+)\]\(https:\/\/github\.com\/diegosouzapw\/OmniRoute\/pull\/(\d+)\/?(?:[?#][^)]*)?\)/g
|
||||
)) {
|
||||
const expected = `#${match[2]}`;
|
||||
if (match[1].trim() !== expected) return `pull link label must be "${expected}"`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -88,27 +104,72 @@ export function collectFragments(root) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append bullets at the END of a living-section heading's bullet block (before the
|
||||
* next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this
|
||||
* repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested.
|
||||
* Throws when a needed heading is missing (the release captain adds the heading; the
|
||||
* script never invents structure).
|
||||
* Append bullets at the END of a target version's section-heading blocks. Pure — unit-tested.
|
||||
* The version is mandatory because Unreleased and released sections intentionally reuse the
|
||||
* same headings.
|
||||
*/
|
||||
export function insertBullets(changelogText, bulletsBySection) {
|
||||
export function insertBullets(changelogText, bulletsBySection, { version } = {}) {
|
||||
assertTargetVersion(version);
|
||||
|
||||
let lines = changelogText.split("\n");
|
||||
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const targetHeading = new RegExp(`^## \\[${escapedVersion}\\](?:\\s|$)`);
|
||||
const targetMatches = lines.flatMap((line, index) => (targetHeading.test(line) ? [index] : []));
|
||||
if (targetMatches.length === 0) {
|
||||
throw new Error(`target version [${version}] not found in CHANGELOG.md`);
|
||||
}
|
||||
if (targetMatches.length > 1) {
|
||||
throw new Error(`target version [${version}] appears ${targetMatches.length} times`);
|
||||
}
|
||||
|
||||
const targetStart = targetMatches[0];
|
||||
let targetEnd = lines.findIndex((line, index) => index > targetStart && /^##\s/.test(line));
|
||||
if (targetEnd === -1) targetEnd = lines.length;
|
||||
|
||||
const insertions = [];
|
||||
const targetBody = `\n${lines
|
||||
.slice(targetStart + 1, targetEnd)
|
||||
.join("\n")
|
||||
.trimEnd()}\n`;
|
||||
const seenFragmentText = new Map();
|
||||
for (const [section, heading] of Object.entries(SECTIONS)) {
|
||||
const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b);
|
||||
const entries = bulletsBySection[section] || [];
|
||||
const bullets = entries.map((entry) => {
|
||||
const text = String(entry.text ?? entry).trimEnd();
|
||||
const file = entry.file || `${section} fragment`;
|
||||
const firstFile = seenFragmentText.get(text);
|
||||
if (firstFile) {
|
||||
throw new Error(`duplicate fragment content in ${firstFile} and ${file}`);
|
||||
}
|
||||
seenFragmentText.set(text, file);
|
||||
if (targetBody.includes(`\n${text}\n`)) {
|
||||
throw new Error(`fragment content is already present in [${version}]: ${file}`);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
if (bullets.length === 0) continue;
|
||||
const headIdx = lines.findIndex((l) => l.trim() === heading);
|
||||
if (headIdx === -1) {
|
||||
const headingMatches = lines.flatMap((line, index) =>
|
||||
index > targetStart && index < targetEnd && line.trim() === heading ? [index] : []
|
||||
);
|
||||
if (headingMatches.length === 0) {
|
||||
throw new Error(
|
||||
`heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments`
|
||||
`heading "${heading}" not found inside target version [${version}] before aggregating ${section} fragments`
|
||||
);
|
||||
}
|
||||
if (headingMatches.length > 1) {
|
||||
throw new Error(
|
||||
`heading "${heading}" appears ${headingMatches.length} times inside target version [${version}]`
|
||||
);
|
||||
}
|
||||
insertions.push({ headIdx: headingMatches[0], bullets });
|
||||
}
|
||||
|
||||
// Work from the bottom up so earlier insertions cannot invalidate later section indexes.
|
||||
for (const { headIdx, bullets } of insertions.sort((a, b) => b.headIdx - a.headIdx)) {
|
||||
// End of this section's block: last non-empty line before the next heading.
|
||||
let nextHead = lines.length;
|
||||
for (let i = headIdx + 1; i < lines.length; i++) {
|
||||
if (/^##/.test(lines[i])) {
|
||||
let nextHead = targetEnd;
|
||||
for (let i = headIdx + 1; i < targetEnd; i++) {
|
||||
if (/^#{2,3}\s/.test(lines[i])) {
|
||||
nextHead = i;
|
||||
break;
|
||||
}
|
||||
@@ -125,7 +186,8 @@ export function insertBullets(changelogText, bulletsBySection) {
|
||||
* Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is
|
||||
* true nothing is written or deleted.
|
||||
*/
|
||||
export function aggregate({ root = ROOT, dryRun = false } = {}) {
|
||||
export function aggregate({ root = ROOT, version, dryRun = false } = {}) {
|
||||
assertTargetVersion(version);
|
||||
const collected = collectFragments(root);
|
||||
if (collected.invalid.length > 0) {
|
||||
const detail = collected.invalid.map((i) => ` ✗ ${i.file}: ${i.error}`).join("\n");
|
||||
@@ -134,7 +196,7 @@ export function aggregate({ root = ROOT, dryRun = false } = {}) {
|
||||
const total = collected.features.length + collected.fixes.length + collected.maintenance.length;
|
||||
const changelogPath = join(root, "CHANGELOG.md");
|
||||
const before = readFileSync(changelogPath, "utf8");
|
||||
const after = total === 0 ? before : insertBullets(before, collected);
|
||||
const after = insertBullets(before, collected, { version });
|
||||
if (!dryRun && total > 0) {
|
||||
writeFileSync(changelogPath, after);
|
||||
for (const section of Object.keys(SECTIONS)) {
|
||||
@@ -144,24 +206,67 @@ export function aggregate({ root = ROOT, dryRun = false } = {}) {
|
||||
return { total, collected, changed: total > 0, after };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
const result = aggregate({ dryRun });
|
||||
function parseCliArgs(argv) {
|
||||
let version;
|
||||
let dryRun = false;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--dry-run") {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--version") {
|
||||
if (version !== undefined) throw new Error("--version may only be provided once");
|
||||
const value = argv[++i];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error("--version <version> is required");
|
||||
}
|
||||
version = value;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
if (!version) throw new Error("--version <version> is required");
|
||||
return { version, dryRun };
|
||||
}
|
||||
|
||||
export function main(
|
||||
argv = process.argv.slice(2),
|
||||
{ root = ROOT, stdout = process.stdout, stderr = process.stderr } = {}
|
||||
) {
|
||||
let args;
|
||||
let result;
|
||||
try {
|
||||
args = parseCliArgs(argv);
|
||||
result = aggregate({ root, ...args });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
stderr.write(`[aggregate-changelog] error: ${message}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const log = args.dryRun ? stderr : stdout;
|
||||
if (args.dryRun) {
|
||||
stdout.write(result.after);
|
||||
if (!result.after.endsWith("\n")) stdout.write("\n");
|
||||
}
|
||||
if (result.total === 0) {
|
||||
console.log("[aggregate-changelog] no fragments to aggregate — nothing to do.");
|
||||
log.write("[aggregate-changelog] no fragments to aggregate — nothing to do.\n");
|
||||
return 0;
|
||||
}
|
||||
for (const section of Object.keys(SECTIONS)) {
|
||||
for (const { file } of result.collected[section]) {
|
||||
console.log(`[aggregate-changelog] ${dryRun ? "would aggregate" : "aggregated"} ${file}`);
|
||||
log.write(
|
||||
`[aggregate-changelog] ${args.dryRun ? "would aggregate" : "aggregated"} ${file}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[aggregate-changelog] ${result.total} fragment(s) → CHANGELOG.md${dryRun ? " (dry-run, nothing written)" : " (fragments deleted — commit CHANGELOG.md + deletions together)"}`
|
||||
log.write(
|
||||
`[aggregate-changelog] ${result.total} fragment(s) → CHANGELOG.md${args.dryRun ? " (dry-run, nothing written)" : " (fragments deleted — commit CHANGELOG.md + deletions together)"}\n`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main());
|
||||
process.exitCode = main();
|
||||
}
|
||||
|
||||
@@ -65,7 +65,10 @@ export function parseContributors(sectionText) {
|
||||
if (!agg.has(handle)) agg.set(handle, new Set());
|
||||
for (const r of refs) agg.get(handle).add(r);
|
||||
};
|
||||
const handlesIn = (s) => [...s.matchAll(/@([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
|
||||
// A slash is a contributor separator only when another @handle follows it. This prevents
|
||||
// GitHub App identities such as `@app/dependabot` from being truncated and credited as `@app`.
|
||||
const handlesIn = (s) =>
|
||||
[...s.matchAll(/@([A-Za-z0-9_-]+)(?=$|[\s,;:.)\]}>]|\/\s*@)/g)].map((m) => m[1]);
|
||||
const refsIn = (s) => [...s.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
|
||||
|
||||
for (const raw of sectionText.split("\n")) {
|
||||
@@ -98,7 +101,9 @@ export function parseContributors(sectionText) {
|
||||
}
|
||||
|
||||
// (3) "Extracted from #N by @X" (links already collapsed by the preprocessing above)
|
||||
for (const em of line.matchAll(/[Ee]xtracted from #(\d+)\s+by\s+@([A-Za-z0-9_-]+)/g)) {
|
||||
for (const em of line.matchAll(
|
||||
/[Ee]xtracted from #(\d+)\s+by\s+@([A-Za-z0-9_-]+)(?![A-Za-z0-9_/-])/g
|
||||
)) {
|
||||
add(em[2], [Number(em[1])]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user