Skip to content

Add update_tab_from_markdown tool and fix addDocumentTab response key - #727

Merged
taylorwilsdon merged 26 commits into
taylorwilsdon:mainfrom
juliandickie:fork-extension
Apr 26, 2026
Merged

Add update_tab_from_markdown tool and fix addDocumentTab response key#727
taylorwilsdon merged 26 commits into
taylorwilsdon:mainfrom
juliandickie:fork-extension

Conversation

@juliandickie

@juliandickie juliandickie commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Adds a new MCP tool update_tab_from_markdown that parses CommonMark+GFM markdown and populates a specific tab in a Google Doc via batchUpdate. Fills a directional gap in the existing gdocs module - the fork already converts Docs to markdown (docs_markdown.py) but had no converter for the write direction.

Also fixes two pre-existing bugs discovered during integration testing against real Google Docs:

  1. insert_doc_tab silently returning tab_id=None - the batchUpdate reply for an addDocumentTab request comes back under the key addDocumentTab, not createDocumentTab as the fork assumed. Same bug existed in batch_operation_manager._extract_created_tabs. Both fixed with dual-key lookup (primary addDocumentTab, defensive fallback createDocumentTab).

  2. deleteContentRange off-by-one - when clearing existing tab content, endIndex = last_element.endIndex includes the segment-terminating newline that Google Docs refuses to delete. Fixed to endIndex = tab_end - 1 with skip threshold raised to tab_end > 2 for empty tabs.

New module gdocs/docs_markdown_writer.py (~290 lines) using markdown-it-py (new dependency, permissive MIT license). Every request threads an optional tab_id through to the range/location objects, making this usable for both whole-doc writes and tab-scoped writes.

Type of Change

  • Bug fix - two pre-existing bugs in insert_doc_tab and batch_operation_manager (non-breaking)
  • New feature - update_tab_from_markdown MCP tool (non-breaking)
  • Documentation update - new section in skills/managing-google-workspace/references/docs.md and README tool matrix row

This is NOT a breaking change. All modifications are additive or defensively backwards-compatible.

Testing
Tests added and passing locally, and tested manually.

Test summary:

  • 24 unit tests for the new markdown writer (tests/gdocs/test_docs_markdown_writer.py) including a real-world fixture smoke test using 15 KB of production blog-article markdown
  • 6 regression tests for the addDocumentTab response-key fix (tests/gdocs/test_insert_doc_tab_response.py)
  • 2 integration tests against a real Google Doc (tests/integration/test_update_tab_from_markdown.py, opt-in via -m integration) covering populate-empty-tab and replace-existing-content flows
  • Full fork test suite - 813 passed, 0 failed after all changes
  • Manual acceptance - populated a real-world 15,891-char blog article into a tab via update_tab_from_markdown, visually verified rendering matches the reference "paste-from-markdown" UI output after adding blank-paragraph spacers between top-level blocks

Checklist
Code style - matches the existing @server.tool() @handle_http_errors @require_google_service decorator stack, uses asyncio.to_thread for blocking .execute() calls, follows the existing List[dict] / Optional[str] typing conventions.

Self-review completed.

Comments added in the cursor-arithmetic logic and the tab-id threading in docs_markdown_writer.py.

No new warnings introduced by this change.

"Allow edits from maintainers" is enabled on this PR.

Additional Notes
Supported markdown constructs - H1-H6 headings, paragraphs, inline bold / italic / code / links, ordered and unordered lists, fenced code blocks, blockquotes, horizontal rules.

Not yet supported (documented as such in the tool docstring) - images (would need Drive upload first), tables (falls through to plain text), footnotes, smart chips, equations.

Dependencies added - markdown-it-py>=3.0.0 and linkify-it-py>=2.0.0. Both are MIT-licensed, actively maintained, zero native deps.

Example usage

Create a tab

insert_doc_tab(
document_id="...",
title="Blog Article",
index=0,
)

Populate it from a markdown file

update_tab_from_markdown(
document_id="...",
tab_id="t.abc123",
markdown_text=open("post.md").read(),
replace_existing=True,
)
Blast radius - All changes are additive (new module, new tool, new tests) except the two bug fixes in insert_doc_tab and batch_operation_manager._extract_created_tabs. Those fixes use dual-key lookup so existing callers that happened to receive createDocumentTab-keyed responses (theoretical, never observed in testing) continue to work. Public API surface unchanged.

Motivation - Built to support batch population of client documents with tabs from markdown source files. Full design doc available in the contributor's linked repo if helpful context for review.

Summary by CodeRabbit

  • New Features

    • Added a unified tab management tool to create, rename, delete, and populate document tabs from Markdown.
  • Bug Fixes

    • Improved tab ID extraction to handle both current and legacy API response formats.
  • Documentation

    • Updated docs and README to describe the consolidated tab management interface and Markdown population options.
  • Tests

    • Added extensive unit and integration tests and a sample Markdown fixture for Markdown-to-docs and tab workflows.
  • Chores

    • Added Markdown parsing dependency and a pytest integration marker.

juliandickie and others added 18 commits April 24, 2026 18:17
Add two tests that exercise tab_id through every supported markdown
construct - heading, paragraph, bold span, list, and fenced code - and
verify the tabId field appears on every location and range when tab_id
is provided, and is absent when it is omitted.

All existing helpers (including the inline createParagraphBullets dict
in the list branch and the inline updateParagraphStyle dict in the
blockquote branch) were already threading tab_id correctly; no writer
changes were needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifies against HONOUR-HEALTH-01 A01 doc that:
- OAuth against a user-provided GCP client
- Docs API addDocumentTab creates a tab
- Markdown writer emits tab-targeted batchUpdate requests
- batchUpdate applies the writer's output into the tab
- includeTabsContent doc.get returns the populated tab body

Spike revealed the request field is addDocumentTab not createDocumentTab
(the fork's insert_doc_tab reads the reply from the wrong key - fix in
a follow-up commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Docs batchUpdate response for an addDocumentTab request comes back under
the key "addDocumentTab" matching the request field name, not
"createDocumentTab". Two spots in the fork silently extracted tab_id=None
because of this mismatch:

  - gdocs/docs_tools.py insert_doc_tab (the MCP tool)
  - gdocs/managers/batch_operation_manager.py _extract_created_tabs

Both now check for "addDocumentTab" first and fall back to
"createDocumentTab" in case the API ever surfaces the legacy name.

Discovered via spike testing in commit b374139 against a real Google Doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Google Docs API rejects deleteContentRange ranges that include the
trailing newline terminating a segment body. The previous code passed
endIndex equal to the last element's endIndex, which is one past the
final newline for a fresh tab. The API returns "The range cannot
include the newline character at the end of the segment."

Fix by using tab_end - 1 as endIndex, and raising the skip threshold
from tab_end > 1 to tab_end > 2 so a tab containing only its mandatory
terminating newline is correctly treated as empty.

Discovered by the integration test added in the next commit.
Two pytest-asyncio tests verify end-to-end behaviour of the
update_tab_from_markdown MCP tool:

1. Populate a fresh tab with markdown, assert the result payload and
   that the tab now contains multiple structural elements and a
   substantive character count.
2. Populate a tab, then call update_tab_from_markdown again with
   different markdown and replace_existing=True; assert the content
   length changed, confirming the replace path clears old content.

Both tests create a scratch tab via direct Docs API addDocumentTab
(sidesteps insert_doc_tab string parsing), invoke the unwrapped MCP
tool, and clean up with deleteTab.

Authentication reuses the OAuth token cached by
scripts/spike/spike_tab_operations.py at ~/.workspace-mcp/spike_token.json.
The tests skip cleanly when GOOGLE_CLIENT_SECRET_PATH, USER_GOOGLE_EMAIL,
INTEGRATION_TEST_DOC_ID, or the cached token are not present.

Also adds the 'integration' pytest marker so the non-integration suite
runs unchanged with `pytest -m "not integration"`.
Google Docs renders adjacent paragraphs tightly stacked when written
via the batchUpdate API. Reference "paste from markdown" rendering
shows a visible blank line between paragraphs. To match, emit an
extra blank paragraph after each top-level block (heading, paragraph,
fence, blockquote, list). List items and blockquote-internal paragraphs
stay tight - spacers only emit at the top level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pushes the real blog-article.md (15,891 chars) via update_tab_from_markdown
into a scratch tab on the A01 condition doc. User visually compared the
rendered output against the paste-from-markdown reference tab and
confirmed content identity. The only noted difference (paragraph
spacing) has been fixed in commit e25270c by emitting blank spacer
paragraphs between top-level markdown blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Consolidates three Google Docs tab tools into a single action-based manage_doc_tab tool and adds markdown_to_docs_requests to convert CommonMark into Google Docs batchUpdate requests; updates tests, docs, dependency, and batch-reply handling for legacy keys.

Changes

Cohort / File(s) Summary
Markdown Parser Implementation
gdocs/docs_markdown_writer.py
New module: markdown_to_docs_requests(markdown_text, tab_id=None, start_index=1) parses CommonMark (MarkdownIt) and emits Google Docs batchUpdate request dicts for headings, paragraphs, lists, fenced code, blockquotes, horizontal rules, inline styles (bold/italic/code/links/images) and threads optional tabId into request ranges.
Tab Tool Consolidation
gdocs/docs_tools.py
Replaces separate insert/update/delete tab tools with manage_doc_tab(action=...) MCP tool (actions: create, rename, delete, populate_from_markdown). Implements tab creation/delete/rename and markdown population flow (fetch with includeTabsContent, _find_tab_end_index, optional deleteContentRange, apply parser-generated requests) and returns structured TypedDict responses.
Batch Reply Handling
gdocs/managers/batch_operation_manager.py
Enhances _extract_created_tabs() to detect both addDocumentTab and legacy createDocumentTab keys when extracting created tab metadata from batchUpdate replies.
Tests: Unit & Fixtures
tests/gdocs/test_docs_markdown_writer.py, tests/gdocs/fixtures/sample_blog_article.md
Adds comprehensive unit tests for markdown_to_docs_requests() (empty input, paragraphs, headings H1–H6, inline styles, links/images, lists, fenced code, blockquotes, horizontal rules, tabId propagation) and a rich markdown fixture for smoke tests.
Tests: Regression & Integration
tests/gdocs/test_insert_doc_tab_response.py, tests/integration/test_update_tab_from_markdown.py
Adds regression tests for batch reply tab extraction (including legacy key fallback) and integration tests exercising manage_doc_tab(action="populate_from_markdown") against real Google Docs (auth fixtures, create/populate/delete scratch tab, two-pass replace test).
Docs & Config
README.md, skills/managing-google-workspace/..., core/tool_tiers.yaml, pyproject.toml, tests/test_main_permissions_tier.py
Docs updated to document unified manage_doc_tab; core/tool_tiers.yaml registers manage_doc_tab; pyproject.toml adds markdown-it-py>=3.0.0 and pytest integration marker; README and skill docs merged; tests add env overrides for deterministic import-time config.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant User
    participant Tool as "manage_doc_tab\n(tool wrapper)"
    participant Parser as "markdown_to_docs_requests\n(MarkdownIt)"
    participant DocsAPI as "Google Docs API\n(get & batchUpdate)"
    participant BOM as "BatchOperationManager"

    User->>Tool: call manage_doc_tab(action="populate_from_markdown", markdown_text, tab_id?)
    Tool->>DocsAPI: get(documentId, includeTabsContent=true)
    DocsAPI-->>Tool: document with tabs
    Tool->>Tool: _find_tab_end_index(tab_id)
    alt markdown provided
        Tool->>Parser: markdown_to_docs_requests(markdown_text, tab_id, start_index)
        Parser-->>Tool: list of batchUpdate requests
        Tool->>DocsAPI: batchUpdate(requests)
        DocsAPI-->>BOM: batchUpdate replies
        BOM-->>Tool: extract_created_tabs / normalize replies
        Tool-->>User: return {success, action, tab_id, requests_applied, link}
    else no markdown
        Tool-->>User: return {success, action, tab_id, requests_applied:0}
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I hopped through headings, code, and link,
I stitched the tabs before you could blink.
One tool to manage, one parser to thread —
A rabbit’s nibble, and the Docs are fed! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 49.15% 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 clearly and concisely summarizes the main changes: adding update_tab_from_markdown tool and fixing the addDocumentTab response key issue.
Description check ✅ Passed The PR description provides comprehensive coverage of all required template sections including clear description, type of changes, thorough testing details, and checklist confirmation.
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 unit tests (beta)
  • Create PR with unit tests

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
tests/gdocs/test_docs_markdown_writer.py (2)

236-237: Pass encoding="utf-8" to read_text().

Relying on the platform default encoding can flake on Windows (cp1252) or locales without LANG set. Since the fixture is markdown that almost certainly contains non-ASCII, pin the encoding.

🧹 Suggested diff
-    md = md_path.read_text()
+    md = md_path.read_text(encoding="utf-8")

(Both test functions.)

Also applies to: 251-252

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

In `@tests/gdocs/test_docs_markdown_writer.py` around lines 236 - 237, The test
reads the markdown fixtures with md_path.read_text() without specifying
encoding, which can cause flaky behavior on non-UTF-8 platforms; update both
places where md_path.read_text() is called (the two occurrences in the test
file) to pass encoding="utf-8" so the fixture is read deterministically (i.e.,
replace md_path.read_text() with md_path.read_text(encoding="utf-8")).

107-116: Tighten the fontFamily assertion — the writer only emits Courier New.

_build_text_style in gdocs/docs_markdown_writer.py (Line 138, 267) hardcodes fontFamily: "Courier New". Accepting "Roboto Mono" or "Consolas" as alternatives here means a future silent change of the hardcoded font would go undetected. Tighten to the exact value the code emits.

🧹 Suggested diff
-    assert ts.get("weightedFontFamily", {}).get("fontFamily") in (
-        "Courier New",
-        "Roboto Mono",
-        "Consolas",
-    )
+    assert ts.get("weightedFontFamily", {}).get("fontFamily") == "Courier New"

(Apply in both test_inline_code_emits_monospace_style and test_fenced_code_block_emits_monospace_style.)

Also applies to: 167-183

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

In `@tests/gdocs/test_docs_markdown_writer.py` around lines 107 - 116, The tests
accept multiple monospace fonts but the writer's _build_text_style function
hardcodes fontFamily to "Courier New", so update the assertions in
test_inline_code_emits_monospace_style and
test_fenced_code_block_emits_monospace_style to expect exactly "Courier New"
(use ts.get("weightedFontFamily", {}).get("fontFamily") == "Courier New" or an
assertion that the value equals "Courier New" instead of allowing "Roboto Mono"
or "Consolas").
gdocs/docs_markdown_writer.py (1)

126-147: Fenced code blocks emit one more trailing newline than other blocks.

text = content_with_nl + "\n" (Line 129-130) already bakes in one blank line, then Line 144 appends another spacer "\n". Every other block branch (paragraph, heading, list, blockquote) only emits a single spacer. Net result: fenced code has two trailing blank paragraphs and the existing unit test at tests/gdocs/test_docs_markdown_writer.py line 174 pins this behavior ("def foo():\n return 42\n\n"). If the double gap is intentional, a one-line comment here would help; otherwise drop the text += "\n" on Line 130 so code blocks match the rest.

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

In `@gdocs/docs_markdown_writer.py` around lines 126 - 147, Fenced code blocks
currently append an extra blank line because text is set to content + "\n" and
then a separate spacer "\n" is inserted later; update the fenced-block branch
(where tok.type == "fence", the local variable text and the subsequent
_build_insert_text(cursor[0], "\n", tab_id) spacer) to emit only a single
trailing blank paragraph — remove the inner text += "\n" so text is just content
(or content if it already ends with "\n") and let the later spacer provide the
single blank line, or alternatively remove the final spacer instead if you
prefer keeping the inner newline; adjust tests if the intent is to change the
current behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@gdocs/docs_markdown_writer.py`:
- Around line 1-40: The module docstring claims "CommonMark+GFM" but
markdown_to_docs_requests creates the parser with MarkdownIt("commonmark"),
which does not enable GFM features; either switch the parser to the GFM preset
(replace the MarkdownIt("commonmark") call in markdown_to_docs_requests with the
GFM preset, e.g., MarkdownIt("gfm") or equivalent plugin-enabled factory so
tables/strikethrough/linkify work) or update the top-level module docstring to
state "CommonMark only" so documentation matches behavior.

In `@gdocs/docs_tools.py`:
- Around line 2500-2505: The docstring for the function that accepts tab_id
incorrectly instructs callers to obtain the tab ID from list_doc_tabs (which
doesn't exist); update the help text to point to inspect_doc_structure instead.
Edit the Args section where tab_id is described so it reads something like
"tab_id: Target tab ID (obtain from insert_doc_tab or
inspect_doc_structure)"—make this change in the docstring associated with the
function that mentions markdown_text, replace_existing and tab_id (the
insert_doc_tab-related docstring) so generated help/schema references the
correct helper inspect_doc_structure.
- Around line 2482-2485: The docstring for update_tab_from_markdown incorrectly
references list_doc_tabs; update the documentation to reference
inspect_doc_structure (which is the function that provides tab information) and
adjust any example/description text to mention inspect_doc_structure by name; do
not change the decorator usage on update_tab_from_markdown — leave the bare
`@server.tool`() decorator as-is.

In `@scripts/spike/spike_tab_operations.py`:
- Line 44: Replace the hard-coded TARGET_DOC_ID constant by reading it from an
environment variable (use the same pattern/name as
tests/integration/test_update_tab_from_markdown.py, e.g.
INTEGRATION_TEST_DOC_ID) so the script works for any user; update the
TARGET_DOC_ID reference in spike_tab_operations.py to fetch
os.environ.get("INTEGRATION_TEST_DOC_ID") (optionally with a safe fallback or a
clear error if not set) and ensure any doc-comment is removed or generalized.

In `@tests/integration/test_update_tab_from_markdown.py`:
- Around line 68-81: The docs_service fixture currently only checks
INTEGRATION_TEST_DOC_ID and credentials but not USER_GOOGLE_EMAIL, which later
leads to KeyError when code does os.environ["USER_GOOGLE_EMAIL"]; update the
docs_service fixture to also check os.environ.get("USER_GOOGLE_EMAIL") and call
pytest.skip with a clear message if it's not set so tests that reference
os.environ["USER_GOOGLE_EMAIL"] (e.g., later uses around lines that access that
env) are skipped instead of erroring.

---

Nitpick comments:
In `@gdocs/docs_markdown_writer.py`:
- Around line 126-147: Fenced code blocks currently append an extra blank line
because text is set to content + "\n" and then a separate spacer "\n" is
inserted later; update the fenced-block branch (where tok.type == "fence", the
local variable text and the subsequent _build_insert_text(cursor[0], "\n",
tab_id) spacer) to emit only a single trailing blank paragraph — remove the
inner text += "\n" so text is just content (or content if it already ends with
"\n") and let the later spacer provide the single blank line, or alternatively
remove the final spacer instead if you prefer keeping the inner newline; adjust
tests if the intent is to change the current behavior.

In `@tests/gdocs/test_docs_markdown_writer.py`:
- Around line 236-237: The test reads the markdown fixtures with
md_path.read_text() without specifying encoding, which can cause flaky behavior
on non-UTF-8 platforms; update both places where md_path.read_text() is called
(the two occurrences in the test file) to pass encoding="utf-8" so the fixture
is read deterministically (i.e., replace md_path.read_text() with
md_path.read_text(encoding="utf-8")).
- Around line 107-116: The tests accept multiple monospace fonts but the
writer's _build_text_style function hardcodes fontFamily to "Courier New", so
update the assertions in test_inline_code_emits_monospace_style and
test_fenced_code_block_emits_monospace_style to expect exactly "Courier New"
(use ts.get("weightedFontFamily", {}).get("fontFamily") == "Courier New" or an
assertion that the value equals "Courier New" instead of allowing "Roboto Mono"
or "Consolas").
🪄 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: 6471a35e-627e-41ca-8c73-8b4db2cd127e

📥 Commits

Reviewing files that changed from the base of the PR and between 6851528 and 57cf334.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • README.md
  • gdocs/docs_markdown_writer.py
  • gdocs/docs_tools.py
  • gdocs/managers/batch_operation_manager.py
  • pyproject.toml
  • scripts/spike/acceptance_honour_health_a01.py
  • scripts/spike/spike_tab_operations.py
  • skills/managing-google-workspace/references/docs.md
  • tests/gdocs/fixtures/sample_blog_article.md
  • tests/gdocs/test_docs_markdown_writer.py
  • tests/gdocs/test_insert_doc_tab_response.py
  • tests/integration/__init__.py
  • tests/integration/test_update_tab_from_markdown.py

Comment thread gdocs/docs_markdown_writer.py
Comment thread gdocs/docs_tools.py Outdated
Comment thread gdocs/docs_tools.py Outdated
Comment on lines +38 to +44
TOKEN_CACHE = pathlib.Path.home() / ".workspace-mcp" / "spike_token.json"
A01_DOC_ID = "1UyL1dL6GBztnVGpLQ5H0EQ8Cfh_k6mjiRQ56MCsJnb0"
BLOG_ARTICLE_MD = pathlib.Path(
"/Users/juliandickie/Documents/GitHub/ahpra-writing-research-cc/"
"clients/HONOUR-HEALTH-01/content/A01-low-back-pain/"
"honour-health-A01-low-back-pain-blog-article.md"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make this acceptance spike portable and self-bootstrapping.

BLOG_ARTICLE_MD is pinned to one developer machine, and get_credentials() requires ~/.workspace-mcp/spike_token.json to already exist even though the module docs say GOOGLE_CLIENT_SECRET_PATH is enough. As committed, a fresh checkout cannot reproduce the acceptance run. Please switch the markdown source/doc ID/token cache to repo-relative or CLI/env inputs, and either bootstrap credentials when the cache is missing or document that an existing cache is mandatory. The repo already has tests/gdocs/fixtures/sample_blog_article.md you can reuse here.

Also applies to: 47-52

Comment thread scripts/spike/spike_tab_operations.py Outdated
Comment thread tests/integration/test_update_tab_from_markdown.py
…ewline

- gdocs/docs_markdown_writer.py - module docstring accurately claimed
  "CommonMark+GFM" but the parser is created with MarkdownIt("commonmark")
  with no GFM preset or plugins enabled. Updated the docstring to say
  "CommonMark" and explicitly list GFM features as not enabled.

- gdocs/docs_markdown_writer.py - fenced code blocks emitted one more
  trailing newline than other top-level blocks. The fence branch baked
  in an extra "\n" on top of the universal spacer paragraph, producing
  two blank lines after every code block. Removed the redundant newline;
  spacer now provides the single blank line matching every other block.

- gdocs/docs_tools.py - update_tab_from_markdown docstring referenced a
  non-existent list_doc_tabs helper. Corrected to inspect_doc_structure
  which is the real helper for enumerating tab IDs.

- tests/gdocs/test_docs_markdown_writer.py - fixture read_text() calls
  now pass encoding="utf-8" so the test is deterministic on Windows and
  locales where the default encoding is not UTF-8.

- tests/gdocs/test_docs_markdown_writer.py - monospace font assertions
  tightened from "in (Courier New, Roboto Mono, Consolas)" to an exact
  match against "Courier New" since that is what _build_text_style
  actually emits. A future silent change of the font would now fail
  the test instead of passing silently.

- tests/gdocs/test_docs_markdown_writer.py - updated fenced code block
  test expectation to match the single-newline output from the code fix.

- tests/integration/test_update_tab_from_markdown.py - docs_service
  fixture now skips when USER_GOOGLE_EMAIL is unset instead of letting
  tests raise KeyError deep in the async MCP call. Message directs the
  user to export the env var and re-run.

- scripts/spike/spike_tab_operations.py - TARGET_DOC_ID was hardcoded
  to a Julian-specific condition doc. Changed to read from
  INTEGRATION_TEST_DOC_ID env var (same convention as the integration
  test) with a clear error if unset. Any contributor can now run the
  spike against their own scratch Doc.

Full fork suite passes - 817 tests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (6)
gdocs/docs_tools.py (3)

2519-2548: Consider validating that tab_id exists before issuing the batch.

When replace_existing=True and the tab is missing, _find_tab_end_index returns 0, the delete is silently skipped, and then markdown_to_docs_requests emits insertText requests that target a nonexistent tabId. The Docs API will reject the batch (via @handle_http_errors), but the error surface is opaque — callers get a generic API error rather than a clear "tab not found" message. A lightweight check after the documents().get(...) call (e.g., "was the tab present in doc.get('tabs')?") would make failure mode clearer and avoid the unnecessary network round-trip.

Low-priority — the current behavior is not broken, just not maximally helpful.

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

In `@gdocs/docs_tools.py` around lines 2519 - 2548, When replace_existing is true
we call service.documents().get(..., includeTabsContent=True) and rely on
_find_tab_end_index returning 0 for a missing tab, which lets
markdown_to_docs_requests emit insertText requests that reference a nonexistent
tab_id and yields an opaque API error; after fetching the document check that
the requested tab_id actually exists in the returned doc (e.g. inspect
doc.get('tabs') or similar) and if missing raise/return a clear "tab not found"
error before building or sending the batch (affecting the logic around
service.documents().get, _find_tab_end_index, and markdown_to_docs_requests so
that we short-circuit with a descriptive error when the tab is absent).

2510-2510: Prefer top-level import for markdown_to_docs_requests.

The lazy/function-local import avoids a module-load-time dependency, but gdocs.docs_markdown_writer is already an unconditional runtime dependency of this tool. Moving the import to the top of the file matches the rest of docs_tools.py and surfaces ImportErrors at module load rather than on first invocation.

♻️ Proposed refactor
@@ top-level imports
 from gdocs.docs_markdown import (
     convert_doc_to_markdown,
     format_comments_inline,
     format_comments_appendix,
     parse_drive_comments,
 )
+from gdocs.docs_markdown_writer import markdown_to_docs_requests
@@ update_tab_from_markdown body
-    from gdocs.docs_markdown_writer import markdown_to_docs_requests
-
     logger.info(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gdocs/docs_tools.py` at line 2510, Move the function-local import of
markdown_to_docs_requests into the module-level imports: remove "from
gdocs.docs_markdown_writer import markdown_to_docs_requests" from inside the
function where it's currently imported and add a top-level import alongside the
other imports in gdocs/docs_tools.py so markdown_to_docs_requests is imported at
module load (use the exact symbol name markdown_to_docs_requests and module
gdocs.docs_markdown_writer to locate and update the import).

2485-2561: Change return type from bare dict to dict[str, Any] or a typed model.

The function currently returns dict without type parameters. FastMCP cannot derive a precise JSON-Schema from an unparameterized dict; using dict[str, Any] or a TypedDict/Pydantic model will generate a proper schema that documents the response structure to clients.

This aligns with the guideline: "Ensure new/modified FastMCP tools conform to MCP JSON-Schema generated by FastMCP (@mcp.tool) and validate that tool signatures use only primitive or Pydantic types."

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

In `@gdocs/docs_tools.py` around lines 2485 - 2561, update_tab_from_markdown
currently types its return as plain dict; change the annotation to a
parameterized type (e.g., dict[str, Any]) or define and use a TypedDict/Pydantic
model for the response to allow FastMCP to generate a proper JSON schema. Update
the function signature for update_tab_from_markdown to return dict[str, Any] (or
replace with MyResponseTypedDict/MyResponseModel) and add the necessary
import(s) (typing.Any or TypedDict/Pydantic BaseModel) and ensure the returned
dict shape matches that type. Keep the runtime return values the same but make
the static type reflect keys "success", "requests_applied", and "tab_id".
tests/integration/test_update_tab_from_markdown.py (1)

160-188: Integration coverage is reasonable; consider stronger content assertions.

requests_applied >= 5, elements >= 4, and chars > 50 are good lower-bound smoke checks, but they won't catch silent regressions where the writer emits the wrong content (e.g., bold marker residue, dropped list items, or swapped paragraphs). A cheap upgrade is to fetch the tab text and assert "Integration Test - Round 1" in text or check that each list item string is present. Optional — current assertions are already a strong signal given the opt-in nature of the test.

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

In `@tests/integration/test_update_tab_from_markdown.py` around lines 160 - 188,
The test test_update_tab_from_markdown_populates_empty_tab currently only checks
counts; enhance it to assert specific content so regressions that change text
shape are caught: after calling fn (update_tab_from_markdown) and before
cleanup, fetch the tab's plain text (using the existing _count_tab_content
helper or a small helper to read text from service/document_id/tab_id) and
assert that key strings from SAMPLE_MARKDOWN_FIRST (e.g., the expected heading
"Integration Test - Round 1" and each list item text) appear in the fetched
text; keep the existing numeric asserts but add those containment assertions to
verify actual rendered content.
gdocs/docs_markdown_writer.py (1)

235-324: Inline renderer looks correct; stack tuple shape could be tightened.

Bold/italic/code/link handling with the open/close stack correctly supports nesting (e.g., **bold *italic***) and the reverse-index scan pops the most recent matching opener, which is the right behavior for well-formed markdown. The mixed 2-tuple/3-tuple stack entries work but are a readability nit; a small dataclass or a consistent (style_name, start_local_pos, payload_or_none) shape would be easier to follow. Optional.

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

In `@gdocs/docs_markdown_writer.py` around lines 235 - 324, The
_render_inline_with_styles function uses a mixed 2-tuple/3-tuple "stack" which
makes matching logic harder to read; change stack to a consistent shape (e.g.,
tuples of (style_name: str, start_local: int, payload: Optional[str]) or a small
dataclass InlineStyleEntry) and update all push/pop sites (places that append
for "strong_open"/"em_open", "link_open", and the loop that finds matching
openers for "strong_close"/"em_close" and "link_close") to use the new 3-field
shape and access fields by name or fixed indexes; keep the reverse-index scan
logic and _build_text_style calls unchanged apart from reading start_local and
href/payload from the new unified entry.
tests/gdocs/test_docs_markdown_writer.py (1)

1-295: Comprehensive unit coverage for the writer.

The suite exercises indices, request shapes, preset names, style ranges (excluding trailing spacers), tab_id threading in both presence and absence, and fixture-based smoke coverage with monotonic-index assertions — a good mix of structural and semantic checks. The tight assertions on exact texts == [...] sequences for the spacer/list-item/blockquote cases will catch most regressions in the cursor-advancement logic.

One optional addition: a negative-path test for the link_open branch when href is missing (see the related suggestion in docs_markdown_writer.py), which would lock in the desired fallback behavior.

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

In `@tests/gdocs/test_docs_markdown_writer.py` around lines 1 - 295, Add a
negative-path unit test for markdown_to_docs_requests that covers the link_open
branch when an inline link has no href (e.g. the markdown contains "[text]()",
"[text]" without URL, or malformed link), asserting the function falls back to
not emitting a link style (i.e., no updateTextStyle with a "link" entry) and
that insertText still contains the link text plus proper spacer behavior; locate
the test near other inline-style tests (e.g., next to
test_link_emits_link_style) and reference markdown_to_docs_requests and the
link_open handling in docs_markdown_writer.py so the test validates the desired
fallback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@gdocs/docs_markdown_writer.py`:
- Around line 79-130: The code in the list-handling block (using tok, tokens,
the matching-close scan and the inner k loop in gdocs/docs_markdown_writer.py)
fails to account for nested lists of a different type and thus silently drops
them; update the matching-close scan and the item iteration so any nested
list_open/close of either kind is tracked and nested list items are
flattened/processed: in the depth scan, treat tokens[j].type in
("bullet_list_open","ordered_list_open") as an increment and their corresponding
"_close" types as decrements (rather than only comparing to tok.type), and in
the inner loop that iterates items between i and j (the code that finds
"list_item_open" and locates its inline child before calling
_render_inline_with_styles and appending createParagraphBullets), search for
list_item_open tokens at any nesting depth (or search forward to the next
"inline" token inside a list_item_open) so nested ordered/bullet lists are
either flattened into the outer list or correctly captured for bullets created
by createParagraphBullets.
- Around line 300-322: When handling "link_open"/"link_close" tokens in
gdocs/docs_markdown_writer.py, guard against a missing href by checking the
extracted href before creating the link style request: in the branch that pops a
("link_open", ...) from stack and calls _build_text_style, skip (do not append
to style_requests) if href is None (or empty), otherwise call _build_text_style
as before; reference the stack entries, local_pos, base_index, tab_id and the
_build_text_style call so you only suppress invalid {"link": {"url": None}}
requests to the Docs API.

---

Nitpick comments:
In `@gdocs/docs_markdown_writer.py`:
- Around line 235-324: The _render_inline_with_styles function uses a mixed
2-tuple/3-tuple "stack" which makes matching logic harder to read; change stack
to a consistent shape (e.g., tuples of (style_name: str, start_local: int,
payload: Optional[str]) or a small dataclass InlineStyleEntry) and update all
push/pop sites (places that append for "strong_open"/"em_open", "link_open", and
the loop that finds matching openers for "strong_close"/"em_close" and
"link_close") to use the new 3-field shape and access fields by name or fixed
indexes; keep the reverse-index scan logic and _build_text_style calls unchanged
apart from reading start_local and href/payload from the new unified entry.

In `@gdocs/docs_tools.py`:
- Around line 2519-2548: When replace_existing is true we call
service.documents().get(..., includeTabsContent=True) and rely on
_find_tab_end_index returning 0 for a missing tab, which lets
markdown_to_docs_requests emit insertText requests that reference a nonexistent
tab_id and yields an opaque API error; after fetching the document check that
the requested tab_id actually exists in the returned doc (e.g. inspect
doc.get('tabs') or similar) and if missing raise/return a clear "tab not found"
error before building or sending the batch (affecting the logic around
service.documents().get, _find_tab_end_index, and markdown_to_docs_requests so
that we short-circuit with a descriptive error when the tab is absent).
- Line 2510: Move the function-local import of markdown_to_docs_requests into
the module-level imports: remove "from gdocs.docs_markdown_writer import
markdown_to_docs_requests" from inside the function where it's currently
imported and add a top-level import alongside the other imports in
gdocs/docs_tools.py so markdown_to_docs_requests is imported at module load (use
the exact symbol name markdown_to_docs_requests and module
gdocs.docs_markdown_writer to locate and update the import).
- Around line 2485-2561: update_tab_from_markdown currently types its return as
plain dict; change the annotation to a parameterized type (e.g., dict[str, Any])
or define and use a TypedDict/Pydantic model for the response to allow FastMCP
to generate a proper JSON schema. Update the function signature for
update_tab_from_markdown to return dict[str, Any] (or replace with
MyResponseTypedDict/MyResponseModel) and add the necessary import(s) (typing.Any
or TypedDict/Pydantic BaseModel) and ensure the returned dict shape matches that
type. Keep the runtime return values the same but make the static type reflect
keys "success", "requests_applied", and "tab_id".

In `@tests/gdocs/test_docs_markdown_writer.py`:
- Around line 1-295: Add a negative-path unit test for markdown_to_docs_requests
that covers the link_open branch when an inline link has no href (e.g. the
markdown contains "[text]()", "[text]" without URL, or malformed link),
asserting the function falls back to not emitting a link style (i.e., no
updateTextStyle with a "link" entry) and that insertText still contains the link
text plus proper spacer behavior; locate the test near other inline-style tests
(e.g., next to test_link_emits_link_style) and reference
markdown_to_docs_requests and the link_open handling in docs_markdown_writer.py
so the test validates the desired fallback behavior.

In `@tests/integration/test_update_tab_from_markdown.py`:
- Around line 160-188: The test
test_update_tab_from_markdown_populates_empty_tab currently only checks counts;
enhance it to assert specific content so regressions that change text shape are
caught: after calling fn (update_tab_from_markdown) and before cleanup, fetch
the tab's plain text (using the existing _count_tab_content helper or a small
helper to read text from service/document_id/tab_id) and assert that key strings
from SAMPLE_MARKDOWN_FIRST (e.g., the expected heading "Integration Test - Round
1" and each list item text) appear in the fetched text; keep the existing
numeric asserts but add those containment assertions to verify actual rendered
content.
🪄 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: ef487bd5-fa65-4915-aaf5-0e6b80c5bcd5

📥 Commits

Reviewing files that changed from the base of the PR and between 57cf334 and 66db431.

📒 Files selected for processing (5)
  • gdocs/docs_markdown_writer.py
  • gdocs/docs_tools.py
  • scripts/spike/spike_tab_operations.py
  • tests/gdocs/test_docs_markdown_writer.py
  • tests/integration/test_update_tab_from_markdown.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/spike/spike_tab_operations.py

Comment on lines +79 to +130
if tok.type in ("bullet_list_open", "ordered_list_open"):
preset = (
"BULLET_DISC_CIRCLE_SQUARE"
if tok.type == "bullet_list_open"
else "NUMBERED_DECIMAL_ALPHA_ROMAN"
)
list_start = cursor[0]
# Find the matching closing token
close_type = tok.type.replace("_open", "_close")
depth = 1
j = i + 1
while j < len(tokens) and depth > 0:
if tokens[j].type == tok.type:
depth += 1
elif tokens[j].type == close_type:
depth -= 1
if depth == 0:
break
j += 1
# Iterate items between i and j
k = i + 1
while k < j:
item = tokens[k]
if item.type == "list_item_open":
# Inner structure typically - list_item_open, paragraph_open, inline, paragraph_close, list_item_close
# Find the inline token within this list_item
if k + 2 < j and tokens[k + 2].type == "inline":
inline_tok = tokens[k + 2]
text, inline_styles = _render_inline_with_styles(
inline_tok.children or [], cursor[0], tab_id
)
text += "\n"
requests.append(_build_insert_text(cursor[0], text, tab_id))
cursor[0] += len(text)
requests.extend(inline_styles)
k += 1
list_end = cursor[0]
# One createParagraphBullets covering the full list range
rng = {"startIndex": list_start, "endIndex": list_end}
if tab_id:
rng["tabId"] = tab_id
requests.append({
"createParagraphBullets": {
"range": rng,
"bulletPreset": preset,
}
})
# Blank spacer paragraph between top-level blocks for visual spacing
requests.append(_build_insert_text(cursor[0], "\n", tab_id))
cursor[0] += 1
i = j + 1
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Nested lists of different types are silently dropped.

The matching-close scan at lines 90–97 only tracks tokens whose type equals tok.type (e.g., bullet_list_open); a nested ordered_list inside a bullet list increments nothing but still consumes tokens that the inner k loop (lines 99–114) skips because it only processes list_item_open at the current nesting level. As a result, nested lists of a different kind are not rendered into the output at all.

This is consistent with the "unsupported features" note at the top of the module, but it's a silent data loss rather than a syntax error for callers. Consider either (a) documenting this more prominently (e.g., in the tool docstring for update_tab_from_markdown) or (b) falling back to flattening nested-list items into the outer list at a single bullet level. Not a blocker.

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

In `@gdocs/docs_markdown_writer.py` around lines 79 - 130, The code in the
list-handling block (using tok, tokens, the matching-close scan and the inner k
loop in gdocs/docs_markdown_writer.py) fails to account for nested lists of a
different type and thus silently drops them; update the matching-close scan and
the item iteration so any nested list_open/close of either kind is tracked and
nested list items are flattened/processed: in the depth scan, treat
tokens[j].type in ("bullet_list_open","ordered_list_open") as an increment and
their corresponding "_close" types as decrements (rather than only comparing to
tok.type), and in the inner loop that iterates items between i and j (the code
that finds "list_item_open" and locates its inline child before calling
_render_inline_with_styles and appending createParagraphBullets), search for
list_item_open tokens at any nesting depth (or search forward to the next
"inline" token inside a list_item_open) so nested ordered/bullet lists are
either flattened into the outer list or correctly captured for bullets created
by createParagraphBullets.

Comment thread gdocs/docs_markdown_writer.py
@taylorwilsdon taylorwilsdon self-assigned this Apr 25, 2026
@taylorwilsdon taylorwilsdon added the enhancement New feature or request label Apr 25, 2026
@taylorwilsdon

Copy link
Copy Markdown
Owner

Noticed this and several other tab functions were missing from tool tiers so I did a refactor and consolidation as well as cleaning this up.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
skills/managing-google-workspace/references/docs.md (1)

217-217: Use boolean for type-consistency in the parameter table.

Every other boolean row in this file uses the spelling boolean (e.g. include_comments, match_case, bold_headers, detailed); only this row reads bool. Aligns the doc table style.

📝 Suggested change
-| replace_existing | bool | no | `True` | Clear tab body before inserting markdown |
+| replace_existing | boolean | no | `True` | Clear tab body before inserting markdown |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@skills/managing-google-workspace/references/docs.md` at line 217, The table
row for the parameter replace_existing uses the type `bool` but should use
`boolean` to match the rest of the file; update the type cell for the parameter
named replace_existing to `boolean` so it is consistent with other boolean
parameters like include_comments, match_case, bold_headers, and detailed.
gdocs/docs_tools.py (1)

2458-2493: Optional: surface a cleaner error when tab_id doesn't exist on populate.

_find_tab_end_index returns 0 when the target tab isn't found. With replace_existing=True, the tab_end > 2 check then silently skips the clear, and markdown_to_docs_requests(..., tab_id=tab_id) is still emitted against the non-existent tab. The batchUpdate ultimately fails with a generic Google Docs error, which is harder to act on than an upfront UserInputError.

Not a blocker — the operation does fail rather than corrupt data — but a pre-flight check would give the caller a clearer, faster diagnostic.

♻️ Suggested guard
-    if replace_existing:
-        doc = await asyncio.to_thread(
-            service.documents()
-            .get(documentId=document_id, includeTabsContent=True)
-            .execute
-        )
-        tab_end = _find_tab_end_index(doc, tab_id)
+    if replace_existing:
+        doc = await asyncio.to_thread(
+            service.documents()
+            .get(documentId=document_id, includeTabsContent=True)
+            .execute
+        )
+        tab_end = _find_tab_end_index(doc, tab_id)
+        if tab_end == 0:
+            raise UserInputError(
+                f"Tab '{tab_id}' was not found in document {document_id}. "
+                "Use inspect_doc_structure to discover valid tab IDs."
+            )
         # tab_end includes the segment-terminating newline that Google Docs
         # refuses to delete, so we delete up to tab_end - 1. Empty tabs
         # (tab_end <= 2) have nothing to clear.
-        if tab_end and tab_end > 2:
+        if tab_end > 2:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gdocs/docs_tools.py` around lines 2458 - 2493, The code currently proceeds to
build markdown_to_docs_requests even when _find_tab_end_index(document, tab_id)
returns 0 (tab missing), causing a generic downstream Google Docs batchUpdate
error; add a pre-flight check after calling _find_tab_end_index to detect a
missing tab and raise a UserInputError like "'tab_id' not found in document"
when tab_end == 0 (or falsy) before appending any requests or calling
markdown_to_docs_requests, keeping the existing replace_existing logic and using
the same service.documents().get(...).execute call that retrieves the document.
gdocs/docs_markdown_writer.py (1)

1-46: Remove unused linkify-it-py dependency from pyproject.toml (line 29).

The package is declared as a dependency but never imported or used in the codebase. Since MarkdownIt("commonmark") does not enable linkify functionality and no other module references this package, it can be safely removed.

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

In `@gdocs/docs_markdown_writer.py` around lines 1 - 46, The project declares the
dependency "linkify-it-py" but the code (e.g., markdown_to_docs_requests and its
use of MarkdownIt("commonmark")) never imports or uses it, so remove
"linkify-it-py" from the dependency list in pyproject.toml and regenerate the
lockfile (poetry/pip-tools/pipenv/etc.) or run your package manager's install to
update the environment; after that run tests and lint to ensure nothing else
relies on that package.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@gdocs/docs_markdown_writer.py`:
- Around line 1-46: The project declares the dependency "linkify-it-py" but the
code (e.g., markdown_to_docs_requests and its use of MarkdownIt("commonmark"))
never imports or uses it, so remove "linkify-it-py" from the dependency list in
pyproject.toml and regenerate the lockfile (poetry/pip-tools/pipenv/etc.) or run
your package manager's install to update the environment; after that run tests
and lint to ensure nothing else relies on that package.

In `@gdocs/docs_tools.py`:
- Around line 2458-2493: The code currently proceeds to build
markdown_to_docs_requests even when _find_tab_end_index(document, tab_id)
returns 0 (tab missing), causing a generic downstream Google Docs batchUpdate
error; add a pre-flight check after calling _find_tab_end_index to detect a
missing tab and raise a UserInputError like "'tab_id' not found in document"
when tab_end == 0 (or falsy) before appending any requests or calling
markdown_to_docs_requests, keeping the existing replace_existing logic and using
the same service.documents().get(...).execute call that retrieves the document.

In `@skills/managing-google-workspace/references/docs.md`:
- Line 217: The table row for the parameter replace_existing uses the type
`bool` but should use `boolean` to match the rest of the file; update the type
cell for the parameter named replace_existing to `boolean` so it is consistent
with other boolean parameters like include_comments, match_case, bold_headers,
and detailed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 81c46d4a-50af-4042-a9ee-dc0332c4cec8

📥 Commits

Reviewing files that changed from the base of the PR and between 66db431 and 57c16b5.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • README.md
  • core/tool_tiers.yaml
  • gdocs/docs_markdown_writer.py
  • gdocs/docs_tools.py
  • pyproject.toml
  • skills/managing-google-workspace/SKILL.md
  • skills/managing-google-workspace/references/docs.md
  • tests/gdocs/test_docs_markdown_writer.py
  • tests/gdocs/test_insert_doc_tab_response.py
  • tests/integration/test_update_tab_from_markdown.py
  • tests/test_main_permissions_tier.py
✅ Files skipped from review due to trivial changes (3)
  • core/tool_tiers.yaml
  • skills/managing-google-workspace/SKILL.md
  • README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyproject.toml

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 (5)
gdocs/docs_tools.py (4)

2470-2495: Tab existence is only validated when replace_existing=True.

When replace_existing=False, _find_tab_end_index is skipped, so a typo in tab_id is only surfaced by the downstream Docs batchUpdate failure (raised as a generic HttpError/ToolExecutionError) rather than the friendly UserInputError("'<tab_id>' not found in document") users get on the replace_existing=True path. Consider validating tab_id existence unconditionally for populate_from_markdown so behavior is consistent.

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

In `@gdocs/docs_tools.py` around lines 2470 - 2495, In populate_from_markdown,
always validate that tab_id exists by calling _find_tab_end_index(document,
tab_id) unconditionally (before branching on replace_existing) and raise the
same UserInputError("'<tab_id>' not found in document") if it returns falsy;
keep the existing deleteContentRange append (using tab_end > 2 and tab_end - 1)
only when replace_existing is True, but perform the existence check and capture
tab_end first via service.documents().get(...).execute and store it for later
use so the behavior is consistent regardless of replace_existing.

2426-2511: Inconsistent return-shape across actions.

The four action branches return dicts with different keys: create returns {"message", "tab_id"}, delete/rename return {"message"} only, and populate_from_markdown returns {"success", "requests_applied", "tab_id"} with no message and no link. Callers (and the FastMCP-generated schema) get a heterogeneous payload that's awkward to consume and to test.

Consider standardizing on a common shape like {"success": bool, "message": str, "tab_id": Optional[str], ...} so each action returns the same minimum keys.

♻️ Suggested unified shape
-        return {"message": f"{msg} Link: {link}", "tab_id": new_tab_id}
+        return {
+            "success": True,
+            "message": f"{msg} Link: {link}",
+            "tab_id": new_tab_id,
+        }
@@
-        return {
-            "message": f"Deleted tab '{tab_id}' from document {document_id}. Link: {link}"
-        }
+        return {
+            "success": True,
+            "message": f"Deleted tab '{tab_id}' from document {document_id}. Link: {link}",
+            "tab_id": tab_id,
+        }
@@
-        return {
-            "message": f"Renamed tab '{tab_id}' to '{title}' in document {document_id}. Link: {link}"
-        }
+        return {
+            "success": True,
+            "message": f"Renamed tab '{tab_id}' to '{title}' in document {document_id}. Link: {link}",
+            "tab_id": tab_id,
+        }
@@
-    if not all_requests:
-        return {"success": True, "requests_applied": 0, "tab_id": tab_id}
+    if not all_requests:
+        return {
+            "success": True,
+            "message": f"No changes applied to tab '{tab_id}' in document {document_id}. Link: {link}",
+            "tab_id": tab_id,
+            "requests_applied": 0,
+        }
@@
-    return {
-        "success": True,
-        "requests_applied": len(all_requests),
-        "tab_id": tab_id,
-    }
+    return {
+        "success": True,
+        "message": f"Populated tab '{tab_id}' from markdown in document {document_id}. Link: {link}",
+        "tab_id": tab_id,
+        "requests_applied": len(all_requests),
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gdocs/docs_tools.py` around lines 2426 - 2511, The action branches (when
action == "create", "delete", "rename", and "populate_from_markdown") return
inconsistent dict shapes; update each branch so they all return a unified
response schema (e.g., keys: "success": bool, "message": str, "tab_id":
Optional[str], "requests_applied": int, "link": Optional[str]) — ensure the
create/delete/rename branches add "success" and "requests_applied" (0 when
none), include "tab_id" and "link" where available, and make
populate_from_markdown include a human-readable "message" and "link"; modify the
return statements in the create/delete/rename blocks and the final
populate_from_markdown return to conform, referencing the existing variables
action, tab_id, link, all_requests, and markdown_text to populate fields.

2335-2354: Empty/missing-body tab is indistinguishable from a normal tab here.

_find_tab_end_index returns 1 both when the tab exists with no content and when documentTab is absent (e.g., a non-document tab). The populate_from_markdown caller treats 1 as "valid empty tab" and proceeds to apply markdown requests, which will then fail at the API layer with a less helpful error. Consider distinguishing "tab not a document tab" up front:

♻️ Optional guard
     def walk(tabs: list) -> int:
         for tab in tabs:
             tab_props = tab.get("tabProperties", {})
             if tab_props.get("tabId") == target_tab_id:
-                body = tab.get("documentTab", {}).get("body", {})
+                if "documentTab" not in tab:
+                    raise UserInputError(
+                        f"Tab '{target_tab_id}' is not a document tab and cannot be populated."
+                    )
+                body = tab["documentTab"].get("body", {})
                 content = body.get("content", [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gdocs/docs_tools.py` around lines 2335 - 2354, _find_tab_end_index currently
returns 1 when a tab exists but lacks a documentTab (because body defaults to
{}), which makes empty/missing-body tabs indistinguishable; update
_find_tab_end_index to explicitly detect when tab.get("documentTab") is missing
and return 0 (or another falsy sentinel) in that case, while preserving the
existing return of 1 for a real document tab with an empty body, and ensure
callers like populate_from_markdown treat 0 as "not a document tab / invalid for
markdown" and skip applying markdown requests.

2371-2371: Use a Pydantic model or TypedDict for the return type instead of bare dict.

The function returns different dict shapes based on the action parameter:

  • create, populate_from_markdown: include success and/or tab_id
  • delete, rename: include only message

Define a union of Pydantic models (or TypedDicts) to accurately represent these variants. This aligns with the coding guidelines: "Use Pydantic v2 models for strict typing of request/response schemas in FastMCP tools" and ensures FastMCP generates a proper JSON-Schema that documents the actual response structure for LLM clients, instead of a permissive additionalProperties: true schema.

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

In `@gdocs/docs_tools.py` at line 2371, The function currently returns a bare dict
with different shapes depending on the action parameter; replace that loose
return type with a precise union of Pydantic v2 models (or TypedDicts) that
capture each variant: e.g., CreatePopulateResponse with fields success: bool and
optional tab_id: str, and DeleteRenameResponse with field message: str, then
update the function signature to return Union[CreatePopulateResponse,
DeleteRenameResponse] (or the equivalent TypedDict union) and update the code
paths for actions 'create', 'populate_from_markdown', 'delete', and 'rename' to
return instances/dicts matching those models so FastMCP produces strict JSON
schema for responses.
skills/managing-google-workspace/references/docs.md (1)

217-217: Use lowercase true for boolean default for consistency.

Other rows in this and surrounding tables use lowercase true/false (e.g., bold_headers default true, match_case default false). The Python-literal True here is inconsistent with the rest of the doc.

-| replace_existing | boolean | no | `True` | Clear tab body before inserting markdown |
+| replace_existing | boolean | no | true | Clear tab body before inserting markdown |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@skills/managing-google-workspace/references/docs.md` at line 217, Update the
table row for the parameter `replace_existing` so its default value uses
lowercase `true` instead of the Python literal `True`; mirror the casing used by
other rows such as `bold_headers` and `match_case` to keep boolean defaults
consistent across the docs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@skills/managing-google-workspace/references/docs.md`:
- Around line 219-225: The docs currently contradict themselves about image
handling between the "Supported markdown" section (which says "images rendered
as linked alt text") and the "Not yet supported" list ("embedded image
insertion"); update the text to be consistent by choosing one approach: either
remove images from the Supported list or explicitly clarify the distinction
(e.g., state under "Supported markdown" that images are supported only as linked
alt text fallback, and under "Not yet supported" keep "embedded image insertion"
to indicate inline embedding is unsupported). Edit the "Supported markdown"
heading content and the "Not yet supported" bullet so the phrasing matches
exactly (refer to the phrases "images rendered as linked alt text" and "embedded
image insertion") and ensure the PR description's claim ("Not supported: images,
tables, footnotes, smart chips, equations") is reflected or reconciled.

---

Nitpick comments:
In `@gdocs/docs_tools.py`:
- Around line 2470-2495: In populate_from_markdown, always validate that tab_id
exists by calling _find_tab_end_index(document, tab_id) unconditionally (before
branching on replace_existing) and raise the same UserInputError("'<tab_id>' not
found in document") if it returns falsy; keep the existing deleteContentRange
append (using tab_end > 2 and tab_end - 1) only when replace_existing is True,
but perform the existence check and capture tab_end first via
service.documents().get(...).execute and store it for later use so the behavior
is consistent regardless of replace_existing.
- Around line 2426-2511: The action branches (when action == "create", "delete",
"rename", and "populate_from_markdown") return inconsistent dict shapes; update
each branch so they all return a unified response schema (e.g., keys: "success":
bool, "message": str, "tab_id": Optional[str], "requests_applied": int, "link":
Optional[str]) — ensure the create/delete/rename branches add "success" and
"requests_applied" (0 when none), include "tab_id" and "link" where available,
and make populate_from_markdown include a human-readable "message" and "link";
modify the return statements in the create/delete/rename blocks and the final
populate_from_markdown return to conform, referencing the existing variables
action, tab_id, link, all_requests, and markdown_text to populate fields.
- Around line 2335-2354: _find_tab_end_index currently returns 1 when a tab
exists but lacks a documentTab (because body defaults to {}), which makes
empty/missing-body tabs indistinguishable; update _find_tab_end_index to
explicitly detect when tab.get("documentTab") is missing and return 0 (or
another falsy sentinel) in that case, while preserving the existing return of 1
for a real document tab with an empty body, and ensure callers like
populate_from_markdown treat 0 as "not a document tab / invalid for markdown"
and skip applying markdown requests.
- Line 2371: The function currently returns a bare dict with different shapes
depending on the action parameter; replace that loose return type with a precise
union of Pydantic v2 models (or TypedDicts) that capture each variant: e.g.,
CreatePopulateResponse with fields success: bool and optional tab_id: str, and
DeleteRenameResponse with field message: str, then update the function signature
to return Union[CreatePopulateResponse, DeleteRenameResponse] (or the equivalent
TypedDict union) and update the code paths for actions 'create',
'populate_from_markdown', 'delete', and 'rename' to return instances/dicts
matching those models so FastMCP produces strict JSON schema for responses.

In `@skills/managing-google-workspace/references/docs.md`:
- Line 217: Update the table row for the parameter `replace_existing` so its
default value uses lowercase `true` instead of the Python literal `True`; mirror
the casing used by other rows such as `bold_headers` and `match_case` to keep
boolean defaults consistent across the docs.
🪄 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: 4aea0fe0-14cf-4a39-9da9-14fdbae9fd74

📥 Commits

Reviewing files that changed from the base of the PR and between 57c16b5 and f2a6153.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • gdocs/docs_tools.py
  • pyproject.toml
  • skills/managing-google-workspace/references/docs.md
  • tests/gdocs/test_insert_doc_tab_response.py
✅ Files skipped from review due to trivial changes (1)
  • pyproject.toml

Comment thread skills/managing-google-workspace/references/docs.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
skills/managing-google-workspace/references/docs.md (1)

205-205: Capitalize "Markdown" as a proper noun.

Static analysis flagged this; "Markdown" is conventionally capitalized when referring to the format.

📝 Proposed wording
-Create, rename, delete, or populate tabs from markdown. Uses `action` to select the operation.
+Create, rename, delete, or populate tabs from Markdown. Uses `action` to select the operation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@skills/managing-google-workspace/references/docs.md` at line 205, The
sentence "Create, rename, delete, or populate tabs from markdown. Uses `action`
to select the operation." incorrectly lowercases the format name; update the
documentation in skills/managing-google-workspace/references/docs.md to
capitalize Markdown (change "markdown" to "Markdown") so it reads "Create,
rename, delete, or populate tabs from Markdown." and keep the rest of the
sentence unchanged.
gdocs/docs_tools.py (1)

2413-2413: Use Literal for the action parameter to improve generated schema.

The tool currently types action: str and validates membership at runtime against valid_actions. Switching the annotation to Literal["create", "rename", "delete", "populate_from_markdown"] lets FastMCP surface the allowed values directly in the JSON schema, gives clients/IDEs autocomplete, and removes the need for the runtime tuple check. This also matches the discriminator already encoded in CreateDocTabResponse.action, etc.

♻️ Proposed refactor
-    action: str,
+    action: Literal["create", "rename", "delete", "populate_from_markdown"],
     tab_id: Optional[str] = None,
     ...
 ):
     ...
-    valid_actions = ("create", "rename", "delete", "populate_from_markdown")
-    if action not in valid_actions:
-        raise UserInputError(
-            f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
-        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gdocs/docs_tools.py` at line 2413, The parameter annotation for action should
be changed from a generic str to a Literal of allowed values so the generated
schema surfaces valid options and removes the need for the runtime membership
check; update the function/method signature that currently declares action: str
to use typing.Literal["create","rename","delete","populate_from_markdown"], add
the Literal import, and then remove the manual tuple/valid_actions membership
validation in the same function (and any redundant runtime checks) since
CreateDocTabResponse.action already encodes the discriminator.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@gdocs/docs_tools.py`:
- Around line 2540-2541: The current _find_tab_end_index returns 0 for both "not
found" and "found-but-no-documentTab", so the caller sees the misleading
UserInputError when tab_end is falsy; update _find_tab_end_index to disambiguate
by returning a sentinel (e.g., None or -1) when the tab id is not found and
returning 0 only when the tab exists but has no documentTab, or alternatively
raise a specific exception (e.g., TabHasNoDocumentError or ValueError with a
clear message) when a matching tab lacks documentTab; then update callers that
check tab_end (and the code that raises UserInputError) to handle the new
sentinel/exception and surface a precise error mentioning the tab exists but is
not a documentTab (using the function name _find_tab_end_index, variable
tab_end, and UserInputError in your changes).
- Around line 2536-2560: The current logic calls
markdown_to_docs_requests(markdown_text, tab_id=tab_id) with its default
start_index=1 so when replace_existing is False new content is prepended; update
the behavior so non-replacing calls append instead: detect non-empty tab via
tab_end (e.g., tab_end > 2) and pass start_index=tab_end - 1 to
markdown_to_docs_requests(markdown_text, tab_id=tab_id, start_index=tab_end - 1)
so content is appended after existing text, or alternatively raise
UserInputError when replace_existing is False and tab_end indicates a non-empty
tab if you prefer rejecting mixed content. Ensure you reference tab_end,
replace_existing, tab_id and markdown_to_docs_requests when making the change.

---

Nitpick comments:
In `@gdocs/docs_tools.py`:
- Line 2413: The parameter annotation for action should be changed from a
generic str to a Literal of allowed values so the generated schema surfaces
valid options and removes the need for the runtime membership check; update the
function/method signature that currently declares action: str to use
typing.Literal["create","rename","delete","populate_from_markdown"], add the
Literal import, and then remove the manual tuple/valid_actions membership
validation in the same function (and any redundant runtime checks) since
CreateDocTabResponse.action already encodes the discriminator.

In `@skills/managing-google-workspace/references/docs.md`:
- Line 205: The sentence "Create, rename, delete, or populate tabs from
markdown. Uses `action` to select the operation." incorrectly lowercases the
format name; update the documentation in
skills/managing-google-workspace/references/docs.md to capitalize Markdown
(change "markdown" to "Markdown") so it reads "Create, rename, delete, or
populate tabs from Markdown." and keep the rest of the sentence unchanged.
🪄 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: c168f3a0-0e45-4e27-9b7a-15584335398c

📥 Commits

Reviewing files that changed from the base of the PR and between f2a6153 and c4dc3c1.

📒 Files selected for processing (3)
  • gdocs/docs_tools.py
  • skills/managing-google-workspace/references/docs.md
  • tests/gdocs/test_insert_doc_tab_response.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/gdocs/test_insert_doc_tab_response.py

Comment thread gdocs/docs_tools.py Outdated
Comment thread gdocs/docs_tools.py Outdated
@taylorwilsdon

Copy link
Copy Markdown
Owner

This turned into a huge change but I just took it for a spin and she works beautifully!

@taylorwilsdon
taylorwilsdon merged commit 3abdbb0 into taylorwilsdon:main Apr 26, 2026
5 checks passed
@juliandickie

Copy link
Copy Markdown
Contributor Author

This turned into a huge change but I just took it for a spin and she works beautifully!

Working great so far using the manage doc tab function heaps, thanks for the quick merge

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants