Skip to main content
Glama
zentao-api.mjs20.9 kB
// ZenTao API 模块 // 封装所有与禅道API相关的操作 export class ZenTaoAPI { constructor(baseUrl, account, password) { this.baseUrl = baseUrl.replace(/\/$/, ''); this.account = account; this.password = password; this.token = ''; } /** * 登录禅道获取Token */ async login() { const url = `${this.baseUrl}/api.php/v1/tokens`; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account: this.account, password: this.password }) }); if (!resp.ok) { const text = await resp.text().catch(() => ''); throw new Error(`Login failed ${resp.status}: ${text}`); } const json = await resp.json(); if (!json?.token) { throw new Error('Login response missing token'); } this.token = json.token; return this.token; } /** * 获取认证头 */ getAuthHeaders() { return { 'Content-Type': 'application/json', 'Token': this.token }; } /** * 搜索产品 * @param {string} keyword - 搜索关键词 * @param {number} limit - 返回数量限制 * @returns {Promise<Array>} 产品列表 */ async searchProducts(keyword, limit = 10) { const url = new URL(`${this.baseUrl}/api.php/v1/products`); url.searchParams.set('page', '1'); url.searchParams.set('limit', '100'); const resp = await fetch(url, { headers: this.getAuthHeaders() }); if (!resp.ok) { throw new Error(`GET /products failed: ${resp.status}`); } const data = await resp.json(); const list = Array.isArray(data.products) ? data.products : []; // 模糊搜索 const filtered = list.filter(p => String(p.name || '').toLowerCase().includes(keyword.toLowerCase()) ).slice(0, limit); // 精简返回字段 return filtered.map(p => ({ id: p.id, name: p.name })); } /** * 通过产品名称获取一个BUG的详情 * @param {string} productName - 产品名称 * @param {Object} options - 搜索选项 * @param {string} options.keyword - BUG标题关键词,用于快速定位特定类型的BUG * @returns {Promise<Object>} BUG详情对象 */ async getBugByProductName(productName, options = {}) { const { keyword = '' } = options; // 1. 先搜索产品 const products = await this.searchProducts(productName, 10); if (products.length === 0) { throw new Error(`未找到产品: ${productName}`); } // 2. 检查产品数量,如果多个产品需要用户选择 if (products.length > 1) { const productList = products.map((p, index) => `${index + 1}. ${p.name} (ID: ${p.id})` ).join('\n'); throw new Error(`找到多个匹配的产品,请选择其中一个:\n${productList}\n\n请使用更精确的产品名称重新查询。`); } // 3. 使用唯一匹配的产品 const product = products[0]; // 4. 获取该产品的第一个指派给我的激活BUG const bug = await this.searchFirstActiveBug(product.id, { keyword, assignedToMe: true }); if (!bug) { throw new Error(`产品 "${product.name}" 中没有指派给你的激活BUG${keyword ? `(关键词: ${keyword})` : ''}`); } // 5. 获取BUG的完整详情 const bugDetail = await this.getBugDetail(bug.id); return { bug: bugDetail, product: { id: product.id, name: product.name } }; } /** * 搜索BUG * @param {number} productId - 产品ID(必需) * @param {Object} options - 搜索选项 * @param {string} options.keyword - BUG标题关键词 * @param {boolean} options.allStatuses - 是否返回所有状态 * @param {number} options.limit - 返回数量限制 * @param {boolean} options.assignedToMe - 是否只查询指派给我的BUG * @returns {Promise<Array>} BUG列表 */ async searchBugs(productId, options = {}) { const { keyword = '', allStatuses = false, limit = 10, assignedToMe = false } = options; // productId现在是必需参数,不再支持0表示所有产品 // 注意:禅道API的分页问题是当超出最大页数时,返回的页码与请求的页码不一致 // 我们需要逐页获取数据,直到获取足够的数据或到达最后一页 let allBugs = []; let page = 1; const maxPages = 50; // 设置最大页数限制,防止无限循环 while (allBugs.length < limit && page <= maxPages) { const url = new URL(`${this.baseUrl}/api.php/v1/products/${productId}/bugs`); url.searchParams.set('page', page.toString()); url.searchParams.set('limit', '100'); // 每页100条数据 // 如果只查询指派给我的BUG if (assignedToMe) { url.searchParams.set('status', 'assigntome'); } const resp = await fetch(url, { headers: this.getAuthHeaders() }); if (!resp.ok) { throw new Error(`GET /products/${productId}/bugs failed: ${resp.status}`); } const data = await resp.json(); const bugs = Array.isArray(data.bugs) ? data.bugs : []; // 检查分页是否有效:如果返回的页码与请求的页码不一致,说明已超出最大页数 if (data.page && data.page !== page) { break; // 已到达最后一页 } // 如果当前页没有数据,说明已到最后一页 if (bugs.length === 0) { break; } allBugs = allBugs.concat(bugs); // 如果当前页数据少于limit,说明已到最后一页 if (bugs.length < 100) { break; } page++; } // 关键词过滤 if (keyword) { allBugs = allBugs.filter(b => String(b.title || '').toLowerCase().includes(keyword.toLowerCase()) ); } // 状态过滤 if (!allStatuses) { allBugs = allBugs.filter(b => { const status = String(b.status || '').toLowerCase(); // 处理status可能是对象或字符串的情况 if (typeof b.status === 'object' && b.status.code) { return b.status.code.toLowerCase() === 'active'; } return status === 'active'; }); } // 限制返回数量 return allBugs.slice(0, limit); } /** * 使用 for yield 检索第一个激活的BUG * @param {number} productId - 产品ID * @param {Object} options - 搜索选项 * @param {string} options.keyword - BUG标题关键词 * @param {boolean} options.assignedToMe - 是否只查询指派给我的BUG * @returns {Promise<Object|null>} 第一个激活的BUG,如果没有则返回null */ async* searchFirstActiveBugGenerator(productId, options = {}) { const { keyword = '', assignedToMe = false } = options; let page = 1; const maxPages = 50; // 最多搜索50页 while (page <= maxPages) { const url = new URL(`${this.baseUrl}/api.php/v1/products/${productId}/bugs`); url.searchParams.set('page', page.toString()); url.searchParams.set('limit', '50'); // 每页50条,减少流量 // 如果只查询指派给我的BUG if (assignedToMe) { url.searchParams.set('status', 'assigntome'); } const resp = await fetch(url, { headers: this.getAuthHeaders() }); if (!resp.ok) { throw new Error(`GET /products/${productId}/bugs failed: ${resp.status}`); } const data = await resp.json(); const bugs = Array.isArray(data.bugs) ? data.bugs : []; // 检查分页是否有效:如果返回的页码与请求的页码不一致,说明已超出最大页数 if (data.page && data.page !== page) { break; // 已到达最后一页 } if (bugs.length === 0) break; // 没有更多数据了 // 如果当前页数据少于limit,说明已到最后一页 if (bugs.length < 50) { // 处理完当前页的数据后退出 for (const bug of bugs) { // 处理status可能是对象或字符串的情况 let isActive = false; const status = bug.status; if (typeof status === 'object' && status.code) { isActive = status.code === 'active'; } else { isActive = String(status || '').toLowerCase() === 'active'; } if (isActive) { // 如果有关键词,检查标题是否匹配 if (keyword) { const kw = String(keyword).toLowerCase(); if (!String(bug.title || '').toLowerCase().includes(kw)) { continue; } } // yield 匹配的BUG yield { id: bug.id, title: bug.title, severity: bug.severity, status: bug.status, assignedTo: bug.assignedTo?.realname || bug.assignedTo?.account }; } } break; // 最后一页处理完毕,退出循环 } // yield 每一个激活的BUG for (const bug of bugs) { // 处理status可能是对象或字符串的情况 let isActive = false; const status = bug.status; if (typeof status === 'object' && status.code) { isActive = status.code === 'active'; } else { isActive = String(status || '').toLowerCase() === 'active'; } if (isActive) { // 如果有关键词,检查标题是否匹配 if (keyword) { const kw = String(keyword).toLowerCase(); if (!String(bug.title || '').toLowerCase().includes(kw)) { continue; } } // yield 匹配的BUG yield { id: bug.id, title: bug.title, severity: bug.severity, status: bug.status, assignedTo: bug.assignedTo?.realname || bug.assignedTo?.account }; } } page++; } } /** * 检索第一个激活的BUG(使用generator) * @param {number} productId - 产品ID * @param {Object} options - 搜索选项 * @param {string} options.keyword - BUG标题关键词 * @param {boolean} options.assignedToMe - 是否只查询指派给我的BUG * @returns {Promise<Object|null>} 第一个激活的BUG,如果没有则返回null */ async searchFirstActiveBug(productId, options = {}) { const generator = this.searchFirstActiveBugGenerator(productId, options); for await (const bug of generator) { // 返回第一个匹配的BUG return bug; } return null; // 没有找到激活的BUG } /** * 检索BUG总数和第一页数据 * @param {number} productId - 产品ID * @param {Object} options - 搜索选项 * @param {string} options.keyword - BUG标题关键词 * @param {boolean} options.activeOnly - 是否只统计激活的BUG * @param {boolean} options.assignedToMe - 是否只查询指派给我的BUG * @returns {Promise<Object>} 包含总数和第一页数据的对象 */ async searchBugsWithTotal(productId, options = {}) { const { keyword = '', activeOnly = false, assignedToMe = false } = options; const url = new URL(`${this.baseUrl}/api.php/v1/products/${productId}/bugs`); url.searchParams.set('page', '1'); url.searchParams.set('limit', '20'); // 第一页只返回20条,用于预览 // 如果只查询指派给我的BUG if (assignedToMe) { url.searchParams.set('status', 'assigntome'); } const resp = await fetch(url, { headers: this.getAuthHeaders() }); if (!resp.ok) { throw new Error(`GET /products/${productId}/bugs failed: ${resp.status}`); } const data = await resp.json(); let bugs = Array.isArray(data.bugs) ? data.bugs : []; // 按标题关键词筛选 if (keyword) { const kw = String(keyword).toLowerCase(); bugs = bugs.filter(b => String(b.title || '').toLowerCase().includes(kw) ); } // 如果只需要激活的BUG,进行状态过滤 let filteredBugs = bugs; if (activeOnly) { filteredBugs = bugs.filter(b => { const status = b.status; // 处理status可能是对象或字符串的情况 if (typeof status === 'object' && status.code) { return status.code === 'active'; } return String(status || '').toLowerCase() === 'active'; }); } // 使用API返回的total字段,如果需要过滤激活BUG,需要重新计算总数 let total = data.total || 0; let filteredTotal = total; if ((activeOnly || keyword) && !assignedToMe) { // 如果需要过滤但不是指派给我的情况,需要重新计算总数 filteredTotal = await this.calculateFilteredTotal(productId, { keyword, activeOnly }); } else if (assignedToMe && (activeOnly || keyword)) { // 如果是指派给我的且需要其他过滤条件 filteredTotal = await this.calculateFilteredTotal(productId, { keyword, activeOnly, assignedToMe }); } return { total: filteredTotal, hasMore: filteredTotal > filteredBugs.length, bugs: filteredBugs.map(b => ({ id: b.id, title: b.title, severity: b.severity, status: b.status, assignedTo: b.assignedTo?.realname || b.assignedTo?.account })) }; } /** * 计算过滤后的总数(用于精确计算) * @param {number} productId - 产品ID * @param {Object} options - 过滤选项 * @returns {Promise<number>} 过滤后的总数 */ async calculateFilteredTotal(productId, options = {}) { const { keyword = '', activeOnly = false, assignedToMe = false } = options; let page = 1; const maxPages = 50; // 最多检查50页来计算总数 let totalCount = 0; while (page <= maxPages) { const url = new URL(`${this.baseUrl}/api.php/v1/products/${productId}/bugs`); url.searchParams.set('page', page.toString()); url.searchParams.set('limit', '100'); // 如果只查询指派给我的BUG if (assignedToMe) { url.searchParams.set('status', 'assigntome'); } const resp = await fetch(url, { headers: this.getAuthHeaders() }); if (!resp.ok) break; const data = await resp.json(); const bugs = Array.isArray(data.bugs) ? data.bugs : []; // 检查分页是否有效:如果返回的页码与请求的页码不一致,说明已超出最大页数 if (data.page && data.page !== page) { break; // 已到达最后一页 } if (bugs.length === 0) break; // 应用过滤条件 let filteredBugs = bugs; if (keyword) { const kw = String(keyword).toLowerCase(); filteredBugs = filteredBugs.filter(b => String(b.title || '').toLowerCase().includes(kw) ); } if (activeOnly) { filteredBugs = filteredBugs.filter(b => { const status = b.status; if (typeof status === 'object' && status.code) { return status.code === 'active'; } return String(status || '').toLowerCase() === 'active'; }); } totalCount += filteredBugs.length; // 如果当前页的数据少于limit,说明已经是最后一页 if (bugs.length < 100) break; page++; } return totalCount; } /** * 从HTML内容中提取图片URL * @param {string} htmlContent - HTML内容 * @returns {Array<string>} 图片URL数组 */ extractImagesFromHtml(htmlContent) { if (!htmlContent || typeof htmlContent !== 'string') { return []; } // 使用正则表达式匹配img标签的src属性 const imgRegex = /<img[^>]+src\s*=\s*["']([^"']+)["'][^>]*>/gi; const images = []; let match; while ((match = imgRegex.exec(htmlContent)) !== null) { const src = match[1]; if (src && src.startsWith('http')) { images.push(src); } } return images; } /** * 获取BUG详情 * @param {number} bugId - BUG ID * @returns {Promise<Object>} BUG详情 */ async getBugDetail(bugId) { const resp = await fetch( `${this.baseUrl}/api.php/v1/bugs/${bugId}`, { headers: this.getAuthHeaders() } ); if (!resp.ok) { throw new Error(`GET /bugs/${bugId} failed: ${resp.status}`); } const bug = await resp.json(); // 从steps中提取图片 const stepsImages = this.extractImagesFromHtml(bug.steps); // 精简返回字段 return { id: bug.id, title: bug.title, severity: bug.severity, priority: bug.pri, status: bug.status, steps: bug.steps, stepsImages: stepsImages, assignedTo: bug.assignedTo, openedBy: bug.openedBy, product: bug.product, type: bug.type }; } /** * 标记BUG为已解决 * @param {number} bugId - BUG ID * @param {string} comment - 解决备注 * @returns {Promise<Object>} 操作结果 */ async markBugResolved(bugId, comment = '') { const body = { resolution: 'fixed', ...(comment ? { comment: String(comment) } : {}) }; const resp = await fetch( `${this.baseUrl}/api.php/v1/bugs/${bugId}/resolve`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify(body) } ); if (!resp.ok) { const text = await resp.text().catch(() => ''); throw new Error(`POST /bugs/${bugId}/resolve failed: ${resp.status} ${text}`); } return await resp.json().catch(() => ({})); } /** * 智能搜索产品和BUG * @param {string} keyword - 产品搜索关键词 * @param {Object} options - 搜索选项 * @param {string} options.bugKeyword - BUG标题关键词 * @param {number} options.productId - 直接指定产品ID * @param {boolean} options.allStatuses - 是否返回所有状态的BUG * @param {boolean} options.assignedToMe - 是否只查询指派给我的BUG * @returns {Promise<Object>} 搜索结果 */ async searchProductBugs(keyword, options = {}) { const { bugKeyword = '', productId, allStatuses = false, assignedToMe = false } = options; // 如果直接提供了 productId,直接搜索该产品的BUG if (productId) { if (!Number.isFinite(productId)) { throw new UserError('productId 必须为数字'); } const bugs = await this.searchBugs(productId, { keyword: bugKeyword, allStatuses, assignedToMe }); return { bugs }; } // 否则先搜索产品 const products = await this.searchProducts(keyword); // 如果只找到1个产品,直接返回该产品的BUG列表 if (products.length === 1) { const product = products[0]; const bugs = await this.searchBugs(product.id, { keyword: bugKeyword, allStatuses, assignedToMe }); return { product, bugs }; } // 找到多个产品或没有产品,返回产品列表供用户选择 return { products }; } /** * 搜索所有产品的BUG * @param {Object} options - 搜索选项 * @returns {Promise<Array>} BUG列表 */ async searchAllProductsBugs(options = {}) { const { keyword = '', allStatuses = false, limit = 10, assignedToMe = false } = options; // 获取所有产品 const products = await this.searchProducts('', 50); let allBugs = []; // 遍历所有产品获取BUG for (const product of products) { try { const bugs = await this.searchBugs(product.id, { keyword, allStatuses, limit: Math.ceil(limit / products.length) + 5, // 分配limit,避免总数不够 assignedToMe }); // 添加产品信息到每个BUG const bugsWithProduct = bugs.map(bug => ({ ...bug, product: { id: product.id, name: product.name } })); allBugs = allBugs.concat(bugsWithProduct); } catch (err) { // 如果某个产品查询失败,跳过继续查询其他产品 console.warn(`Failed to search bugs for product ${product.id}: ${err.message}`); } } // 按优先级或时间排序(这里可以按需要调整) allBugs.sort((a, b) => { // 可以按severity、openedDate等排序 return (b.severity || 0) - (a.severity || 0); }); return allBugs.slice(0, limit); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/evlon/mcp-zentao-bugs'

If you have feedback or need assistance with the MCP directory API, please join our Discord server