Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1033,7 +1033,7 @@ The required scopes (`chat.spaces.readonly`, `chat.messages.readonly`, `chat.mes
| <sub>`get_script_project`</sub> | <sub>Core</sub> | <sub>Get complete project with all files</sub> |
| <sub>`get_script_content`</sub> | <sub>Core</sub> | <sub>Retrieve specific file content</sub> |
| <sub>`create_script_project`</sub> | <sub>Core</sub> | <sub>Create new standalone or bound project</sub> |
| <sub>`update_script_content`</sub> | <sub>Core</sub> | <sub>Update or create script files</sub> |
| <sub>`update_script_content`</sub> | <sub>Core</sub> | <sub>Merge or replace script project files</sub> |
| <sub>`run_script_function`</sub> | <sub>Core</sub> | <sub>Execute function with parameters</sub> |
| <sub>`list_deployments`</sub> | <sub>Extended</sub> | <sub>List all project deployments</sub> |
| <sub>`manage_deployment`</sub> | <sub>Extended</sub> | <sub>Create, update, or delete script deployments</sub> |
Expand Down
2 changes: 1 addition & 1 deletion README_NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only
| `get_script_project` | Core | Get complete project with all files |
| `get_script_content` | Core | Retrieve specific file content |
| `create_script_project` | Core | Create new standalone or bound project |
| `update_script_content` | Core | Update or create script files |
| `update_script_content` | Core | Merge or replace script project files |
| `run_script_function` | Core | Execute function with parameters |
| `list_deployments` | Extended | List all project deployments |
| `manage_deployment` | Extended | Create, update, or delete script deployments |
Expand Down
6 changes: 4 additions & 2 deletions gappsscript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Essential operations for reading, writing, and executing scripts:
- `get_script_project`: Get full project with all files
- `get_script_content`: Get specific file content
- `create_script_project`: Create new project
- `update_script_content`: Modify project files
- `update_script_content`: Merge or replace project files (`merge=true` by default)
- `run_script_function`: Execute functions
- `generate_trigger_code`: Generate trigger setup code

Expand Down Expand Up @@ -271,7 +271,9 @@ Files:
The AI will:
1. Read current code
2. Generate improved version
3. Call `update_script_content` with new files
3. Call `update_script_content` with the changed files (`merge=true` merges by file name)

To delete files or replace the entire project, pass `merge=false` with the complete desired file set.

### Run Script Function

Expand Down
88 changes: 81 additions & 7 deletions gappsscript/apps_script_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,53 @@
logger = logging.getLogger(__name__)


def _normalize_script_file(file: Dict[str, Any]) -> Dict[str, str]:
"""Return the Script API file fields used for updateContent requests.

Output-only fields returned by getContent (createTime, functionSet, ...)
are dropped. Fields the caller omitted stay omitted so a merge can fall
back to the existing value instead of blanking it.
"""
return {key: file[key] for key in ("name", "type", "source") if key in file}


def _merge_script_files(
existing_files: List[Dict[str, Any]],
updated_files: List[Dict[str, Any]],
) -> List[Dict[str, str]]:
"""Overlay updated files onto the current project.

Files are keyed by (name, type) because Script API names exclude the
extension, so one project may hold both Code.gs and Code.html as "Code".
An update that omits `type` falls back to matching by name, but only when
exactly one existing file carries that name; otherwise the type cannot be
inferred and a ValueError is raised rather than pushing an untyped file.
"""
merged = {
(file["name"], file.get("type")): _normalize_script_file(file)
for file in existing_files
if file.get("name")
}

for file in updated_files:
name = file.get("name")
if not name:
continue
key = (name, file.get("type"))
if key not in merged and file.get("type") is None:
same_name = [existing for existing in merged if existing[0] == name]
if len(same_name) != 1:
raise ValueError(
f"File '{name}' is missing 'type'; it must be one of "
"SERVER_JS, HTML, or JSON because the existing project "
"does not identify a single file with that name."
)
key = same_name[0]
merged[key] = {**merged.get(key, {}), **_normalize_script_file(file)}

return list(merged.values())


# Internal implementation functions for testing
async def _list_script_projects_impl(
service: Any,
Expand Down Expand Up @@ -320,26 +367,45 @@ async def _update_script_content_impl(
user_google_email: str,
script_id: str,
files: List[Dict[str, str]],
merge: bool = True,
) -> str:
"""Internal implementation for update_script_content."""
logger.info(
f"[update_script_content] Email: {user_google_email}, ID: {script_id}, Files: {len(files)}"
f"[update_script_content] Email: {user_google_email}, ID: {script_id}, "
f"Files: {len(files)}, merge: {merge}"
)

request_body = {"files": files}
files_to_push = [_normalize_script_file(file) for file in files]

if merge:
current_content = await asyncio.to_thread(
service.projects().getContent(scriptId=script_id).execute
)
files_to_push = _merge_script_files(
current_content.get("files", []), files_to_push
)

request_body = {"files": files_to_push}

updated_content = await asyncio.to_thread(
service.projects().updateContent(scriptId=script_id, body=request_body).execute
)

output = [f"Updated script project: {script_id}", "", "Modified files:"]
mode = "merged into project" if merge else "replaced entire project"
output = [
f"Updated script project: {script_id} ({mode})",
"",
"Files in project after update:",
]

for file in updated_content.get("files", []):
file_name = file.get("name", "Untitled")
file_type = file.get("type", "Unknown")
output.append(f"- {file_name} ({file_type})")

logger.info(f"[update_script_content] Updated {len(files)} files in {script_id}")
logger.info(
f"[update_script_content] Pushed {len(files_to_push)} files to {script_id}"
)
return "\n".join(output)


Expand All @@ -359,21 +425,29 @@ async def update_script_content(
user_google_email: str,
script_id: str,
files: List[Dict[str, str]],
merge: bool = True,
) -> str:
"""
Updates or creates files in a script project.
Update or create files in a script project.

By default this merges the supplied files into the existing project by file
name, leaving other files untouched. Set merge=False to replace the entire
project: any existing file omitted from `files` is permanently deleted.

Args:
service: Injected Google API service client
user_google_email: User's email address
script_id: The script project ID
files: List of file objects with name, type, and source
files: File objects with name, type, and source to create or update
merge: When True (default), overlay these files onto the current
project. When False, replace the full project file set; omitted
files are deleted.

Returns:
str: Formatted string confirming update with file list
"""
return await _update_script_content_impl(
service, user_google_email, script_id, files
service, user_google_email, script_id, files, merge
)


Expand Down
5 changes: 3 additions & 2 deletions skills/managing-google-workspace/references/apps-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,14 @@ Retrieves content of a specific file within a project.
| file_name | string | yes | | |

### update_script_content
Updates or creates files in a script project.
Merge or replace files in a script project. Defaults to merging supplied files by name into the existing project; set `merge=false` to replace the full project (omitted files are deleted).

| Parameter | Type | Required | Default | Notes |
|-----------|------|----------|---------|-------|
| user_google_email | string | yes | | |
| script_id | string | yes | | |
| files | array | yes | | List of objects with `name`, `type`, and `source` |
| files | array | yes | | Objects with `name`, `type`, and `source` |
| merge | boolean | no | true | `true` overlays updates; `false` replaces the entire project |

---

Expand Down
110 changes: 109 additions & 1 deletion tests/gappsscript/test_apps_script_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
_get_script_project_impl,
_create_script_project_impl,
_update_script_content_impl,
_merge_script_files,
_run_script_function_impl,
_create_deployment_impl,
_list_deployments_impl,
Expand Down Expand Up @@ -140,7 +141,7 @@ async def test_create_script_project():

@pytest.mark.asyncio
async def test_update_script_content():
"""Test updating script project files"""
"""Test updating script project files with merge disabled."""
mock_service = Mock()
files_to_update = [
{"name": "Code", "type": "SERVER_JS", "source": "function main() {}"}
Expand All @@ -154,10 +155,117 @@ async def test_update_script_content():
user_google_email="test@example.com",
script_id="test123",
files=files_to_update,
merge=False,
)

assert "Updated script project: test123" in result
assert "replaced entire project" in result
assert "Code" in result
mock_service.projects().getContent.assert_not_called()


def test_merge_script_files_overlays_updates_and_preserves_existing():
existing = [
{"name": "Code", "type": "SERVER_JS", "source": "old code"},
{"name": "appsscript", "type": "JSON", "source": "{}"},
]
updates = [{"name": "Code", "type": "SERVER_JS", "source": "new code"}]

merged = _merge_script_files(existing, updates)

assert len(merged) == 2
by_name = {file["name"]: file for file in merged}
assert by_name["Code"]["source"] == "new code"
assert by_name["appsscript"]["source"] == "{}"


def test_merge_script_files_adds_new_file():
existing = [{"name": "Code", "type": "SERVER_JS", "source": "code"}]
updates = [{"name": "Utils", "type": "SERVER_JS", "source": "function util() {}"}]

merged = _merge_script_files(existing, updates)

assert len(merged) == 2
by_name = {file["name"]: file for file in merged}
assert by_name["Utils"]["source"] == "function util() {}"
assert by_name["Code"]["source"] == "code"


def test_merge_script_files_keeps_existing_fields_when_omitted():
existing = [{"name": "Code", "type": "SERVER_JS", "source": "code"}]
updates = [{"name": "Code", "source": "new code"}]

merged = _merge_script_files(existing, updates)

assert merged == [{"name": "Code", "type": "SERVER_JS", "source": "new code"}]


def test_merge_script_files_keeps_same_name_different_type():
"""Script API names exclude extensions, so Code.gs and Code.html collide."""
existing = [
{"name": "Code", "type": "SERVER_JS", "source": "server"},
{"name": "Code", "type": "HTML", "source": "<html></html>"},
]
updates = [{"name": "Code", "type": "SERVER_JS", "source": "new server"}]

merged = _merge_script_files(existing, updates)

assert merged == [
{"name": "Code", "type": "SERVER_JS", "source": "new server"},
{"name": "Code", "type": "HTML", "source": "<html></html>"},
]


def test_merge_script_files_does_not_guess_when_name_is_ambiguous():
"""An update without a type must not clobber one of two same-name files."""
existing = [
{"name": "Code", "type": "SERVER_JS", "source": "server"},
{"name": "Code", "type": "HTML", "source": "<html></html>"},
]
updates = [{"name": "Code", "source": "new source"}]

with pytest.raises(ValueError, match="missing 'type'"):
_merge_script_files(existing, updates)


def test_merge_script_files_requires_type_for_new_file():
existing = [{"name": "Code", "type": "SERVER_JS", "source": "server"}]
updates = [{"name": "Utils", "source": "function util() {}"}]

with pytest.raises(ValueError, match="missing 'type'"):
_merge_script_files(existing, updates)


@pytest.mark.asyncio
async def test_update_script_content_merge_fetches_existing_files():
"""Test the default merge mode overlays updates onto the current project."""
mock_service = Mock()
existing_files = [
{"name": "Code", "type": "SERVER_JS", "source": "old code"},
{"name": "appsscript", "type": "JSON", "source": "{}"},
]
files_to_update = [{"name": "Code", "type": "SERVER_JS", "source": "new code"}]
merged_files = _merge_script_files(existing_files, files_to_update)

mock_service.projects().getContent().execute.return_value = {
"files": existing_files
}
mock_service.projects().updateContent().execute.return_value = {
"files": merged_files
}

result = await _update_script_content_impl(
service=mock_service,
user_google_email="test@example.com",
script_id="test123",
files=files_to_update,
)

update_body = mock_service.projects().updateContent.call_args.kwargs["body"]
assert update_body == {"files": merged_files}
assert "merged into project" in result
assert "Code" in result
assert "appsscript" in result


@pytest.mark.asyncio
Expand Down
Loading