Skip to content

feat(doc): add docs +batch-update for multi-operation sequential updates#623

Closed
herbertliu wants to merge 3 commits intomainfrom
feat/docs-batch-update
Closed

feat(doc): add docs +batch-update for multi-operation sequential updates#623
herbertliu wants to merge 3 commits intomainfrom
feat/docs-batch-update

Conversation

@herbertliu
Copy link
Copy Markdown
Collaborator

@herbertliu herbertliu commented Apr 23, 2026

Summary

Addresses Case 10 in the lark-cli pitfall list: Agents applying several related edits to one document currently pay N MCP round-trips and get N separate `{success:true}` responses, with no unified error/recovery story. This PR adds a `docs +batch-update` shortcut that accepts a JSON array of operations and runs them sequentially against a single document.

Changes

  • `shortcuts/doc/docs_batch_update.go` (new): `DocsBatchUpdate` shortcut; `batchUpdateOp` / `batchUpdateResult` types; `parseBatchUpdateOps` / `validateBatchUpdateOp` / `buildBatchUpdateArgs` helpers.
  • `shortcuts/doc/shortcuts.go`: register the new shortcut.
  • `shortcuts/doc/docs_batch_update_test.go` (new): parse (8 cases) + validate (9 cases) + build-args (3 subtests) coverage.

Explicit non-goal: atomicity

There is no server-side transaction for `update-doc`. A mid-batch failure leaves the document in a partial-apply state. The shortcut description states this up front. Callers choose how to handle it:

  • `--on-error=stop` (default): halt at the first failure; the response still reports how many ops landed.
  • `--on-error=continue`: best-effort; every op runs, each entry in `results[]` has its own `success`/`error`.

Pair with `docs +fetch` after a partial run to reconcile state manually.

Shape

```bash
lark-cli docs +batch-update --doc --operations '[
{"mode":"replace_range","markdown":"New intro","selection_by_title":"## Intro"},
{"mode":"insert_before","markdown":"> Note: revised","selection_with_ellipsis":"## Intro"},
{"mode":"delete_range","selection_with_ellipsis":"stale section...end"}
]'
```

Response:

```json
{
"doc": "",
"total": 3,
"applied": 3,
"stopped_early": false,
"results": [
{"index":0, "mode":"replace_range", "success":true, "result":{...}},
...
]
}
```

Validation semantics

Single-op validation runs against every op before any MCP call, so a malformed entry fails the whole invocation up front rather than after N successful ops. This matches what batch-users expect from `kubectl apply`-style tooling.

Test Plan

  • `go test ./shortcuts/doc/...` passes
  • `go vet ./...` clean
  • `gofmt -l` clean
  • Coverage: parse/validate/build 90-100%
  • Manual smoke: run a 3-op batch against a test doc and verify `results[]` shape + partial-failure path

Summary by CodeRabbit

  • New Features

    • Added a batch document update command: accepts a JSON array of operations, supports dry-run (planned per-op sequence), executes ops sequentially, and returns aggregated metadata plus per-op results.
  • Bug Fixes / Behavior

    • Improved validation (JSON shape, UTF‑8 BOM trimming, mutually exclusive selection rules, mode-specific requirements), advisory warnings per op, configurable stop-on-error vs continue behavior, and proper handling of cancellation with partial results.
  • Tests

    • Extensive tests covering parsing, validation, dry-run output, execution scenarios (full success, stop-on-failure, continue-on-error) and cancellation.

@github-actions github-actions Bot added domain/ccm PR touches the ccm domain size/M Single-domain feat or fix with limited business impact labels Apr 23, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

Adds a new docs +batch-update CLI shortcut that accepts a JSON array of operations, validates them (including selection rules and BOM trimming), supports --on-error=stop|continue, provides DryRun planning, and sequentially invokes MCP update-doc per operation while aggregating per-op results and stopped_early state.

Changes

Cohort / File(s) Summary
Batch-update Shortcut Implementation
shortcuts/doc/docs_batch_update.go
Adds DocsBatchUpdate shortcut with flags --doc, --operations, --on-error. Implements validation (JSON array, BOM trimming, per-op mode/markdown/selection rules), DryRun that emits planned MCP calls and count, Execute that sequentially calls MCP /update-doc, emits advisory warnings, normalizes per-op results, aggregates envelope {doc,total,applied,results,stopped_early}, and handles stop-on-error vs continue-on-error and context cancellation.
Batch-update Test Suite
shortcuts/doc/docs_batch_update_test.go
Adds extensive unit and CLI tests: operations parsing (malformed/empty/UTF-8 BOM), per-op validation rules, argument-building rules (omit empty optionals, no empty markdown for delete_range), DryRun output checks, and mocked MCP flows validating full success, stop-on-first-failure, continue-on-error, and context cancellation behavior.
Shortcuts Registry
shortcuts/doc/shortcuts.go
Registers DocsBatchUpdate in Shortcuts() so the new shortcut is exposed by the CLI.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as "DocsBatchUpdate\n(shortcut)"
    participant MCP as "MCP /update-doc"
    participant Store as "Document Store"

    User->>CLI: run docs +batch-update --doc <id> --operations <JSON> --on-error=<mode>
    CLI->>CLI: parse & validate operations array (trim BOM, validate modes/selection)
    alt DryRun
      CLI->>User: emit planned per-op MCP calls and op count
    else Execute
      loop for each operation
        CLI->>MCP: call /update-doc with built args
        MCP->>Store: apply update
        Store-->>MCP: response (success/error + payload)
        MCP-->>CLI: normalized result
        CLI->>User: (optionally) advisory warnings per op
      end
      CLI-->>User: aggregated envelope {doc,total,applied,results,stopped_early}
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

size/L

Suggested reviewers

  • fangshuyu-768

Poem

🐇 I nibble JSON carrots, one by one I hop,
Trim BOM crumbs, validate each crop.
Dry-run dreams then real calls sent,
Results in a basket — ordered, well-meant.
If a stumble halts, I leave a map to the stop.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main change: introduction of a new docs +batch-update shortcut for sequential multi-operation updates.
Description check ✅ Passed The PR description comprehensively covers all required template sections: Summary explains the motivation (Case 10), Changes lists all file modifications with detailed scope, Test Plan checks off completed tests, and explicitly documents the non-goal of atomicity.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/docs-batch-update

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 23, 2026

Codecov Report

❌ Patch coverage is 93.38843% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.65%. Comparing base (2e4cfb4) to head (b39d578).
⚠️ Report is 33 commits behind head on main.

Files with missing lines Patch % Lines
shortcuts/doc/docs_batch_update.go 93.38% 4 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #623      +/-   ##
==========================================
+ Coverage   63.20%   64.65%   +1.45%     
==========================================
  Files         491      517      +26     
  Lines       42237    45871    +3634     
==========================================
+ Hits        26694    29659    +2965     
- Misses      13211    13621     +410     
- Partials     2332     2591     +259     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

🚀 PR Preview Install Guide

🧰 CLI update

npm i -g https://pkg.pr.new/larksuite/cli/@larksuite/cli@b39d5783a32f711f2c76fd5829df582c0dfc00da

🧩 Skill update

npx skills add larksuite/cli#feat/docs-batch-update -y -g

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
shortcuts/doc/docs_batch_update.go (2)

185-201: Prefix check rejects valid JSON arrays with UTF‑8 BOM / leading whitespace inside the payload.

strings.HasPrefix(trimmed, "[") runs after TrimSpace, but if the input has a UTF‑8 BOM (\uFEFF) at the start — common when users --operations @file.json`` with an editor-saved file — TrimSpace does not strip the BOM and the check reports "must be a JSON array" even though `json.Unmarshal` would accept the body. This is a minor UX rough edge given the `File` input mode on line 61.

Either strip a leading BOM before the prefix check, or detect the array-vs-object case by inspecting the first non-space, non-BOM rune. Not a blocker.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/doc/docs_batch_update.go` around lines 185 - 201, The prefix check
in parseBatchUpdateOps wrongly rejects valid JSON when the input begins with a
UTF‑8 BOM; before testing strings.HasPrefix(trimmed, "["), strip any leading BOM
(U+FEFF) or determine the first non-space, non‑BOM rune and use that for the
array-vs-object check so valid JSON files pass; adjust the logic that prepares
trimmed (and the subsequent json.Unmarshal fallback) to remove a leading BOM
character if present so that ops parsing and the existing error paths remain
intact.

104-146: Execute re-parses --operations but skips per-op validation.

Validate runs parseBatchUpdateOps + validateBatchUpdateOp for every op, and Execute re-parses here (line 105) but does not re-run validateBatchUpdateOp. For the normal CLI path this is fine because Validate gates Execute, but:

  • It's a latent footgun for any future caller that invokes Execute without going through Validate (tests, programmatic reuse, or a refactor that rewires the pipeline) — malformed ops would reach MCP unchecked.
  • You already pay the JSON unmarshal cost three times (Validate, DryRun, Execute).

Consider parsing+validating once and stashing the result on the runtime, or at minimum re-running validateBatchUpdateOp in the Execute loop for defense-in-depth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/doc/docs_batch_update.go` around lines 104 - 146, Execute currently
re-parses --operations via parseBatchUpdateOps but skips per-op validation
(validateBatchUpdateOp), risking malformed ops if Execute is called without
Validate and wasting repeated unmarshalling; fix by parsing once and storing the
parsed/validated ops on the runtime context (e.g., attach to runtime with a
stable key) in Validate/DryRun and have Execute read that cached slice, or if
you prefer a minimal change, call validateBatchUpdateOp for each op inside
Execute's loop before using it (referencing Execute, parseBatchUpdateOps,
validateBatchUpdateOp, Validate and DryRun to locate the related code).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@shortcuts/doc/docs_batch_update.go`:
- Around line 185-201: The prefix check in parseBatchUpdateOps wrongly rejects
valid JSON when the input begins with a UTF‑8 BOM; before testing
strings.HasPrefix(trimmed, "["), strip any leading BOM (U+FEFF) or determine the
first non-space, non‑BOM rune and use that for the array-vs-object check so
valid JSON files pass; adjust the logic that prepares trimmed (and the
subsequent json.Unmarshal fallback) to remove a leading BOM character if present
so that ops parsing and the existing error paths remain intact.
- Around line 104-146: Execute currently re-parses --operations via
parseBatchUpdateOps but skips per-op validation (validateBatchUpdateOp), risking
malformed ops if Execute is called without Validate and wasting repeated
unmarshalling; fix by parsing once and storing the parsed/validated ops on the
runtime context (e.g., attach to runtime with a stable key) in Validate/DryRun
and have Execute read that cached slice, or if you prefer a minimal change, call
validateBatchUpdateOp for each op inside Execute's loop before using it
(referencing Execute, parseBatchUpdateOps, validateBatchUpdateOp, Validate and
DryRun to locate the related code).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6d7433de-4443-48b2-8ad6-cba6785fc7a3

📥 Commits

Reviewing files that changed from the base of the PR and between 81d22c6 and 6e89797.

📒 Files selected for processing (3)
  • shortcuts/doc/docs_batch_update.go
  • shortcuts/doc/docs_batch_update_test.go
  • shortcuts/doc/shortcuts.go

herbertliu added a commit that referenced this pull request Apr 23, 2026
…isfy codecov 60% patch gate

Prior unit tests covered parse / validate / build-args only, leaving
DryRun and Execute at 0% which dropped the codecov/patch gate on #623
to 35.5%. Add mount-and-run tests that exercise the remaining paths:

- TestDocsBatchUpdateDryRun — 3-op dry-run: asserts op_count, per-op
  step desc ("[1/3] replace_range" etc.) and overall batch description
- TestDocsBatchUpdateValidateRejectsMalformedOp — validates the
  per-op Validate loop rejects a replace_range op missing its selection
  before any MCP call
- TestDocsBatchUpdateValidateRejectsInvalidOnError — rejects a bogus
  --on-error enum value up front
- TestDocsBatchUpdateExecuteAllSuccess — mocks two /mcp stubs and
  asserts stdout envelope: total=2, applied=2, stopped_early=false,
  per-op success=true
- TestDocsBatchUpdateStopsOnFirstFailure — only one MCP stub for a
  2-op batch, so the second call trips 'no stub' in httpmock; asserts
  applied=1, stopped_early=true, and the partial-result envelope still
  gets emitted before the error returns

Introduces registerUpdateDocMCPStubs(count, payload) helper because
httpmock stubs match exactly once, so batch paths need one stub per
expected call.

Coverage on docs_batch_update.go rises from ~35% to ~76%, above the
codecov/patch 60% threshold.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@shortcuts/doc/docs_batch_update_test.go`:
- Around line 394-441: Add a new test (e.g.,
TestDocsBatchUpdateContinuesOnError) alongside
TestDocsBatchUpdateStopsOnFirstFailure that uses registerUpdateDocMCPStubs and
runBatchUpdateShortcut to exercise the new --on-error=continue behavior: arrange
stubs so one operation fails and a later one succeeds, invoke
runBatchUpdateShortcut with the "--on-error=continue" flag, assert the command
does not abort remaining ops (check err is nil or expected non-fatal behavior),
unmarshal stdout into the same envelope shape used in
TestDocsBatchUpdateStopsOnFirstFailure and assert Data.Total equals number of
ops, Data.Applied equals number of successful ops, Data.StoppedEarly is false,
and additionally inspect the per-operation result entries in the output to
verify the failed op contains an error and the later op shows success; reuse
helpers registerUpdateDocMCPStubs and runBatchUpdateShortcut to set up stubs and
run the shortcut.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 23d82d57-4524-43f3-bd0b-1c3d90190e78

📥 Commits

Reviewing files that changed from the base of the PR and between 6e89797 and 5d23894.

📒 Files selected for processing (1)
  • shortcuts/doc/docs_batch_update_test.go

Comment thread shortcuts/doc/docs_batch_update_test.go
Copy link
Copy Markdown
Collaborator

@fangshuyu-768 fangshuyu-768 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some design questions about partial-failure recovery for sequential batches; nothing blocking, but worth thinking through before this is the recommended path.

1. Two error signals on --on-error=stop. When op N fails, Execute calls runtime.Out() to emit the partial result, then returns callErr. Callers see both a JSON result (with stopped_early=true) and a non-zero exit code. Single-update doesn't have this ambiguity because it fails fast. Worth deciding whether one of the two signals is sufficient.

2. Result envelope lacks a per-op block-id map. The PR says "pair with docs +fetch after a partial run to recover manually", but reconciling a 10-op batch mid-failure without knowing which blocks each op touched is hard. Including affected block ids per op would make recovery tractable.

3. Validation can't catch in-batch staleness. The Validate hook runs upfront, but it can't guard against later ops referencing block-ids that earlier ops invalidated (e.g., op[0] deletes a section that op[1]'s selection-by-title resolves into). For sequential semantics this may be acceptable, but it's worth calling out in the docs alongside the partial-failure note.

Copy link
Copy Markdown
Collaborator

@fangshuyu-768 fangshuyu-768 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three design notes inline; expands on the earlier summary review.

fmt.Fprintf(runtime.IO().ErrOut,
"error: --operations[%d] failed; stopping (--on-error=stop); %d/%d applied before the failure\n",
i, successCount, len(ops))
runtime.Out(map[string]interface{}{
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two error signals on --on-error=stop: runtime.Out(...) emits the partial result with stopped_early=true AND the function returns callErr, so callers see both a JSON success-shaped response and a non-zero exit code. Single-update doesn't have this ambiguity because it fails fast. Worth deciding which signal is canonical — scripts that key on exit code will see the JSON as residual stderr/stdout, and agents that key on JSON shape may not check exit code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented as a deliberate design decision in 3d0302e — added a 'Two design notes' block to the Go doc on DocsBatchUpdate:

On --on-error=stop the shortcut emits TWO failure signals: the stdout JSON envelope (with stopped_early=true and the partial result list), AND a non-zero exit via the returned error. They are complementary: scripts that key on exit code see 'the batch did not complete cleanly'; callers parsing JSON see 'exactly which ops succeeded and how far we got'. Don't treat them as redundant.

Rationale: collapsing to one signal would cost callers in the other camp — exit-code-only loses the per-op detail; JSON-only would force every shell script to JQ-parse to know whether to abort. Keeping both feels right for a CLI surface.

successCount++
}

runtime.Out(map[string]interface{}{
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result envelope (total / applied / stopped_early) doesn't include a per-op block-id map. The PR description says 'pair with docs +fetch after a partial run to recover manually,' but reconciling a 10-op batch mid-failure without knowing which blocks each op touched is hard. Including affected block ids per op (e.g., new applied: [{op_index, block_ids: [...]}, ...]) would make recovery tractable for agents.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged but deferring to a follow-up. The MCP update-doc v1 response doesn't carry block-id information for affected blocks (just {success: true} typically), so producing a per-op block-id map would require an extra fetch-doc per op (or a diff against a pre-batch snapshot) — that's a non-trivial scope.

The cleaner path is to layer this on top of v2 docs +update, where --block-id is already the input/output contract: a v2 batch wrapper can echo the block IDs each op touched without any extra API calls. Since v1 is hidden/deprecated, investing here would be writing throwaway code.

For now the recovery story is what's stated in the description: pair with docs +fetch after a partial run. Will revisit once v2 batch surface is decided.

{Name: "operations", Desc: "JSON array of operations (each entry: mode, markdown, selection_with_ellipsis, selection_by_title, new_title)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "on-error", Default: "stop", Desc: "behavior when a single operation fails: stop | continue"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upfront Validate hook can't catch in-batch staleness: if op[0] deletes a section that op[1]'s selection-by-title resolves into, op[1] passes Validate but fails at MCP time. For sequential semantics this may be by design, but it's worth calling out alongside the partial-failure note in the docstring/skill md so agents don't assume Validate guards against everything pre-execution.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented as deliberate sequential semantics in 3d0302e — same Go doc block on DocsBatchUpdate:

Validate runs once up front against the static op list, before any MCP write. It cannot foresee in-batch staleness — for example, if op[0] deletes a section that op[1]'s --selection-by-title resolves into, op[1] passes Validate but fails at execute time. This is by design (sequential semantics; each op sees the doc state produced by the previous op). If you need stricter atomicity, split the batch or fetch the doc between groups of related ops.

Adding mid-batch re-validation would couple Validate to MCP state and lose the up-front 'fail before any MCP call' guarantee, which is the primary value of the static check. Documenting the limit explicitly seems like the better trade.

Agents editing a document often need to apply several related changes
(rename a heading, update intro paragraph, insert a new section). Doing
this today means N independent 'docs +update' invocations, each with
its own MCP round-trip, partial-validation scope, and inconsistent
reporting. Case 10 in the pitfall list tracks this as the 'no batch
semantics' gap.

Add a docs +batch-update shortcut that accepts a JSON array of update
operations and applies them sequentially against a single document. The
shape of each operation mirrors the flags of docs +update (mode,
markdown, selection_with_ellipsis, selection_by_title, new_title), so
converting an existing workflow is mechanical.

Explicit non-goal: atomicity. There is no server-side transaction for
update-doc, so a mid-batch failure leaves the document in a partial-
apply state. The shortcut is honest about that in its description and
supports --on-error=stop (default) to halt at the first failure plus
--on-error=continue for best-effort runs. The response always reports
per-op success/failure and how many ops landed before any halt, so
callers can pair with docs +fetch to reconcile state.

Validate runs the single-op rule set across every op before any MCP
call, so a malformed entry fails the whole invocation up front rather
than after N successful ops. Dry-run prints the update-doc argument
map for each op.

Coverage: 8-case parse test (object/scalar/empty/malformed rejections
plus single and multi-op success), 9-case validate test (mode /
markdown / selection combinations), and a buildBatchUpdateArgs test
verifying optional fields are omitted when empty.
…isfy codecov 60% patch gate

Prior unit tests covered parse / validate / build-args only, leaving
DryRun and Execute at 0% which dropped the codecov/patch gate on #623
to 35.5%. Add mount-and-run tests that exercise the remaining paths:

- TestDocsBatchUpdateDryRun — 3-op dry-run: asserts op_count, per-op
  step desc ("[1/3] replace_range" etc.) and overall batch description
- TestDocsBatchUpdateValidateRejectsMalformedOp — validates the
  per-op Validate loop rejects a replace_range op missing its selection
  before any MCP call
- TestDocsBatchUpdateValidateRejectsInvalidOnError — rejects a bogus
  --on-error enum value up front
- TestDocsBatchUpdateExecuteAllSuccess — mocks two /mcp stubs and
  asserts stdout envelope: total=2, applied=2, stopped_early=false,
  per-op success=true
- TestDocsBatchUpdateStopsOnFirstFailure — only one MCP stub for a
  2-op batch, so the second call trips 'no stub' in httpmock; asserts
  applied=1, stopped_early=true, and the partial-result envelope still
  gets emitted before the error returns

Introduces registerUpdateDocMCPStubs(count, payload) helper because
httpmock stubs match exactly once, so batch paths need one stub per
expected call.

Coverage on docs_batch_update.go rises from ~35% to ~76%, above the
codecov/patch 60% threshold.
@herbertliu herbertliu force-pushed the feat/docs-batch-update branch from 5d23894 to 3d0302e Compare April 28, 2026 08:56
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
shortcuts/doc/docs_batch_update.go (1)

130-163: Consider checking context cancellation within the loop.

For batches with many operations, the loop doesn't check ctx.Done(), so a cancellation signal (e.g., user interrupt, timeout) won't be honored until after the current MCP call completes and the next iteration begins. While common.CallMCPTool may internally respect context, explicitly checking at loop entry ensures prompt termination.

♻️ Proposed fix to respect context cancellation
 	for i, op := range ops {
+		select {
+		case <-ctx.Done():
+			runtime.Out(map[string]interface{}{
+				"doc":           runtime.Str("doc"),
+				"total":         len(ops),
+				"applied":       successCount,
+				"results":       results,
+				"stopped_early": true,
+			}, nil)
+			return ctx.Err()
+		default:
+		}
+
 		// Re-run the same static warnings the single-op shortcut emits so
 		// batch users get the same advisory signal per-op.
 		for _, w := range docsUpdateWarnings(op.Mode, op.Markdown) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/doc/docs_batch_update.go` around lines 130 - 163, The batch loop
over ops doesn't check for context cancellation; add an early check of the
context's Done channel at the top of the loop (before emitting
docsUpdateWarnings/buildBatchUpdateArgs and calling common.CallMCPTool) and
return the context error if cancelled so the operation can terminate promptly;
use runtime.Context().Done() (or the appropriate ctx in scope) and return
runtime.Context().Err() (or ctx.Err()) when cancelled, ensuring this check is
present before calling common.CallMCPTool and before appending success results
(affecting the logic around normalizeWhiteboardResult, results = append(...
batchUpdateResult ...), successCount, and the stopOnError handling).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@shortcuts/doc/docs_batch_update.go`:
- Around line 130-163: The batch loop over ops doesn't check for context
cancellation; add an early check of the context's Done channel at the top of the
loop (before emitting docsUpdateWarnings/buildBatchUpdateArgs and calling
common.CallMCPTool) and return the context error if cancelled so the operation
can terminate promptly; use runtime.Context().Done() (or the appropriate ctx in
scope) and return runtime.Context().Err() (or ctx.Err()) when cancelled,
ensuring this check is present before calling common.CallMCPTool and before
appending success results (affecting the logic around normalizeWhiteboardResult,
results = append(... batchUpdateResult ...), successCount, and the stopOnError
handling).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b7913659-4685-4cbe-a3e0-780890508c2c

📥 Commits

Reviewing files that changed from the base of the PR and between 5d23894 and 3d0302e.

📒 Files selected for processing (3)
  • shortcuts/doc/docs_batch_update.go
  • shortcuts/doc/docs_batch_update_test.go
  • shortcuts/doc/shortcuts.go
✅ Files skipped from review due to trivial changes (1)
  • shortcuts/doc/shortcuts.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • shortcuts/doc/docs_batch_update_test.go

…el mid-batch

Two CodeRabbit nitpicks from PR #623 review:

1. parseBatchUpdateOps's `[` prefix probe was running on the output of
   strings.TrimSpace, which doesn't treat U+FEFF (UTF-8 BOM) as
   whitespace. Payloads written by editors / shells that prepend a BOM
   (PowerShell redirection, some Windows editors) would fail with a
   confusing "must be a JSON array" error before the JSON ever got
   parsed. Trim the BOM explicitly between two TrimSpace passes so
   bytes around it don't matter.

2. The Execute loop didn't check ctx.Err() between ops, so a parent
   timeout or user interrupt would have to wait for the current MCP
   call to finish AND for the next op to also start before being
   honored — for a 10-op batch with a slow doc, that's a long tail.
   Check at the top of each iteration and emit the partial envelope
   (applied, results, stopped_early=true) before returning ctx.Err(),
   mirroring the --on-error=stop shape so callers don't need a separate
   parse path for cancellation.

Tests: two new BOM cases in TestParseBatchUpdateOps (bare BOM + BOM
sandwiched in whitespace), and TestDocsBatchUpdateRespectsContextCancellation
which pre-cancels the context and verifies (a) ExecuteContext returns
context.Canceled, (b) zero MCP stubs were consumed (no calls escaped
the cancelled-ctx guard), (c) the partial envelope shows applied=0,
stopped_early=true.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
shortcuts/doc/docs_batch_update_test.go (1)

410-453: ⚡ Quick win

Assert the failed op is preserved in results[] on stop-on-error.

This test only checks total, applied, and stopped_early. A regression that stops before appending the failing op to results[] would still pass, even though that per-op entry is part of the batch contract. Please also assert the results length and that the last entry is the failed op with a non-empty error.

As per coding guidelines: "Every behavior change must have an accompanying test."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/doc/docs_batch_update_test.go` around lines 410 - 453, Update
TestDocsBatchUpdateStopsOnFirstFailure to also assert that the per-op results
array preserves the failing operation: extend the envelope struct to include
Data.Results as a slice (e.g., []struct{Op map[string]interface{}
`json:"operation"`; Error string `json:"error"`} or match the actual result
schema), then after the existing envelope checks assert
len(envelope.Data.Results) == 2 and that the last entry
(envelope.Data.Results[1]) has a non-empty error string (or non-nil error field)
to ensure the failing op is recorded; place these assertions immediately after
the stopped_early checks in TestDocsBatchUpdateStopsOnFirstFailure (the test
using runBatchUpdateShortcut).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@shortcuts/doc/docs_batch_update_test.go`:
- Around line 270-345: The mounted-unit tests (TestDocsBatchUpdateDryRun,
TestDocsBatchUpdateValidateRejectsMalformedOp,
TestDocsBatchUpdateValidateRejectsInvalidOnError) only exercise shortcut logic;
add a real CLI end-to-end dry-run test under tests/cli_e2e/dryrun that invokes
the built CLI with the docs +batch-update command (same ops payload used by
TestDocsBatchUpdateDryRun) to verify the process exit code, that stdout is a
JSON envelope containing "op_count":3 and per-step descriptions like "[1/3]
replace_range", and that a malformed op triggers a non-zero exit code and an
error JSON from the Validate stage (mirror assertions from
TestDocsBatchUpdateValidateRejectsMalformedOp/InvalidOnError). Ensure the new
test uses the same request shapes and checks stdout JSON and process exit status
rather than internal runBatchUpdateShortcut calls.

---

Nitpick comments:
In `@shortcuts/doc/docs_batch_update_test.go`:
- Around line 410-453: Update TestDocsBatchUpdateStopsOnFirstFailure to also
assert that the per-op results array preserves the failing operation: extend the
envelope struct to include Data.Results as a slice (e.g., []struct{Op
map[string]interface{} `json:"operation"`; Error string `json:"error"`} or match
the actual result schema), then after the existing envelope checks assert
len(envelope.Data.Results) == 2 and that the last entry
(envelope.Data.Results[1]) has a non-empty error string (or non-nil error field)
to ensure the failing op is recorded; place these assertions immediately after
the stopped_early checks in TestDocsBatchUpdateStopsOnFirstFailure (the test
using runBatchUpdateShortcut).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 656d8805-fe4e-4f4c-a9e3-9bf112530935

📥 Commits

Reviewing files that changed from the base of the PR and between 3d0302e and b39d578.

📒 Files selected for processing (2)
  • shortcuts/doc/docs_batch_update.go
  • shortcuts/doc/docs_batch_update_test.go

Comment on lines +270 to +345
func TestDocsBatchUpdateDryRun(t *testing.T) {
t.Parallel()

f, stdout, _, _ := cmdutil.TestFactory(t, batchUpdateTestConfig())

ops := `[
{"mode":"replace_range","markdown":"A","selection_with_ellipsis":"old...A"},
{"mode":"insert_before","markdown":"B","selection_by_title":"## Intro"},
{"mode":"delete_range","selection_with_ellipsis":"stale...end"}
]`
err := runBatchUpdateShortcut(t, f, stdout, []string{
"+batch-update",
"--doc", "DOC123",
"--operations", ops,
"--dry-run",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "3-op sequential batch") {
t.Errorf("dry-run should describe 3-op batch, got: %s", out)
}
if !strings.Contains(out, `"op_count": 3`) && !strings.Contains(out, `"op_count":3`) {
t.Errorf("dry-run should set op_count=3, got: %s", out)
}
// Each op index appears as [i/N] inside a step desc.
for _, prefix := range []string{"[1/3] replace_range", "[2/3] insert_before", "[3/3] delete_range"} {
if !strings.Contains(out, prefix) {
t.Errorf("dry-run missing step %q; got: %s", prefix, out)
}
}
}

// TestDocsBatchUpdateValidateRejectsMalformedOp ensures a bad op inside
// --operations short-circuits before any MCP call. Covers the per-op
// Validate loop path that the parse/validate unit tests alone don't
// exercise from the CLI entry point.
func TestDocsBatchUpdateValidateRejectsMalformedOp(t *testing.T) {
t.Parallel()

f, _, _, _ := cmdutil.TestFactory(t, batchUpdateTestConfig())
err := runBatchUpdateShortcut(t, f, nil, []string{
"+batch-update",
"--doc", "DOC123",
"--operations", `[{"mode":"replace_range","markdown":"x"}]`, // missing selection
"--dry-run",
"--as", "bot",
})
if err == nil {
t.Fatalf("expected validation error for missing selection, got nil")
}
if !strings.Contains(err.Error(), "requires selection_with_ellipsis or selection_by_title") {
t.Fatalf("unexpected error message: %v", err)
}
}

func TestDocsBatchUpdateValidateRejectsInvalidOnError(t *testing.T) {
t.Parallel()

f, _, _, _ := cmdutil.TestFactory(t, batchUpdateTestConfig())
err := runBatchUpdateShortcut(t, f, nil, []string{
"+batch-update",
"--doc", "DOC123",
"--operations", `[{"mode":"append","markdown":"x"}]`,
"--on-error", "panic-on-everything",
"--dry-run",
"--as", "bot",
})
if err == nil {
t.Fatalf("expected validation error for bad --on-error, got nil")
}
if !strings.Contains(err.Error(), "invalid --on-error") {
t.Fatalf("unexpected error message: %v", err)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a real CLI dry-run E2E for docs +batch-update.

These mounted-command tests cover the shortcut logic, but they do not exercise the runner’s actual dry-run surface: exit code, stdout JSON envelope, and the repo-specific Validate-stage reject behavior. Please add a tests/cli_e2e/... case for this shortcut so the dry-run path is covered through the same CLI layer users hit.

Based on learnings: "Dry-run E2E tests are required for every shortcut change; place in tests/cli_e2e/dryrun/ or corresponding domain directory" and "Validate-stage rejects → non-zero exit code."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/doc/docs_batch_update_test.go` around lines 270 - 345, The
mounted-unit tests (TestDocsBatchUpdateDryRun,
TestDocsBatchUpdateValidateRejectsMalformedOp,
TestDocsBatchUpdateValidateRejectsInvalidOnError) only exercise shortcut logic;
add a real CLI end-to-end dry-run test under tests/cli_e2e/dryrun that invokes
the built CLI with the docs +batch-update command (same ops payload used by
TestDocsBatchUpdateDryRun) to verify the process exit code, that stdout is a
JSON envelope containing "op_count":3 and per-step descriptions like "[1/3]
replace_range", and that a malformed op triggers a non-zero exit code and an
error JSON from the Validate stage (mirror assertions from
TestDocsBatchUpdateValidateRejectsMalformedOp/InvalidOnError). Ensure the new
test uses the same request shapes and checks stdout JSON and process exit status
rather than internal runBatchUpdateShortcut calls.

@fangshuyu-768
Copy link
Copy Markdown
Collaborator

Stepping back from the line-by-line review to raise a structural concern: should +batch-update exist on the v1 surface at all?

Codebase context:

  • versioned_help.go:46 prints [deprecated] docs +update with v1 API is deprecated and will be removed in a future release on every v1 invocation.
  • docs +create, +fetch, and +update all have *_v2.go variants. This PR is the only doc shortcut that lands purely on v1 with no v2 path.

What pushes me to raise this now (rather than as a v2 follow-up) is that the rationale used in the per-op block_id thread above applies to the whole PR by identical logic:

"v1 is hidden/deprecated, investing here would be writing throwaway code."

That argument is correct, and it's self-defeating. Adding one field to v1 was rejected as not worth it, but landing a whole new shortcut on v1 is several times the surface area — tests, docstring, skill teaching, plus the migration cost when v1 is removed. If "one field of v1 investment is throwaway," "one shortcut of v1 investment" is the same conclusion at higher cost. Either both are worth doing or neither is.

The two shapes I think justify shipping:

  1. v2-only. Drop the MCP update-doc call. Each op becomes {command, block_id, content, pattern, src_block_ids, revision_id} against PUT /open-apis/docs_ai/v1/documents/<token>. Side benefit: the per-op block_id map from the earlier thread falls out of the v2 response contract for free, closing the partial-recovery gap with zero extra round-trips.
  2. Dual-track from day one, mirroring docs +update's --api-version v1|v2. v1 path keeps current behavior with the same deprecation warning; v2 path uses the v2 op schema. More work than (1), but matches the existing pattern in this package.

What I'd push back on is the current shape: v1-only with v2 deferred. That commits the team to maintaining a v1-pinned shortcut on a sunset timeline, asks agents to adopt a surface that will need to be rewritten or removed, and — by the same logic used to defer block-id reporting — burns review/test budget on code that's expected to be thrown away.

The one thing that flips this: an internal v1 sunset timeline substantially longer than the rest of the v2 migration. If v1 has guaranteed remaining life that's long enough to amortize this work, v1-only is defensible as a near-term ergonomic win. Without that, the default "deprecated = no new investment" convention should apply.

Happy to be talked out of this if I'm misreading the timeline or the migration path. Otherwise I'd rather we land (1) or (2) than ship v1-only and chase a v2 follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/ccm PR touches the ccm domain size/M Single-domain feat or fix with limited business impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants