AI_UIAutomation/utils/common/zendesk.helper.ts

163 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 拆成 模块 -> 版本。兼容两种格式:
* ① **多行**(真实工单):每行一个 "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<string, string> {
const out: Record<string, string> = {};
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<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);
// 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<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: {} };
}