Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
getModule,
getModuleAction,
} from './modules/registry.js';
export { uploadFile, type UploadedFile } from './modules/upload.js';
export {
request,
type BuiltinRequestName,
Expand Down
2 changes: 1 addition & 1 deletion src/misc/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,让打包器无法在静态分析阶段把
Expand Down
3 changes: 3 additions & 0 deletions src/misc/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<objectID>".',
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。 */
Expand Down
107 changes: 63 additions & 44 deletions src/modules/override.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { extendModuleAction, defineModules } from './define.js';
import { extendModuleAction } from './define.js';

/**
* 内置覆盖 / 扩展定义。
Expand All @@ -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
*/
Expand All @@ -69,6 +27,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<string, unknown>;
} | 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<string, unknown>;
Expand Down Expand Up @@ -116,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<string, Record<string, unknown>> | 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;
});
});
}
8 changes: 8 additions & 0 deletions src/modules/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ function buildQuery(action: ModuleAction, params: Record<string, unknown>): 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;
}
Expand Down
71 changes: 71 additions & 0 deletions src/modules/upload.ts
Original file line number Diff line number Diff line change
@@ -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<UploadedFile> {
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,
};
}