Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eda235f62 | ||
|
|
390b6115bf | ||
|
|
fc9acb9007 | ||
|
|
aa6abc5ed6 | ||
|
|
ea6fd16d11 |
@@ -0,0 +1,54 @@
|
||||
name: Verify Gitee release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 2 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to verify; defaults to the latest stable GitHub release"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve release version
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
version="$INPUT_VERSION"
|
||||
if [ -z "$version" ]; then
|
||||
version="$(gh release view --repo "${{ github.repository }}" --json tagName --jq .tagName)"
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ steps.release.outputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
|
||||
- name: Verify matching Gitee release
|
||||
run: ./scripts/release/verify-gitee-release.sh
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
@@ -42,5 +42,8 @@ dws.zip
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
/auto-test/
|
||||
/eval-runs/
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
@@ -6,9 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
### Added
|
||||
|
||||
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
|
||||
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
|
||||
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
|
||||
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
|
||||
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
|
||||
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
|
||||
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
|
||||
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
|
||||
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
|
||||
|
||||
## [1.0.50] - 2026-07-08
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# cli_to_mcp smoke tests
|
||||
|
||||
This directory contains lightweight command-to-tool contract tests for hardcoded
|
||||
DWS commands synced from `dws-wukong`.
|
||||
|
||||
The tests do not call live DingTalk APIs. They exercise command help, validation,
|
||||
and `--dry-run` output so command paths and MCP argument mappings stay stable.
|
||||
|
||||
Run with an already built binary:
|
||||
|
||||
```bash
|
||||
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
|
||||
```
|
||||
|
||||
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
|
||||
root.
|
||||
@@ -1,193 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"list",
|
||||
"--scope-type",
|
||||
"PERSONAL",
|
||||
"--scope-id",
|
||||
"user123",
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_strategy_decodings" in output
|
||||
assert dry_run_args(output) == {
|
||||
"scopeType": "PERSONAL",
|
||||
"openId": "user123",
|
||||
"requestId": "req-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"update",
|
||||
"--profile-id",
|
||||
"profile123",
|
||||
"--content",
|
||||
'[{"id":"e1","title":{"title":"new"}}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_strategy_decoding" in output
|
||||
assert dry_run_args(output) == {
|
||||
"profileId": "profile123",
|
||||
"content": [{"id": "e1", "title": {"title": "new"}}],
|
||||
}
|
||||
|
||||
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
|
||||
assert "list_op_contract_fields" in output
|
||||
assert dry_run_args(output) == {}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"contract",
|
||||
"update",
|
||||
"--contract-id",
|
||||
"contract123",
|
||||
"--dimensions",
|
||||
'[{"id":"dim1","title":"metric"}]',
|
||||
"--audit-config",
|
||||
'{"needAudit":true}',
|
||||
"--objective-template",
|
||||
'{"id":"tpl1"}',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_op_contract" in output
|
||||
assert dry_run_args(output) == {
|
||||
"contractId": "contract123",
|
||||
"dimensions": [{"id": "dim1", "title": "metric"}],
|
||||
"auditConfig": '{"needAudit":true}',
|
||||
"objectiveTemplate": '{"id":"tpl1"}',
|
||||
}
|
||||
|
||||
|
||||
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"detail",
|
||||
"--selected-time",
|
||||
"2026-01-01T00:00:00+08:00",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_score_card_detail" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["deptId"] == "dept123"
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"update",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--selected-time",
|
||||
"2026-01-01",
|
||||
"--id",
|
||||
"sc123",
|
||||
"--tracking-period-type",
|
||||
"MONTHLY",
|
||||
"--content",
|
||||
'[{"id":"dim1","items":[]}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_score_card" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
assert args["content"] == [{"id": "dim1", "items": []}]
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"user",
|
||||
"objectives",
|
||||
"--user-id",
|
||||
"user123",
|
||||
"--rule-id",
|
||||
"rule123",
|
||||
"--period-ids",
|
||||
"p1,p2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_user_objectives" in output
|
||||
assert dry_run_args(output) == {
|
||||
"dingUserId": "user123",
|
||||
"objectiveRuleId": "rule123",
|
||||
"periodIds": ["p1", "p2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"report",
|
||||
"submit-detail",
|
||||
"--template-id",
|
||||
"tpl123",
|
||||
"--submit-state",
|
||||
"LATE",
|
||||
"--query-date",
|
||||
"2026-06-18T00:00:00+08:00",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--keyword",
|
||||
"alice",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_submit_detail" in output
|
||||
assert dry_run_args(output) == {
|
||||
"templateId": "tpl123",
|
||||
"submitState": "LATE",
|
||||
"queryDate": "2026-06-18",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"keyword": "alice",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"obj-template",
|
||||
"create-or-update",
|
||||
"--title",
|
||||
"tpl",
|
||||
"--dimensions",
|
||||
'[{"title":"dim"}]',
|
||||
"--objective-weight",
|
||||
"--dimension-weight",
|
||||
"--compute-by-weight",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_or_update_obj_template" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "tpl",
|
||||
"dimensions": '[{"title":"dim"}]',
|
||||
"objectiveWeight": True,
|
||||
"dimensionWeight": True,
|
||||
"computeByWeight": True,
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_notice_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"create",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--content",
|
||||
"maintenance tonight",
|
||||
"--sticky",
|
||||
"--send-ding",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"content": "maintenance tonight",
|
||||
"sticky": True,
|
||||
"sendDing": True,
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"edit",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--content",
|
||||
"updated",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "edit_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
"content": "updated",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"get",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"list",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--limit",
|
||||
"20",
|
||||
"--cursor",
|
||||
"next",
|
||||
"--scheduled",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_group_notices" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"limit": 20,
|
||||
"cursor": "next",
|
||||
"scheduled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_misc_new_commands_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"share-invite",
|
||||
"--source",
|
||||
"sourceCid",
|
||||
"--target",
|
||||
"targetCid",
|
||||
"--expires-seconds",
|
||||
"3600",
|
||||
"--uuid",
|
||||
"uuid-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "share_group_invite_url" in output
|
||||
assert dry_run_args(output) == {
|
||||
"sourceOpenConversationId": "sourceCid",
|
||||
"targetOpenConversationId": "targetCid",
|
||||
"expiresSeconds": 3600,
|
||||
"uuid": "uuid-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
|
||||
)
|
||||
assert "translate" in output
|
||||
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"category",
|
||||
"create-smart",
|
||||
"--name",
|
||||
"priority",
|
||||
"--keywords",
|
||||
"alpha,beta",
|
||||
"--members",
|
||||
"uid1,uid2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_smart_conv_category" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "priority",
|
||||
"keywords": ["alpha", "beta"],
|
||||
"memberOpenDingTalkIds": ["uid1", "uid2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"message",
|
||||
"list-emotion-replies",
|
||||
"--msg-ids",
|
||||
"msg1,msg2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_message_emotion_replies" in output
|
||||
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
|
||||
@@ -1,8 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from test_utils import DWSRunner
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dws():
|
||||
return DWSRunner()
|
||||
@@ -1,54 +0,0 @@
|
||||
from test_utils import combined_output
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_doc_import_help_and_validation(dws, tmp_path):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "--help"))
|
||||
assert "dws doc import" in output
|
||||
assert "--file string" in output
|
||||
assert "--workspace string" in output
|
||||
assert "--name string" in output
|
||||
|
||||
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "cannot read file" in output
|
||||
|
||||
bad = tmp_path / "bad.exe"
|
||||
bad.write_text("bad", encoding="utf-8")
|
||||
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "unsupported file format" in output
|
||||
|
||||
|
||||
def test_doc_import_dry_run(dws, tmp_path):
|
||||
source = tmp_path / "sample.md"
|
||||
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"doc",
|
||||
"import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--name",
|
||||
"Imported Sample",
|
||||
"--workspace",
|
||||
"workspace123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "Imported Sample" in output
|
||||
assert "sample.md" in output
|
||||
assert "md" in output
|
||||
|
||||
|
||||
def test_doc_import_get_dry_run(dws):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
|
||||
assert "task123" in output
|
||||
@@ -1,143 +0,0 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def assert_fails(result, expected: str):
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0, output
|
||||
assert expected in output
|
||||
|
||||
|
||||
def test_mailbox_profile_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
|
||||
assert "dws mail mailbox profile" in output
|
||||
assert "--email string" in output
|
||||
|
||||
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
|
||||
)
|
||||
assert "get_mailbox_profile" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
|
||||
def test_message_batch_get_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
|
||||
assert "dws mail message batch-get" in output
|
||||
assert "--email string" in output
|
||||
assert "--ids string" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
|
||||
"ids",
|
||||
)
|
||||
|
||||
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
too_many_ids,
|
||||
"--dry-run",
|
||||
),
|
||||
"20",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
"msg_001,msg_002",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_email_by_message_id" in output
|
||||
assert "msg_001" in output
|
||||
assert "msg_002" in output
|
||||
|
||||
|
||||
def test_sent_message_recall_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
|
||||
assert "dws mail sent-message recall" in output
|
||||
assert "--subject string" in output
|
||||
assert "--yes" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "recall_sent_message" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"id": "msg_001",
|
||||
"subject": "subject",
|
||||
}
|
||||
|
||||
|
||||
def test_sent_message_recall_detail_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
|
||||
assert "dws mail sent-message recall-detail" in output
|
||||
assert "--email string" in output
|
||||
assert "--id string" in output
|
||||
assert "FINISHED" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall-detail",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"task_001",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_recall_detail" in output
|
||||
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
|
||||
@@ -1,133 +0,0 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_auto_reply_update_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
|
||||
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
|
||||
assert flag in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"auto-reply",
|
||||
"update",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--enabled",
|
||||
"true",
|
||||
"--start",
|
||||
"2026/07/01 09:00:00 +0800",
|
||||
"--end",
|
||||
"2026/07/07 18:00:00 +0800",
|
||||
"--scope",
|
||||
"all",
|
||||
"--content",
|
||||
"out of office",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_auto_reply" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"enabled": True,
|
||||
"startTime": "2026/07/01 09:00:00 +0800",
|
||||
"endTime": "2026/07/07 18:00:00 +0800",
|
||||
"scope": "all",
|
||||
"content": "out of office",
|
||||
}
|
||||
|
||||
|
||||
def test_allow_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com,@example.org",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com", "@example.org"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com"],
|
||||
}
|
||||
|
||||
|
||||
def test_block_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com,@junk.example",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com", "@junk.example"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com"],
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
|
||||
assert "dws sheet group-dimension" in output
|
||||
assert "--group-state string" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"fold",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "group_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
}
|
||||
|
||||
|
||||
def test_ungroup_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
|
||||
assert "dws sheet ungroup-dimension" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"ungroup-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"C:F",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "ungroup_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
}
|
||||
|
||||
|
||||
def test_group_dimension_rejects_invalid_state(dws):
|
||||
result = dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"invalid",
|
||||
"--dry-run",
|
||||
)
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "group-state" in output
|
||||
@@ -1,58 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root(start_file: str) -> Path:
|
||||
current = Path(start_file).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "go.mod").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"cannot locate repo root from {start_file}")
|
||||
|
||||
|
||||
def resolve_dws_cmd(start_file: str) -> list[str]:
|
||||
root = repo_root(start_file)
|
||||
if env_bin := os.environ.get("DWS_BIN"):
|
||||
return shlex.split(env_bin)
|
||||
|
||||
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
|
||||
candidate = root / rel
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return [str(candidate)]
|
||||
|
||||
return ["go", "run", "./cmd"]
|
||||
|
||||
|
||||
def combined_output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def dry_run_args(output: str) -> dict:
|
||||
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
|
||||
assert match, f"dry-run output does not contain Arguments JSON: {output}"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
class DWSRunner:
|
||||
def __init__(self):
|
||||
self.root = repo_root(__file__)
|
||||
self.cmd = resolve_dws_cmd(__file__)
|
||||
|
||||
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[*self.cmd, *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def run(self, *args: str, timeout: int = 45):
|
||||
result = self.run_raw(*args, timeout=timeout)
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return json.loads(result.stdout)
|
||||
@@ -13,7 +13,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
|
||||
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -4927,17 +4928,26 @@ status 可选值:
|
||||
dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2
|
||||
dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "name"); err != nil {
|
||||
return err
|
||||
name := strings.TrimSpace(mustGetFlag(cmd, "name"))
|
||||
if name == "" {
|
||||
return apperrors.NewValidation("--name must not be blank")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"title": mustGetFlag(cmd, "name"),
|
||||
"categoryName": name,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keywords"); v != "" {
|
||||
toolArgs["keywords"] = parseCSVValues(v)
|
||||
if cmd.Flags().Changed("keywords") {
|
||||
values := parseCSVValues(mustGetFlag(cmd, "keywords"))
|
||||
if len(values) == 0 {
|
||||
return apperrors.NewValidation("--keywords must contain at least one non-empty value")
|
||||
}
|
||||
toolArgs["groupNameKeywords"] = values
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("members"); v != "" {
|
||||
toolArgs["memberOpenDingTalkIds"] = parseCSVValues(v)
|
||||
if cmd.Flags().Changed("members") {
|
||||
values := parseCSVValues(mustGetFlag(cmd, "members"))
|
||||
if len(values) == 0 {
|
||||
return apperrors.NewValidation("--members must contain at least one non-empty value")
|
||||
}
|
||||
toolArgs["memberOpenDingTalkIds"] = values
|
||||
}
|
||||
return callMCPToolOnServer("im", "create_smart_conv_category", toolArgs)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatMessageSearchCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatMessageSearchCaller struct {
|
||||
calls []chatMessageSearchCall
|
||||
}
|
||||
|
||||
func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatMessageSearchCaller) Format() string { return "json" }
|
||||
func (*chatMessageSearchCaller) DryRun() bool { return false }
|
||||
func (*chatMessageSearchCaller) Fields() string { return "" }
|
||||
func (*chatMessageSearchCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
start := "2026-07-09T00:00:00+08:00"
|
||||
end := "2026-07-11T00:00:00+08:00"
|
||||
startTime, err := time.Parse(time.RFC3339, start)
|
||||
if err != nil {
|
||||
t.Fatalf("parse start time: %v", err)
|
||||
}
|
||||
endTime, err := time.Parse(time.RFC3339, end)
|
||||
if err != nil {
|
||||
t.Fatalf("parse end time: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
productID string
|
||||
toolName string
|
||||
wantToolArg map[string]any
|
||||
}{
|
||||
{
|
||||
name: "keyword search",
|
||||
args: []string{"message", "search", "--query", "categoryName", "--group", "cid-1", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"},
|
||||
productID: "chat",
|
||||
toolName: "search_messages_by_keyword",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationId": "cid-1",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "advanced search",
|
||||
args: []string{"message", "search-advanced", "--query", "categoryName", "--conversation-ids", "cid-1,cid-2", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"},
|
||||
productID: "im",
|
||||
toolName: "search_messages",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationIds": []string{"cid-1", "cid-2"},
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(tt.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat search returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != tt.productID || call.toolName != tt.toolName {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, tt.wantToolArg) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, tt.wantToolArg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatSmartCategoryCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatSmartCategoryCaller struct {
|
||||
calls []chatSmartCategoryCall
|
||||
}
|
||||
|
||||
func (c *chatSmartCategoryCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatSmartCategoryCall{
|
||||
productID: productID,
|
||||
toolName: toolName,
|
||||
args: args,
|
||||
})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatSmartCategoryCaller) Format() string { return "json" }
|
||||
func (*chatSmartCategoryCaller) DryRun() bool { return false }
|
||||
func (*chatSmartCategoryCaller) Fields() string { return "" }
|
||||
func (*chatSmartCategoryCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatCategoryCreateSmartUsesMCPParameterNames(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{
|
||||
"category", "create-smart",
|
||||
"--name", "priority",
|
||||
"--keywords", "alpha, ,beta,",
|
||||
"--members", "open-id-1, ,open-id-2,",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat category create-smart returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "im" || call.toolName != "create_smart_conv_category" {
|
||||
t.Fatalf("tool call = %s/%s, want im/create_smart_conv_category", call.productID, call.toolName)
|
||||
}
|
||||
want := map[string]any{
|
||||
"categoryName": "priority",
|
||||
"groupNameKeywords": []string{"alpha", "beta"},
|
||||
"memberOpenDingTalkIds": []string{"open-id-1", "open-id-2"},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCategoryCreateSmartRejectsBlankInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
args: []string{"--name", ""},
|
||||
wantErr: "--name must not be blank",
|
||||
},
|
||||
{
|
||||
name: "blank name",
|
||||
args: []string{"--name", " \t "},
|
||||
wantErr: "--name must not be blank",
|
||||
},
|
||||
{
|
||||
name: "empty keywords",
|
||||
args: []string{"--name", "priority", "--keywords", ""},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank keywords CSV",
|
||||
args: []string{"--name", "priority", "--keywords", " , , "},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "empty keywords JSON array",
|
||||
args: []string{"--name", "priority", "--keywords", "[]"},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "empty members",
|
||||
args: []string{"--name", "priority", "--members", ""},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank members CSV",
|
||||
args: []string{"--name", "priority", "--members", " , , "},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank members JSON array",
|
||||
args: []string{"--name", "priority", "--members", `["", " "]`},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"category", "create-smart"}, tt.args...))
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want validation code 3", got)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool call count = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCategoryCreateSmartAllowsOmittedRulesAndTrimsName(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{"category", "create-smart", "--name", " priority "})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat category create-smart returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"categoryName": "priority"}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,50 @@ func connectDaemonDir(dirKey string) (string, error) {
|
||||
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
|
||||
func daemonStatePath(dir string) string { return filepath.Join(dir, "daemon-state.json") }
|
||||
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
|
||||
func daemonExecutablePath(dir string) string {
|
||||
return filepath.Join(dir, "dws-daemon")
|
||||
}
|
||||
|
||||
// stageDaemonExecutable snapshots the current dws binary into the connector's
|
||||
// persistent directory. One-click launchers may execute a temporary download
|
||||
// (for example dws-latest) and remove it after connect --daemon returns; the
|
||||
// supervisor must not depend on that transient path for future worker restarts.
|
||||
func stageDaemonExecutable(src, dir string) (string, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".dws-daemon-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := tmp.Chmod(daemonExecutablePerm); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(tmp, in); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dst := daemonExecutablePath(dir)
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// writeDaemonState atomically persists the daemon state to daemon-state.json
|
||||
// (persistent, survives supervisor exit) so restart/list can recover config.
|
||||
@@ -169,8 +213,9 @@ func backoffDelay(consecutiveFailures int, base, maxDelay time.Duration) time.Du
|
||||
}
|
||||
|
||||
const (
|
||||
daemonBackoffBase = time.Second
|
||||
daemonBackoffCap = 60 * time.Second
|
||||
daemonBackoffBase = time.Second
|
||||
daemonBackoffCap = 60 * time.Second
|
||||
daemonExecutablePerm os.FileMode = 0o700
|
||||
// daemonMaxFastFailures is the consecutive fast-failure ceiling: after this
|
||||
// many crashes that each happened within daemonHealthyAfter, the supervisor
|
||||
// gives up rather than spin forever (e.g. bad credentials).
|
||||
@@ -226,6 +271,10 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve executable: " + err.Error())
|
||||
}
|
||||
exe, err = stageDaemonExecutable(exe, dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("stage daemon executable: " + err.Error())
|
||||
}
|
||||
superviseArgs := buildSuperviseArgs(os.Args[1:])
|
||||
|
||||
logPath := daemonLogPath(dir)
|
||||
@@ -386,6 +435,7 @@ func runSupervisor(cmd *cobra.Command) error {
|
||||
worker.Stdout = out
|
||||
worker.Stderr = out
|
||||
worker.Env = os.Environ()
|
||||
configureWorkerProcessGroup(worker)
|
||||
if err := worker.Start(); err != nil {
|
||||
fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err)
|
||||
failures++
|
||||
@@ -398,6 +448,7 @@ func runSupervisor(cmd *cobra.Command) error {
|
||||
fmt.Fprintf(out, "[daemon] worker started (pid %d)\n", worker.Process.Pid)
|
||||
|
||||
waitErr := superviseWait(ctx, worker)
|
||||
cleanupWorkerProcessGroup(worker.Process.Pid)
|
||||
ran := time.Since(started)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -68,6 +68,36 @@ func TestDaemonDirKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageDaemonExecutable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + "/source"
|
||||
if err := os.WriteFile(src, []byte("test-binary"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst, err := stageDaemonExecutable(src, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("stageDaemonExecutable: %v", err)
|
||||
}
|
||||
if dst != daemonExecutablePath(dir) {
|
||||
t.Fatalf("path = %q, want %q", dst, daemonExecutablePath(dir))
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "test-binary" {
|
||||
t.Fatalf("content = %q, want test-binary", got)
|
||||
}
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != daemonExecutablePerm {
|
||||
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkerArgs(t *testing.T) {
|
||||
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc", "--channel=claudecode"}
|
||||
got := buildWorkerArgs(in)
|
||||
|
||||
@@ -36,3 +36,17 @@ func detachSysProcAttr() *syscall.SysProcAttr {
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = detachSysProcAttr()
|
||||
}
|
||||
|
||||
// configureWorkerProcessGroup isolates the connector worker and every local
|
||||
// agent process it starts (opencode serve, codex app-server, etc.) in one group.
|
||||
func configureWorkerProcessGroup(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
// cleanupWorkerProcessGroup removes descendants left behind when the worker
|
||||
// exits abruptly and cannot run its normal deferred cleanup.
|
||||
func cleanupWorkerProcessGroup(pid int) {
|
||||
if pid > 0 {
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigureWorkerProcessGroup(t *testing.T) {
|
||||
cmd := exec.Command("true")
|
||||
configureWorkerProcessGroup(cmd)
|
||||
if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid {
|
||||
t.Fatal("worker must start in its own process group")
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,7 @@ const daemonDetachSupported = false
|
||||
|
||||
// applyDetach is a no-op on Windows; daemon mode is rejected earlier.
|
||||
func applyDetach(_ *exec.Cmd) {}
|
||||
|
||||
func configureWorkerProcessGroup(_ *exec.Cmd) {}
|
||||
|
||||
func cleanupWorkerProcessGroup(_ int) {}
|
||||
|
||||
@@ -1117,7 +1117,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
mediaCli = newAICardClient(clientID, clientSecret, "")
|
||||
}
|
||||
|
||||
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30000)
|
||||
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30*time.Second)
|
||||
fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive)
|
||||
cli := client.NewStreamClient(
|
||||
client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "Executor userId list (required)",
|
||||
"检查登录状态后重试": "Check login status and retry",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
|
||||
"检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry",
|
||||
"检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry",
|
||||
@@ -117,6 +118,7 @@
|
||||
"完成状态: true/false": "Done status: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later",
|
||||
"等待用户授权...": "Waiting for user authorization...",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.",
|
||||
"网络错误,继续重试...": "Network error, retrying...",
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||
"检查登录状态后重试": "检查登录状态后重试",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
|
||||
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
|
||||
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
|
||||
@@ -117,6 +118,7 @@
|
||||
"完成状态: true/false": "完成状态: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
|
||||
"等待用户授权...": "等待用户授权...",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
|
||||
"网络错误,继续重试...": "网络错误,继续重试...",
|
||||
|
||||
@@ -416,7 +416,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
||||
}
|
||||
callStart := time.Now()
|
||||
|
||||
resp, err := c.doWithRetry(ctx, endpoint, body)
|
||||
resp, err := c.doWithRetry(ctx, endpoint, body, request.Method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -493,15 +493,19 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) {
|
||||
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte, method string) (*http.Response, error) {
|
||||
endpoint = sanitizeJSONRPCEndpoint(endpoint)
|
||||
operation := strings.TrimSpace(method)
|
||||
if operation == "" {
|
||||
operation = "jsonrpc"
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, apperrors.NewDiscovery(
|
||||
"failed to create JSON-RPC request",
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason("request_build_failed"),
|
||||
apperrors.WithHint(i18n.T("请检查服务 endpoint 是否为空或格式不合法。")),
|
||||
apperrors.WithCause(&CallError{
|
||||
@@ -559,37 +563,52 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
|
||||
statusForLog = resp.StatusCode
|
||||
}
|
||||
delay := c.retryDelayForAttempt(attempt, retryAfter)
|
||||
logging.LogRetryAttempt(c.FileLogger, "jsonrpc", c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr)
|
||||
logging.LogRetryAttempt(c.FileLogger, operation, c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr)
|
||||
if err := c.sleepForRetry(ctx, delay); err != nil {
|
||||
return nil, apperrors.NewDiscovery(
|
||||
"request cancelled during retry",
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
opts := []apperrors.Option{
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason("request_cancelled"),
|
||||
apperrors.WithHint(i18n.T("请求在重试过程中被取消;请检查调用侧超时设置。")),
|
||||
apperrors.WithCause(&CallError{
|
||||
Stage: CallStageRequest,
|
||||
Cause: err,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if operation == "tools/call" {
|
||||
opts = append(opts, apperrors.WithActions(networkActions("")...))
|
||||
return nil, apperrors.NewAPI("request cancelled during retry", opts...)
|
||||
}
|
||||
opts = append(opts, apperrors.WithActions(discoveryActions("")...))
|
||||
return nil, apperrors.NewDiscovery("request cancelled during retry", opts...)
|
||||
}
|
||||
}
|
||||
}
|
||||
reason, hint := classifyRequestFailure(lastErr)
|
||||
logging.LogErrorClassified(c.FileLogger, "jsonrpc", c.ExecutionId,
|
||||
string(apperrors.CategoryDiscovery), reason, 0, 0,
|
||||
category := apperrors.CategoryDiscovery
|
||||
actions := discoveryActions("")
|
||||
if operation == "tools/call" {
|
||||
category = apperrors.CategoryAPI
|
||||
actions = networkActions("")
|
||||
}
|
||||
logging.LogErrorClassified(c.FileLogger, operation, c.ExecutionId,
|
||||
string(category), reason, 0, 0,
|
||||
!isTimeoutError(lastErr), "")
|
||||
return nil, apperrors.NewDiscovery(
|
||||
fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr),
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
opts := []apperrors.Option{
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithRetryable(!isTimeoutError(lastErr)),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(discoveryActions("")...),
|
||||
apperrors.WithActions(actions...),
|
||||
apperrors.WithCause(&CallError{
|
||||
Stage: CallStageRequest,
|
||||
Cause: lastErr,
|
||||
}),
|
||||
)
|
||||
}
|
||||
message := fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr)
|
||||
if category == apperrors.CategoryAPI {
|
||||
return nil, apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
return nil, apperrors.NewDiscovery(message, opts...)
|
||||
}
|
||||
|
||||
func sanitizeJSONRPCEndpoint(endpoint string) string {
|
||||
@@ -1039,6 +1058,17 @@ func runtimeActions(snapshotPath string) []string {
|
||||
return actions
|
||||
}
|
||||
|
||||
func networkActions(snapshotPath string) []string {
|
||||
actions := []string{
|
||||
i18n.T("检查网络、代理和 DNS 配置后重试原命令"),
|
||||
i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"),
|
||||
}
|
||||
if snapshotPath != "" {
|
||||
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// validateCallArguments checks string values in tool call arguments for
|
||||
// control characters and dangerous Unicode at the transport boundary.
|
||||
func validateCallArguments(args map[string]any) error {
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) {
|
||||
})})
|
||||
client.FileLogger = logger
|
||||
|
||||
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`))
|
||||
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`), "initialize")
|
||||
if err != nil {
|
||||
t.Fatalf("doWithRetry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
@@ -53,7 +55,7 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) {
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
_, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`))
|
||||
_, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`), "tools/call")
|
||||
if err == nil {
|
||||
t.Fatal("doWithRetry() error = nil, want request failure")
|
||||
}
|
||||
@@ -67,6 +69,125 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolRequestFailureUsesAPIClassification(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp: connection refused")
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
_, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", map[string]any{"keyword": "marker"})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool() error = nil, want request failure")
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryAPI {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI)
|
||||
}
|
||||
if typed.Operation != "tools/call" {
|
||||
t.Fatalf("Operation = %q, want tools/call", typed.Operation)
|
||||
}
|
||||
if typed.Reason != "connection_refused" {
|
||||
t.Fatalf("Reason = %q, want connection_refused", typed.Reason)
|
||||
}
|
||||
actions := strings.Join(typed.Actions, "\n")
|
||||
if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") {
|
||||
t.Fatalf("runtime network failure contains discovery-only actions: %q", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolRetryCancellationUsesAPIClassification(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: http.NoBody}, nil
|
||||
})})
|
||||
client.MaxRetries = 1
|
||||
client.sleep = func(context.Context, time.Duration) error { return context.Canceled }
|
||||
|
||||
_, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", nil)
|
||||
if err == nil {
|
||||
t.Fatal("CallTool() error = nil, want retry cancellation")
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryAPI {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI)
|
||||
}
|
||||
if typed.Operation != "tools/call" {
|
||||
t.Fatalf("Operation = %q, want tools/call", typed.Operation)
|
||||
}
|
||||
if typed.Reason != "request_cancelled" {
|
||||
t.Fatalf("Reason = %q, want request_cancelled", typed.Reason)
|
||||
}
|
||||
actions := strings.Join(typed.Actions, "\n")
|
||||
if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") {
|
||||
t.Fatalf("runtime retry cancellation contains discovery-only actions: %q", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRequestFailuresKeepDiscoveryClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
failure error
|
||||
operation string
|
||||
wantReason string
|
||||
call func(*Client) error
|
||||
}{
|
||||
{
|
||||
name: "initialize connection refused",
|
||||
failure: errors.New("dial tcp: connection refused"),
|
||||
operation: "initialize",
|
||||
wantReason: "connection_refused",
|
||||
call: func(client *Client) error {
|
||||
_, err := client.Initialize(context.Background(), "https://mcp.dingtalk.com/server")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tools list dns failure",
|
||||
failure: errors.New("dial tcp: lookup mcp.dingtalk.com: no such host"),
|
||||
operation: "tools/list",
|
||||
wantReason: "dns_resolution_failed",
|
||||
call: func(client *Client) error {
|
||||
_, err := client.ListTools(context.Background(), "https://mcp.dingtalk.com/server")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, tt.failure
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
err := tt.call(client)
|
||||
if err == nil {
|
||||
t.Fatal("request error = nil, want failure")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("request error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryDiscovery {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryDiscovery)
|
||||
}
|
||||
if typed.Operation != tt.operation {
|
||||
t.Fatalf("Operation = %q, want %q", typed.Operation, tt.operation)
|
||||
}
|
||||
if typed.Reason != tt.wantReason {
|
||||
t.Fatalf("Reason = %q, want %q", typed.Reason, tt.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -50,6 +50,40 @@ gitee_api() {
|
||||
return 1
|
||||
}
|
||||
|
||||
gitee_release_has_assets() {
|
||||
_version="$1"
|
||||
shift
|
||||
_release="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${_version}" 2>/dev/null || true)"
|
||||
[ -n "$_release" ] && [ "$_release" != "null" ] || return 1
|
||||
_compact="$(printf '%s' "$_release" | tr -d '[:space:]')"
|
||||
for _asset in "$@"; do
|
||||
printf '%s' "$_compact" | grep -Fq "\"name\":\"${_asset}\"" || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_gitee_latest_version() {
|
||||
_tags="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" 2>/dev/null || true)"
|
||||
_candidates="$(printf '%s' "$_tags" \
|
||||
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
|
||||
| sed 's/.*"name":[ ]*"//;s/"$//' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -Vr)"
|
||||
_newest="$(printf '%s\n' "$_candidates" | head -1)"
|
||||
[ -n "$_newest" ] || return 1
|
||||
|
||||
for _candidate in $_candidates; do
|
||||
if gitee_release_has_assets "$_candidate" "$@"; then
|
||||
VERSION="$_candidate"
|
||||
if [ "$_candidate" != "$_newest" ]; then
|
||||
printf ' ⚠ Gitee tag %s has no complete release assets; using %s.\n' "$_newest" "$_candidate"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
pick_source() {
|
||||
[ -n "$GITEE_REPO" ] && return 0
|
||||
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
|
||||
@@ -61,13 +95,7 @@ pick_source() {
|
||||
resolve_version() {
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
if [ -n "$GITEE_REPO" ]; then
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable, so
|
||||
# resolve the newest vN.N.N tag from the git tags endpoint instead.
|
||||
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
|
||||
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
|
||||
| sed 's/.*"name":[ ]*"//;s/"$//' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V | tail -1)"
|
||||
resolve_gitee_latest_version "$@" || VERSION=""
|
||||
else
|
||||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||||
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
|
||||
@@ -210,7 +238,7 @@ install_skills_to_root() {
|
||||
main() {
|
||||
need_cmd curl
|
||||
pick_source
|
||||
resolve_version
|
||||
resolve_version dws-skills.zip
|
||||
|
||||
printf '\n'
|
||||
printf ' ┌──────────────────────────────────────┐\n'
|
||||
|
||||
+39
-10
@@ -152,6 +152,24 @@ function Get-GiteeAssetUrl {
|
||||
return ""
|
||||
}
|
||||
|
||||
function Test-GiteeReleaseAssets {
|
||||
param(
|
||||
[string]$Candidate,
|
||||
[string[]]$RequiredAssets
|
||||
)
|
||||
try {
|
||||
$release = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Candidate"
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
if (-not $release -or -not $release.assets) { return $false }
|
||||
$assetNames = @($release.assets | ForEach-Object { $_.name })
|
||||
foreach ($required in $RequiredAssets) {
|
||||
if ($assetNames -notcontains $required) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Resolve-Source {
|
||||
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
|
||||
if ($GiteeRepo -ne "") { return }
|
||||
@@ -167,18 +185,26 @@ function Resolve-Source {
|
||||
}
|
||||
|
||||
function Resolve-LatestVersion {
|
||||
param([string[]]$RequiredAssets = @())
|
||||
if ($Version -eq "latest") {
|
||||
if ($GiteeRepo -ne "") {
|
||||
try {
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable
|
||||
# (404 / empty even when releases exist), so resolve the newest
|
||||
# vN.N.N tag from the git tags endpoint instead.
|
||||
# Tags can be mirrored before release attachments. Select the
|
||||
# newest stable tag with all assets required by this install.
|
||||
$tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
|
||||
$latest = $tags.name |
|
||||
$candidates = @($tags.name |
|
||||
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
|
||||
ForEach-Object { [version]($_.TrimStart('v')) } |
|
||||
Sort-Object | Select-Object -Last 1
|
||||
if ($latest) { $script:Version = "v$latest"; return }
|
||||
Sort-Object { [version]($_.TrimStart('v')) } -Descending)
|
||||
$newest = $candidates | Select-Object -First 1
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-GiteeReleaseAssets $candidate $RequiredAssets) {
|
||||
$script:Version = $candidate
|
||||
if ($candidate -ne $newest) {
|
||||
Write-Say "⚠ Gitee tag $newest has no complete release assets; using $candidate."
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
|
||||
return
|
||||
@@ -349,9 +375,12 @@ function Write-MultiModeNotice {
|
||||
|
||||
function Install-Binary {
|
||||
$arch = Get-Arch
|
||||
Resolve-LatestVersion
|
||||
|
||||
$archiveName = "${BinName}-windows-${arch}.zip"
|
||||
$requiredAssets = @($archiveName, "checksums.txt")
|
||||
if (!$NoSkills -and $SkillMode -ne "multi") {
|
||||
$requiredAssets += "dws-skills.zip"
|
||||
}
|
||||
Resolve-LatestVersion $requiredAssets
|
||||
if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
|
||||
if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
|
||||
|
||||
@@ -545,7 +574,7 @@ function Install-BinaryFromSource {
|
||||
function Install-Skills {
|
||||
Write-Say ""
|
||||
Write-Say "📦 Installing agent skills from GitHub Releases..."
|
||||
Resolve-LatestVersion
|
||||
Resolve-LatestVersion @("dws-skills.zip")
|
||||
|
||||
if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
|
||||
if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
|
||||
|
||||
+44
-13
@@ -105,6 +105,40 @@ gitee_api() {
|
||||
return 1
|
||||
}
|
||||
|
||||
gitee_release_has_assets() {
|
||||
_version="$1"
|
||||
shift
|
||||
_release="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${_version}" 2>/dev/null || true)"
|
||||
[ -n "$_release" ] && [ "$_release" != "null" ] || return 1
|
||||
_compact="$(printf '%s' "$_release" | tr -d '[:space:]')"
|
||||
for _asset in "$@"; do
|
||||
printf '%s' "$_compact" | grep -Fq "\"name\":\"${_asset}\"" || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_gitee_latest_version() {
|
||||
_tags="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" 2>/dev/null || true)"
|
||||
_candidates="$(printf '%s' "$_tags" \
|
||||
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
|
||||
| sed 's/.*"name":[ ]*"//;s/"$//' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -Vr)"
|
||||
_newest="$(printf '%s\n' "$_candidates" | head -1)"
|
||||
[ -n "$_newest" ] || return 1
|
||||
|
||||
for _candidate in $_candidates; do
|
||||
if gitee_release_has_assets "$_candidate" "$@"; then
|
||||
VERSION="$_candidate"
|
||||
if [ "$_candidate" != "$_newest" ]; then
|
||||
say "⚠ Gitee tag ${_newest} has no complete release assets; using ${_candidate}."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the download URL of a release asset by file name.
|
||||
# GitHub: deterministic template <repo>/releases/download/<version>/<file>.
|
||||
# Gitee : query the release API and pick the asset whose name matches
|
||||
@@ -173,19 +207,13 @@ pick_source() {
|
||||
say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: ${GITEE_REPO}"
|
||||
}
|
||||
|
||||
# Resolve the latest version tag from GitHub
|
||||
# Resolve the latest installable release from the selected source.
|
||||
resolve_version() {
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
if [ -n "$GITEE_REPO" ]; then
|
||||
# Gitee's /releases/latest and /releases endpoints are unreliable — they
|
||||
# return 404 / an empty list even when releases exist — so resolve the
|
||||
# newest version from the git tags endpoint instead: keep only vN.N.N
|
||||
# tags and pick the highest by version order.
|
||||
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
|
||||
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
|
||||
| sed 's/.*"name":[ ]*"//;s/"$//' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V | tail -1)"
|
||||
# Tags can be mirrored before release attachments. Select the newest
|
||||
# stable tag whose release contains every asset needed by this install.
|
||||
resolve_gitee_latest_version "$@" || VERSION=""
|
||||
elif need_cmd curl; then
|
||||
# Follow the redirect from /releases/latest to get the tag
|
||||
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
|
||||
@@ -472,9 +500,12 @@ _copy_skill() {
|
||||
install_binary() {
|
||||
os="$(detect_os)"
|
||||
arch="$(detect_arch)"
|
||||
resolve_version
|
||||
|
||||
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
|
||||
if [ "$NO_SKILLS" != "1" ] && [ "$SKILL_MODE" != "multi" ]; then
|
||||
resolve_version "$archive_name" checksums.txt dws-skills.zip
|
||||
else
|
||||
resolve_version "$archive_name" checksums.txt
|
||||
fi
|
||||
download_url="$(asset_url "$archive_name")"
|
||||
[ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
|
||||
|
||||
@@ -555,8 +586,8 @@ install_skills() {
|
||||
say ""
|
||||
say "📦 Installing agent skills from GitHub Releases..."
|
||||
|
||||
resolve_version
|
||||
skills_archive="dws-skills.zip"
|
||||
resolve_version "$skills_archive"
|
||||
download_url="$(asset_url "$skills_archive")"
|
||||
[ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ require_no_match() {
|
||||
fi
|
||||
}
|
||||
|
||||
require_no_tracked_path() {
|
||||
path="$1"
|
||||
if git ls-files -- "$path" | grep -q .; then
|
||||
git ls-files -- "$path" >&2
|
||||
err "found forbidden tracked path: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
require_heading_if_linked() {
|
||||
label="$1"
|
||||
heading="$2"
|
||||
@@ -62,6 +70,10 @@ require_no_match 'scripts/test\.sh|scripts/check-semantic-fixtures\.sh|scripts/r
|
||||
require_no_match 'scripts/check-semantic-fixtures\.py|test/semantic/cli_to_mcp|test/semantic/model_to_cli|python3 -m pytest test/semantic' CONTRIBUTING.md
|
||||
require_no_match '\.github/CODEOWNERS|Tag-triggered GitHub Release asset publishing' CHANGELOG.md
|
||||
|
||||
require_no_tracked_path 'auto-test'
|
||||
require_no_tracked_path 'eval-runs'
|
||||
require_no_tracked_path '.qoder'
|
||||
|
||||
require_heading_if_linked '[Environment Variables](#environment-variables)' '## Environment Variables'
|
||||
require_heading_if_linked '[Exit Codes](#exit-codes)' '## Exit Codes'
|
||||
|
||||
|
||||
@@ -142,3 +142,6 @@ done
|
||||
|
||||
[ "$failed" -eq 0 ] || err "Gitee publish finished with ${failed} failed upload(s)"
|
||||
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped}"
|
||||
VERSION="$VERSION" GITEE_REPO="$GITEE_REPO" GITEE_API="$GITEE_API" \
|
||||
GITEE_TOKEN="$GITEE_TOKEN" DIST_DIR="$DIST_DIR" \
|
||||
./scripts/release/verify-gitee-release.sh
|
||||
|
||||
@@ -208,5 +208,8 @@ if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)."
|
||||
VERSION="$VERSION" GITEE_REPO="$GITEE_REPO" GITEE_API="$GITEE_API" \
|
||||
GITEE_TOKEN="$GITEE_TOKEN" DIST_DIR="$DIST_DIR" \
|
||||
./scripts/release/verify-gitee-release.sh
|
||||
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
|
||||
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
# Verify that a Gitee release exposes every standard DWS release asset.
|
||||
|
||||
set -eu
|
||||
|
||||
VERSION="${VERSION:-}"
|
||||
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_VERIFY_RETRIES="${GITEE_VERIFY_RETRIES:-6}"
|
||||
GITEE_VERIFY_RETRY_DELAY="${GITEE_VERIFY_RETRY_DELAY:-5}"
|
||||
REQUIRED_ASSETS="${GITEE_REQUIRED_ASSETS:-checksums.txt dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz dws-skills.zip dws-windows-amd64.zip dws-windows-arm64.zip}"
|
||||
|
||||
[ -n "$VERSION" ] || {
|
||||
echo "error: VERSION is required" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
for asset in $REQUIRED_ASSETS; do
|
||||
[ -f "$DIST_DIR/$asset" ] || {
|
||||
echo "error: local release asset is missing: $DIST_DIR/$asset" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
endpoint="${GITEE_API}/repos/${GITEE_REPO}/releases/tags/${VERSION}"
|
||||
if [ -n "${GITEE_TOKEN:-}" ]; then
|
||||
endpoint="${endpoint}?access_token=${GITEE_TOKEN}"
|
||||
fi
|
||||
|
||||
attempt=1
|
||||
missing=""
|
||||
while [ "$attempt" -le "$GITEE_VERIFY_RETRIES" ]; do
|
||||
release_json="$(curl -fsSL "$endpoint" 2>/dev/null || true)"
|
||||
compact="$(printf '%s' "$release_json" | tr -d '[:space:]')"
|
||||
missing=""
|
||||
for asset in $REQUIRED_ASSETS; do
|
||||
count="$(printf '%s' "$compact" | grep -oF "\"name\":\"${asset}\"" | wc -l | tr -d ' ')"
|
||||
if [ "$count" -ne 1 ]; then
|
||||
missing="${missing} ${asset}(count=${count})"
|
||||
fi
|
||||
done
|
||||
if [ -n "$release_json" ] && [ "$release_json" != "null" ] && [ -z "$missing" ]; then
|
||||
echo "Gitee release ${VERSION} is complete (${GITEE_REPO})."
|
||||
exit 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le "$GITEE_VERIFY_RETRIES" ] && sleep "$GITEE_VERIFY_RETRY_DELAY"
|
||||
done
|
||||
|
||||
echo "error: Gitee release ${VERSION} is incomplete:${missing:- release not found}" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,88 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyGiteeReleaseRequiresEveryAsset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remote string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "complete",
|
||||
remote: `{"assets":[{"name":"checksums.txt"},{"name":"dws-linux-amd64.tar.gz"},{"name":"dws-skills.zip"}]}`,
|
||||
},
|
||||
{
|
||||
name: "missing remote asset",
|
||||
remote: `{"assets":[{"name":"checksums.txt"},{"name":"dws-linux-amd64.tar.gz"}]}`,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
distDir := filepath.Join(root, "dist")
|
||||
for _, name := range []string{"checksums.txt", "dws-linux-amd64.tar.gz", "dws-skills.zip"} {
|
||||
mustWriteFile(t, filepath.Join(distDir, name), []byte(name), 0o644)
|
||||
}
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "curl"), []byte("#!/bin/sh\nprintf '%s' \"$FAKE_GITEE_RELEASE\"\n"), 0o755)
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-gitee-release.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(verify-gitee-release.sh) error = %v", err)
|
||||
}
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+stubRoot+":"+os.Getenv("PATH"),
|
||||
"VERSION=v1.2.3",
|
||||
"DIST_DIR="+distDir,
|
||||
"GITEE_REQUIRED_ASSETS=checksums.txt dws-linux-amd64.tar.gz dws-skills.zip",
|
||||
"GITEE_VERIFY_RETRIES=1",
|
||||
"FAKE_GITEE_RELEASE="+tt.remote,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("verify-gitee-release.sh error = nil, want failure\noutput:\n%s", string(output))
|
||||
}
|
||||
if !strings.Contains(string(output), "dws-skills.zip(count=0)") {
|
||||
t.Fatalf("missing asset not reported:\n%s", string(output))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("verify-gitee-release.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
if !strings.Contains(string(output), "Gitee release v1.2.3 is complete") {
|
||||
t.Fatalf("success output missing completeness result:\n%s", string(output))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteePublishersRunCompletenessVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, relPath := range []string{
|
||||
filepath.Join("..", "..", "scripts", "release", "sync-to-gitee.sh"),
|
||||
filepath.Join("..", "..", "scripts", "release", "publish-gitee-local.sh"),
|
||||
filepath.Join("..", "..", ".github", "workflows", "verify-gitee-release.yml"),
|
||||
} {
|
||||
data, err := os.ReadFile(relPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", relPath, err)
|
||||
}
|
||||
if !strings.Contains(string(data), "verify-gitee-release.sh") {
|
||||
t.Fatalf("%s does not run verify-gitee-release.sh", relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -191,6 +193,104 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallScriptGiteeLatestUsesNewestCompleteRelease(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX shell installer test is for unix-like hosts")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
installDir := filepath.Join(root, "bin")
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
releaseDir := filepath.Join(root, "release")
|
||||
archiveName := "dws-linux-amd64.tar.gz"
|
||||
archivePath := filepath.Join(releaseDir, archiveName)
|
||||
writeTarGz(t, archivePath, map[string]string{
|
||||
"dws": "#!/bin/sh\nprintf 'Version: v1.0.49\\n'\n",
|
||||
})
|
||||
archiveData, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", archivePath, err)
|
||||
}
|
||||
checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(archiveData), archiveName)
|
||||
mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(checksum), 0o644)
|
||||
|
||||
scriptData, err := os.ReadFile(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(install.sh) error = %v", err)
|
||||
}
|
||||
scriptPath := filepath.Join(root, "standalone", "install.sh")
|
||||
mustWriteFile(t, scriptPath, scriptData, 0o755)
|
||||
writeFakeGiteeCurl(t, filepath.Join(stubRoot, "curl"))
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "uname"), []byte(`#!/bin/sh
|
||||
case "${1:-}" in
|
||||
-s) echo Linux ;;
|
||||
-m) echo x86_64 ;;
|
||||
*) echo Linux ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HOME="+filepath.Join(root, "home"),
|
||||
"PATH="+stubRoot+":"+os.Getenv("PATH"),
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_GITEE_REPO=example/dws",
|
||||
"DWS_VERSION=latest",
|
||||
"DWS_NO_SKILLS=1",
|
||||
"FAKE_RELEASE_DIR="+releaseDir,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
got := string(output)
|
||||
if !strings.Contains(got, "Gitee tag v1.0.51 has no complete release assets; using v1.0.49") {
|
||||
t.Fatalf("install output missing incomplete-release fallback:\n%s", got)
|
||||
}
|
||||
installed := filepath.Join(installDir, "dws")
|
||||
versionOutput, err := exec.Command(installed).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("installed dws error = %v\noutput:\n%s", err, string(versionOutput))
|
||||
}
|
||||
if !strings.Contains(string(versionOutput), "Version: v1.0.49") {
|
||||
t.Fatalf("installed version output = %q, want v1.0.49", string(versionOutput))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallersRequireCompleteGiteeReleaseAssets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checks := []struct {
|
||||
relPath string
|
||||
wants []string
|
||||
}{
|
||||
{
|
||||
relPath: filepath.Join("..", "..", "scripts", "install.sh"),
|
||||
wants: []string{"resolve_gitee_latest_version", `resolve_version "$archive_name" checksums.txt dws-skills.zip`, `resolve_version "$skills_archive"`},
|
||||
},
|
||||
{
|
||||
relPath: filepath.Join("..", "..", "scripts", "install-skills.sh"),
|
||||
wants: []string{"resolve_gitee_latest_version", "resolve_version dws-skills.zip"},
|
||||
},
|
||||
{
|
||||
relPath: filepath.Join("..", "..", "scripts", "install.ps1"),
|
||||
wants: []string{"Test-GiteeReleaseAssets", `$requiredAssets += "dws-skills.zip"`, `Resolve-LatestVersion @("dws-skills.zip")`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range checks {
|
||||
data, err := os.ReadFile(tc.relPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", tc.relPath, err)
|
||||
}
|
||||
for _, want := range tc.wants {
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Fatalf("%s missing %q", tc.relPath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallEventScriptStaticExpectations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -822,6 +922,47 @@ func writeFakeGH(t *testing.T, path, version string) {
|
||||
mustWriteFile(t, path, []byte(script), 0o755)
|
||||
}
|
||||
|
||||
func writeFakeGiteeCurl(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
const script = `#!/bin/sh
|
||||
set -eu
|
||||
out=""
|
||||
url=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) out="$2"; shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
case "$url" in
|
||||
*/tags)
|
||||
printf '%s' '[{"name":"v1.0.49"},{"name":"v1.0.51"},{"name":"v1.0.50"}]'
|
||||
;;
|
||||
*/releases/tags/v1.0.51)
|
||||
printf '%s' 'null'
|
||||
;;
|
||||
*/releases/tags/v1.0.50)
|
||||
printf '%s' '{"tag_name":"v1.0.50","assets":[{"name":"checksums.txt","browser_download_url":"https://assets.invalid/checksums.txt"}]}'
|
||||
;;
|
||||
*/releases/tags/v1.0.49)
|
||||
printf '%s' '{"tag_name":"v1.0.49","assets":[{"name":"dws-linux-amd64.tar.gz","browser_download_url":"https://assets.invalid/dws-linux-amd64.tar.gz"},{"name":"checksums.txt","browser_download_url":"https://assets.invalid/checksums.txt"}]}'
|
||||
;;
|
||||
https://assets.invalid/dws-linux-amd64.tar.gz)
|
||||
cp "$FAKE_RELEASE_DIR/dws-linux-amd64.tar.gz" "$out"
|
||||
;;
|
||||
https://assets.invalid/checksums.txt)
|
||||
cp "$FAKE_RELEASE_DIR/checksums.txt" "$out"
|
||||
;;
|
||||
*)
|
||||
echo "fake curl: unexpected URL $url" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
`
|
||||
mustWriteFile(t, path, []byte(script), 0o755)
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path string, data []byte, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user