Compare commits

...
26 changed files with 822 additions and 97 deletions
+285
View File
@@ -0,0 +1,285 @@
'use strict';
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
const PRODUCT_GROUPS = [
{
primary: 'wxianfeng',
backup: 'typefield',
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
},
{
primary: 'typefield',
backup: 'wxianfeng',
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
},
{
primary: 'haofeng0705',
backup: 'typefield',
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
},
];
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
const MODULES = [
{
id: 'security',
label: '登录、认证、权限、安全',
primary: 'hlzjsong',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'internal/auth/',
'internal/keychain/',
'internal/audit/',
'internal/pat/',
'internal/security/',
'internal/safety/',
'pkg/edition/',
]),
},
{
id: 'delivery',
label: 'CI、测试、发布、安装',
primary: 'haofeng0705',
backup: 'wxianfeng',
requiresSecondary: true,
matches: (path) =>
path.startsWith('.github/') ||
path.startsWith('scripts/release/') ||
path.startsWith('scripts/policy/') ||
path.startsWith('scripts/dev/') ||
path.startsWith('scripts/install') ||
path.startsWith('Formula/') ||
path.startsWith('build/') ||
path.startsWith('internal/upgrade/') ||
path.startsWith('internal/app/upgrade') ||
path.startsWith('test/') ||
path.startsWith('verify/') ||
path.startsWith('.workflow/') ||
path === 'coverage.txt' ||
path === 'coverage-base.txt' ||
path === '.goreleaser.yaml' ||
path === 'package.json' ||
path === 'package-lock.json' ||
path === 'docs/releasing.md',
},
{
id: 'architecture',
label: 'DWS 架构、公共内核',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'cmd/',
'internal/apiclient/',
'internal/app/',
'internal/cli/',
'internal/cobracmd/',
'internal/corecmd/',
'internal/errors/',
'internal/executor/',
'internal/generator/',
'internal/i18n/',
'internal/interfacesnapshot/',
'internal/jsonutil/',
'internal/localio/',
'internal/logging/',
'internal/output/',
'internal/pipeline/',
'internal/plugin/',
'internal/profilectx/',
'internal/registry/',
'internal/syncdata/',
'internal/testseam/',
'internal/transport/',
'pkg/',
]),
},
{
id: 'compatibility',
label: '兼容性',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: (path) =>
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
path.includes('schema_compat'),
},
];
function productMatches(path, product) {
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
}
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
group.products.map((product) => ({
id: `product:${product}`,
label: `产品:${product}`,
primary: group.primary,
backup: group.backup,
requiresSecondary: false,
matches: (path) => productMatches(path, product),
})),
);
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
function normalizedPaths(file) {
return [file?.filename, file?.previous_filename]
.filter((path) => typeof path === 'string' && path !== '')
.map((path) => path.toLowerCase());
}
function compareStats(left, right) {
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
}
function classifyFiles(files) {
const counts = new Map();
for (const file of files || []) {
const matchingModules = new Set();
for (const path of normalizedPaths(file)) {
const matches = ALL_MODULES.filter((module) => module.matches(path));
const securityOrDelivery = matches.filter(
(module) => module.id === 'security' || module.id === 'delivery',
);
const effectiveMatches = securityOrDelivery.length > 0
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
: matches;
for (const match of effectiveMatches) {
matchingModules.add(match.id);
}
if (
effectiveMatches.length === 0 &&
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
) {
matchingModules.add('architecture');
}
}
for (const moduleID of matchingModules) {
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
}
}
return [...counts.entries()]
.map(([id, files]) => {
const index = ALL_MODULES.findIndex((module) => module.id === id);
return {module: {...ALL_MODULES[index], order: index}, files};
})
.sort(compareStats);
}
function chooseModuleReviewer(module, unavailable) {
return [module.primary, module.backup].find(
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
);
}
function addReviewer(reviewers, reviewer) {
if (reviewer && !reviewers.includes(reviewer)) {
reviewers.push(reviewer);
}
}
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
const candidates = [];
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
if (
eligible.has(reviewer.toLowerCase()) &&
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
) {
candidates.push(reviewer);
}
}
return candidates;
}
async function requestReviewersWithFallback({
candidates,
requiredReviewers,
satisfiedReviewers = [],
requestReviewer,
onFailure = () => {},
}) {
const alreadySatisfied = new Set(
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
);
const satisfied = new Set();
const requested = [];
for (const reviewer of candidates) {
if (satisfied.size >= requiredReviewers) {
break;
}
const normalizedReviewer = reviewer.toLowerCase();
if (alreadySatisfied.has(normalizedReviewer)) {
satisfied.add(normalizedReviewer);
continue;
}
try {
const shouldContinue = await requestReviewer(reviewer);
if (shouldContinue === false) {
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
}
requested.push(reviewer);
satisfied.add(normalizedReviewer);
} catch (error) {
onFailure(reviewer, error);
}
}
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
}
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
const modules = classifyFiles(files);
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
const reviewers = [];
const primaryModule = modules[0];
if (!primaryModule) {
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
}
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
const requiresSecondary =
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
if (requiresSecondary) {
addReviewer(
reviewers,
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
);
}
for (const reviewer of fallbackReviewers) {
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
break;
}
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
addReviewer(reviewers, reviewer);
}
}
return {
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
reviewers,
requiredReviewers: requiresSecondary ? 2 : 1,
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
};
}
module.exports = {
REVIEWER_POOL,
classifyFiles,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
const assert = require('node:assert/strict');
const {
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./reviewer-routing');
function route(files, author = 'author', latestPusher = author) {
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
}
{
const result = route(['internal/helpers/chat_toolbar.go']);
assert.deepEqual(result.reviewers, ['wxianfeng']);
assert.equal(result.requiredReviewers, 1);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
}
{
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['.github/workflows/ci.yml']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
assert.equal(result.reason, 'cross_or_sensitive');
}
{
const result = route(['internal/auth/login.go'], 'hlzjsong');
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/upgrade/downloader.go']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['pkg/edition/edition.go']);
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
}
{
const result = route(['internal/helpers/leaf_dispatch.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['docs/unknown-area.md']);
assert.deepEqual(result.reviewers, []);
assert.equal(result.reason, 'unknown_paths');
}
async function testSingleReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['wxianfeng'],
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 1,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('cannot request primary'), {status: 422});
}
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['typefield']);
assert.equal(result.satisfiedReviewers.length, 1);
}
async function testTwoReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['haofeng0705', 'wxianfeng'],
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 2,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('temporary failure'), {status: 503});
}
return true;
},
});
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
assert.equal(result.satisfiedReviewers.length, 2);
}
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
const attempts = [];
const result = await requestReviewersWithFallback({
candidates: ['wxianfeng', 'typefield'],
requiredReviewers: 1,
satisfiedReviewers: ['typefield'],
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng']);
assert.deepEqual(result.requested, ['wxianfeng']);
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
}
Promise.all([
testSingleReviewerFallback(),
testTwoReviewerFallback(),
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
])
.then(() => console.log('reviewer routing policy tests passed'))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
+4
View File
@@ -433,6 +433,10 @@ jobs:
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
- name: Test reviewer routing policy
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: node .github/reviewer-routing.test.js
test-focused:
name: Test (changed packages)
needs: lint
+87 -44
View File
@@ -21,6 +21,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted routing policy
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Route review and enable auto-merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
@@ -29,12 +34,13 @@ jobs:
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const reviewerPool = [
'sczheng189',
'shangguanxuan633-lab',
'audanye-sudo',
'wxianfeng',
];
const {
REVIEWER_POOL,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./.github/reviewer-routing.js');
const reviewerPool = REVIEWER_POOL;
async function getReadyEventPull(phase) {
const {data: currentPull} = await github.rest.pulls.get({
@@ -67,6 +73,21 @@ jobs:
: author;
async function routeReview() {
let changedFiles;
try {
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
});
} catch (error) {
core.warning(
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
);
changedFiles = [];
}
const eligible = reviewerPool.filter(
reviewer =>
reviewer.toLowerCase() !== author &&
@@ -77,13 +98,9 @@ jobs:
return;
}
const alreadyRequested =
(pullRequest.requested_reviewers || []).length > 0 ||
(pullRequest.requested_teams || []).length > 0;
if (alreadyRequested) {
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
return;
}
const existingRequestedReviewers = new Set(
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
);
let reviews;
try {
@@ -116,22 +133,18 @@ jobs:
latestDecisionByLogin.set(login, review);
}
}
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer =>
reviewer.toLowerCase() ===
review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
const currentHeadReviewers = new Set(
[...latestDecisionByLogin.values()]
.filter(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
)
.map(review => review.user.login.toLowerCase()),
);
if (currentHeadDecision) {
core.info(
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
);
return;
}
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
try {
@@ -178,20 +191,42 @@ jobs:
tieOrder.get(left) - tieOrder.get(right),
);
for (const reviewer of ranked) {
try {
const routing = resolveReviewRouting({
files: changedFiles,
author,
latestPusher,
fallbackReviewers: ranked,
});
const candidates = reviewerCandidates({
preferredReviewers: routing.reviewers,
fallbackReviewers: ranked,
eligibleReviewers: eligible,
});
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
core.info(
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
);
return;
}
core.info(
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
);
const satisfiedReviewers = new Set([
...currentHeadReviewers,
...[...existingRequestedReviewers].filter((reviewer) =>
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
),
]);
const requestResult = await requestReviewersWithFallback({
candidates,
requiredReviewers: routing.requiredReviewers,
satisfiedReviewers: [...satisfiedReviewers],
requestReviewer: async (reviewer) => {
const currentPull = await getReadyEventPull('review request');
if (!currentPull) {
return;
}
if (
(currentPull.requested_reviewers || []).length > 0 ||
(currentPull.requested_teams || []).length > 0
) {
core.info(
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
);
return;
return false;
}
await github.rest.pulls.requestReviewers({
owner,
@@ -202,15 +237,23 @@ jobs:
core.info(
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
);
return;
} catch (error) {
return true;
},
onFailure: (reviewer, error) => {
core.warning(
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
);
}
},
});
if (requestResult.aborted) {
return;
}
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
core.warning(
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
);
}
}
async function enableAutoMerge() {
+7 -3
View File
@@ -139,6 +139,10 @@ func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
}
initialCursor, _ := requestArgs["cursor"].(string)
effectivePageLimit := pageLimit
if pageLimit == 0 {
effectivePageLimit = paging.UnlimitedPageLimit
}
result := paging.FetchAll(ctx, func(ctx context.Context, cursor string) (paging.Page, error) {
if cursor == "" {
delete(requestArgs, "cursor")
@@ -159,7 +163,7 @@ func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
}
return paging.Page{}, fmt.Errorf("query_records returned no non-empty text content")
}, paging.Options{
PageLimit: pageLimit,
PageLimit: effectivePageLimit,
InterPageDelay: paging.DefaultInterPageDelay,
InitialCursor: initialCursor,
})
@@ -7047,8 +7051,8 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
recordQueryCmd.Flags().Int("page-size", 0, "--limit 的别名(兼容 LLM 常见误用)")
_ = recordQueryCmd.Flags().MarkHidden("page-size")
recordQueryCmd.Flags().String("cursor", "", "分页游标,首次查询不传;cursor 为空表示已取完全部记录")
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取全部记录。传入时自动循环直到无更多数据或达到 --page-limit 上限")
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(5000 条),设为 0 表示无限制")
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取完整记录集;达到 --page-limit 且仍有更多页时返回非零结构化错误,不把不完整结果作为成功输出")
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
_ = recordQueryCmd.Flags().MarkHidden("view-id")
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
@@ -5,6 +5,7 @@ package helpers
import (
"encoding/json"
"fmt"
"strings"
"testing"
@@ -66,3 +67,27 @@ func TestCrossPlatformCoverageAITableIncompleteResultMetadata(t *testing.T) {
t.Fatalf("incomplete result error = %v", err)
}
}
func TestCrossPlatformCoverageAITableExplicitUnlimitedPaginationE2E(t *testing.T) {
responses := make([]string, paging.DefaultPageLimit+1)
for index := range responses {
next := ""
if index < len(responses)-1 {
next = fmt.Sprintf(`,"nextCursor":"cursor-%d"`, index+1)
}
responses[index] = fmt.Sprintf(`{"records":[{"recordId":"record-%d"}]%s}`, index, next)
}
caller := &aitableTestCaller{responses: responses}
out := installAitableDeps(t, caller)
if err := recordQueryFetchAll(map[string]any{}, 0); err != nil {
t.Fatalf("explicit unlimited pagination failed: %v", err)
}
if len(caller.calls) != len(responses) {
t.Fatalf("explicit unlimited pagination calls = %d, want %d", len(caller.calls), len(responses))
}
for _, want := range []string{`"pages": 51`, `"fetchedCount": 51`, `"complete": true`} {
if !strings.Contains(out.String(), want) {
t.Fatalf("explicit unlimited output missing %s: %s", want, out.String())
}
}
}
@@ -10,6 +10,7 @@ import (
"fmt"
"os"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -171,3 +172,21 @@ func TestCrossPlatformCoverageRecordQueryCLIInitialCursorE2E(t *testing.T) {
t.Fatalf("resume args = %#v, want %#v", caller.calls[0].args, want)
}
}
func TestCrossPlatformCoverageRecordQueryCLIPageLimitHelpE2E(t *testing.T) {
command := newAitableCommand()
command.SilenceErrors = true
command.SilenceUsage = true
out := &bytes.Buffer{}
command.SetOut(out)
command.SetErr(out)
command.SetArgs([]string{"record", "query", "--help"})
if err := command.Execute(); err != nil {
t.Fatalf("record query help failed: %v", err)
}
for _, want := range []string{"返回非零结构化错误", "不完整结果", "错误详情保留已取记录和续传 cursor"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("record query help missing %q:\n%s", want, out.String())
}
}
}
+2 -2
View File
@@ -255,8 +255,8 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
// acknowledgement. This low-level parser cannot know the business contract,
// so preserve that representation. Data-returning callers must validate the
// operation-specific shape (for example records/tables/valid) before they
// report success; write orchestrators use CallMCPWriteData, which treats an
// empty response as unknown unless an independent read-back proves the state.
// report success. Orchestrators whose contract requires a business result use
// CallMCPWriteDataStrict and may prove an unknown effect by independent read-back.
return "", nil
}
@@ -21,7 +21,14 @@ import (
const maxAttachmentFileSize = int64(100 * 1024 * 1024)
var attachmentHTTPDo = http.DefaultClient.Do
func rejectAttachmentUploadRedirect(*http.Request, []*http.Request) error {
// Upload credentials are issued for one exact endpoint. Do not let a 3xx
// response redirect local file bytes or the credential to another host or
// to a URL outside validateAttachmentUploadURL's transport policy.
return http.ErrUseLastResponse
}
var attachmentHTTPDo = (&http.Client{CheckRedirect: rejectAttachmentUploadRedirect}).Do
var AttachmentPut = shortcut.Shortcut{
Service: "aitable",
@@ -121,7 +128,7 @@ func executeAttachmentPut(rt *shortcut.RuntimeContext) error {
result.Executed = false
return rt.Output(result)
}
prepareData, err := rt.CallMCPWriteData(serverMain, "prepare_attachment_upload", map[string]any{"baseId": baseID, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType})
prepareData, err := rt.CallMCPWriteDataStrict(serverMain, "prepare_attachment_upload", map[string]any{"baseId": baseID, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType})
if err != nil {
result.Status = "unknown"
return compositeError(result, err, false)
@@ -156,7 +163,7 @@ func executeAttachmentPut(rt *shortcut.RuntimeContext) error {
}
desired = append(desired, map[string]any{"fileToken": fileToken})
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "HTTP PUT", "fileToken": fileToken, "fileName": info.Name(), "size": info.Size()})
_, writeErr := rt.CallMCPWriteData(serverMain, "update_records", map[string]any{
_, writeErr := rt.CallMCPWriteDataStrict(serverMain, "update_records", map[string]any{
"baseId": baseID, "tableId": tableID,
"records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}},
})
@@ -226,7 +233,7 @@ func executeAttachmentRemove(rt *shortcut.RuntimeContext) error {
result.Executed = false
return rt.Output(result)
}
_, writeErr := rt.CallMCPWriteData(serverMain, "update_records", map[string]any{
_, writeErr := rt.CallMCPWriteDataStrict(serverMain, "update_records", map[string]any{
"baseId": baseID, "tableId": tableID,
"records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}},
})
@@ -112,6 +112,35 @@ func TestCrossPlatformCoverageAttachmentPutFailuresDoNotBecomeSuccessE2E(t *test
})
}
func TestCrossPlatformCoverageAttachmentPutRejectsHTTPSRedirectToPlaintextNonLoopbackE2E(t *testing.T) {
filePath := writeAttachmentFixture(t, "redirect-sensitive bytes")
sourceHits := 0
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
sourceHits++
w.Header().Set("Location", "http://example.com/upload")
w.WriteHeader(http.StatusFound)
}))
defer server.Close()
client := server.Client()
client.CheckRedirect = rejectAttachmentUploadRedirect
testseam.Swap(t, &attachmentHTTPDo, client.Do)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL + "/put", "fileToken": "ft-redirect"})},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" ||
typed.Cause == nil || !strings.Contains(typed.Cause.Error(), "status 302") {
t.Fatalf("redirect upload = output:%q err:%v", out, err)
}
if sourceHits != 1 || len(caller.calls) != 2 {
t.Fatalf("redirect upload reached an unexpected target: sourceHits=%d calls=%#v", sourceHits, caller.calls)
}
}
func TestCrossPlatformCoverageAttachmentRemoveClearAndSelectiveE2E(t *testing.T) {
t.Run("clear empty write response recovered by empty read-back", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+4 -4
View File
@@ -44,7 +44,7 @@ var BaseBootstrap = shortcut.Shortcut{
Intent: "当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent",
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: aitableCompositeContract(
"+base-bootstrap",
@@ -191,7 +191,7 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
if rt.Changed("template-id") {
baseArgs["templateId"] = rt.Str("template-id")
}
baseData, err := rt.CallMCPWriteData(serverMain, "create_base", baseArgs)
baseData, err := rt.CallMCPWriteDataStrict(serverMain, "create_base", baseArgs)
if err != nil {
result.Status = "unknown"
result.FailedCount = len(tables)
@@ -222,7 +222,7 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
for index, spec := range tables {
initialEnd := minInt(15, len(spec.Fields))
initialFields := spec.Fields[:initialEnd]
createData, createErr := rt.CallMCPWriteData(serverMain, "create_table", map[string]any{
createData, createErr := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{
"baseId": baseID, "tableName": spec.Name, "fields": initialFields,
})
tableID := findStringByKeys(createData, "tableId", "sheetId")
@@ -239,7 +239,7 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": baseID, "tableId": tableID, "name": spec.Name})
for offset := initialEnd; offset < len(spec.Fields); offset += 15 {
end := minInt(offset+15, len(spec.Fields))
_, fieldErr := rt.CallMCPWriteData(serverMain, "create_fields", map[string]any{
_, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{
"baseId": baseID, "tableId": tableID, "fields": spec.Fields[offset:end],
})
if fieldErr != nil {
@@ -108,7 +108,7 @@ func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testin
{text: `{"tables":[{"tableId":"t-new","name":"任务"}]}`},
{text: fieldReadBackJSON(t, fields)},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, fields))
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, fields), "--yes")
if err != nil {
t.Fatalf("bootstrap error = %v", err)
}
@@ -117,9 +117,21 @@ func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testin
t.Fatalf("bootstrap output missing %s: %s", want, out)
}
}
if len(caller.calls) != 6 || caller.calls[2].tool != "create_table" || caller.calls[3].tool != "create_fields" {
if len(caller.calls) != 6 || caller.calls[0].tool != "create_base" || caller.calls[2].tool != "create_table" || caller.calls[3].tool != "create_fields" {
t.Fatalf("bootstrap calls = %#v", caller.calls)
}
if caller.calls[0].args["baseName"] != "项目" {
t.Fatalf("create_base args = %#v", caller.calls[0].args)
}
createBaseCalls := 0
for _, call := range caller.calls {
if call.tool == "create_base" {
createBaseCalls++
}
}
if createBaseCalls != 1 {
t.Fatalf("create_base calls = %d, want exactly 1; calls = %#v", createBaseCalls, caller.calls)
}
if got := len(caller.calls[2].args["fields"].([]any)); got != 15 {
t.Fatalf("create_table fields = %d, want 15", got)
}
@@ -128,10 +140,25 @@ func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testin
}
}
func TestCrossPlatformCoverageBaseBootstrapRequiresConfirmationBeforeMCP(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil))
if out != "" {
t.Fatalf("unconfirmed bootstrap output = %q, want empty", out)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
t.Fatalf("unconfirmed bootstrap error = %#v, want confirmation_required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed bootstrap reached MCP: %#v", caller.calls)
}
}
func TestCrossPlatformCoverageBaseBootstrapUnknownAndDryRunE2E(t *testing.T) {
t.Run("empty create response is unknown and not retry-safe", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}}}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil))
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil), "--yes")
if err == nil || out != "" {
t.Fatalf("unknown base create = output:%q err:%v", out, err)
}
@@ -207,7 +234,7 @@ func TestCrossPlatformCoverageBaseSnapshotFailureStagesE2E(t *testing.T) {
}
func TestCrossPlatformCoverageBaseBootstrapExecuteRejectsInvalidTablesE2E(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-bootstrap", "--name", "Project", "--tables", `{`)
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-bootstrap", "--name", "Project", "--tables", `{`, "--yes")
if err == nil || out != "" {
t.Fatalf("invalid bootstrap tables = output:%q err:%v", out, err)
}
@@ -239,6 +266,7 @@ func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) {
}
args := []string{"--name", "Project", "--tables", inputTables}
args = append(args, tc.extra...)
args = append(args, "--yes")
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-bootstrap", args...)
if err == nil || out != "" {
t.Fatalf("bootstrap failure = output:%q err:%v", out, err)
@@ -258,7 +286,7 @@ func TestCrossPlatformCoverageBaseBootstrapRecoversFieldCallErrorE2E(t *testing.
{text: fieldReadBackJSON(t, fields)},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap",
"--name", "Project", "--tables", marshalBootstrapTables(t, fields), "--folder-id", "folder", "--template-id", "template")
"--name", "Project", "--tables", marshalBootstrapTables(t, fields), "--folder-id", "folder", "--template-id", "template", "--yes")
if err != nil || !strings.Contains(out, `"status": "success"`) || !strings.Contains(out, "create_fields offset") {
t.Fatalf("field recovery = output:%q err:%v", out, err)
}
+2 -2
View File
@@ -87,7 +87,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
for offset := 0; offset < len(ids); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(ids))
batch := ids[offset:end]
writeData, writeErr := rt.CallMCPWriteData(serverMain, "delete_records", map[string]any{
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": batch,
})
step := compositeStep{
@@ -192,7 +192,7 @@ func executeRecordBatches(
wireRecords = append(wireRecords, record)
}
params := map[string]any{"baseId": baseID, "tableId": tableID, "records": wireRecords}
writeData, writeErr := rt.CallMCPWriteData(product, tool, params)
writeData, writeErr := rt.CallMCPWriteDataStrict(product, tool, params)
step := compositeStep{
Index: len(result.CompletedSteps) + 1, Name: "write record batch", Tool: tool,
Status: "completed", Offset: offset, Count: len(batch), Result: writeData,
@@ -324,6 +324,24 @@ func TestCrossPlatformCoverageRecordUpsertJSONScalarSuccessE2E(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecordUpsertRejectsTrailingJSONBeforeMCPE2E(t *testing.T) {
for name, value := range map[string]string{
"trailing garbage": `1 garbage`,
"second JSON value": `1 2`,
"second string value": `true "extra"`,
} {
t.Run(name, func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key",
"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", value,
"--cells", `{"value":1}`, "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("trailing JSON = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) {
if records, ok := findRecords(nil); ok || records != nil {
t.Fatalf("nil records = %#v, %v", records, ok)
@@ -343,7 +361,8 @@ func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) {
if err := verifyRecordCells(map[string]any{"recordId": "r"}, map[string]any{"f": 1}); err == nil {
t.Fatal("missing cells must fail")
}
if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" {
if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" ||
responseCursor(map[string]any{"result": map[string]any{"cursor": " legacy "}}) != "legacy" {
t.Fatal("responseCursor shape mismatch")
}
}
@@ -54,7 +54,7 @@ func responseCursor(data map[string]any) string {
if data == nil {
return ""
}
for _, key := range []string{"nextCursor", "next_cursor"} {
for _, key := range []string{"nextCursor", "next_cursor", "cursor"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
@@ -6,6 +6,7 @@ package aitable
import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
"strings"
@@ -129,7 +130,7 @@ func executeRecordUpsertByKey(rt *shortcut.RuntimeContext) error {
return rt.Output(result)
}
writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params)
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
writeStep := compositeStep{Index: 1, Name: action + " record", Tool: tool, Status: "completed", Count: 1, Result: writeData}
if writeErr != nil {
writeStep.Status = "unknown"
@@ -191,6 +192,10 @@ func recordKeyValue(rt *shortcut.RuntimeContext) (any, error) {
if err := decoder.Decode(&value); err != nil {
return nil, apperrors.NewValidation("--key-value-json 不是合法 JSON: " + err.Error())
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return nil, apperrors.NewValidation("--key-value-json 必须只包含一个 JSON 标量")
}
if value == nil || reflect.ValueOf(value).Kind() == reflect.Map || reflect.ValueOf(value).Kind() == reflect.Slice {
return nil, apperrors.NewValidation("--key-value-json 只接受 string/number/bool 标量")
}
+3 -3
View File
@@ -113,7 +113,7 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error {
}
initialEnd := minInt(15, len(createFields))
createData, err := rt.CallMCPWriteData(serverMain, "create_table", map[string]any{
createData, err := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{
"baseId": targetBase, "tableName": rt.Str("new-name"), "fields": createFields[:initialEnd],
})
targetTable := findStringByKeys(createData, "tableId", "sheetId")
@@ -129,7 +129,7 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error {
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": targetBase, "tableId": targetTable})
for offset := initialEnd; offset < len(createFields); offset += 15 {
end := minInt(offset+15, len(createFields))
_, fieldErr := rt.CallMCPWriteData(serverMain, "create_fields", map[string]any{"baseId": targetBase, "tableId": targetTable, "fields": createFields[offset:end]})
_, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{"baseId": targetBase, "tableId": targetTable, "fields": createFields[offset:end]})
if fieldErr != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("create_fields offset %d returned an error; final schema verification decides success: %v", offset, fieldErr))
}
@@ -172,7 +172,7 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error {
batch = append(batch, record)
wire = append(wire, record)
}
writeData, writeErr := rt.CallMCPWriteData(serverMain, "create_records", map[string]any{"baseId": targetBase, "tableId": targetTable, "records": wire})
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "create_records", map[string]any{"baseId": targetBase, "tableId": targetTable, "records": wire})
createdIDs := createdRecordIDs(writeData)
if len(createdIDs) == 0 {
if returned, found := findRecords(writeData); found {
@@ -41,6 +41,25 @@ func TestCrossPlatformCoverageRecordBulkPatchPaginatesChunksAndVerifiesE2E(t *te
}
}
func TestCrossPlatformCoverageRecordBulkPatchPaginatesNestedCursorShapeE2E(t *testing.T) {
records := updateFixtureRecords(0, 2, "旧")
patched := updateFixtureRecords(0, 2, "新")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSONText(t, map[string]any{"data": map[string]any{"records": records[:1], "hasMore": true, "cursor": "legacy-next"}})},
{text: mustJSONText(t, map[string]any{"data": map[string]any{"records": records[1:], "hasMore": false}})},
{text: `{"updatedCount":2}`},
{text: recordListJSON(t, patched)},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"fldStatus":"新"}`, "--yes")
if err != nil || !strings.Contains(out, `"verifiedCount": 2`) {
t.Fatalf("nested cursor bulk patch = output:%q err:%v", out, err)
}
if len(caller.calls) != 4 || caller.calls[1].args["cursor"] != "legacy-next" || caller.calls[2].tool != "update_records" {
t.Fatalf("nested cursor calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageRecordBulkPatchSelectionBoundsAndEmptyE2E(t *testing.T) {
t.Run("explicit no matches is success", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
+1 -1
View File
@@ -96,7 +96,7 @@ func executeViewPresetApply(rt *shortcut.RuntimeContext) error {
result.Executed = false
return rt.Output(result)
}
writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params)
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
if action == "create" {
viewID = findStringByKeys(writeData, "viewId")
}
+2 -2
View File
@@ -75,7 +75,7 @@ func executeWorkflowDeploy(rt *shortcut.RuntimeContext) error {
result.Executed = false
return rt.Output(result)
}
writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params)
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
if writeErr != nil {
result.Status = "unknown"
result.Checkpoint = map[string]any{"nextStep": "resolve workflow existence and definition before retrying", "workflowId": workflowID}
@@ -105,7 +105,7 @@ func executeWorkflowDeploy(rt *shortcut.RuntimeContext) error {
return compositeError(result, verifyErr, action == "update")
}
if rt.Bool("enable") {
enableData, enableErr := rt.CallMCPWriteData(serverHelper, "enable_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID})
enableData, enableErr := rt.CallMCPWriteDataStrict(serverHelper, "enable_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID})
if enableErr != nil {
result.Warnings = append(result.Warnings, "enable response error: "+enableErr.Error())
}
+12 -1
View File
@@ -155,8 +155,19 @@ func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]an
// CallMCPWriteData dispatches a write tool call and returns its parsed response.
// Unlike CallMCPData, it refuses to run under --dry-run so smart shortcuts cannot
// accidentally perform writes while rendering a preview.
// accidentally perform writes while rendering a preview. For compatibility with
// existing write shortcuts, an empty text acknowledgement remains an empty map.
func (rt *RuntimeContext) CallMCPWriteData(product, tool string, params map[string]any) (map[string]any, error) {
if rt.DryRun() {
return nil, dryRunWriteError(product, tool)
}
return rt.callMCPData(product, tool, params)
}
// CallMCPWriteDataStrict dispatches a write tool call whose contract requires a
// non-empty JSON business result. An empty acknowledgement is reported as an
// unknown remote effect so callers can verify it independently before success.
func (rt *RuntimeContext) CallMCPWriteDataStrict(product, tool string, params map[string]any) (map[string]any, error) {
if rt.DryRun() {
return nil, dryRunWriteError(product, tool)
}
+16
View File
@@ -4,9 +4,11 @@
package shortcut
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/spf13/cobra"
)
@@ -31,4 +33,18 @@ func TestCrossPlatformCoverageRuntimeWriteDataRemainingBranches(t *testing.T) {
if caller.args == nil {
t.Fatal("nil write parameters were not normalized")
}
caller.text = ""
legacy, err := rt.CallMCPWriteData("chat", "send_personal_message", nil)
if err != nil || legacy == nil || len(legacy) != 0 {
t.Fatalf("legacy empty acknowledgement = %#v, %v", legacy, err)
}
if _, err = rt.CallMCPWriteDataStrict("aitable", "update_records", nil); err == nil {
t.Fatal("strict empty acknowledgement was accepted")
} else {
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "empty_tool_response" || !typed.RetryableSet || typed.Retryable {
t.Fatalf("strict empty acknowledgement error = %#v", err)
}
}
}
+11 -6
View File
@@ -671,12 +671,17 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
}
rt := &RuntimeContext{cmd: cmd, shortcut: Shortcut{Service: "calendar"}}
_, err := rt.CallMCPWriteData("calendar", "create_calendar_event", map[string]any{"summary": "x"})
if err == nil {
t.Fatal("expected dry-run write guard error")
}
if !strings.Contains(err.Error(), "calendar/create_calendar_event") {
t.Fatalf("error = %q, want tool name", err.Error())
for name, call := range map[string]func(string, string, map[string]any) (map[string]any, error){
"legacy": rt.CallMCPWriteData,
"strict": rt.CallMCPWriteDataStrict,
} {
_, err := call("calendar", "create_calendar_event", map[string]any{"summary": "x"})
if err == nil {
t.Fatalf("%s: expected dry-run write guard error", name)
}
if !strings.Contains(err.Error(), "calendar/create_calendar_event") {
t.Fatalf("%s: error = %q, want tool name", name, err.Error())
}
}
}
+13 -4
View File
@@ -15,7 +15,7 @@
// 继续取"循环。设计目标:
//
// 1. 调用方只需提供 fetcher(拿单页)和 cursor 提取函数,循环逻辑由本包处理
// 2. 支持 --page-limit 安全阀(调用方可用 DefaultPageLimit;0 = 无限制)
// 2. 支持分页安全阀(零值默认 50 页;显式 UnlimitedPageLimit 才不限页数)
// 3. 支持页间退避,避免触发上游限流(默认 200ms)
// 4. 任何一页失败时保留已取数据和可重试 cursor,并显式标记结果不完整
//
@@ -50,12 +50,12 @@ type Page struct {
// 返回给调用方,不直接中断流程。
type Fetcher func(ctx context.Context, cursor string) (Page, error)
// Options 控制 FetchAll 的行为。零值表示不限页数并使用默认页间退避。
// Options 控制 FetchAll 的行为。零值使用默认页数上限和默认页间退避。
type Options struct {
// PageLimit 最大翻页次数。
// - 0 表示无限制(取到 NextCursor 为空为止)
// - 0 使用 DefaultPageLimit,保持 Options{} 的安全默认语义
// - UnlimitedPageLimit 显式表示不限页数(取到 NextCursor 为空为止)
// - >0 达到该次数后立即停止,返回 LastCursor 让调用方手动续拉
// 面向终端用户的调用方可显式传 DefaultPageLimit 作为安全默认值。
PageLimit int
// InterPageDelay 是页间退避时长。默认 200ms。
@@ -68,6 +68,10 @@ type Options struct {
}
const (
// UnlimitedPageLimit 是调用方明确选择无限分页时使用的哨兵值。
// 零值保留 DefaultPageLimit 的兼容和资源安全语义。
UnlimitedPageLimit = -1
// DefaultPageLimit 是 PageLimit 字段的默认值。
DefaultPageLimit = 50
@@ -123,6 +127,11 @@ type Result struct {
// 不会主动抛 error;上游可基于 Result.Err / Result.Partial 决定如何向用户报告。
func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result {
pageLimit := opts.PageLimit
if pageLimit == UnlimitedPageLimit {
pageLimit = 0
} else if pageLimit <= 0 {
pageLimit = DefaultPageLimit
}
delay := opts.InterPageDelay
if delay == 0 {
delay = DefaultInterPageDelay
+32 -3
View File
@@ -6,6 +6,7 @@ package paging
import (
"context"
"errors"
"fmt"
"testing"
"time"
)
@@ -137,8 +138,7 @@ func TestCrossPlatformCoverageFetchAllContextCancel(t *testing.T) {
}
func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) {
// PageLimit=0 是公开契约中的“无限制”。用超过默认安全阀的页数防止
// 实现再次偷偷把 0 改写成 DefaultPageLimit。
// 无限分页必须由哨兵值显式选择;用超过默认安全阀的页数证明它生效。
pages := make([]Page, DefaultPageLimit+1)
for i := range pages {
next := ""
@@ -149,7 +149,7 @@ func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) {
}
s := &stubFetcher{pages: pages}
got := FetchAll(context.Background(), s.Fetch, Options{
PageLimit: 0,
PageLimit: UnlimitedPageLimit,
InterPageDelay: 1 * time.Millisecond,
})
if got.HasMore || !got.Complete || got.Pages != len(pages) || len(got.Records) != len(pages) {
@@ -157,6 +157,35 @@ func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) {
}
}
func TestCrossPlatformCoverageFetchAllZeroValueUsesDefaultPageLimit(t *testing.T) {
pages := make([]Page, DefaultPageLimit+1)
for i := range pages {
pages[i] = Page{Records: []any{i}, NextCursor: fmt.Sprintf("cursor-%d", i+1)}
}
s := &stubFetcher{pages: pages}
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Nanosecond})
if got.Pages != DefaultPageLimit || got.Attempts != DefaultPageLimit || len(got.Records) != DefaultPageLimit {
t.Fatalf("zero-value page limit = %+v", got)
}
if !got.HasMore || got.Complete || got.Partial || got.StopReason != StopPageLimit || !errors.Is(got.Err, ErrPageLimitReached) {
t.Fatalf("zero-value safety result = %+v", got)
}
if s.calls != DefaultPageLimit {
t.Fatalf("zero-value fetch calls = %d, want %d", s.calls, DefaultPageLimit)
}
}
func TestCrossPlatformCoverageFetchAllUnsupportedNegativeUsesDefaultPageLimit(t *testing.T) {
calls := 0
got := FetchAll(context.Background(), func(context.Context, string) (Page, error) {
calls++
return Page{NextCursor: fmt.Sprintf("cursor-%d", calls)}, nil
}, Options{PageLimit: -2, InterPageDelay: time.Nanosecond})
if calls != DefaultPageLimit || got.StopReason != StopPageLimit {
t.Fatalf("unsupported negative page limit = %+v calls:%d", got, calls)
}
}
func TestCrossPlatformCoverageFetchAllPreservesServerTotalCount(t *testing.T) {
total := 123
s := &stubFetcher{pages: []Page{{Records: []any{1, 2}, TotalCount: &total}}}
+30 -10
View File
@@ -50,6 +50,23 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("yaml.Unmarshal(%s) error = %v", path, err)
}
policyPath := filepath.Join(filepath.Dir(filepath.Dir(path)), "reviewer-routing.js")
policy, err := os.ReadFile(policyPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", policyPath, err)
}
for _, want := range []string{
"'wxianfeng'",
"'typefield'",
"'haofeng0705'",
"'hlzjsong'",
"resolveReviewRouting",
"unknown_paths",
} {
if !strings.Contains(string(policy), want) {
t.Errorf("reviewer routing policy is missing contract marker %q", want)
}
}
if len(workflow.On) != 1 {
t.Fatalf("workflow triggers = %v, want pull_request_target only", workflow.On)
@@ -84,18 +101,18 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
if job.If != "github.event.pull_request.draft == false" {
t.Fatalf("route.if = %q, want non-draft guard", job.If)
}
const checkoutSHA = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683"
const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b"
if len(job.Steps) != 1 || job.Steps[0].Uses != githubScriptSHA {
t.Fatalf("route steps = %#v, want one pinned base-owned github-script step", job.Steps)
if len(job.Steps) != 2 || job.Steps[0].Uses != checkoutSHA || job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || job.Steps[0].With["persist-credentials"] != "false" || job.Steps[1].Uses != githubScriptSHA {
t.Fatalf("route steps = %#v, want trusted base checkout followed by pinned github-script", job.Steps)
}
script := job.Steps[0].With["script"]
script := job.Steps[1].With["script"]
for _, want := range []string{
"'sczheng189'",
"'shangguanxuan633-lab'",
"'audanye-sudo'",
"'wxianfeng'",
"REVIEWER_POOL",
"resolveReviewRouting",
"github.rest.pulls.get",
"github.rest.pulls.listFiles",
"currentPull.head.sha !== eventHeadSha",
"currentPull.state !== 'open'",
"currentPull.draft",
@@ -107,9 +124,10 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
"reviewer.toLowerCase() !== author",
"reviewer.toLowerCase() !== latestPusher",
"pullRequest.requested_reviewers",
"pullRequest.requested_teams",
"existingRequestedReviewers",
"github.rest.pulls.listReviews",
"['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes",
"currentHeadReviewers",
"review.commit_id === headSha",
"['APPROVED', 'CHANGES_REQUESTED'].includes(review.state)",
"review.state === 'CHANGES_REQUESTED'",
@@ -117,7 +135,10 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
"state: 'open'",
"loads.set(candidate, loads.get(candidate) + 1)",
"loads.get(left) - loads.get(right)",
"for (const reviewer of ranked)",
"routing.requiredReviewers",
"reviewerCandidates",
"requestReviewersWithFallback",
"satisfiedReviewers",
"github.rest.pulls.requestReviewers",
"trying the next candidate",
"Reviewer routing hit an unexpected error",
@@ -131,7 +152,6 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
}
for _, forbidden := range []string{
"actions/checkout",
"['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']",
"PeterGuy326",
"core.setFailed",