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
2 changes: 1 addition & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

- **Skill 命令契约**: 对齐 `zentao-api` 当前模块 schema,修正列表作用域、创建字段、状态流转必填参数、过滤 OR 语义和分页说明。
- **帮助命令**: 后缀 `help` 语法改为鉴权前处理,并仅展示当前动作实际支持的分页和结果选项。
- **`--all` 显式失败**: 在自动翻页尚未实现时返回明确错误,避免静默把默认页误当全量数据
- **`--all` 自动翻页**: 列表操作支持自动读取全部分页,并在聚合后统一应用过滤、搜索、排序、限制和字段摘取

### 🚀 优化与重构 (Refactor)

Expand Down
8 changes: 6 additions & 2 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,9 +637,10 @@ $ zentao ls product --sort=name:asc

* `--page=<pageNumber>`:指定页码,默认值为 1
* `--recPerPage=<recPerPage>`:指定分页大小,默认值为 20
* `--limit=<number>`:截取当前返回页的数据;仅非负有限数值生效,小数向下取整
* `--all`:自动翻页获取全部数据;不能与 `--page` 或 `--format=raw` 同时使用
* `--limit=<number>`:限制返回数量;仅非负有限数值生效,小数向下取整

CLI 当前不会自动翻页。仅当该列表操作的 `--help` 显示 `--page` / `--recPerPage` 时才可使用这两个参数;需要全量数据且返回了 pager 时,重复调整 `--page`,直到已读取条数覆盖总数
仅当列表操作的 `--help` 显示相应选项时,才可使用 `--page``--recPerPage` 或 `--all`。使用 `--all` 时,CLI 会先聚合全部分页,再统一应用过滤、搜索、排序、`--limit` 和 `--pick`

```bash
# 获取禅道产品信息,并分页获取
Expand All @@ -651,6 +652,9 @@ $ zentao product --page=1 --recPerPage=100
| 2 | 产品2 |

已显示 2 项,共 2 项,当前第 1 页,每页 100 条

# 自动获取全部产品,并在完整结果上排序和限制数量
$ zentao product --all --sort=id:desc --limit=10
```

## 设置默认配置
Expand Down
7 changes: 4 additions & 3 deletions skills/zentao-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,11 @@ zentao bug --product=1 --sort=pri:asc,severity:asc

```bash
zentao bug --product=1 --page=1 --recPerPage=50
zentao bug --product=1 --limit=10 # 只取当前返回页的前 10 条
zentao bug --product=1 --all # 自动获取全部分页
zentao bug --product=1 --all --limit=10 # 获取全部后只保留前 10 条
```

CLI 不会自动翻页。仅当该列表操作的 `--help` 显示 `--page` / `--recPerPage` 时才可使用这两个参数;部分列表接口不支持服务端分页参数。需要全量数据且返回了 pager 时,重复调整 `--page`,直到已读取条数覆盖总数。`--limit` 只在当前返回页上截取
仅当列表操作的 `--help` 显示相应选项时,才可使用 `--page``--recPerPage` 或 `--all`。`--all` 会先聚合全部分页,再统一应用过滤、搜索、排序、`--limit` 和字段摘取;不能与 `--page``--format=raw` 同时使用

## 常用操作示例

Expand Down Expand Up @@ -236,7 +237,7 @@ zentao help # 查看所有命令

| 用户意图 | CLI 命令 |
|---------|---------|
| 所有产品/项目/项目集 | `zentao product` / `zentao project --browseType=all` / `zentao program`,全量结果按 pager 逐页读取 |
| 所有产品/项目/项目集 | `zentao product --all` / `zentao project --browseType=all --all` / `zentao program --all` |
| 进行中的项目 | `zentao project --browseType=doing` |
| 某产品的 Bug | `zentao bug --product=<id>` |
| 某执行的任务 | `zentao task --executionID=<id>` |
Expand Down
20 changes: 10 additions & 10 deletions skills/zentao-tour/roles/executive.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ zentao project --browseType=doing --pick=id,name,begin,end,progress
让他扫一眼,问:"有没有哪条看着不对劲?进度慢的、日期要到的?"——挑出一个深入看:

```bash
zentao execution --status=all --filter='project=<id>' --pick=id,name,status --page=<页码> --recPerPage=100
zentao task --executionID=<执行ID> --status=all --pick=id,status --page=<页码> --recPerPage=100 --format=json
zentao execution --status=all --filter='project=<id>' --pick=id,name,status --all
zentao task --executionID=<执行ID> --status=all --pick=id,status --all --format=json
```

执行列表没有项目作用域,所以用 `--filter` 在当前页本地筛选;必须扫完全局执行列表的每一页,才能收集某项目的全部执行 ID。再对每个执行按任务 pager 逐页读取,本地聚合"wait/doing/done 各多少",用一句话汇报给用户。
执行列表没有项目作用域,所以用 `--all` 获取全部执行后再过滤,收集某项目的全部执行 ID。再对每个执行获取全部任务,本地聚合"wait/doing/done 各多少",用一句话汇报给用户。

## 如果他关心产品健康度

Expand All @@ -43,11 +43,11 @@ zentao product --pick=id,name,status
挑他在意的那个产品:

```bash
zentao story --product=<id> --browseType=allstory --pick=id,pri,stage,plan --page=<页码> --recPerPage=100 --format=json
zentao bug --product=<id> --browseType=all --pick=id,severity,pri,status --page=<页码> --recPerPage=100 --format=json
zentao story --product=<id> --browseType=allstory --pick=id,pri,stage,plan --all --format=json
zentao bug --product=<id> --browseType=all --pick=id,severity,pri,status --all --format=json
```

根据 pager 逐页读完再做简单统计(高优先级未处理需求数、严重 Bug 数),用两句话告诉用户:"《XXX》当前有 N 条高优需求还没排期,严重 Bug M 条——主要堆在这几个 severity 上。"
获取全部结果后再做简单统计(高优先级未处理需求数、严重 Bug 数),用两句话告诉用户:"《XXX》当前有 N 条高优需求还没排期,严重 Bug M 条——主要堆在这几个 severity 上。"

## 如果他关心发布与版本

Expand Down Expand Up @@ -85,10 +85,10 @@ zentao ticket --productID=<id> --browseType=all --pick=id,title,status,pri
| 关注点 | 命令 |
|--------|------|
| 进行中的项目 | `zentao project --browseType=doing --pick=id,name,progress,begin,end` |
| 项目下的执行 | `zentao execution --status=all --filter='project=<id>' --pick=id,name,status --page=<页码> --recPerPage=100`|
| 任务状态聚合 | `zentao task --executionID=<id> --status=all --pick=status --page=<页码> --recPerPage=100 --format=json` |
| 产品下需求概览 | `zentao story --product=<id> --browseType=allstory --pick=id,pri,stage,plan --page=<页码> --recPerPage=100 --format=json` |
| 产品下 Bug 概览 | `zentao bug --product=<id> --browseType=all --pick=id,severity,pri,status --page=<页码> --recPerPage=100 --format=json` |
| 项目下的执行 | `zentao execution --status=all --filter='project=<id>' --pick=id,name,status --all`|
| 任务状态聚合 | `zentao task --executionID=<id> --status=all --pick=status --all --format=json` |
| 产品下需求概览 | `zentao story --product=<id> --browseType=allstory --pick=id,pri,stage,plan --all --format=json` |
| 产品下 Bug 概览 | `zentao bug --product=<id> --browseType=all --pick=id,severity,pri,status --all --format=json` |
| 即将 / 最近发布 | `zentao release --productID=<id> --pick=id,name,date,status` |
| 版本 | `zentao build --project=<id> --pick=id,name,date` |
| 用户反馈 | `zentao feedback --productID=<id> --browseType=all --pick=id,title,status,pri` |
Expand Down
6 changes: 3 additions & 3 deletions skills/zentao-tour/roles/pm.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ zentao story update <storyID> --plan=<计划ID>
全部挂完后,顺手列一张表给用户看成果(**不要**加"小结"这种字眼):

```bash
zentao story --product=<产品ID> --pick=id,title,pri,plan --page=<页码> --recPerPage=100
zentao story --product=<产品ID> --pick=id,title,pri,plan --all
```

根据 pager 逐页读完后,再给用户看完整结果。
获取全部结果后,再给用户看完整结果。

像朋友一样指着说:"喏,你看这几条都绑在《MVP 首发》上了——从一个空白的点子到这张表,其实你已经走完了产品经理最核心的一条线:**产品 → 需求 → 计划**。研发同事打开禅道就能按这个打工。"

Expand Down Expand Up @@ -139,5 +139,5 @@ zentao story --product=<产品ID> --pick=id,title,pri,plan --page=<页码> --rec
| 建需求 | `zentao story create --productID= --title= --pri= --spec=` |
| 改需求所属计划 | `zentao story update <id> --plan=<planID>` |
| 建计划 | `zentao productplan create --productID= --title= --begin= --end=` |
| 查看产品下所有需求 | `zentao story --product=<id> --pick=id,title,pri,plan --page=<页码> --recPerPage=100`,按 pager 逐页读取 |
| 查看产品下所有需求 | `zentao story --product=<id> --pick=id,title,pri,plan --all` |
| 查看参数 | `zentao <module> <action> --help` |
12 changes: 10 additions & 2 deletions src/commands/module-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,11 @@ export function showModuleHelp(mod: ModuleDefinition): void {
if (listParams.some((param) => param.name === 'recPerPage')) {
commonOpts.push({ name: 'recPerPage', placeholder: 'number', description: '每页条数,适用于 list 操作' });
}
commonOpts.push({ name: 'limit', placeholder: 'number', description: '限制当前返回结果数量,适用于 list 操作' });
if (listParams.some((param) => param.name === 'pageID')
&& listParams.some((param) => param.name === 'recPerPage')) {
commonOpts.push({ name: 'all', description: '自动翻页获取全部数据,不可与 --page 或 --format=raw 同时使用' });
}
commonOpts.push({ name: 'limit', placeholder: 'number', description: '限制返回结果数量,适用于 list 操作' });
}
if (createAction || updateAction || actions.length > 0) {
commonOpts.push({ name: 'data', placeholder: 'json', description: '请求数据(JSON 格式),适用于 create/update/状态流转操作' });
Expand Down Expand Up @@ -395,7 +399,11 @@ export function showModuleActionHelp(mod: ModuleDefinition, action: ModuleAction
if (actionParams.some((param) => param.name === 'recPerPage')) {
commonOpts.push({ name: 'recPerPage', placeholder: 'number', description: '每页条数' });
}
commonOpts.push({ name: 'limit', placeholder: 'number', description: '限制当前返回结果数量' });
if (actionParams.some((param) => param.name === 'pageID')
&& actionParams.some((param) => param.name === 'recPerPage')) {
commonOpts.push({ name: 'all', description: '自动翻页获取全部数据,不可与 --page 或 --format=raw 同时使用' });
}
commonOpts.push({ name: 'limit', placeholder: 'number', description: '限制返回结果数量' });
} else if (action.type === 'get') {
commonOpts.push(
{ name: 'pick', placeholder: 'fields', description: '摘取指定字段(逗号分隔),仅输出指定的字段' },
Expand Down
2 changes: 1 addition & 1 deletion src/commands/register-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function addDataOptions(cmd: Command): Command {
.option('--search-fields <fields>', '搜索字段(逗号分隔)')
.option('--page <number>', '页码')
.option('--recPerPage <number>', '每页条数')
.option('--all', '尚未支持自动翻页,请使用 --page 和 --recPerPage')
.option('--all', '自动翻页获取全部数据')
.option('--limit <number>', '限制获取数量')
.option('--data <json>', 'JSON 数据')
.option('--params <json>', 'API 调用参数')
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function buildInputSchema(mod: ModuleDefinition) {
searchFields: z.string().optional().describe('搜索字段(逗号分隔),配合 search 使用'),
page: z.number().optional().describe('页码'),
recPerPage: z.number().optional().describe('每页条数'),
all: z.boolean().optional().describe('对支持分页的列表操作自动获取全部数据,不能与 page 同时使用'),
};
}

Expand All @@ -75,6 +76,7 @@ interface ToolInput {
searchFields?: string;
page?: number;
recPerPage?: number;
all?: boolean;
}

async function handleProfileTool(auth: AuthProvider): Promise<CallToolResult> {
Expand Down Expand Up @@ -149,6 +151,7 @@ async function handleModuleTool(
searchFields: input.searchFields,
page: input.page != null ? String(input.page) : undefined,
recPerPage: input.recPerPage != null ? String(input.recPerPage) : undefined,
all: input.all,
format: 'json',
yes: true,
};
Expand Down
150 changes: 134 additions & 16 deletions src/modules/executor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { request } from 'zentao-api';
import { processData, request } from 'zentao-api';
import type { ZentaoClient } from '../api/index.js';
import { mapSdkError } from '../errors.js';
import type {
Expand Down Expand Up @@ -34,6 +34,20 @@ function parseFields(fields?: string): string[] | undefined {
return parsed && parsed.length > 0 ? parsed : undefined;
}

interface ValidPager {
total: number;
page: number;
recPerPage: number;
}

function isValidPager(value: unknown, expectedPage: number): value is ValidPager {
if (!value || typeof value !== 'object') return false;
const pager = value as Partial<ValidPager>;
return pager.page === expectedPage
&& Number.isInteger(pager.total) && Number(pager.total) >= 0
&& Number.isInteger(pager.recPerPage) && Number(pager.recPerPage) > 0;
}

/**
* 执行模块级 CRUD 或扩展操作。
*
Expand All @@ -53,44 +67,148 @@ export async function executeModuleCommand(
if (!action) {
throw new ZentaoError('E2005', { module: module.name });
}
if (options.all) {
if (options.all && action.type !== 'list') {
throw new ZentaoError('E2009', {
option: 'all',
reason: '尚未支持自动翻页,请使用 --page 和 --recPerPage 逐页获取',
reason: '仅列表操作支持自动翻页',
});
}
if (options.all && options.page !== undefined) {
throw new ZentaoError('E2009', {
option: 'all',
reason: '不能与 --page 同时使用',
});
}

const params = buildParams(options, actionName, args);
const requestName = `${module.name}/${normalizeActionName(actionName)}`;
const fields = parseFields(options.pick);
const rawOutput = (options.format ?? config.defaultOutputFormat ?? 'markdown') === 'raw';
if (options.all && rawOutput) {
throw new ZentaoError('E2009', {
option: 'all',
reason: '不能与 --format=raw 同时使用',
});
}
if (options.all) {
const paramNames = new Set((action.params ?? []).map((param) => param.name));
if (!paramNames.has('pageID') || !paramNames.has('recPerPage')) {
throw new ZentaoError('E2009', {
option: 'all',
reason: '当前列表操作不支持分页',
});
}
delete params.page;
params.pageID = 1;
}
const shouldProcess = !rawOutput;
const processList = shouldProcess && action.type === 'list';
const processSingle = shouldProcess && action.type === 'get';

const requestOptions = {
client,
autoFill: action.type === 'update',
throwOnFail: true,
recPerPage: options.recPerPage,
timeout: options.timeout,
insecure: options.insecure,
};

let response;
try {
response = await request(requestName, params, {
client,
autoFill: action.type === 'update',
throwOnFail: true,
recPerPage: options.recPerPage,
timeout: options.timeout,
insecure: options.insecure,
...requestOptions,
raw: rawOutput,
convert: processList && config.htmlToMarkdown !== false
convert: processList && !options.all && config.htmlToMarkdown !== false
? convertHtmlFieldsInArray
: undefined,
convertSingle: processSingle && config.htmlToMarkdown !== false
? convertHtmlFields
: undefined,
filter: processList ? options.filter : undefined,
search: processList ? options.search : undefined,
searchFields: processList ? parseFields(options.searchFields) : undefined,
sort: processList ? options.sort : undefined,
limit: processList ? options.limit : undefined,
pick: processList || processSingle ? fields : undefined,
filter: processList && !options.all ? options.filter : undefined,
search: processList && !options.all ? options.search : undefined,
searchFields: processList && !options.all ? parseFields(options.searchFields) : undefined,
sort: processList && !options.all ? options.sort : undefined,
limit: processList && !options.all ? options.limit : undefined,
pick: !options.all && (processList || processSingle) ? fields : undefined,
});

if (options.all) {
if (!isValidPager(response.pager, 1) || !Array.isArray(response.data)) {
throw new ZentaoError('E2009', {
option: 'all',
reason: '服务端响应缺少有效的分页信息',
});
}

const records = [...response.data] as Record<string, unknown>[];
let pager = response.pager;
const expectedTotal = pager.total;
const expectedPageSize = pager.recPerPage;
if (records.length === 0 && pager.total > 0) {
throw new ZentaoError('E2009', {
option: 'all',
reason: '第 1 页为空,无法确认已获取全部数据',
});
}
if (records.length > expectedTotal) {
throw new ZentaoError('E2009', {
option: 'all',
reason: '服务端返回的记录数超过分页总数',
});
}

while (records.length < expectedTotal) {
const nextPage = pager.page + 1;
const pageResponse = await request(requestName, { ...params, pageID: nextPage }, requestOptions);
if (!isValidPager(pageResponse.pager, nextPage) || !Array.isArray(pageResponse.data)) {
throw new ZentaoError('E2009', {
option: 'all',
reason: `第 ${nextPage} 页响应缺少有效的分页信息`,
});
}
if (pageResponse.pager.total !== expectedTotal
|| pageResponse.pager.recPerPage !== expectedPageSize) {
throw new ZentaoError('E2009', {
option: 'all',
reason: `第 ${nextPage} 页的分页总数或页大小发生变化`,
});
}
if (pageResponse.data.length === 0) {
throw new ZentaoError('E2009', {
option: 'all',
reason: `第 ${nextPage} 页为空,无法确认已获取全部数据`,
});
}
records.push(...pageResponse.data as Record<string, unknown>[]);
pager = pageResponse.pager;
}
if (records.length !== expectedTotal) {
throw new ZentaoError('E2009', {
option: 'all',
reason: '服务端返回的记录数与分页总数不一致',
});
}

const data = processData(records, {
convert: config.htmlToMarkdown !== false ? convertHtmlFieldsInArray : undefined,
filter: options.filter,
search: options.search,
searchFields: parseFields(options.searchFields),
sort: options.sort,
limit: options.limit,
pick: fields,
});
response = {
...response,
data,
pager: {
total: expectedTotal,
page: 1,
recPerPage: expectedTotal > 0 ? expectedTotal : expectedPageSize,
},
};
}
} catch (error) {
throw mapSdkError(error);
}
Expand Down
Loading