137 lines
5.8 KiB
TypeScript
137 lines
5.8 KiB
TypeScript
/**
|
||
* 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 认证 `<email>/token:<api_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<string, string>; // 解析后的 模块 -> 版本,如 { 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(/(?<!\d)(\d{6,10})(?!\d)/);
|
||
return fallback ? fallback[1] : null;
|
||
}
|
||
|
||
/** 解析工单号:入参 > 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<string, string> {
|
||
const out: Record<string, string> = {};
|
||
// 模块名:大写字母开头的驼峰/带数字 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<RnPluginInfo, 'ticketId'> {
|
||
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<string[]> {
|
||
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<RnPluginInfo> {
|
||
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: {} };
|
||
}
|