From 37d59c4b74165bc297c68b13242f8a6c30bfe929 Mon Sep 17 00:00:00 2001 From: chendx Date: Fri, 17 Jul 2026 19:13:30 +0800 Subject: [PATCH 1/2] * fix: pass productID as query param for story/create and bug/create Zentao server requires productID in URL query string for POST /stories and POST /bugs, but the action definition only declares productID in requestBody.schema, not action.params. This caused buildQuery to never include it in the URL and buildRequestBody to throw E_MISSING_PARAM when callers pass --product=N (without an explicit productID=N). Changes: - override.ts: add productID to action.params and remove it from requestBody.schema so buildQuery moves it to the URL query and buildRequestBody no longer validates it. - resolve.ts: buildQuery falls back to the base key (e.g. product) when a param is named with the ID suffix (e.g. productID) to match the CLI calling convention. --- src/modules/override.ts | 37 +++++++++++++++++++++++++++++++++++++ src/modules/resolve.ts | 8 ++++++++ 2 files changed, 45 insertions(+) diff --git a/src/modules/override.ts b/src/modules/override.ts index f782093..1e01222 100644 --- a/src/modules/override.ts +++ b/src/modules/override.ts @@ -69,6 +69,43 @@ export function applyBuiltinOverrides(): void { return action; }); + // 修复 story/create、bug/create 等产品级创建接口的 productID 传递问题: + // 服务端要求 productID 放在 URL 查询参数里,而不是请求体里, + // 但 OpenAPI 定义将 productID 放在了 requestBody 中,且 action.params 为空。 + // 修复动作: + // 1. 在 action.params 中补充 productID,让 buildQuery 把它拼到 URL query 上; + // 2. 从 requestBody.schema 中移除 productID,避免 buildRequestBody 因缺少该字段而抛错。 + [ + ['story', 'create'], + ['bug', 'create'], + ].forEach(([moduleName, actionName]) => { + extendModuleAction(moduleName, actionName, (action) => { + const existing = action.params ?? []; + const hasProductID = existing.some((p) => p.name === 'productID'); + if (!hasProductID) { + const params = [{ + name: 'productID', + required: true, + type: 'number' as const, + description: '产品ID(查询参数)', + }, ...existing]; + action.params = params; + } + // 将 productID 从 requestBody 移到 query,避免 buildRequestBody 校验失败 + const schema = action.requestBody?.schema as { + required?: string[]; + properties?: Record; + } | undefined; + if (schema?.properties && 'productID' in schema.properties) { + if (Array.isArray(schema.required)) { + schema.required = schema.required.filter((key) => key !== 'productID'); + } + delete schema.properties.productID; + } + return action; + }); + }); + // 修改 story/update 字段定义 extendModuleAction('story', 'update', (action) => { const properties = action.requestBody!.schema?.properties as Record; diff --git a/src/modules/resolve.ts b/src/modules/resolve.ts index 575dd7c..63fb8d1 100644 --- a/src/modules/resolve.ts +++ b/src/modules/resolve.ts @@ -145,6 +145,14 @@ function buildQuery(action: ModuleAction, params: Record): Reco if (value === undefined && param.name === 'pageID') { value = params.page; } + // 兼容 CLI 调用惯例:若参数名为 xxxID(如 productID/projectID/executionID), + // 但调用方按 xxx 传参(如 product=1),则自动 fallback 到 xxx 取值。 + if (value === undefined && param.name.endsWith('ID') && param.name.length > 2) { + const baseKey = param.name.slice(0, -2); + if (params[baseKey] !== undefined) { + value = params[baseKey]; + } + } if (value === undefined) { value = param.defaultValue ?? param.options?.[0]?.value; } From d1e3af13b404c9b2c81bfb2a6ea24e250cefdc3d Mon Sep 17 00:00:00 2001 From: chendx Date: Tue, 21 Jul 2026 10:26:00 +0800 Subject: [PATCH 2/2] + feat: add uploadFile for image embedding in content fields --- package.json | 4 +++ src/index.ts | 1 + src/misc/environment.ts | 2 +- src/misc/errors.ts | 3 ++ src/modules/override.ts | 70 +++++++++++++++------------------------- src/modules/upload.ts | 71 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 45 deletions(-) create mode 100644 src/modules/upload.ts diff --git a/package.json b/package.json index 34535cc..faa88e5 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ "import": "./dist/browser.js" }, "./browser/global": "./dist/browser/zentao-api.global.js", + "./modules/upload": { + "types": "./dist/modules/upload.d.ts", + "import": "./dist/modules/upload.js" + }, "./package.json": "./package.json" }, "files": [ diff --git a/src/index.ts b/src/index.ts index 6684709..3ec84a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export { getModule, getModuleAction, } from './modules/registry.js'; +export { uploadFile, type UploadedFile } from './modules/upload.js'; export { request, type BuiltinRequestName, diff --git a/src/misc/environment.ts b/src/misc/environment.ts index 48f1c30..68b2885 100644 --- a/src/misc/environment.ts +++ b/src/misc/environment.ts @@ -2,7 +2,7 @@ import { ZentaoError } from './errors.js'; /** 判断当前运行时是否为 Node.js。 */ export function isNodeRuntime(): boolean { - return typeof process !== 'undefined' && Boolean(process.versions?.node); + return typeof process !== 'undefined' && Boolean(process.versions?.node || process.versions?.bun); } // 通过函数参数间接化 `import(specifier)`,让打包器无法在静态分析阶段把 diff --git a/src/misc/errors.ts b/src/misc/errors.ts index f185b9f..5ea3e3a 100644 --- a/src/misc/errors.ts +++ b/src/misc/errors.ts @@ -27,6 +27,9 @@ export const ERRORS = { E_INVALID_PARAM: 'Invalid value for parameter {param}: {value}', E_INVALID_REQUEST_NAME: 'Request name must use the form "moduleName", "moduleName/methodName", or "moduleName/".', E_API_FAILED: 'ZenTao API returned failure: {message}', + E_UPLOAD_NODE_ONLY: 'File upload is only supported in Node.js runtimes.', + E_UPLOAD_NOT_A_FILE: 'Upload path is not a file: {path}', + E_UPLOAD_FAILED: 'File upload failed: {message}', } as const; /** SDK 已知错误码,对应 {@link ERRORS} 的 key。 */ diff --git a/src/modules/override.ts b/src/modules/override.ts index 1e01222..c31af0a 100644 --- a/src/modules/override.ts +++ b/src/modules/override.ts @@ -1,4 +1,4 @@ -import { extendModuleAction, defineModules } from './define.js'; +import { extendModuleAction } from './define.js'; /** * 内置覆盖 / 扩展定义。 @@ -13,49 +13,7 @@ import { extendModuleAction, defineModules } from './define.js'; * 维护约定: * - 不要修改 `./generated.ts`(它由 `scripts/update-registry.ts` 自动生成)。 * 能通过更新 OpenAPI 数据解决的,优先走生成流程;只有生成器无法表达的扩展才写在这里。 - * - 复用 {@link defineModules} / {@link defineModuleActions} 的语义: - * - {@link defineModuleActions}:为**已存在**的模块追加动作(同名替换、未知追加)。 - * - {@link defineModules}:登记**新模块**,或对已存在模块做合并 / 整体替换(`replace`)。 - * - 写入会自动深克隆 + 深冻结,无需自己处理不可变性。 - * - * @example 为已存在的 `bug` 模块补充一个自定义动作: - * ```ts - * defineModuleActions('bug', { - * name: 'assignTo', - * display: '指派 Bug', - * type: 'action', - * method: 'put', - * path: '/bugs/{bugID}/assignto', - * resultType: 'text', - * pathParams: { bugID: 'Bug ID' }, - * requestBody: { - * required: true, - * schema: { - * assignedTo: { type: 'string', description: '指派给' }, - * comment: { type: 'string', description: '备注' }, - * }, - * }, - * }); - * ``` - * - * @example 登记一个 OpenAPI 未覆盖的新模块: - * ```ts - * defineModules({ - * name: 'custom', - * display: '自定义模块', - * actions: [ - * { - * name: 'list', - * type: 'list', - * method: 'get', - * path: '/customs', - * resultType: 'list', - * pagerGetter: 'pager', - * resultGetter: 'customs', - * }, - * ], - * }); - * ``` + * - 复用 {@link defineModuleActions} / {@link extendModuleAction} 的语义。 * * @internal */ @@ -153,4 +111,28 @@ export function applyBuiltinOverrides(): void { return action; }); }); + + // story/bug create 的 spec/steps 字段支持图片标记语法 ![alt](path) + // 这些字段在 schema 中声明为 string 类型,CLI 会在上传图片后将路径替换为禅道图片标记 + [ + ['story', 'create'], + ['bug', 'create'], + ].forEach(([moduleName, actionName]) => { + extendModuleAction(moduleName, actionName, (action) => { + const properties = action.requestBody!.schema?.properties as Record> | undefined; + if (!properties) return action; + + const contentFields = moduleName === 'story' + ? ['spec', 'verify'] + : ['steps']; + + for (const field of contentFields) { + const prop = properties[field]; + if (prop && typeof prop === 'object') { + prop.description = `${prop.description ?? ''}(支持图片标记:![描述](本地路径))`; + } + } + return action; + }); + }); } diff --git a/src/modules/upload.ts b/src/modules/upload.ts new file mode 100644 index 0000000..aea9845 --- /dev/null +++ b/src/modules/upload.ts @@ -0,0 +1,71 @@ +import { ZentaoClient } from '../client/index.js'; + +/** 上传文件到禅道后返回的文件信息。 */ +export interface UploadedFile { + /** 文件 ID。 */ + id: number; + /** 文件访问地址。 */ + url: string; +} + +/** + * 上传本地文件到禅道。 + * + * 通过 multipart/form-data 方式上传文件,适用于在 story/bug 等对象的内容中插入图片。 + * 仅支持 Node.js 运行时(需要读取本地文件)。 + * + * @param client - 已认证的 ZentaoClient 实例。 + * @param filePath - 本地文件路径。 + * @param options - 上传选项,可指定关联的对象类型和 ID。 + * @returns 上传成功后的文件信息(id、url)。 + */ +export async function uploadFile( + client: ZentaoClient, + filePath: string, + options?: { objectType?: string; objectID?: number }, +): Promise { + const { isNodeRuntime } = await import('../misc/environment.js'); + if (!isNodeRuntime()) { + const { ZentaoError } = await import('../misc/errors.js'); + throw new ZentaoError('E_UPLOAD_NODE_ONLY'); + } + + const { readFile, stat } = await import('node:fs/promises'); + const fileStat = await stat(filePath); + if (!fileStat.isFile()) { + const { ZentaoError } = await import('../misc/errors.js'); + throw new ZentaoError('E_UPLOAD_NOT_A_FILE', { path: filePath }); + } + + const fileName = filePath.split('/').pop() ?? 'uploaded-file'; + const fileBuffer = await readFile(filePath); + + const formData = new FormData(); + formData.append('file', new Blob([fileBuffer], { type: 'image/png' }), fileName); + if (options?.objectType) { + formData.append('objectType', options.objectType); + } + if (options?.objectID !== undefined) { + formData.append('objectID', String(options.objectID)); + } + + const result = await client.request<{ + status: string; + id: number; + url: string; + data?: { id: number; url: string }; + }>('/files', { + method: 'POST', + body: formData, + }); + + if (!result || result.status !== 'success') { + const { ZentaoError } = await import('../misc/errors.js'); + throw new ZentaoError('E_UPLOAD_FAILED', { message: result ? '文件上传失败' : '服务器返回空响应,请检查 objectType/objectID 参数' }); + } + + return { + id: result.data?.id ?? result.id, + url: result.data?.url ?? result.url, + }; +}