chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+894
View File
@@ -0,0 +1,894 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
/*
* Issue labeler for this repository.
*
* Implements only:
* - Type labels (Section 2)
* - Domain labels (Section 4)
*
* Notes:
* - Type: only applied when keyword matched. If no match, keep current type labels unchanged.
* - Domain: default is add-only; strict sync is optional via --sync-domains.
*/
const API_BASE = "https://api.github.com";
const TYPE_LABELS = [
"bug",
"enhancement",
"question",
"documentation",
"performance",
"security",
];
const TYPE_LABEL_SET = new Set(TYPE_LABELS);
const DOMAIN_SERVICES = [
"im",
"doc",
"drive",
"base",
"sheets",
"calendar",
"mail",
"task",
"vc",
"whiteboard",
"minutes",
"wiki",
"event",
"auth",
"core",
];
const DOMAIN_ALIASES = ["docs"];
const DOMAIN_REGEX_ALTERNATION = [...DOMAIN_SERVICES, ...DOMAIN_ALIASES].join("|");
const DOMAIN_LABELS = DOMAIN_SERVICES.map((s) => `domain/${s}`);
const DOMAIN_LABEL_SET = new Set(DOMAIN_LABELS);
const MANAGED_LABELS = [...TYPE_LABELS, ...DOMAIN_LABELS];
const TYPE_TIE_BREAKER = [
"security",
"bug",
"performance",
"enhancement",
"documentation",
"question",
];
// More conservative type labeling: prefer "no label" over mislabeling.
// - Require a minimum score.
// - When the top two candidates are too close, treat as ambiguous and do not label.
const TYPE_MIN_SCORE = 2;
const TYPE_MIN_MARGIN = 1;
/**
* Pause execution for the provided number of milliseconds.
*
* @param {number} ms
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Read an environment variable and trim surrounding whitespace.
*
* @param {string} name
* @returns {string}
*/
function envValue(name) {
const value = process.env[name];
return value ? String(value).trim() : "";
}
/**
* Read a required environment variable.
*
* @param {string} name
* @returns {string}
*/
function envOrFail(name) {
const value = envValue(name);
if (!value) throw new Error(`missing required environment variable: ${name}`);
return value;
}
/**
* Parse an integer value with a fallback when parsing fails.
*
* @param {string|number|undefined|null} value
* @param {number} fallback
* @returns {number}
*/
function toInt(value, fallback) {
const n = Number.parseInt(String(value || ""), 10);
return Number.isFinite(n) ? n : fallback;
}
/**
* Parse a boolean-ish value from CLI or environment input.
*
* @param {string|boolean|undefined|null} value
* @returns {boolean}
*/
function toBool(value) {
if (typeof value === "boolean") return value;
const v = String(value || "").trim().toLowerCase();
if (!v) return false;
if (v === "1" || v === "true" || v === "yes" || v === "y") return true;
return false;
}
/**
* Normalize issue title and body into a single lowercase string.
*
* @param {string} title
* @param {string} body
* @returns {string}
*/
function normalizeText(title, body) {
return `${String(title || "")}\n\n${String(body || "")}`.toLowerCase();
}
/**
* Infer candidate domain services from issue title and body text.
*
* @param {string} title
* @param {string} body
* @returns {string[]}
*/
function collectDomainsFromText(title, body) {
const normalizedBody = String(body || "")
.replace(/(["'])(?:(?=(\\?))\2.)*?\1/gs, (segment) => {
return /lark-cli\s+/i.test(segment) && segment.length > 80 ? '""' : segment;
});
const text = normalizeText(title, normalizedBody);
const titleText = String(title || "").toLowerCase();
const hits = new Set();
function normalizeService(svc) {
const s = String(svc || "").toLowerCase();
if (s === "docs") return "doc";
return s;
}
// 1) Explicit domain labels in text: domain/<service>
const explicit = new RegExp(`\\bdomain\\/(${DOMAIN_REGEX_ALTERNATION})\\b`, "gi");
for (const match of text.matchAll(explicit)) {
const svc = match && match[1] ? normalizeService(match[1]) : "";
if (DOMAIN_SERVICES.includes(svc)) hits.add(svc);
}
// 2) Command mention: lark-cli <service> / lark cli <service>
const cmd = new RegExp(`\\blark[-\\s]?cli\\s+(${DOMAIN_REGEX_ALTERNATION})\\b`, "gi");
for (const match of text.matchAll(cmd)) {
const svc = match && match[1] ? normalizeService(match[1]) : "";
if (DOMAIN_SERVICES.includes(svc)) hits.add(svc);
}
// 3) Loose title match: if title contains a standalone service word.
// This is intentionally limited to TITLE to reduce false positives.
// NOTE: exclude `im` here because it's too common in English text (e.g. "im stuck").
const looseServices = DOMAIN_SERVICES.filter((s) => s !== "im");
for (const svc of looseServices) {
const pattern = svc === "doc" ? "\\bdocs?\\b" : `\\b${svc}\\b`;
const re = new RegExp(pattern, "i");
if (re.test(titleText)) hits.add(svc);
}
// 4) Keyword heuristics (for users who don't paste the exact command)
// Keep this conservative; add keywords only when they are strongly tied to a domain.
const keywordMap = {
base: [/\bbase\s*\+/i, /\bbase-token\b/i, /open-apis\/bitable\//i, /\brecords?\/(search|list)\b/i, /多维表格/],
doc: [/\bdocx\b/i, /\bfeishu document\b/i, /\blark document\b/i, /\bdocument comments?\b/i, /飞书文档|云文档|文档/],
drive: [/\bdrive\b/i, /\bfolder token\b/i, /create_folder/i, /drive\/v1\/files/i, /\bdrive\s*\+/i],
sheets: [/电子表格/, /\bsheets\s*\+/i],
calendar: [/日历/, /\bcalendar\s*\+/i],
mail: [/邮件/, /\bmail\s*\+/i],
task: [/任务清单/, /飞书任务/, /\btask\s*\+/i],
wiki: [/知识库/, /\bwiki\s*\+/i],
minutes: [/妙记/, /\bminutes\s*\+/i],
vc: [/\bvc\s*\+/i, /飞书会议|视频会议|创建会议/],
im: [/消息|群聊|私聊/, /\bim\s*\+/i, /im\/v1/i],
auth: [/\bauth\s+(login|status|check|logout)\b/i, /\bkeychain\b/i, /\buser_access_token\b/i, /\buser token\b/i, /\bconsent\b/i, /授权|登录|scope authorization/],
core: [/\bpostinstall\b/i, /\bconfig(\.json)?\b/i, /\bconfig\s+(init|show|remove)\b/i, /\bpackage\.json\b/i, /\bscripts\/install\.js\b/i, /\bbun\b/i, /\bskills?\b/i, /\btrae\b/i, /\bprofile\b/i, /\bmulti-account\b/i, /\bprivate deployment\b/i, /\bbinary release\b/i, /\bbinary fails?\b/i, /\bunsupported platform\b/i, /\bebadplatform\b/i, /\bwindows\b.*\bbinary\b|\bbinary\b.*\bwindows\b/i, /\briscv64\b.*\bsupport/i, /私有化|安装脚本|配置文件|多账号|多个应用|多用户|持久化连接|服务器端/],
};
for (const [svc, patterns] of Object.entries(keywordMap)) {
if (!DOMAIN_SERVICES.includes(svc)) continue;
for (const re of patterns) {
if (re.test(text)) {
hits.add(svc);
break;
}
}
}
return [...hits].sort();
}
/**
* Score each type label against the issue content.
*
* @param {string} title
* @param {string} body
* @returns {Record<string, number>}
*/
function scoreTypeFromText(title, body) {
const text = normalizeText(title, body);
const titleText = String(title || "").toLowerCase();
const rules = {
bug: [
// explicit
{ re: /\bbug\b/i, w: 2 },
// strong signals (stack traces, errors, crashes)
{ re: /\berror\b|\bexception\b|\bcrash\b|\bpanic\b|\bstack\s*trace\b|\bbroken\b|\bfails?\b|\bsigkill\b|\binvalid json\b|\bno stdout\b|\bno stderr\b|\bno output\b|\bsilently fail\w*\b|\bsilently drop\w*\b|\bdiscard\w*\b/i, w: 2 },
// Chinese strong/medium signals
{ re: /报错|错误|异常|崩溃|闪退|卡死|死机|无输出|静默失败|截断|不生效|无法正确|不能正确|被忽略|丢失|没有给/, w: 2 },
// Contextual "cannot/fail" patterns that are usually bugs (avoid labeling based on a bare "无法/失败").
{ re: /(无法|失败)(正常)?(.{0,16})?(使用|运行|执行|发送|创建|获取|写入|读取|安装|登录|导出|更新|上传|下载)/, w: 2 },
// weak motivation words (do NOT label bug based on these alone)
{ re: /无法|失败|没法|不能用|不可用/, w: 1 },
],
enhancement: [
// Chinese/English explicit feature request
{ re: /功能请求|需求|\bfeature request\b|\badd support\b|\bplease add\b/i, w: 2 },
{ re: /希望支持|建议|新增|支持.*(能力|功能)/, w: 2 },
// common Chinese ask forms that usually indicate a request
{ re: /能不能支持|能否支持|希望增加|希望新增/, w: 2 },
// weak asks
{ re: /能否|是否可以|可否|能不能|是否能够|希望能|希望可以|请求/, w: 1 },
{ re: /\benhancement\b|\bfeature\b/i, w: 1 },
],
question: [
// comparison/usage questions
{ re: /有什么区别|有什么不同|区别是什么|\bwhat is the difference\b/i, w: 2 },
{ re: /\bhow to\b|\busage\b|\bis it possible\b|\bdoes it support\b|\bquestion\b/i, w: 2 },
// weak question forms
{ re: /为什么/, w: 2 },
{ re: /请问|是否支持|有没有.*(支持|能力)|怎么(用|配置|接入|做)|如何(使用|配置|接入|做)|可以.*吗|能.*吗|对比/, w: 1 },
],
documentation: [
// Treat docs-related words as weaker unless paired with an explicit docs-fix signal.
{ re: /\btypo\b|\bspell(ing)?\b/i, w: 2 },
// Avoid generic "文档" (many issues are about the document product); require a docs-fix context.
{ re: /拼写|文档(错误|修正|修复|补充|改进)|文档.*(缺失|不完整)|安装说明/, w: 2 },
{ re: /\bdocumentation\b|\breadme\b|\bexample\b|\bbest practice\b/i, w: 1 },
{ re: /示例/, w: 1 },
],
performance: [
// Avoid generic "slow" causing false positives (many issues mention slow networks).
{ re: /\bperformance\b|\bperf\b|\bhang\b|\btimeout\b|\blatency\b|\boom\b|10-100x faster|60\+ seconds/i, w: 2 },
{ re: /\bslow\b/i, w: 1 },
{ re: /慢|卡住|超时|高内存|响应慢|耗时/, w: 1 },
],
security: [
{ re: /\bvuln\b|\bcve\b|\binjection\b|\btoken exposure\b|\bpermission bypass\b|\bcredential leak\b/i, w: 2 },
{ re: /凭据泄漏|注入|权限绕过|token\s*暴露|密钥泄露/, w: 2 },
],
};
const scores = {};
for (const type of TYPE_LABELS) {
scores[type] = 0;
for (const rule of rules[type] || []) {
const re = rule && rule.re;
const w = rule && typeof rule.w === "number" ? rule.w : 1;
if (re && re.test(text)) scores[type] += w;
}
}
if (/^\s*\[bug\]/i.test(titleText) || /^\s*bug[:(]/i.test(titleText)) {
scores.bug += 3;
}
if (/^\s*\[(feature|feature request)\]/i.test(titleText) || /\bfeature request\b/i.test(titleText) || /^\s*feat[:(]/i.test(titleText)) {
scores.enhancement += 3;
}
if (/^\s*[【\[]\s*(feature|需求|功能)\s*[】\]]/.test(titleText)) {
scores.enhancement += 3;
}
// Common Chinese feature request prefixes.
if (/^\s*(功能请求|需求)[:]/.test(titleText) || /\bfeature\b[:]/i.test(titleText)) {
scores.enhancement += 3;
}
if (/希望支持|能否支持|是否可以/.test(titleText)) {
scores.enhancement += 1;
}
if (/^\s*\[doc\]/i.test(titleText)) {
scores.documentation += 4;
// If user explicitly marks it as a documentation issue, reduce the chance of mislabeling it as a bug.
if (scores.bug > 0) scores.bug = Math.max(0, scores.bug - 3);
}
if (/^request\b/i.test(titleText)) {
scores.enhancement += 3;
}
return scores;
}
/**
* Choose the highest-scoring type using the configured tie breaker.
*
* @param {Record<string, number>} scores
* @returns {string|null}
*/
function chooseTypeFromScores(scores) {
const entries = TYPE_LABELS.map((t) => ({ t, v: (scores && scores[t]) || 0 }))
.sort((a, b) => b.v - a.v);
const top = entries[0] || { t: null, v: 0 };
const second = entries[1] || { t: null, v: 0 };
if (top.v < TYPE_MIN_SCORE) return null;
// Ambiguous: top two are too close.
if (top.v - second.v < TYPE_MIN_MARGIN) return null;
// Preserve deterministic choice when multiple labels have same score (should be rare after margin check).
const candidates = TYPE_LABELS.filter((t) => (scores && scores[t]) === top.v);
if (candidates.length === 1) return candidates[0];
for (const t of TYPE_TIE_BREAKER) {
if (candidates.includes(t)) return t;
}
return candidates[0] || null;
}
/**
* Classify issue text into one type label and zero or more domains.
*
* @param {string} title
* @param {string} body
* @returns {{type: string|null, domains: string[]}}
*/
function classifyIssueText(title, body) {
const scores = scoreTypeFromText(title, body);
const type = chooseTypeFromScores(scores);
const domains = collectDomainsFromText(title, body);
return { type, domains };
}
/**
* Format a GitHub issue reference for logs.
*
* @param {string} repo
* @param {number} number
* @returns {string}
*/
function formatIssueRef(repo, number) {
return `${repo}#${number}`;
}
/**
* Minimal GitHub REST client for issue labeling operations.
*/
class GitHubClient {
/**
* @param {string} token
* @param {string} repo
*/
constructor(token, repo) {
this.token = token;
this.repo = repo;
}
/**
* Build standard GitHub API headers.
*
* @param {boolean} hasBody
* @returns {Record<string, string>}
*/
buildHeaders(hasBody = false) {
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (this.token) headers.Authorization = `Bearer ${this.token}`;
if (hasBody) headers["Content-Type"] = "application/json";
return headers;
}
/**
* Execute a GitHub API request with retry and rate-limit handling.
*
* @param {string} endpoint
* @param {{method?: string, payload?: any, allow404?: boolean, retry?: number}} options
* @returns {Promise<any>}
*/
async request(endpoint, options = {}) {
const {
method = "GET",
payload,
allow404 = false,
retry = 5,
} = options;
const hasBody = payload !== undefined;
const url = endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`;
for (let attempt = 0; attempt <= retry; attempt += 1) {
const response = await fetch(url, {
method,
headers: this.buildHeaders(hasBody),
body: hasBody ? JSON.stringify(payload) : undefined,
});
if (allow404 && response.status === 404) return null;
const text = await response.text();
const remaining = toInt(response.headers.get("x-ratelimit-remaining"), -1);
const reset = toInt(response.headers.get("x-ratelimit-reset"), -1);
const retryAfter = toInt(response.headers.get("retry-after"), -1);
const lower = String(text || "").toLowerCase();
const isSecondary = lower.includes("secondary rate") || lower.includes("abuse detection");
if (response.ok) {
return text ? JSON.parse(text) : null;
}
const canRetry = attempt < retry;
if (!canRetry) {
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${text}`);
error.status = response.status;
throw error;
}
// Rate-limit handling
if (response.status === 429 || isSecondary) {
const waitMs = retryAfter > 0
? retryAfter * 1000
: isSecondary
? 60_000
: (attempt + 1) * 1000;
await sleep(waitMs);
continue;
}
if (response.status === 403 && remaining === 0 && reset > 0) {
const nowSec = Math.floor(Date.now() / 1000);
const waitMs = Math.max(1, reset - nowSec + 1) * 1000;
await sleep(waitMs);
continue;
}
// transient-ish failures
if (response.status >= 500) {
await sleep((attempt + 1) * 500);
continue;
}
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${text}`);
error.status = response.status;
throw error;
}
throw new Error(`unreachable: request retry loop exceeded for ${method} ${url}`);
}
/**
* Search for currently unlabeled issues in the repository.
*
* This is intentionally a one-shot triage pass: once any label is added, the
* issue falls out of scope for future scheduled runs.
*
* @param {{state?: string, maxPages?: number, maxIssues?: number}} params
* @returns {Promise<any[]>}
*/
async searchUnlabeledIssues(params) {
const issues = [];
const {
state = "open",
maxPages = 10,
maxIssues = 300,
} = params || {};
const qualifiers = [
`repo:${this.repo}`,
"is:issue",
"no:label",
state === "all" ? "" : `state:${state}`,
].filter(Boolean);
const q = qualifiers.join(" ");
for (let page = 1; page <= maxPages; page += 1) {
const search = new URLSearchParams({
q,
sort: "updated",
order: "desc",
per_page: "100",
page: String(page),
});
const result = await this.request(`/search/issues?${search}`);
const batch = result && Array.isArray(result.items) ? result.items : [];
if (batch.length === 0) break;
for (const item of batch) {
issues.push(item);
if (issues.length >= maxIssues) break;
}
if (issues.length >= maxIssues) break;
if (batch.length < 100) break;
}
return issues;
}
/**
* List all repository labels needed for managed-label checks.
*
* @returns {Promise<any[]>}
*/
async listRepositoryLabels() {
const labels = [];
for (let page = 1; page <= 10; page += 1) {
const search = new URLSearchParams({
per_page: "100",
page: String(page),
});
const batch = await this.request(`/repos/${this.repo}/labels?${search}`);
if (!batch || batch.length === 0) break;
labels.push(...batch);
if (batch.length < 100) break;
}
return labels;
}
/**
* Return managed labels that are not currently present in the repository.
*
* @returns {Promise<string[]>}
*/
async listMissingManagedLabels() {
const existing = new Set((await this.listRepositoryLabels()).map((label) => label && label.name));
return MANAGED_LABELS.filter((name) => !existing.has(name));
}
/**
* Add one or more labels to an issue.
*
* @param {number} issueNumber
* @param {string[]} labels
* @returns {Promise<void>}
*/
async addIssueLabels(issueNumber, labels) {
if (!labels || labels.length === 0) return;
await this.request(`/repos/${this.repo}/issues/${issueNumber}/labels`, {
method: "POST",
payload: { labels },
});
}
/**
* Remove a single label from an issue.
*
* @param {number} issueNumber
* @param {string} name
* @returns {Promise<void>}
*/
async removeIssueLabel(issueNumber, name) {
await this.request(`/repos/${this.repo}/issues/${issueNumber}/labels/${encodeURIComponent(name)}`, {
method: "DELETE",
allow404: true,
});
}
}
/**
* Compute label mutations for the current issue state.
*
* @param {{currentLabels: Set<string>|string[], desiredType: string|null, desiredDomainLabels: string[], syncDomains: boolean, overrideType: boolean}} params
* @returns {{toAdd: string[], toRemove: string[]}}
*/
function planIssueLabelChanges(params) {
const {
currentLabels,
desiredType,
desiredDomainLabels,
syncDomains,
overrideType,
} = params;
const current = currentLabels instanceof Set ? currentLabels : new Set(currentLabels || []);
const toAdd = new Set();
const toRemove = new Set();
// Type: only apply when desiredType exists.
// Safety: by default, do NOT override existing type labels to avoid reverting manual triage.
if (desiredType) {
const currentType = [...current].filter((l) => TYPE_LABEL_SET.has(l));
const shouldApplyType = overrideType || currentType.length === 0;
if (shouldApplyType) {
if (!current.has(desiredType)) {
toAdd.add(desiredType);
}
for (const t of currentType) {
if (t !== desiredType) toRemove.add(t);
}
}
}
// Domain: add-only by default; strict sync via --sync-domains.
const desiredDomains = new Set(desiredDomainLabels || []);
for (const d of desiredDomains) {
if (!current.has(d)) toAdd.add(d);
}
// Safety: only remove domains when we can positively match at least one domain.
if (syncDomains && desiredDomains.size > 0) {
for (const d of current) {
if (DOMAIN_LABEL_SET.has(d) && !desiredDomains.has(d)) {
toRemove.add(d);
}
}
}
return {
toAdd: [...toAdd],
toRemove: [...toRemove],
};
}
/**
* Parse CLI arguments into runtime options.
*
* @param {string[]} argv
* @returns {{dryRun: boolean, json: boolean, token: string, repo: string, maxPages: number, maxIssues: number, onlyMissing: boolean, syncDomains: boolean, overrideType: boolean, state: string, help?: boolean}}
*/
function parseArgs(argv) {
const args = {
dryRun: false,
json: false,
token: "",
repo: "",
maxPages: 10,
maxIssues: 300,
onlyMissing: true,
syncDomains: false,
overrideType: false,
state: "open",
};
let i = 0;
function readFlagValue(flag) {
const value = argv[i + 1];
if (value === undefined || String(value).startsWith("-")) {
throw new Error(`missing value for ${flag}`);
}
i += 1;
return String(value);
}
for (; i < argv.length; i += 1) {
const a = argv[i];
if (a === "--help" || a === "-h") {
args.help = true;
continue;
}
if (a === "--dry-run") {
args.dryRun = true;
continue;
}
if (a === "--json") {
args.json = true;
continue;
}
if (a === "--token") {
args.token = readFlagValue("--token");
continue;
}
if (a === "--repo") {
args.repo = readFlagValue("--repo");
continue;
}
if (a === "--max-pages") {
args.maxPages = toInt(readFlagValue("--max-pages"), args.maxPages);
continue;
}
if (a === "--max-issues") {
args.maxIssues = toInt(readFlagValue("--max-issues"), args.maxIssues);
continue;
}
if (a === "--process-all") {
args.onlyMissing = false;
continue;
}
if (a === "--only-missing") {
args.onlyMissing = true;
continue;
}
if (a === "--sync-domains") {
args.syncDomains = true;
continue;
}
if (a === "--override-type") {
args.overrideType = true;
continue;
}
if (a === "--state") {
args.state = readFlagValue("--state");
continue;
}
throw new Error(`unknown argument: ${a}`);
}
return args;
}
/**
* Print CLI help text.
*
* @returns {void}
*/
function printHelp() {
const msg = `Usage: node scripts/issue-labels/index.js [options]
Options:
--dry-run Do not write labels
--json Output JSON (useful with --dry-run)
--repo <owner/name> Override GITHUB_REPOSITORY
--token <token> Override GITHUB_TOKEN
--max-pages <n> Max search result pages to scan (default: 10)
--max-issues <n> Max unlabeled issues to process (default: 300)
--only-missing Only write when changes are needed (default)
--process-all Evaluate all fetched unlabeled issues
--sync-domains Strictly sync domain/* (remove stale) when domain matched
--override-type Override existing type labels (default: false)
--state open|all Issue state to scan (default: open)
`;
console.log(msg);
}
/**
* Entry point for the issue labeler CLI.
*
* @returns {Promise<void>}
*/
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
return;
}
const token = args.token || envOrFail("GITHUB_TOKEN");
const repo = args.repo || envOrFail("GITHUB_REPOSITORY");
const client = new GitHubClient(token, repo);
const missingManagedLabels = new Set(await client.listMissingManagedLabels());
const scanned = await client.searchUnlabeledIssues({
state: args.state,
maxPages: args.maxPages,
maxIssues: args.maxIssues,
});
const results = {
repo,
dryRun: args.dryRun,
query: "unlabeled issues (intentional one-shot scope)",
scanned: 0,
skippedPR: 0,
skippedIssue: 0,
updated: 0,
changes: [],
};
for (const issue of scanned) {
results.scanned += 1;
if (issue && issue.pull_request) {
results.skippedPR += 1;
continue;
}
const currentLabels = new Set((issue.labels || []).map((l) => l.name));
const { type: desiredType, domains } = classifyIssueText(issue.title, issue.body);
const desiredDomainLabels = domains.map((d) => `domain/${d}`);
const { toAdd, toRemove } = planIssueLabelChanges({
currentLabels,
desiredType,
desiredDomainLabels,
syncDomains: args.syncDomains,
overrideType: args.overrideType,
});
// If some managed labels do not exist in the repository, drop only those labels
// (still apply the rest) instead of skipping the entire issue.
const missingForIssue = toAdd.filter((name) => missingManagedLabels.has(name));
const effectiveToAdd = missingForIssue.length > 0
? toAdd.filter((name) => !missingManagedLabels.has(name))
: toAdd;
if (missingForIssue.length > 0) {
const warning = `warning: ${formatIssueRef(repo, issue.number)} missing labels in ${repo}: ${missingForIssue.join(", ")}`;
console.warn(warning);
}
const hasChange = effectiveToAdd.length > 0 || toRemove.length > 0;
// When --only-missing is enabled (default), we still want JSON output to reflect
// issues that were "actionable" only by missing repo labels.
if (args.onlyMissing && !hasChange) {
if (args.json && missingForIssue.length > 0) {
results.skippedIssue += 1;
results.changes.push({
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
},
desired: {
type: desiredType,
domains,
},
change: { toAdd: [], toRemove: [] },
skipped: true,
reason: "missing_managed_labels",
missingLabels: missingForIssue,
});
}
continue;
}
const record = {
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
},
desired: {
type: desiredType,
domains,
},
change: { toAdd: effectiveToAdd, toRemove },
};
if (missingForIssue.length > 0) {
record.missingLabels = missingForIssue;
}
if (args.json) {
results.changes.push(record);
} else {
console.log(`[${formatIssueRef(repo, issue.number)}] +${effectiveToAdd.join(", ") || "-"} -${toRemove.join(", ") || "-"}`);
}
if (!args.dryRun) {
// Add first to avoid leaving a temporary empty state.
if (effectiveToAdd.length > 0) {
await client.addIssueLabels(issue.number, effectiveToAdd);
}
for (const name of toRemove) {
await client.removeIssueLabel(issue.number, name);
}
}
if (hasChange) {
results.updated += 1;
}
}
if (args.json) {
console.log(JSON.stringify(results));
} else {
console.log(`done: scanned=${results.scanned} updated=${results.updated} skipped_pr=${results.skippedPR} skipped_issue=${results.skippedIssue}`);
}
}
if (require.main === module) {
main().catch((err) => {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
});
}
module.exports = {
classifyIssueText,
collectDomainsFromText,
scoreTypeFromText,
chooseTypeFromScores,
planIssueLabelChanges,
TYPE_LABELS,
DOMAIN_SERVICES,
};