Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c8fd0a48c | ||
|
|
b186e59a01 | ||
|
|
8eeabd1419 | ||
|
|
6035d43899 | ||
|
|
ba375b40fa | ||
|
|
0a063662a0 | ||
|
|
4148a90bf5 | ||
|
|
6572010922 | ||
|
|
6738d0f29e | ||
|
|
384b067ead | ||
|
|
c32d10bd43 | ||
|
|
2a7ab22e85 | ||
|
|
757f45df31 | ||
|
|
5d25a10223 | ||
|
|
0d5c6d92e3 | ||
|
|
88f7ea5679 | ||
|
|
c0b562cc07 | ||
|
|
70d58648c8 | ||
|
|
868d9ff2b0 | ||
|
|
cb2f240c0c | ||
|
|
d800060e06 | ||
|
|
8d8206f791 | ||
|
|
15c2bd50b8 | ||
|
|
711d557b93 | ||
|
|
6102e9fc68 | ||
|
|
ae426f37de | ||
|
|
4dbfaa4cef |
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 180; got != want {
|
||||
if got, want := int(product["count"].(float64)), 187; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
|
||||
+978
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type LeafContract = corecmd.ContractDecl
|
||||
|
||||
type LeafSpec struct {
|
||||
Safety contract.SafetySpec
|
||||
Contract LeafContract
|
||||
}
|
||||
|
||||
type Deps struct {
|
||||
GroupRunE func(*cobra.Command, []string) error
|
||||
CallMCPToolOnServer func(string, string, map[string]any) error
|
||||
DeclareLeafMetadata func(*cobra.Command, LeafSpec) *cobra.Command
|
||||
ValidateRequiredFlag func(*cobra.Command, ...string) error
|
||||
}
|
||||
|
||||
var deps = Deps{
|
||||
GroupRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
CallMCPToolOnServer: func(serverID, toolName string, args map[string]any) error {
|
||||
return fmt.Errorf("im helpers dependency CallMCPToolOnServer is not initialized")
|
||||
},
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return cmd
|
||||
},
|
||||
ValidateRequiredFlag: validateRequiredFlagsLocal,
|
||||
}
|
||||
|
||||
func SetDeps(next Deps) {
|
||||
if next.GroupRunE != nil {
|
||||
deps.GroupRunE = next.GroupRunE
|
||||
}
|
||||
if next.CallMCPToolOnServer != nil {
|
||||
deps.CallMCPToolOnServer = next.CallMCPToolOnServer
|
||||
}
|
||||
if next.DeclareLeafMetadata != nil {
|
||||
deps.DeclareLeafMetadata = next.DeclareLeafMetadata
|
||||
}
|
||||
if next.ValidateRequiredFlag != nil {
|
||||
deps.ValidateRequiredFlag = next.ValidateRequiredFlag
|
||||
}
|
||||
}
|
||||
|
||||
func NewChatToolbarCommand() *cobra.Command {
|
||||
return newChatToolbarCommand()
|
||||
}
|
||||
|
||||
func groupRunE(cmd *cobra.Command, args []string) error {
|
||||
return deps.GroupRunE(cmd, args)
|
||||
}
|
||||
|
||||
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
|
||||
return deps.CallMCPToolOnServer(serverID, toolName, args)
|
||||
}
|
||||
|
||||
func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return deps.DeclareLeafMetadata(cmd, spec)
|
||||
}
|
||||
|
||||
func validateRequiredFlags(cmd *cobra.Command, names ...string) error {
|
||||
return deps.ValidateRequiredFlag(cmd, names...)
|
||||
}
|
||||
|
||||
func mustGetFlag(cmd *cobra.Command, name string) string {
|
||||
val, _ := cmd.Flags().GetString(name)
|
||||
if val == "" {
|
||||
val, _ = cmd.InheritedFlags().GetString(name)
|
||||
}
|
||||
return strings.TrimSpace(val)
|
||||
}
|
||||
|
||||
func parseCSVInt64(raw string) ([]int64, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]int64, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid integer %q", v)
|
||||
}
|
||||
result = append(result, n)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("at least one ID is required")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateRequiredFlagsLocal(cmd *cobra.Command, names ...string) error {
|
||||
for _, name := range names {
|
||||
if strings.TrimSpace(mustGetFlag(cmd, name)) == "" {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("flag --%s is required", name),
|
||||
apperrors.WithReason("missing_required_flag"),
|
||||
apperrors.WithHint(fmt.Sprintf("使用 --%s 指定必填参数", name)),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commandBoolFlag(cmd *cobra.Command, name string) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
value, _ := cmd.Flags().GetBool(name)
|
||||
if !value {
|
||||
value, _ = cmd.InheritedFlags().GetBool(name)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newChatToolbarCommand() *cobra.Command {
|
||||
toolbarCmd := &cobra.Command{
|
||||
Use: "toolbar",
|
||||
Short: "快捷栏管理",
|
||||
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
toolbarCmd.DisableAutoGenTag = true
|
||||
|
||||
toolbarCmd.AddCommand(
|
||||
newToolbarListCommand(),
|
||||
newToolbarAddCommand(),
|
||||
newToolbarHideCommand(),
|
||||
newToolbarSortCommand(),
|
||||
newToolbarCreateCustomCommand(),
|
||||
newToolbarRemoveCustomCommand(),
|
||||
newToolbarUpdateCustomCommand(),
|
||||
)
|
||||
|
||||
return toolbarCmd
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarAddCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "将入口添加到快捷栏可见区",
|
||||
Long: "将一个或多个快捷入口从隐藏区移至可见区。入口 ID 使用逗号分隔。",
|
||||
Example: ` dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--shortcut-ids: %w", err)
|
||||
}
|
||||
err = callMCPToolOnServer("im", "add_shortcut_to_bar", map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutIds": ids,
|
||||
})
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "add_shortcut_to_bar",
|
||||
CanonicalPath: "chat.add_shortcut_to_bar",
|
||||
CLIPath: "chat toolbar add",
|
||||
PrimaryCLIPath: "chat toolbar add",
|
||||
},
|
||||
Description: "将快捷入口添加到会话快捷栏可见区",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_shortcut_to_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将快捷入口添加到会话快捷栏可见区",
|
||||
UseWhen: []string{"需要将隐藏的快捷入口重新显示在快捷栏中"},
|
||||
AvoidWhen: []string{"创建新的自定义入口应使用 chat toolbar create-custom"},
|
||||
Examples: []string{"dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarCreateCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create-custom",
|
||||
Short: "创建自定义快捷栏入口",
|
||||
Long: `创建一个新的自定义快捷栏入口。
|
||||
|
||||
必填参数:
|
||||
--conversation-id 会话 openConversationId
|
||||
--title 入口标题
|
||||
--url 入口跳转链接
|
||||
--icon-url 入口图标 URL
|
||||
--pc-url PC 端跳转链接(可与 --url 相同)
|
||||
|
||||
可选参数:
|
||||
--extension 扩展信息,格式 key=value,可重复使用
|
||||
--desc 入口描述(为空时使用 --title)
|
||||
--tag 入口标签
|
||||
--sort-index 排序权重`,
|
||||
Example: ` dws chat toolbar create-custom --conversation-id <cid> --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
|
||||
dws chat toolbar create-custom --conversation-id <cid> --title "工具" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com" --extension color=blue --desc "常用工具"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
|
||||
"url": mustGetFlag(cmd, "url"),
|
||||
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
|
||||
"pcUrl": mustGetFlag(cmd, "pc-url"),
|
||||
}
|
||||
|
||||
descValue := mustGetFlag(cmd, "desc")
|
||||
if descValue == "" {
|
||||
descValue = mustGetFlag(cmd, "title")
|
||||
}
|
||||
toolArgs["desc"] = toolbarI18nJSONString(descValue)
|
||||
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
|
||||
toolArgs["tag"] = tag
|
||||
}
|
||||
if cmd.Flags().Changed("sort-index") {
|
||||
sortIndex, _ := cmd.Flags().GetInt("sort-index")
|
||||
toolArgs["sortIndex"] = sortIndex
|
||||
}
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ext != nil {
|
||||
toolArgs["extension"] = ext
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "create_custom_shortcut", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("title", "", "入口标题")
|
||||
cmd.Flags().String("url", "", "入口跳转链接")
|
||||
cmd.Flags().String("icon-url", "", "入口图标 URL")
|
||||
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
|
||||
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
|
||||
cmd.Flags().String("desc", "", "入口描述(为空时使用 --title)")
|
||||
cmd.Flags().String("tag", "", "入口标签")
|
||||
cmd.Flags().Int("sort-index", 0, "排序权重")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("title")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
_ = cmd.MarkFlagRequired("icon-url")
|
||||
_ = cmd.MarkFlagRequired("pc-url")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "create_custom_shortcut",
|
||||
CanonicalPath: "chat.create_custom_shortcut",
|
||||
CLIPath: "chat toolbar create-custom",
|
||||
PrimaryCLIPath: "chat toolbar create-custom",
|
||||
},
|
||||
Description: "创建自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建自定义快捷栏入口",
|
||||
UseWhen: []string{"需要在快捷栏中新增一个自定义入口(含标题、链接、图标等)"},
|
||||
AvoidWhen: []string{"移动已有入口使用 chat toolbar add / hide;更新已有自定义入口使用 chat toolbar update-custom"},
|
||||
Examples: []string{"dws chat toolbar create-custom --conversation-id <cid> --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "title", Property: "name", Required: boolPtr(true)},
|
||||
{Name: "url", Property: "url", Required: boolPtr(true)},
|
||||
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
|
||||
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
|
||||
{Name: "extension", Property: "extension", Required: boolPtr(false)},
|
||||
{Name: "desc", Property: "desc", Required: boolPtr(false)},
|
||||
{Name: "tag", Property: "tag", Required: boolPtr(false)},
|
||||
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func toolbarI18nJSONString(value string) string {
|
||||
payload, _ := json.Marshal(map[string]string{"zh_CN": value})
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func toolbarIconsJSONString(iconMobile string) string {
|
||||
payload, _ := json.Marshal(map[string]string{"iconMobile": iconMobile})
|
||||
return string(payload)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func hasIntersection(a, b []int64) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(a))
|
||||
for _, v := range a {
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
for _, v := range b {
|
||||
if _, ok := seen[v]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSystemBusy(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "SYSTEM_BUSY")
|
||||
}
|
||||
|
||||
func parseExtension(cmd *cobra.Command) (map[string]string, error) {
|
||||
raw, _ := cmd.Flags().GetStringArray("extension")
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string, len(raw))
|
||||
for _, pair := range raw {
|
||||
idx := strings.Index(pair, "=")
|
||||
if idx <= 0 {
|
||||
return nil, fmt.Errorf("--extension 格式错误,期望 key=value,实际 %q", pair)
|
||||
}
|
||||
key := strings.TrimSpace(pair[:idx])
|
||||
value := strings.TrimSpace(pair[idx+1:])
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("--extension key 不能为空: %q", pair)
|
||||
}
|
||||
if _, exists := result[key]; exists {
|
||||
return nil, fmt.Errorf("--extension 重复 key %q,每个 key 只能指定一次", key)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toolbarConversationID(cmd *cobra.Command) (string, error) {
|
||||
cid, _ := cmd.Flags().GetString("conversation-id")
|
||||
cid = strings.TrimSpace(cid)
|
||||
if cid == "" {
|
||||
return "", apperrors.NewValidation(
|
||||
"flag --conversation-id is required",
|
||||
apperrors.WithReason("missing_required_flag"),
|
||||
apperrors.WithHint("使用 --conversation-id 指定会话 openConversationId"),
|
||||
apperrors.WithActions("通过 dws chat search 或 dws chat group list 获取会话 ID"),
|
||||
)
|
||||
}
|
||||
return cid, nil
|
||||
}
|
||||
|
||||
func toolbarNewSystemBusyError() error {
|
||||
return apperrors.NewValidation(
|
||||
"服务端繁忙(SYSTEM_BUSY),请稍后重试",
|
||||
apperrors.WithReason("system_busy"),
|
||||
apperrors.WithHint("服务端当前负载较高,建议等待几秒后重试"),
|
||||
apperrors.WithActions("等待后使用相同参数重试"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type toolbarCall struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type toolbarTestCaller struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
err error
|
||||
calls []toolbarCall
|
||||
}
|
||||
|
||||
func (c *toolbarTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, toolbarCall{product: productID, tool: toolName, args: args})
|
||||
c.product = productID
|
||||
c.tool = toolName
|
||||
c.args = args
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "{}"}}}, nil
|
||||
}
|
||||
|
||||
func (c *toolbarTestCaller) Format() string { return "json" }
|
||||
func (c *toolbarTestCaller) DryRun() bool { return false }
|
||||
func (c *toolbarTestCaller) Fields() string { return "" }
|
||||
func (c *toolbarTestCaller) JQ() string { return "" }
|
||||
|
||||
func executeToolbarCommand(t *testing.T, cmd *cobra.Command, caller *toolbarTestCaller, args ...string) error {
|
||||
t.Helper()
|
||||
initToolbarDepsForTest(t, caller)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func initToolbarDepsForTest(t *testing.T, caller *toolbarTestCaller) {
|
||||
t.Helper()
|
||||
previous := deps
|
||||
SetDeps(Deps{
|
||||
GroupRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
CallMCPToolOnServer: func(productID, toolName string, args map[string]any) error {
|
||||
_, err := caller.CallTool(context.Background(), productID, toolName, args)
|
||||
return err
|
||||
},
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return cmd
|
||||
},
|
||||
ValidateRequiredFlag: validateRequiredFlagsLocal,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
deps = previous
|
||||
})
|
||||
}
|
||||
|
||||
func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "获得用户确认后加 --yes 执行") {
|
||||
t.Fatalf("expected confirmation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireToolbarCall(
|
||||
t *testing.T,
|
||||
caller *toolbarTestCaller,
|
||||
wantProduct, wantTool string,
|
||||
wantArgs map[string]any,
|
||||
) {
|
||||
t.Helper()
|
||||
if caller.product != wantProduct || caller.tool != wantTool {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", caller.product, caller.tool, wantProduct, wantTool)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func toolbarCustomArgs(extra ...string) []string {
|
||||
args := []string{"--conversation-id", "cid", "--title", "Title", "--url", "https://example.com/mobile", "--icon-url", "https://example.com/icon.png", "--pc-url", "https://example.com/pc"}
|
||||
return append(args, extra...)
|
||||
}
|
||||
|
||||
func TestHasIntersection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b []int64
|
||||
want bool
|
||||
}{
|
||||
{"both empty", nil, nil, false},
|
||||
{"a empty", nil, []int64{1}, false},
|
||||
{"b empty", []int64{1}, nil, false},
|
||||
{"no overlap", []int64{1, 2, 3}, []int64{4, 5, 6}, false},
|
||||
{"has overlap", []int64{1, 2, 3}, []int64{3, 4, 5}, true},
|
||||
{"single match", []int64{1}, []int64{1}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasIntersection(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("hasIntersection(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSystemBusy(t *testing.T) {
|
||||
if isSystemBusy(nil) {
|
||||
t.Error("isSystemBusy(nil) should be false")
|
||||
}
|
||||
if !isSystemBusy(errors.New("error code: SYSTEM_BUSY")) {
|
||||
t.Error("isSystemBusy should detect SYSTEM_BUSY in error message")
|
||||
}
|
||||
if isSystemBusy(errors.New("some other error")) {
|
||||
t.Error("isSystemBusy should return false for non-SYSTEM_BUSY errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtension(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().StringArray("extension", nil, "")
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("empty extension should not error: %v", err)
|
||||
}
|
||||
if ext != nil {
|
||||
t.Fatalf("empty extension should return nil, got %v", ext)
|
||||
}
|
||||
|
||||
_ = cmd.Flags().Set("extension", "key1=val1")
|
||||
_ = cmd.Flags().Set("extension", "key2=val2")
|
||||
ext, err = parseExtension(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("valid extension should not error: %v", err)
|
||||
}
|
||||
if ext["key1"] != "val1" || ext["key2"] != "val2" {
|
||||
t.Fatalf("unexpected extension map: %v", ext)
|
||||
}
|
||||
|
||||
cmd2 := &cobra.Command{Use: "test2"}
|
||||
cmd2.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd2.Flags().Set("extension", "badformat")
|
||||
_, err = parseExtension(cmd2)
|
||||
if err == nil {
|
||||
t.Fatal("invalid extension format should error")
|
||||
}
|
||||
|
||||
cmd3 := &cobra.Command{Use: "test3"}
|
||||
cmd3.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd3.Flags().Set("extension", "color=blue")
|
||||
_ = cmd3.Flags().Set("extension", "color=red")
|
||||
_, err = parseExtension(cmd3)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate extension keys should error")
|
||||
}
|
||||
|
||||
cmd4 := &cobra.Command{Use: "test4"}
|
||||
cmd4.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd4.Flags().Set("extension", " =value")
|
||||
_, err = parseExtension(cmd4)
|
||||
if err == nil {
|
||||
t.Fatal("blank extension key should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarConversationID(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("conversation-id", "", "")
|
||||
|
||||
_, err := toolbarConversationID(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("missing conversation-id should error")
|
||||
}
|
||||
|
||||
_ = cmd.Flags().Set("conversation-id", "cid123")
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("valid conversation-id should not error: %v", err)
|
||||
}
|
||||
if cid != "cid123" {
|
||||
t.Fatalf("expected cid123, got %s", cid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarDepsDefaultWrappersAndValidationEdges(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "toolbar"}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
if err := groupRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("default groupRunE returned error: %v", err)
|
||||
}
|
||||
if err := callMCPToolOnServer("im", "tool", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "CallMCPToolOnServer is not initialized") {
|
||||
t.Fatalf("default callMCPToolOnServer error = %v", err)
|
||||
}
|
||||
if got := DeclareLeafMetadata(cmd, LeafSpec{}); got != cmd {
|
||||
t.Fatal("default DeclareLeafMetadata should return the original command")
|
||||
}
|
||||
|
||||
toolbar := NewChatToolbarCommand()
|
||||
if toolbar.Name() != "toolbar" || len(toolbar.Commands()) != 7 {
|
||||
t.Fatalf("toolbar command = %q with %d children, want toolbar with 7 children", toolbar.Name(), len(toolbar.Commands()))
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "parent"}
|
||||
parent.PersistentFlags().String("conversation-id", "", "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
parent.AddCommand(child)
|
||||
_ = parent.PersistentFlags().Set("conversation-id", " inherited ")
|
||||
if got := mustGetFlag(child, "conversation-id"); got != "inherited" {
|
||||
t.Fatalf("mustGetFlag inherited value = %q, want inherited", got)
|
||||
}
|
||||
|
||||
if _, err := parseCSVInt64(" , "); err == nil || !strings.Contains(err.Error(), "at least one ID") {
|
||||
t.Fatalf("empty csv error = %v", err)
|
||||
}
|
||||
if _, err := parseCSVInt64("1,bad"); err == nil || !strings.Contains(err.Error(), `invalid integer "bad"`) {
|
||||
t.Fatalf("invalid csv error = %v", err)
|
||||
}
|
||||
required := &cobra.Command{Use: "required"}
|
||||
required.Flags().String("title", "", "")
|
||||
if err := validateRequiredFlagsLocal(required, "title"); err == nil ||
|
||||
!strings.Contains(err.Error(), "flag --title is required") {
|
||||
t.Fatalf("validateRequiredFlagsLocal error = %v", err)
|
||||
}
|
||||
if commandBoolFlag(nil, "yes") {
|
||||
t.Fatal("commandBoolFlag(nil) should be false")
|
||||
}
|
||||
boolParent := &cobra.Command{Use: "parent"}
|
||||
boolParent.PersistentFlags().Bool("yes", false, "")
|
||||
boolChild := &cobra.Command{Use: "child"}
|
||||
boolParent.AddCommand(boolChild)
|
||||
_ = boolParent.PersistentFlags().Set("yes", "true")
|
||||
if !commandBoolFlag(boolChild, "yes") {
|
||||
t.Fatal("commandBoolFlag should read inherited true value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCommandsValidationEdges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "add blank conversation id",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "add blank shortcut ids",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
|
||||
wantError: "flag --shortcut-ids is required",
|
||||
},
|
||||
{
|
||||
name: "add invalid shortcut ids",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
|
||||
wantError: "--shortcut-ids",
|
||||
},
|
||||
{
|
||||
name: "hide blank conversation id",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "hide blank shortcut ids",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
|
||||
wantError: "flag --shortcut-ids is required",
|
||||
},
|
||||
{
|
||||
name: "hide invalid shortcut ids",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
|
||||
wantError: "--shortcut-ids",
|
||||
},
|
||||
{
|
||||
name: "list blank conversation id",
|
||||
cmd: newToolbarListCommand,
|
||||
args: []string{"--conversation-id", " "},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "sort blank conversation id",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", " ", "--sorted-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "sort blank sorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", " "},
|
||||
wantError: "flag --sorted-ids is required",
|
||||
},
|
||||
{
|
||||
name: "sort invalid sorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,bad"},
|
||||
wantError: "--sorted-ids",
|
||||
},
|
||||
{
|
||||
name: "sort invalid unsorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1", "--unsorted-ids", "bad"},
|
||||
wantError: "--unsorted-ids",
|
||||
},
|
||||
{
|
||||
name: "create blank conversation id",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--conversation-id", " "),
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "create blank title",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--title", " "),
|
||||
wantError: "flag --title is required",
|
||||
},
|
||||
{
|
||||
name: "create invalid extension",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--extension", "bad"),
|
||||
wantError: "--extension",
|
||||
},
|
||||
{
|
||||
name: "remove blank conversation id",
|
||||
cmd: newToolbarRemoveCustomCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-id", "42", "--yes"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "update blank conversation id",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--conversation-id", " "),
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "update blank title",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--title", " "),
|
||||
wantError: "flag --title is required",
|
||||
},
|
||||
{
|
||||
name: "update invalid extension",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--extension", "bad"),
|
||||
wantError: "--extension",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCommandsConvertSystemBusy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "add",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "hide",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "remove-custom",
|
||||
cmd: newToolbarRemoveCustomCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-id", "1", "--yes"},
|
||||
},
|
||||
{
|
||||
name: "create-custom",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs(),
|
||||
},
|
||||
{
|
||||
name: "update-custom",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "99"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{
|
||||
err: errors.New("remote SYSTEM_BUSY"),
|
||||
}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "SYSTEM_BUSY") {
|
||||
t.Fatalf("expected system_busy validation error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCreateAndUpdateCustomIncludeOptionalFields(t *testing.T) {
|
||||
createCaller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), createCaller, toolbarCustomArgs(
|
||||
"--desc", "Description",
|
||||
"--tag", "Tag",
|
||||
"--sort-index", "7",
|
||||
"--extension", "color=blue",
|
||||
)...)
|
||||
if err != nil {
|
||||
t.Fatalf("create-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, createCaller, "im", "create_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Description"}`,
|
||||
"tag": "Tag",
|
||||
"sortIndex": 7,
|
||||
"extension": map[string]string{"color": "blue"},
|
||||
})
|
||||
|
||||
updateCaller := &toolbarTestCaller{}
|
||||
err = executeToolbarCommand(t, newToolbarUpdateCustomCommand(), updateCaller, toolbarCustomArgs(
|
||||
"--shortcut-id", "99",
|
||||
"--desc", "Description",
|
||||
"--tag", "Tag",
|
||||
"--sort-index", "7",
|
||||
"--extension", "color=blue",
|
||||
)...)
|
||||
if err != nil {
|
||||
t.Fatalf("update-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, updateCaller, "im", "update_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"shortcutId": int64(99),
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Description"}`,
|
||||
"tag": "Tag",
|
||||
"sortIndex": 7,
|
||||
"extension": map[string]string{"color": "blue"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolbarCreateCustomDefaultsDescToTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing desc", args: toolbarCustomArgs()},
|
||||
{name: "blank desc", args: toolbarCustomArgs("--desc", " ")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("create-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "create_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Title"}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarUpdateCustomDefaultsDescToTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing desc", args: toolbarCustomArgs("--shortcut-id", "99")},
|
||||
{name: "blank desc", args: toolbarCustomArgs("--shortcut-id", "99", "--desc", " ")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, newToolbarUpdateCustomCommand(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("update-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "update_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"shortcutId": int64(99),
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Title"}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarSortValidatesOptionalUnsortedIDs(t *testing.T) {
|
||||
intersectionErr := executeToolbarCommand(t, newToolbarSortCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cid",
|
||||
"--sorted-ids", "1,2",
|
||||
"--unsorted-ids", "2,3",
|
||||
)
|
||||
if intersectionErr == nil || !strings.Contains(intersectionErr.Error(), "不能有交集") {
|
||||
t.Fatalf("expected id_intersection error, got %v", intersectionErr)
|
||||
}
|
||||
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarSortCommand(), caller,
|
||||
"--conversation-id", "cid",
|
||||
"--sorted-ids", "1,2",
|
||||
"--unsorted-ids", "3,4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("sort returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "sort_shortcut_bar", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"sortedShortcutIds": []int64{1, 2},
|
||||
"unsortedShortcutIds": []int64{3, 4},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarAddHideAndListCallMCPWithExactArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
wantTool string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "add",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "101,102"},
|
||||
wantTool: "add_shortcut_to_bar",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
"shortcutIds": []int64{101, 102},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "201,202"},
|
||||
wantTool: "hide_shortcut_from_bar",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
"shortcutIds": []int64{201, 202},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
cmd: newToolbarListCommand,
|
||||
args: []string{"--conversation-id", "cidX"},
|
||||
wantTool: "list_chat_toolbar_shortcuts",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, tt.cmd(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("%s returned error: %v", tt.name, err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", tt.wantTool, tt.wantArgs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomRejectsWithoutYes(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42")
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
if caller.product != "" || caller.tool != "" || caller.args != nil {
|
||||
t.Fatalf("expected no MCP call recorded, got product=%q tool=%q args=%#v", caller.product, caller.tool, caller.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomCallsMCPWithExactArgsWhenYes(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.product != "im" {
|
||||
t.Fatalf("product = %q, want im", call.product)
|
||||
}
|
||||
if call.tool != "remove_custom_shortcut" {
|
||||
t.Fatalf("tool = %q, want remove_custom_shortcut", call.tool)
|
||||
}
|
||||
wantArgs := map[string]any{"openConversationId": "cidX", "shortcutId": int64(42)}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
type removeCustomSeamCall struct {
|
||||
openConversationId string
|
||||
shortcutId int64
|
||||
}
|
||||
|
||||
type removeCustomSeamStub struct {
|
||||
calls []removeCustomSeamCall
|
||||
err error
|
||||
}
|
||||
|
||||
// invoke records the seam call and forwards to deps.Caller.CallTool so the
|
||||
// command path still records the same remote invocation shape as production.
|
||||
func (s *removeCustomSeamStub) invoke(openConversationId string, shortcutId int64) error {
|
||||
s.calls = append(s.calls, removeCustomSeamCall{openConversationId: openConversationId, shortcutId: shortcutId})
|
||||
if err := callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
|
||||
"openConversationId": openConversationId,
|
||||
"shortcutId": shortcutId,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomSeamRejectsWithoutYes(t *testing.T) {
|
||||
stub := &removeCustomSeamStub{}
|
||||
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
|
||||
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42")
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(stub.calls) != 0 {
|
||||
t.Fatalf("expected 0 seam calls before --yes, got %d: %+v", len(stub.calls), stub.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomSeamCallsWithExactArgsWhenYes(t *testing.T) {
|
||||
stub := &removeCustomSeamStub{}
|
||||
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
|
||||
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(stub.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 seam call, got %d: %+v", len(stub.calls), stub.calls)
|
||||
}
|
||||
call := stub.calls[0]
|
||||
if call.openConversationId != "cidX" {
|
||||
t.Fatalf("seam openConversationId = %q, want cidX", call.openConversationId)
|
||||
}
|
||||
if call.shortcutId != int64(42) {
|
||||
t.Fatalf("seam shortcutId = %d, want 42", call.shortcutId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarRemoveCustomDeclaresDestructiveHighRiskConfirmation(t *testing.T) {
|
||||
var got LeafSpec
|
||||
next := deps
|
||||
next.DeclareLeafMetadata = func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
got = spec
|
||||
return cmd
|
||||
}
|
||||
testseam.Swap(t, &deps, next)
|
||||
|
||||
_ = newToolbarRemoveCustomCommand()
|
||||
if got.Safety.Effect != "destructive" || got.Safety.Risk != "high" ||
|
||||
got.Safety.Confirmation != "user_required" {
|
||||
t.Fatalf("remove-custom safety = %#v, want destructive/high/user_required", got.Safety)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarHideCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "hide",
|
||||
Short: "将入口从快捷栏可见区隐藏",
|
||||
Long: "将一个或多个快捷入口从可见区移至隐藏区。入口 ID 使用逗号分隔。",
|
||||
Example: ` dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--shortcut-ids: %w", err)
|
||||
}
|
||||
err = callMCPToolOnServer("im", "hide_shortcut_from_bar", map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutIds": ids,
|
||||
})
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "hide_shortcut_from_bar",
|
||||
CanonicalPath: "chat.hide_shortcut_from_bar",
|
||||
CLIPath: "chat toolbar hide",
|
||||
PrimaryCLIPath: "chat toolbar hide",
|
||||
},
|
||||
Description: "将快捷入口从会话快捷栏可见区移至隐藏区",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "hide_shortcut_from_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将快捷入口从会话快捷栏可见区隐藏",
|
||||
UseWhen: []string{"需要将快捷栏中的入口隐藏到隐藏区"},
|
||||
AvoidWhen: []string{"永久删除自定义入口应使用 chat toolbar remove-custom"},
|
||||
Examples: []string{"dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询会话快捷栏入口列表",
|
||||
Long: "查询指定会话的快捷栏入口列表,包括可见区和隐藏区的所有入口。",
|
||||
Example: ` dws chat toolbar list --conversation-id <cid>
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "list_chat_toolbar_shortcuts", map[string]any{
|
||||
"openConversationId": cid,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "list_chat_toolbar_shortcuts",
|
||||
CanonicalPath: "chat.list_chat_toolbar_shortcuts",
|
||||
CLIPath: "chat toolbar list",
|
||||
PrimaryCLIPath: "chat toolbar list",
|
||||
},
|
||||
Description: "查询会话快捷栏入口列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_chat_toolbar_shortcuts"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询会话快捷栏入口列表",
|
||||
UseWhen: []string{"需要查看会话快捷栏中有哪些入口、获取入口 ID 时"},
|
||||
AvoidWhen: []string{"修改快捷栏排序使用 chat toolbar sort;创建自定义入口使用 chat toolbar create-custom"},
|
||||
Examples: []string{"dws chat toolbar list --conversation-id <cid>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// removeChatToolbarCustomShortcutFn is the package-level injection seam for
|
||||
// the remove-custom command's remote call. The default implementation routes
|
||||
// through callMCPToolOnServer against the im server, so production behavior is
|
||||
// unchanged. Tests swap it with testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub)
|
||||
// to capture the call without hitting a real remote. Like every package-var
|
||||
// seam in this repo it is not safe for t.Parallel tests.
|
||||
var removeChatToolbarCustomShortcutFn = func(openConversationId string, shortcutId int64) error {
|
||||
return callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
|
||||
"openConversationId": openConversationId,
|
||||
"shortcutId": shortcutId,
|
||||
})
|
||||
}
|
||||
|
||||
func newToolbarRemoveCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove-custom",
|
||||
Short: "删除自定义快捷栏入口",
|
||||
Long: "删除一个自定义快捷栏入口。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
|
||||
Example: ` dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"删除自定义快捷入口不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标入口及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标自定义入口", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
err = removeChatToolbarCustomShortcutFn(cid, shortcutId)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
|
||||
cmd.Flags().Bool("yes", false, "确认执行删除操作")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-id")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "remove_custom_shortcut",
|
||||
CanonicalPath: "chat.remove_custom_shortcut",
|
||||
CLIPath: "chat toolbar remove-custom",
|
||||
PrimaryCLIPath: "chat toolbar remove-custom",
|
||||
},
|
||||
Description: "删除自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "删除自定义快捷栏入口",
|
||||
UseWhen: []string{"需要永久删除某个自定义快捷栏入口"},
|
||||
AvoidWhen: []string{"仅隐藏入口使用 chat toolbar hide;更新入口使用 chat toolbar update-custom"},
|
||||
Examples: []string{"dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func newToolbarSortCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "sort",
|
||||
Short: "排序快捷栏入口",
|
||||
Long: "对会话快捷栏入口进行排序。sorted-ids 指定排序后的可见区入口 ID 列表,unsorted-ids 指定不参与排序放在末尾的入口 ID 列表。两个列表不能有交集。",
|
||||
Example: ` dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103
|
||||
dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102 --unsorted-ids 103,104
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "sorted-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
sortedIds, err := parseCSVInt64(mustGetFlag(cmd, "sorted-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--sorted-ids: %w", err)
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"sortedShortcutIds": sortedIds,
|
||||
}
|
||||
|
||||
if unsortedRaw, _ := cmd.Flags().GetString("unsorted-ids"); unsortedRaw != "" {
|
||||
unsortedIds, err := parseCSVInt64(unsortedRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--unsorted-ids: %w", err)
|
||||
}
|
||||
if hasIntersection(sortedIds, unsortedIds) {
|
||||
return apperrors.NewValidation(
|
||||
"--sorted-ids 与 --unsorted-ids 不能有交集",
|
||||
apperrors.WithReason("id_intersection"),
|
||||
apperrors.WithHint("检查两个 ID 列表,确保同一个 ID 不同时出现在两个列表中"),
|
||||
apperrors.WithActions("修正 ID 列表后重试"),
|
||||
)
|
||||
}
|
||||
toolArgs["unsortedShortcutIds"] = unsortedIds
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "sort_shortcut_bar", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("sorted-ids", "", "排序后的入口 ID 列表(逗号分隔)")
|
||||
cmd.Flags().String("unsorted-ids", "", "不参与排序放在末尾的入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("sorted-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "sort_shortcut_bar",
|
||||
CanonicalPath: "chat.sort_shortcut_bar",
|
||||
CLIPath: "chat toolbar sort",
|
||||
PrimaryCLIPath: "chat toolbar sort",
|
||||
},
|
||||
Description: "对会话快捷栏入口进行排序",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "sort_shortcut_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对会话快捷栏入口进行排序",
|
||||
UseWhen: []string{"需要调整快捷栏入口的显示顺序"},
|
||||
AvoidWhen: []string{"仅添加或隐藏入口使用 chat toolbar add / hide"},
|
||||
Examples: []string{"dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "sorted-ids", Property: "sortedShortcutIds", Required: boolPtr(true)},
|
||||
{Name: "unsorted-ids", Property: "unsortedShortcutIds", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarUpdateCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update-custom",
|
||||
Short: "更新自定义快捷栏入口",
|
||||
Long: `更新一个已有的自定义快捷栏入口。
|
||||
|
||||
必填参数:
|
||||
--conversation-id 会话 openConversationId
|
||||
--shortcut-id 自定义入口 ID
|
||||
--title 入口标题
|
||||
--url 入口跳转链接
|
||||
--icon-url 入口图标 URL
|
||||
--pc-url PC 端跳转链接
|
||||
|
||||
可选参数:
|
||||
--extension 扩展信息,格式 key=value,可重复使用
|
||||
--desc 入口描述
|
||||
--tag 入口标签
|
||||
--sort-index 排序权重`,
|
||||
Example: ` dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
|
||||
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutId": shortcutId,
|
||||
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
|
||||
"url": mustGetFlag(cmd, "url"),
|
||||
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
|
||||
"pcUrl": mustGetFlag(cmd, "pc-url"),
|
||||
}
|
||||
|
||||
descValue := mustGetFlag(cmd, "desc")
|
||||
if descValue == "" {
|
||||
descValue = mustGetFlag(cmd, "title")
|
||||
}
|
||||
toolArgs["desc"] = toolbarI18nJSONString(descValue)
|
||||
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
|
||||
toolArgs["tag"] = tag
|
||||
}
|
||||
if cmd.Flags().Changed("sort-index") {
|
||||
sortIndex, _ := cmd.Flags().GetInt("sort-index")
|
||||
toolArgs["sortIndex"] = sortIndex
|
||||
}
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ext != nil {
|
||||
toolArgs["extension"] = ext
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "update_custom_shortcut", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
|
||||
cmd.Flags().String("title", "", "入口标题")
|
||||
cmd.Flags().String("url", "", "入口跳转链接")
|
||||
cmd.Flags().String("icon-url", "", "入口图标 URL")
|
||||
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
|
||||
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
|
||||
cmd.Flags().String("desc", "", "入口描述")
|
||||
cmd.Flags().String("tag", "", "入口标签")
|
||||
cmd.Flags().Int("sort-index", 0, "排序权重")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-id")
|
||||
_ = cmd.MarkFlagRequired("title")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
_ = cmd.MarkFlagRequired("icon-url")
|
||||
_ = cmd.MarkFlagRequired("pc-url")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "update_custom_shortcut",
|
||||
CanonicalPath: "chat.update_custom_shortcut",
|
||||
CLIPath: "chat toolbar update-custom",
|
||||
PrimaryCLIPath: "chat toolbar update-custom",
|
||||
},
|
||||
Description: "更新自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新自定义快捷栏入口",
|
||||
UseWhen: []string{"需要修改已有自定义快捷栏入口的标题、链接、图标等属性"},
|
||||
AvoidWhen: []string{"创建新入口使用 chat toolbar create-custom;删除入口使用 chat toolbar remove-custom"},
|
||||
Examples: []string{"dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
|
||||
{Name: "title", Property: "name", Required: boolPtr(true)},
|
||||
{Name: "url", Property: "url", Required: boolPtr(true)},
|
||||
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
|
||||
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
|
||||
{Name: "extension", Property: "extension", Required: boolPtr(false)},
|
||||
{Name: "desc", Property: "desc", Required: boolPtr(false)},
|
||||
{Name: "tag", Property: "tag", Required: boolPtr(false)},
|
||||
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatCatalogSafetyMetadataIsExplicit(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(newChatCommand())
|
||||
|
||||
checked := 0
|
||||
walkChatCatalogLeaves(root, func(cmd *cobra.Command) {
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok || final.Identity == nil || final.Safety == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(final.Identity.ProductID) != "chat" {
|
||||
return
|
||||
}
|
||||
checked++
|
||||
canonical := strings.TrimSpace(final.Identity.CanonicalPath)
|
||||
safety := final.Safety
|
||||
if safety.Effect == "" || safety.Risk == "" ||
|
||||
safety.Confirmation == "" || safety.Idempotency == "" {
|
||||
t.Fatalf("chat Catalog leaf %s has incomplete Safety: %+v", canonical, *safety)
|
||||
}
|
||||
if safety.Effect == "read" {
|
||||
if safety.Risk != "low" ||
|
||||
safety.Confirmation != "not_required" ||
|
||||
safety.Idempotency != "idempotent" {
|
||||
t.Fatalf("chat read Catalog leaf %s Safety = %+v, want read/low/not_required/idempotent",
|
||||
canonical, *safety)
|
||||
}
|
||||
}
|
||||
})
|
||||
if checked == 0 {
|
||||
t.Fatal("no chat Catalog leaves checked")
|
||||
}
|
||||
}
|
||||
|
||||
func walkChatCatalogLeaves(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Runnable() && !cmd.HasSubCommands() {
|
||||
fn(cmd)
|
||||
return
|
||||
}
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
walkChatCatalogLeaves(child, fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
chathelpers "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers/chat"
|
||||
)
|
||||
|
||||
func newChatToolbarCommand() *cobra.Command {
|
||||
chathelpers.SetDeps(chathelpers.Deps{
|
||||
GroupRunE: GroupRunE,
|
||||
CallMCPToolOnServer: CallMCPToolOnServer,
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec chathelpers.LeafSpec) *cobra.Command {
|
||||
return DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: spec.Safety,
|
||||
Contract: spec.Contract,
|
||||
})
|
||||
},
|
||||
})
|
||||
return chathelpers.NewChatToolbarCommand()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatToolbarParentCaller struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (c *chatToolbarParentCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.product = productID
|
||||
c.tool = toolName
|
||||
c.args = args
|
||||
return textToolResult(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (*chatToolbarParentCaller) Format() string { return "json" }
|
||||
func (*chatToolbarParentCaller) DryRun() bool { return false }
|
||||
func (*chatToolbarParentCaller) Fields() string { return "" }
|
||||
func (*chatToolbarParentCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageChatToolbarMountedThroughParentDeps(t *testing.T) {
|
||||
caller := &chatToolbarParentCaller{}
|
||||
installHelpersCoreDeps(t, caller)
|
||||
|
||||
root := newChatCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
if command, _, err := root.Find([]string{"toolbar", "list"}); err != nil || command == nil || command.Name() != "list" {
|
||||
t.Fatalf("find chat toolbar list = command %v, err %v", command, err)
|
||||
}
|
||||
|
||||
root.SetArgs([]string{"toolbar", "list", "--conversation-id", "cidX"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("chat toolbar list returned error: %v", err)
|
||||
}
|
||||
if caller.product != "im" || caller.tool != "list_chat_toolbar_shortcuts" {
|
||||
t.Fatalf("tool call = %s/%s, want im/list_chat_toolbar_shortcuts", caller.product, caller.tool)
|
||||
}
|
||||
wantArgs := map[string]any{"openConversationId": "cidX"}
|
||||
if !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,53 @@ func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChmodRequiresConfirmationBeforeRemoteCall(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"chmod", "chat.message:send",
|
||||
"--agentCode", "wukong",
|
||||
"--grant-type", "timed",
|
||||
"--ttl", "24h",
|
||||
"--conversation-id", "cidX",
|
||||
)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChmodCallsRemoteWithExactArgsWhenConfirmed(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"chmod", "chat.message:send",
|
||||
"--agentCode", "wukong",
|
||||
"--grant-type", "timed",
|
||||
"--ttl", "24h",
|
||||
"--conversation-id", "cidX",
|
||||
"--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat chmod with --yes returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "im" || call.toolName != "chat_permission_grant" {
|
||||
t.Fatalf("tool call = %s/%s, want im/chat_permission_grant", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"agentCode": "wukong",
|
||||
"scope": "chat.message:send",
|
||||
"grantType": "timed",
|
||||
"ttl": "24h",
|
||||
"grantParams": `{"conversationId":"cidX","openCid":"cidX","openConversationId":"cidX"}`,
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
type guardedStepCaller struct {
|
||||
calls []guardedMutationCall
|
||||
steps []string
|
||||
|
||||
@@ -143,39 +143,42 @@ func leafSpecCompositeLits(t *testing.T) []leafSpecLit {
|
||||
}
|
||||
dir := filepath.Dir(file)
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool {
|
||||
name := info.Name()
|
||||
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go")
|
||||
}, 0)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("parse helpers: %v", err)
|
||||
t.Fatalf("read helpers dir: %v", err)
|
||||
}
|
||||
var out []leafSpecLit
|
||||
for _, pkg := range pkgs {
|
||||
for _, f := range pkg.Files {
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
fun, ok := call.Fun.(*ast.Ident)
|
||||
if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" {
|
||||
return true
|
||||
}
|
||||
out = append(out, leafSpecLit{
|
||||
pos: fset.Position(lit.Pos()).String(),
|
||||
lit: lit,
|
||||
})
|
||||
return true
|
||||
})
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
parsed, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse helpers file %s: %v", name, err)
|
||||
}
|
||||
ast.Inspect(parsed, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
fun, ok := call.Fun.(*ast.Ident)
|
||||
if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" {
|
||||
return true
|
||||
}
|
||||
out = append(out, leafSpecLit{
|
||||
pos: fset.Position(lit.Pos()).String(),
|
||||
lit: lit,
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("no NewLeafCommand(LeafSpec{...}) literals found")
|
||||
|
||||
@@ -1884,6 +1884,108 @@ Flags:
|
||||
- record-id、applicant、inviter 可通过 dws chat group list-join-validations 查询获得
|
||||
```
|
||||
|
||||
### toolbar (快捷栏管理)
|
||||
|
||||
快捷栏(toolbar)是会话级快捷入口管理能力,支持查询、添加、隐藏、排序及自定义入口 CRUD。与 `internal/shortcut/` 下的智能快捷方式框架是两套独立能力。
|
||||
|
||||
#### 查询快捷栏入口列表
|
||||
```
|
||||
Usage:
|
||||
dws chat toolbar list [flags]
|
||||
Example:
|
||||
dws chat toolbar list --conversation-id <cid>
|
||||
Flags:
|
||||
--conversation-id string 会话 openConversationId (必填)
|
||||
```
|
||||
|
||||
#### 将入口添加到快捷栏可见区
|
||||
```
|
||||
Usage:
|
||||
dws chat toolbar add [flags]
|
||||
Example:
|
||||
dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102
|
||||
Flags:
|
||||
--conversation-id string 会话 openConversationId (必填)
|
||||
--shortcut-ids string 入口 ID 列表,逗号分隔 (必填)
|
||||
```
|
||||
|
||||
#### 将入口从快捷栏可见区隐藏
|
||||
```
|
||||
Usage:
|
||||
dws chat toolbar hide [flags]
|
||||
Example:
|
||||
dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102
|
||||
Flags:
|
||||
--conversation-id string 会话 openConversationId (必填)
|
||||
--shortcut-ids string 入口 ID 列表,逗号分隔 (必填)
|
||||
```
|
||||
|
||||
#### 排序快捷栏入口
|
||||
```
|
||||
Usage:
|
||||
dws chat toolbar sort [flags]
|
||||
Example:
|
||||
dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103
|
||||
dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102 --unsorted-ids 103,104
|
||||
Flags:
|
||||
--conversation-id string 会话 openConversationId (必填)
|
||||
--sorted-ids string 排序后的入口 ID 列表,逗号分隔 (必填)
|
||||
--unsorted-ids string 不参与排序放在末尾的入口 ID 列表,逗号分隔
|
||||
```
|
||||
注意:`--sorted-ids` 与 `--unsorted-ids` 不能有交集。
|
||||
|
||||
#### 创建自定义快捷栏入口
|
||||
```
|
||||
Usage:
|
||||
dws chat toolbar create-custom [flags]
|
||||
Example:
|
||||
dws chat toolbar create-custom --conversation-id <cid> --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
|
||||
Flags:
|
||||
--conversation-id string 会话 openConversationId (必填)
|
||||
--title string 入口标题 (必填)
|
||||
--url string 入口跳转链接 (必填)
|
||||
--icon-url string 入口图标 URL (必填)
|
||||
--pc-url string PC 端跳转链接 (必填)
|
||||
--extension stringArray 扩展信息,格式 key=value,可重复使用
|
||||
--desc string 入口描述(为空时使用 --title)
|
||||
--tag string 入口标签
|
||||
--sort-index int 排序权重
|
||||
```
|
||||
|
||||
#### 删除自定义快捷栏入口
|
||||
```
|
||||
Usage:
|
||||
dws chat toolbar remove-custom [flags]
|
||||
Example:
|
||||
dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123
|
||||
Flags:
|
||||
--conversation-id string 会话 openConversationId (必填)
|
||||
--shortcut-id int 自定义入口 ID (必填)
|
||||
--yes 确认执行删除操作
|
||||
```
|
||||
注意:删除操作不可逆,必须先获得用户确认后加 `--yes` 执行。
|
||||
|
||||
#### 更新自定义快捷栏入口
|
||||
```
|
||||
Usage:
|
||||
dws chat toolbar update-custom [flags]
|
||||
Example:
|
||||
dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
|
||||
Flags:
|
||||
--conversation-id string 会话 openConversationId (必填)
|
||||
--shortcut-id int 自定义入口 ID (必填)
|
||||
--title string 入口标题 (必填)
|
||||
--url string 入口跳转链接 (必填)
|
||||
--icon-url string 入口图标 URL (必填)
|
||||
--pc-url string PC 端跳转链接 (必填)
|
||||
--extension stringArray 扩展信息,格式 key=value,可重复使用
|
||||
--desc string 入口描述
|
||||
--tag string 入口标签
|
||||
--sort-index int 排序权重
|
||||
```
|
||||
|
||||
入口 ID 可通过 `dws chat toolbar list --conversation-id <cid>` 获取。
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user