Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9401f9921a | ||
|
|
ed951ff0e1 | ||
|
|
b42596214d | ||
|
|
f684c412ea | ||
|
|
a4813f78bc | ||
|
|
415d962371 | ||
|
|
26253a4eeb | ||
|
|
46fdaed90b | ||
|
|
9ea19315e9 | ||
|
|
8549a90402 |
@@ -54,6 +54,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.60-beta.1"
|
||||
version "1.0.60-beta.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
|
||||
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
|
||||
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"mcp_tool_error",
|
||||
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
|
||||
@@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
|
||||
|
||||
@@ -20,18 +20,50 @@ import (
|
||||
)
|
||||
|
||||
type serverFailureClass struct {
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
operation string
|
||||
retryable *bool
|
||||
}
|
||||
|
||||
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
|
||||
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
combined := text + " " + detail
|
||||
|
||||
if code == "999" &&
|
||||
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
|
||||
classified := serverFailureClass{
|
||||
message: message,
|
||||
reason: "upstream_internal_error",
|
||||
origin: "dingtalk_api",
|
||||
stage: "upstream_execution",
|
||||
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
|
||||
actions: []string{
|
||||
"检查目标资源的当前状态,确认本次操作是否已经生效",
|
||||
"状态未确认前不要直接重试写操作",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
},
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
|
||||
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
|
||||
retryable := false
|
||||
classified.operation = "todo/create_personal_todo"
|
||||
classified.retryable = &retryable
|
||||
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
|
||||
classified.actions = []string{
|
||||
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
|
||||
"确认没有创建成功后再重新提交",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
}
|
||||
}
|
||||
return classified, true
|
||||
}
|
||||
|
||||
if code == "NETWORK_ERROR" ||
|
||||
strings.Contains(detail, "statuscode.unavailable") ||
|
||||
@@ -74,6 +106,7 @@ func newServerFailureAPIError(
|
||||
fallbackReason string,
|
||||
fallbackHint string,
|
||||
serverKey string,
|
||||
tool string,
|
||||
diag apperrors.ServerDiagnostics,
|
||||
) error {
|
||||
opts := []apperrors.Option{
|
||||
@@ -84,7 +117,7 @@ func newServerFailureAPIError(
|
||||
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
|
||||
message = classified.message
|
||||
opts = append(opts,
|
||||
apperrors.WithReason(classified.reason),
|
||||
@@ -93,6 +126,12 @@ func newServerFailureAPIError(
|
||||
apperrors.WithHint(classified.hint),
|
||||
apperrors.WithActions(classified.actions...),
|
||||
)
|
||||
if classified.operation != "" {
|
||||
opts = append(opts, apperrors.WithOperation(classified.operation))
|
||||
}
|
||||
if classified.retryable != nil {
|
||||
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
|
||||
}
|
||||
}
|
||||
return apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-local",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"chat",
|
||||
"send_message",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
serverSaysRetryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
"todo",
|
||||
"create_personal_todo",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-todo-create",
|
||||
ServerErrorCode: "999",
|
||||
ServerRetryable: &serverSaysRetryable,
|
||||
},
|
||||
)
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
|
||||
t.Fatalf("hint = %q", typed.Hint)
|
||||
}
|
||||
for _, action := range typed.Actions {
|
||||
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
|
||||
t.Fatalf("misleading action = %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
payload := multiProfileErrorPayload(err)
|
||||
for key, want := range map[string]any{
|
||||
"reason": "upstream_internal_error",
|
||||
"origin": "dingtalk_api",
|
||||
"stage": "upstream_execution",
|
||||
"retryable": false,
|
||||
"trace_id": "trace-todo-create",
|
||||
"server_error_code": "999",
|
||||
} {
|
||||
if got := payload[key]; got != want {
|
||||
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := payload["execution_started"]; ok {
|
||||
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
|
||||
)
|
||||
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
|
||||
@@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-multi",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
|
||||
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.ID,
|
||||
"result": map[string]any{
|
||||
"structuredContent": map[string]any{
|
||||
"success": false,
|
||||
"code": "999",
|
||||
"trace_id": "trace-todo-replay",
|
||||
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := transport.NewClient(server.Client())
|
||||
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
|
||||
runner := &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "local-test-token"},
|
||||
}
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: "todo",
|
||||
Tool: "create_personal_todo",
|
||||
CanonicalPath: "todo.create_personal_todo",
|
||||
Params: map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": "fixture",
|
||||
"executorIds": []string{"user-1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
}
|
||||
|
||||
+64
-18
@@ -96,7 +96,10 @@ func newTodoCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
executorsStr := mustGetFlag(cmd, "executors")
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": flagOrFallback(cmd, "title", "subject", "content"),
|
||||
@@ -111,9 +114,11 @@ func newTodoCommand() *cobra.Command {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
|
||||
n, err := parseTodoPriority(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v
|
||||
@@ -154,7 +159,7 @@ func newTodoCommand() *cobra.Command {
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "due", Property: "PersonalTodoCreateVO.dueTime"},
|
||||
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds"},
|
||||
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
|
||||
{Name: "priority", Property: "PersonalTodoCreateVO.priority"},
|
||||
{Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"},
|
||||
{Name: "title", Property: "PersonalTodoCreateVO.subject"},
|
||||
@@ -190,7 +195,10 @@ func newTodoCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
executorsStr := mustGetFlag(cmd, "executors")
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": flagOrFallback(cmd, "title", "subject", "content"),
|
||||
@@ -206,9 +214,11 @@ func newTodoCommand() *cobra.Command {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
|
||||
n, err := parseTodoPriority(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("recurrence"); v != "" {
|
||||
toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v
|
||||
@@ -249,7 +259,7 @@ func newTodoCommand() *cobra.Command {
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "due", Property: "PersonalTodoCreateVO.dueTime"},
|
||||
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds"},
|
||||
{Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
|
||||
{Name: "parent-id", Property: "PersonalTodoCreateVO.parentId"},
|
||||
{Name: "priority", Property: "PersonalTodoCreateVO.priority"},
|
||||
{Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"},
|
||||
@@ -376,9 +386,11 @@ func newTodoCommand() *cobra.Command {
|
||||
inner["dueTime"] = ms
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
inner["priority"] = n
|
||||
n, err := parseTodoPriority(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inner["priority"] = n
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("done"); v != "" {
|
||||
inner["isDone"] = v == "true"
|
||||
@@ -630,7 +642,10 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
return err
|
||||
}
|
||||
executorsStr := mustGetFlag(cmd, "executors")
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("add_task_executors", map[string]any{
|
||||
"todoExecutorsAddRequest": map[string]any{
|
||||
"taskId": mustGetFlag(cmd, "task-id"),
|
||||
@@ -672,7 +687,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "executors", Property: "todoExecutorsAddRequest.executorIds"},
|
||||
{Name: "executors", Property: "todoExecutorsAddRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
|
||||
{Name: "task-id", Property: "todoExecutorsAddRequest.taskId"},
|
||||
},
|
||||
},
|
||||
@@ -688,7 +703,10 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
return err
|
||||
}
|
||||
executorsStr := mustGetFlag(cmd, "executors")
|
||||
executorIds := parseExecutorIds(executorsStr)
|
||||
executorIds, err := parseRequiredTodoIDs("executors", executorsStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("remove_task_executors", map[string]any{
|
||||
"todoExecutorsRemoveRequest": map[string]any{
|
||||
"taskId": mustGetFlag(cmd, "task-id"),
|
||||
@@ -729,7 +747,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds"},
|
||||
{Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"},
|
||||
{Name: "task-id", Property: "todoExecutorsRemoveRequest.taskId"},
|
||||
},
|
||||
},
|
||||
@@ -1189,14 +1207,14 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
}
|
||||
|
||||
todoTaskCreateCmd.Flags().String("title", "", "待办标题 (必填)")
|
||||
todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
|
||||
todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
|
||||
todoTaskCreateCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")
|
||||
todoTaskCreateCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急")
|
||||
todoTaskCreateCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1")
|
||||
|
||||
todoTaskCreateSubCmd.Flags().String("parent-id", "", "父待办任务 ID (必填)")
|
||||
todoTaskCreateSubCmd.Flags().String("title", "", "子待办标题 (必填)")
|
||||
todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
|
||||
todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
|
||||
todoTaskCreateSubCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)")
|
||||
todoTaskCreateSubCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急")
|
||||
todoTaskCreateSubCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1")
|
||||
@@ -1235,9 +1253,9 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
_ = todoTaskCmd.PersistentFlags().MarkHidden("remind-at")
|
||||
|
||||
todoTaskAddExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
|
||||
todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
|
||||
todoTaskRemoveExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)")
|
||||
todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)")
|
||||
todoTaskAddParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskAddParticipantCmd.Flags().String("participants", "", "参与人 userId 列表 (必填)")
|
||||
todoTaskRemoveParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
@@ -1893,6 +1911,34 @@ func parseExecutorIds(s string) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func parseRequiredTodoIDs(flagName, value string) ([]string, error) {
|
||||
ids := parseExecutorIds(value)
|
||||
if len(ids) > 0 {
|
||||
return ids, nil
|
||||
}
|
||||
return nil, apperrors.NewValidation(
|
||||
fmt.Sprintf("--%s 至少需要一个非空 userId", flagName),
|
||||
apperrors.WithReason("invalid_"+strings.ReplaceAll(flagName, "-", "_")),
|
||||
apperrors.WithHint(fmt.Sprintf("使用 --%s <USER_ID>[,<USER_ID>...] 指定至少一个执行人", flagName)),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
|
||||
func parseTodoPriority(value string) (int, error) {
|
||||
priority, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return priority, nil
|
||||
}
|
||||
return 0, apperrors.NewValidation(
|
||||
"--priority 必须是整数",
|
||||
apperrors.WithReason("invalid_priority"),
|
||||
apperrors.WithHint("常用优先级映射:10=低、20=普通、30=较高、40=紧急"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
|
||||
type todoLocalFileMeta struct {
|
||||
LocalPath string
|
||||
FileName string
|
||||
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func executeTodoEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
@@ -44,9 +47,11 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
|
||||
errorCases := [][]string{
|
||||
{"task", "create", "--title", "x", "--executors", "u", "--remind-at", validDate},
|
||||
{"task", "create", "--title", "x", "--executors", "u", "--due", "bad"},
|
||||
{"task", "create", "--title", "x", "--executors", "u", "--priority", "bad"},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "abc"},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--remind-at", validDate},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--due", "bad"},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"},
|
||||
{"task", "list", "--role-types", "invalid"},
|
||||
{"task", "list", "--plan-finish-date-start", "bad"},
|
||||
{"task", "list", "--plan-finish-date-end", "bad"},
|
||||
@@ -59,9 +64,8 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
|
||||
|
||||
validCases := [][]string{
|
||||
{"task", "create", "--title", "x", "--executors", "u1,u2", "--due", validDate, "--priority", "40", "--recurrence", "daily"},
|
||||
{"task", "create", "--subject", "x", "--executors", "u", "--priority", "bad"},
|
||||
{"task", "create", "--subject", "x", "--executors", "u", "--priority", "20"},
|
||||
{"task", "create-sub", "--content", "x", "--executors", "u", "--parent-id", "1", "--due", validDate, "--priority", "40", "--recurrence", "daily"},
|
||||
{"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"},
|
||||
{"task", "list", "--size", "bad"},
|
||||
{"task", "list", "--status", "true", "--priority", "40,bad", "--role-types", "creator,executor", "--plan-finish-date-start", validDate, "--plan-finish-date-end", validDate},
|
||||
}
|
||||
@@ -75,6 +79,63 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndNonNumericPriorityBeforeCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
args []string
|
||||
}{
|
||||
{name: "create empty executors", reason: "invalid_executors", args: []string{"task", "create", "--title", "x", "--executors", ",,,"}},
|
||||
{name: "create-sub empty executors", reason: "invalid_executors", args: []string{"task", "create-sub", "--parent-id", "1", "--title", "x", "--executors", " , , "}},
|
||||
{name: "add empty executors", reason: "invalid_executors", args: []string{"task", "add-executor", "--task-id", "1", "--executors", ",,,"}},
|
||||
{name: "remove empty executors", reason: "invalid_executors", args: []string{"task", "remove-executor", "--task-id", "1", "--executors", ",,,"}},
|
||||
{name: "create nonnumeric priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "normal"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeTodoEdge(t, caller, test.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != test.reason {
|
||||
t.Fatalf("error = %T %v, want reason %q", err, err, test.reason)
|
||||
}
|
||||
if typed.ExecutionStarted == nil || *typed.ExecutionStarted {
|
||||
t.Fatalf("execution_started = %v, want false", typed.ExecutionStarted)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid input made %d MCP call(s)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoCreateAcceptsNumericPriority(t *testing.T) {
|
||||
for _, priority := range []string{"20", "25"} {
|
||||
t.Run(priority, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := executeTodoEdge(t, caller, "task", "create", "--title", "x", "--executors", "u", "--priority", priority); err != nil {
|
||||
t.Fatalf("create priority %s: %v", priority, err)
|
||||
}
|
||||
if caller.calls != 1 || caller.tool != "create_personal_todo" {
|
||||
t.Fatalf("calls = %d tool = %q", caller.calls, caller.tool)
|
||||
}
|
||||
request, ok := caller.args["PersonalTodoCreateVO"].(map[string]any)
|
||||
if !ok || request["priority"] != mustTodoAtoi(t, priority) {
|
||||
t.Fatalf("request = %#v, want priority %s", caller.args, priority)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustTodoAtoi(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", value, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoSimpleCommandValidationAndSuccessEdges(t *testing.T) {
|
||||
errorCases := [][]string{
|
||||
{"task", "create"},
|
||||
@@ -143,6 +204,7 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
|
||||
errorCases := [][]string{
|
||||
{"task", "update", "--task-id", "1", "--remind-at", validDate},
|
||||
{"task", "update", "--task-id", "1", "--due", "bad"},
|
||||
{"task", "update", "--task-id", "1", "--priority", "bad"},
|
||||
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", "bad"},
|
||||
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[{"baseTime":"customTime","reminderTimeStamp":"bad"}]`},
|
||||
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`},
|
||||
@@ -155,7 +217,6 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
|
||||
}
|
||||
validCases := [][]string{
|
||||
{"task", "update", "--task-id", "1", "--title", "new", "--due", validDate, "--priority", "40", "--done", "true"},
|
||||
{"task", "update", "--task-id", "1", "--priority", "bad"},
|
||||
{"task", "add-reminder", "--task-id", "1", "--base-time", "dueTime", "--due-date-offset", "-10"},
|
||||
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", validDate},
|
||||
}
|
||||
|
||||
@@ -19,17 +19,172 @@ trap cleanup EXIT HUP INT TERM
|
||||
|
||||
git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status"
|
||||
|
||||
normalize_notes() {
|
||||
awk '
|
||||
/^[[:space:]]*$/ && !started { next }
|
||||
{ started = 1; lines[++count] = $0 }
|
||||
END {
|
||||
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
|
||||
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
extract_release_notes() {
|
||||
git -C "$ROOT" show "$1:CHANGELOG.md" |
|
||||
awk -v heading="## [$2] - " '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
'
|
||||
}
|
||||
|
||||
release_heading() {
|
||||
git -C "$ROOT" show "$1:CHANGELOG.md" |
|
||||
awk -v heading="## [$2] - " 'index($0, heading) == 1 { print }'
|
||||
}
|
||||
|
||||
changelog_without_release_notes() {
|
||||
git -C "$ROOT" show "$1:CHANGELOG.md" |
|
||||
awk -v heading="## [$2] - " '
|
||||
index($0, heading) == 1 { print; skipped = 1; next }
|
||||
skipped && /^## / { skipped = 0 }
|
||||
!skipped { print }
|
||||
'
|
||||
}
|
||||
|
||||
# A pre-tag beta amendment must preserve the original seal verbatim except for
|
||||
# appending newly archived fragment bodies to their existing categories. These
|
||||
# helpers canonicalize the renderer's category format before comparing it.
|
||||
canonicalize_fragment_notes() {
|
||||
awk '
|
||||
BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" }
|
||||
function trim(value) {
|
||||
sub(/^[\n]+/, "", value)
|
||||
sub(/[\n]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
function flush() {
|
||||
if (category == "") return
|
||||
value = trim(body)
|
||||
if (value == "" || seen[category]++) invalid = 1
|
||||
blocks[category] = value
|
||||
category = ""
|
||||
body = ""
|
||||
}
|
||||
/^### / {
|
||||
flush()
|
||||
candidate = substr($0, 5)
|
||||
if (candidate !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) {
|
||||
invalid = 1
|
||||
next
|
||||
}
|
||||
category = candidate
|
||||
next
|
||||
}
|
||||
{
|
||||
if (category == "") {
|
||||
if ($0 !~ /^[[:space:]]*$/) invalid = 1
|
||||
next
|
||||
}
|
||||
body = body $0 "\n"
|
||||
}
|
||||
END {
|
||||
flush()
|
||||
if (invalid) exit 1
|
||||
for (i = 1; i <= 6; i++) {
|
||||
category = order[i]
|
||||
if (category in blocks) printf "### %s\n\n%s\n\n", category, blocks[category]
|
||||
}
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
merge_canonical_fragment_notes() {
|
||||
awk '
|
||||
BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" }
|
||||
function trim(value) {
|
||||
sub(/^[\n]+/, "", value)
|
||||
sub(/[\n]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
function flush() {
|
||||
if (category == "") return
|
||||
value = trim(body)
|
||||
if (value == "") invalid = 1
|
||||
if (part == 1) before[category] = value
|
||||
else if (part == 2) addition[category] = value
|
||||
else invalid = 1
|
||||
category = ""
|
||||
body = ""
|
||||
}
|
||||
FNR == 1 {
|
||||
if (started) flush()
|
||||
started = 1
|
||||
part++
|
||||
}
|
||||
/^### / {
|
||||
flush()
|
||||
category = substr($0, 5)
|
||||
if (category !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) invalid = 1
|
||||
next
|
||||
}
|
||||
{ body = body $0 "\n" }
|
||||
END {
|
||||
flush()
|
||||
if (invalid || part != 2) exit 1
|
||||
for (i = 1; i <= 6; i++) {
|
||||
category = order[i]
|
||||
if (!(category in before) && !(category in addition)) continue
|
||||
printf "### %s\n\n", category
|
||||
if (category in before) printf "%s\n\n", before[category]
|
||||
if (category in addition) printf "%s\n\n", addition[category]
|
||||
}
|
||||
}
|
||||
' "$1" "$2"
|
||||
}
|
||||
|
||||
archive_changed=false
|
||||
if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then
|
||||
archive_changed=true
|
||||
fi
|
||||
|
||||
if [ "$archive_changed" = true ]; then
|
||||
release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
|
||||
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2
|
||||
exit 1
|
||||
}
|
||||
new_release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
|
||||
new_release_version_count="$(printf '%s\n' "$new_release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
|
||||
archival_mode=seal
|
||||
if [ "$new_release_version_count" -eq 1 ]; then
|
||||
release_version="$new_release_version"
|
||||
else
|
||||
# An amendment keeps the existing heading. Derive the one allowed target
|
||||
# from the literal archive directory, then verify every move below.
|
||||
release_version="$(awk -F '\t' '$1 == "R100" && NF == 3 && $3 ~ /^\.changes\/released\// { path = $3; sub(/^\.changes\/released\//, "", path); sub(/\/.*/, "", path); print path }' "$tmp_root/status" | sort -u)"
|
||||
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section or one untagged beta amendment target' >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*$' || {
|
||||
printf '%s\n' 'error: only an existing beta CHANGELOG section may receive a pre-tag fragment amendment' >&2
|
||||
exit 1
|
||||
}
|
||||
base_heading="$(release_heading "$merge_base" "$release_version")"
|
||||
head_heading="$(release_heading "$head" "$release_version")"
|
||||
[ "$(printf '%s\n' "$base_heading" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] && [ "$base_heading" = "$head_heading" ] || {
|
||||
printf '%s\n' 'error: beta fragment amendment requires one unchanged pre-existing CHANGELOG heading' >&2
|
||||
exit 1
|
||||
}
|
||||
changelog_without_release_notes "$merge_base" "$release_version" >"$tmp_root/base-changelog-without-amendment"
|
||||
changelog_without_release_notes "$head" "$release_version" >"$tmp_root/head-changelog-without-amendment"
|
||||
if ! cmp -s "$tmp_root/base-changelog-without-amendment" "$tmp_root/head-changelog-without-amendment"; then
|
||||
printf '%s\n' 'error: beta fragment amendment may change only its existing beta CHANGELOG section' >&2
|
||||
exit 1
|
||||
fi
|
||||
if git -C "$ROOT" rev-parse --verify --quiet "refs/tags/v$release_version^{commit}" >/dev/null; then
|
||||
printf 'error: beta fragment amendment is forbidden after tag v%s exists\n' "$release_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
archival_mode=beta-amendment
|
||||
fi
|
||||
# The archive directory is matched as a literal prefix, never as a regex:
|
||||
# interpolating the version into one would make `.` match any character, so
|
||||
# `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break
|
||||
@@ -47,7 +202,7 @@ if [ "$archive_changed" = true ]; then
|
||||
{ invalid = 1 }
|
||||
END { exit !(changelog && moved > 0 && !invalid) }
|
||||
' "$tmp_root/status"; then
|
||||
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal PR' >&2
|
||||
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal or untagged beta-amendment PR' >&2
|
||||
exit 1
|
||||
fi
|
||||
source_changes="$tmp_root/source-changes"
|
||||
@@ -63,17 +218,25 @@ if [ "$archive_changed" = true ]; then
|
||||
esac
|
||||
done
|
||||
"$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes"
|
||||
git -C "$ROOT" show "$head:CHANGELOG.md" |
|
||||
awk -v heading="## [$release_version] - " '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' >"$tmp_root/actual-section"
|
||||
case "$release_version" in
|
||||
*-beta.*)
|
||||
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
|
||||
;;
|
||||
*)
|
||||
if [ "$archival_mode" = beta-amendment ]; then
|
||||
extract_release_notes "$merge_base" "$release_version" >"$tmp_root/base-notes"
|
||||
extract_release_notes "$head" "$release_version" >"$tmp_root/head-notes"
|
||||
if ! canonicalize_fragment_notes "$tmp_root/base-notes" >"$tmp_root/base-notes.canonical" ||
|
||||
! canonicalize_fragment_notes "$tmp_root/expected-notes" >"$tmp_root/amendment-notes.canonical" ||
|
||||
! merge_canonical_fragment_notes "$tmp_root/base-notes.canonical" "$tmp_root/amendment-notes.canonical" >"$tmp_root/expected-notes.merged" ||
|
||||
! canonicalize_fragment_notes "$tmp_root/head-notes" >"$tmp_root/actual-notes.canonical"; then
|
||||
printf '%s\n' 'error: beta fragment amendment requires canonical rendered fragment categories' >&2
|
||||
exit 1
|
||||
fi
|
||||
normalize_notes "$tmp_root/expected-notes.merged" >"$tmp_root/expected-notes.normalized"
|
||||
normalize_notes "$tmp_root/actual-notes.canonical" >"$tmp_root/actual-notes.normalized"
|
||||
else
|
||||
extract_release_notes "$head" "$release_version" >"$tmp_root/actual-section"
|
||||
case "$release_version" in
|
||||
*-beta.*)
|
||||
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
|
||||
;;
|
||||
*)
|
||||
stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")"
|
||||
[ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2
|
||||
@@ -88,22 +251,13 @@ if [ "$archive_changed" = true ]; then
|
||||
found { print }
|
||||
/^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 }
|
||||
' "$tmp_root/actual-section" >"$tmp_root/actual-notes"
|
||||
;;
|
||||
esac
|
||||
normalize_notes() {
|
||||
awk '
|
||||
/^[[:space:]]*$/ && !started { next }
|
||||
{ started = 1; lines[++count] = $0 }
|
||||
END {
|
||||
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
|
||||
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
|
||||
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
|
||||
;;
|
||||
esac
|
||||
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
|
||||
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
|
||||
fi
|
||||
if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then
|
||||
printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2
|
||||
printf '%s\n' 'error: release CHANGELOG section does not exactly match the rendered active release fragments' >&2
|
||||
diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -351,6 +351,79 @@ This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable.
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyAcceptsOnlyUntaggedCanonicalBetaAmendments(t *testing.T) {
|
||||
newAmendmentRepo := func(t *testing.T) (*changelogGateRepo, string) {
|
||||
t.Helper()
|
||||
repo := newChangelogGateRepo(t)
|
||||
sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1")
|
||||
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected initial beta seal: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
changelogGateWrite(t, repo.root, ".changes/1235-sheet.md", "---\ncategory: Added\n---\n\n- Sheet accepts local float image files.\n", 0o644)
|
||||
repo.commit(t, "merge post-seal beta fragment")
|
||||
return repo, strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
}
|
||||
|
||||
stageCanonicalAmendment := func(t *testing.T, repo *changelogGateRepo) {
|
||||
t.Helper()
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- Chat reply mentions.
|
||||
|
||||
- Sheet accepts local float image files.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`, 0o644)
|
||||
archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1")
|
||||
if err := os.Rename(filepath.Join(repo.root, ".changes", "1235-sheet.md"), filepath.Join(archiveDir, "1235-sheet.md")); err != nil {
|
||||
t.Fatalf("Rename beta amendment fragment: %v", err)
|
||||
}
|
||||
repo.commit(t, "amend untagged beta release notes")
|
||||
}
|
||||
|
||||
t.Run("accepts exact pre-tag merge", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
|
||||
if output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected canonical beta amendment: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects tagged beta", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
changelogGateGit(t, repo.root, "tag", "v1.0.1-beta.1")
|
||||
|
||||
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "forbidden after tag v1.0.1-beta.1 exists") {
|
||||
t.Fatalf("tagged beta amendment passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects rewritten sealed prose", func(t *testing.T) {
|
||||
repo, amendmentBase := newAmendmentRepo(t)
|
||||
stageCanonicalAmendment(t, repo)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", strings.Replace(changelogGateSealedRelease, "Chat reply mentions.", "Rewritten sealed note.", 1), 0o644)
|
||||
repo.commit(t, "rewrite sealed beta prose")
|
||||
|
||||
output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD")
|
||||
if err == nil || !strings.Contains(output, "does not exactly match") {
|
||||
t.Fatalf("rewritten beta amendment passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
|
||||
t.Run("invalid active fragment", func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
|
||||
Reference in New Issue
Block a user