/** * Zendesk 工单 helper —— 服务于必测项「获取RN插件」用例。 * * 数据来源:App 提交 feedback 后,Zendesk 工单的**评论**里会自动带一段环境信息 * (App Version / JS_Version / Bundle_Version)。Bundle_Version 即各 RN 插件包版本。 * 注意:这些信息不在工单的结构化字段(custom_fields)里,只在评论正文。 * * 工单号是**变量**,不写死: * 入参 ticketId > env ZENDESK_TICKET_ID > 上游用例写的共享文件 reports/.zendesk-ticket.json * 典型链路:`15945 feedback提交` 提交后拿到工单号 → setSharedTicketId() → * 「获取RN插件」用例 resolveTicketId() 取到 → getTicketRnPlugins() 读版本。 * * 鉴权:Zendesk API Basic 认证 `/token:`,从 .env 读 * ZENDESK_DOMAIN / ZENDESK_EMAIL / ZENDESK_TOKEN。 */ import * as fs from 'fs'; import * as path from 'path'; const SHARED_TICKET_FILE = path.resolve(__dirname, '../../reports/.zendesk-ticket.json'); export interface RnPluginInfo { ticketId: string; appVersion: string | null; // 如 "9.26(23)" jsVersion: string | null; // 如 "1.0.0.2" bundleRaw: string | null; // Bundle_Version 原始串 bundle: Record; // 解析后的 模块 -> 版本,如 { Home: "default 1.0.49.0", Fan2: "1.0.44.0" } } function env(key: string): string { const v = process.env[key]; if (!v) throw new Error(`缺少环境变量 ${key}(请在 .env 配置 Zendesk 连接)`); return v; } /** 上游用例(如 feedback 提交)拿到工单号后写入,供「获取RN插件」用例消费。 */ export function setSharedTicketId(ticketId: string | number): void { fs.mkdirSync(path.dirname(SHARED_TICKET_FILE), { recursive: true }); fs.writeFileSync( SHARED_TICKET_FILE, JSON.stringify({ ticketId: String(ticketId), ts: new Date().toISOString() }, null, 2), 'utf-8' ); } /** * 从 feedback 提交成功页的 UI 源码里抽取工单号(纯函数,便于单测)。 * 优先匹配带标签的格式(工单号/Ticket/Feedback/Request + 数字),兜底取页面里的长数字串。 */ export function extractTicketId(source: string): string | null { const labeled = [ /工\s*单\s*号\s*[::#]?\s*(\d{4,})/i, /(?:ticket|feedback|request|case)\s*(?:id|no|number|#)?\s*[::#]?\s*(\d{4,})/i, /#\s*(\d{6,})/, ]; for (const re of labeled) { const m = source.match(re); if (m) return m[1]; } // 兜底:页面里第一个 6~10 位独立数字(多数工单号长度) const fallback = source.match(/(? env ZENDESK_TICKET_ID > 共享文件。都没有则抛错。 */ export function resolveTicketId(explicit?: string | number): string { if (explicit != null && String(explicit).trim()) return String(explicit).trim(); if (process.env.ZENDESK_TICKET_ID) return process.env.ZENDESK_TICKET_ID.trim(); if (fs.existsSync(SHARED_TICKET_FILE)) { const j = JSON.parse(fs.readFileSync(SHARED_TICKET_FILE, 'utf-8')); if (j?.ticketId) return String(j.ticketId); } throw new Error('未提供工单号:请传入 ticketId、设置 ZENDESK_TICKET_ID,或由上游用例 setSharedTicketId() 写入'); } /** * 把 Bundle_Version 拆成 模块 -> 版本。兼容两种格式: * ① **多行**(真实工单):每行一个 "Module:version",如 "AiHub:1.0.14.0\nHome:apk内置(1.0.51.0)\ndm:1.0.0.2"。 * 模块名允许大小写开头(dm 小写),值允许中文/括号(apk内置(1.0.51.0))。 * ② 单行首尾相连:"Home:default 1.0.49.0Scene:default 1.0.46.0..."(大写开头 token 分隔)。 */ export function parseBundleVersion(raw: string): Record { const out: Record = {}; if (/\r?\n/.test(raw)) { for (const line of raw.split(/\r?\n/)) { const m = line.trim().match(/^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.+)$/); if (m && m[1].trim()) out[m[1].trim()] = m[2].trim(); } return out; } // 单行:模块名是大写字母开头的驼峰/带数字 token,后跟冒号;值取到下一个这样的 token 之前。 const re = /([A-Z][A-Za-z0-9]*)\s*:\s*(.*?)(?=[A-Z][A-Za-z0-9]*\s*:|$)/g; let m: RegExpExecArray | null; while ((m = re.exec(raw)) !== null) { const name = m[1].trim(); const ver = m[2].trim(); if (name) out[name] = ver; } return out; } /** 从一段评论正文里抽取 App/JS/Bundle 版本(取第一处命中)。 */ export function parseDeviceInfo(text: string): Omit { const grab = (re: RegExp): string | null => { const m = text.match(re); return m ? m[1].trim() : null; }; const appVersion = grab(/App\s*Version\s*[::]\s*([^\n\r]+)/i); const jsVersion = grab(/JS[_\s]*Version\s*[::]\s*([^\n\r]+)/i); // Bundle Version 是**多行块**:label 同行第一个模块 + 后续每行一个 "Module:version", // 直到遇到非"单词(无空格)键:值"行(如 "Question Type: App issues" 键名含空格)或块尾。 // 旧实现只截 label 同行第一行 → 只拿到 AiHub,漏掉其余全部插件(本次根因)。 const lines = text.split(/\r?\n/); const li = lines.findIndex((l) => /Bundle[_\s]*Version\s*[::]/i.test(l)); let bundleRaw: string | null = null; if (li >= 0) { const collected: string[] = []; const first = lines[li].replace(/.*?Bundle[_\s]*Version\s*[::]\s*/i, '').trim(); if (first) collected.push(first); for (let i = li + 1; i < lines.length; i++) { const l = lines[i].trim(); // 模块行 = 单个无空格 token + 冒号 + 非空值(键名含空格的字段行如 "Question Type:" 不匹配 → 块结束) if (/^[A-Za-z][A-Za-z0-9]*\s*:\s*\S/.test(l)) collected.push(l); else break; } bundleRaw = collected.length ? collected.join('\n') : null; } return { appVersion, jsVersion, bundleRaw, bundle: bundleRaw ? parseBundleVersion(bundleRaw) : {}, }; } /** 拉取工单全部评论(按时间倒序返回正文数组,最新在前)。 */ export async function fetchTicketComments(ticketId: string): Promise { const domain = env('ZENDESK_DOMAIN').replace(/\/+$/, ''); const auth = Buffer.from(`${env('ZENDESK_EMAIL')}/token:${env('ZENDESK_TOKEN')}`).toString('base64'); const url = `${domain}/api/v2/tickets/${ticketId}/comments.json?sort_order=desc`; const resp = await fetch(url, { headers: { Authorization: `Basic ${auth}` } }); if (!resp.ok) { throw new Error(`Zendesk 取评论失败 ticket=${ticketId} HTTP ${resp.status} ${await resp.text()}`); } const data: any = await resp.json(); return (data.comments || []).map((c: any) => String(c.body || c.html_body || '')); } /** * 「获取RN插件」用例主入口:给定工单号(可省略,走 resolveTicketId), * 返回该工单 app 自动上报的 App/JS/Bundle 版本(扫所有评论,取第一条含 Bundle_Version 的)。 */ export async function getTicketRnPlugins(ticketId?: string | number): Promise { const id = resolveTicketId(ticketId); const comments = await fetchTicketComments(id); for (const body of comments) { if (/Bundle[_\s]*Version/i.test(body)) { return { ticketId: id, ...parseDeviceInfo(body) }; } } return { ticketId: id, appVersion: null, jsVersion: null, bundleRaw: null, bundle: {} }; }