/** * 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 原始串拆成 模块 -> 版本。 * 原始形如 "Home:default 1.0.49.0Scene:default 1.0.46.0Fan2:1.0.44.0..." * (各模块首尾相连,模块名是大写开头的驼峰 token,值可能带 "default " 前缀)。 */ export function parseBundleVersion(raw: string): Record { const out: Record = {}; // 模块名:大写字母开头的驼峰/带数字 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); const bundleRaw = grab(/Bundle[_\s]*Version\s*[::]\s*([^\n\r]+)/i); 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: {} }; }