feat(cli): add forms and appscript commands
This commit is contained in:
parent
b460a426fb
commit
3b2ab325af
35
README.md
35
README.md
@ -3,7 +3,7 @@
|
||||

|
||||
<!-- Created with GitHub Repo Banner by Waren Gonzaga: https://ghrb.waren.build -->
|
||||
|
||||
Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege auth built in.
|
||||
Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Contacts, Tasks, People, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege auth built in.
|
||||
|
||||
## Features
|
||||
|
||||
@ -16,6 +16,8 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
|
||||
- **Contacts** - search/create/update contacts, access Workspace directory/other contacts
|
||||
- **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, repeat schedules
|
||||
- **Sheets** - read/write/update spreadsheets, format cells, create new sheets (and export via Drive)
|
||||
- **Forms** - create/get forms and inspect responses
|
||||
- **Apps Script** - create/get projects, inspect content, and run functions
|
||||
- **Docs/Slides** - export to PDF/DOCX/PPTX via Drive (plus create/copy, docs-to-text)
|
||||
- **People** - access profile information
|
||||
- **Keep (Workspace only)** - list/get/search notes and download attachments (service account + domain-wide delegation)
|
||||
@ -76,6 +78,8 @@ Before adding an account, create OAuth2 credentials from Google Cloud Console:
|
||||
- People API (Contacts): https://console.cloud.google.com/apis/api/people.googleapis.com
|
||||
- Google Tasks API: https://console.cloud.google.com/apis/api/tasks.googleapis.com
|
||||
- Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com
|
||||
- Google Forms API: https://console.cloud.google.com/apis/api/forms.googleapis.com
|
||||
- Apps Script API: https://console.cloud.google.com/apis/api/script.googleapis.com
|
||||
- Cloud Identity API (Groups): https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com
|
||||
3. Configure OAuth consent screen: https://console.cloud.google.com/auth/branding
|
||||
4. If your app is in "Testing", add test users: https://console.cloud.google.com/auth/audience
|
||||
@ -337,10 +341,13 @@ Service scope matrix (auto-generated; run `go run scripts/gen-auth-services-md.g
|
||||
| classroom | yes | Classroom API | `https://www.googleapis.com/auth/classroom.courses`<br>`https://www.googleapis.com/auth/classroom.rosters`<br>`https://www.googleapis.com/auth/classroom.coursework.students`<br>`https://www.googleapis.com/auth/classroom.coursework.me`<br>`https://www.googleapis.com/auth/classroom.courseworkmaterials`<br>`https://www.googleapis.com/auth/classroom.announcements`<br>`https://www.googleapis.com/auth/classroom.topics`<br>`https://www.googleapis.com/auth/classroom.guardianlinks.students`<br>`https://www.googleapis.com/auth/classroom.profile.emails`<br>`https://www.googleapis.com/auth/classroom.profile.photos` | |
|
||||
| drive | yes | Drive API | `https://www.googleapis.com/auth/drive` | |
|
||||
| docs | yes | Docs API, Drive API | `https://www.googleapis.com/auth/drive`<br>`https://www.googleapis.com/auth/documents` | Export/copy/create via Drive |
|
||||
| slides | yes | Slides API, Drive API | `https://www.googleapis.com/auth/drive`<br>`https://www.googleapis.com/auth/presentations` | Create/edit presentations |
|
||||
| contacts | yes | People API | `https://www.googleapis.com/auth/contacts`<br>`https://www.googleapis.com/auth/contacts.other.readonly`<br>`https://www.googleapis.com/auth/directory.readonly` | Contacts + other contacts + directory |
|
||||
| tasks | yes | Tasks API | `https://www.googleapis.com/auth/tasks` | |
|
||||
| sheets | yes | Sheets API, Drive API | `https://www.googleapis.com/auth/drive`<br>`https://www.googleapis.com/auth/spreadsheets` | Export via Drive |
|
||||
| people | yes | People API | `profile` | OIDC profile scope |
|
||||
| forms | yes | Forms API | `https://www.googleapis.com/auth/forms.body`<br>`https://www.googleapis.com/auth/forms.responses.readonly` | |
|
||||
| appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`<br>`https://www.googleapis.com/auth/script.deployments`<br>`https://www.googleapis.com/auth/script.processes` | |
|
||||
| groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only |
|
||||
| keep | no | Keep API | `https://www.googleapis.com/auth/keep.readonly` | Workspace only; service account (domain-wide delegation) |
|
||||
<!-- auth-services:end -->
|
||||
@ -922,6 +929,32 @@ gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"textFormat":{"
|
||||
gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2"
|
||||
```
|
||||
|
||||
### Forms
|
||||
|
||||
```bash
|
||||
# Forms
|
||||
gog forms get <formId>
|
||||
gog forms create --title "Weekly Check-in" --description "Friday async update"
|
||||
|
||||
# Responses
|
||||
gog forms responses list <formId> --max 20
|
||||
gog forms responses get <formId> <responseId>
|
||||
```
|
||||
|
||||
### Apps Script
|
||||
|
||||
```bash
|
||||
# Projects
|
||||
gog appscript get <scriptId>
|
||||
gog appscript content <scriptId>
|
||||
gog appscript create --title "Automation Helpers"
|
||||
gog appscript create --title "Bound Script" --parent-id <driveFileId>
|
||||
|
||||
# Execute functions
|
||||
gog appscript run <scriptId> myFunction --params '["arg1", 123, true]'
|
||||
gog appscript run <scriptId> myFunction --dev-mode
|
||||
```
|
||||
|
||||
### People
|
||||
|
||||
```bash
|
||||
|
||||
276
internal/cmd/appscript.go
Normal file
276
internal/cmd/appscript.go
Normal file
@ -0,0 +1,276 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
scriptapi "google.golang.org/api/script/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
var newAppScriptService = googleapi.NewAppScript
|
||||
|
||||
type AppScriptCmd struct {
|
||||
Get AppScriptGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get Apps Script project metadata"`
|
||||
Content AppScriptContentCmd `cmd:"" name:"content" aliases:"cat" help:"Get Apps Script project content"`
|
||||
Run AppScriptRunCmd `cmd:"" name:"run" help:"Run a deployed Apps Script function"`
|
||||
Create AppScriptCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create an Apps Script project"`
|
||||
}
|
||||
|
||||
type AppScriptGetCmd struct {
|
||||
ScriptID string `arg:"" name:"scriptId" help:"Script ID"`
|
||||
}
|
||||
|
||||
func (c *AppScriptGetCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID))
|
||||
if scriptID == "" {
|
||||
return usage("empty scriptId")
|
||||
}
|
||||
|
||||
svc, err := newAppScriptService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := svc.Projects.Get(scriptID).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"project": project,
|
||||
"editor_url": appScriptEditURL(scriptID),
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
u.Out().Printf("script_id\t%s", project.ScriptId)
|
||||
if project.Title != "" {
|
||||
u.Out().Printf("title\t%s", project.Title)
|
||||
}
|
||||
if project.ParentId != "" {
|
||||
u.Out().Printf("parent_id\t%s", project.ParentId)
|
||||
}
|
||||
if project.CreateTime != "" {
|
||||
u.Out().Printf("created\t%s", project.CreateTime)
|
||||
}
|
||||
if project.UpdateTime != "" {
|
||||
u.Out().Printf("updated\t%s", project.UpdateTime)
|
||||
}
|
||||
u.Out().Printf("editor_url\t%s", appScriptEditURL(scriptID))
|
||||
return nil
|
||||
}
|
||||
|
||||
type AppScriptContentCmd struct {
|
||||
ScriptID string `arg:"" name:"scriptId" help:"Script ID"`
|
||||
}
|
||||
|
||||
func (c *AppScriptContentCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID))
|
||||
if scriptID == "" {
|
||||
return usage("empty scriptId")
|
||||
}
|
||||
|
||||
svc, err := newAppScriptService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := svc.Projects.GetContent(scriptID).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
u.Out().Printf("script_id\t%s", content.ScriptId)
|
||||
u.Out().Printf("files\t%d", len(content.Files))
|
||||
for _, file := range content.Files {
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
u.Out().Printf("file\t%s\t%s", file.Name, file.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AppScriptRunCmd struct {
|
||||
ScriptID string `arg:"" name:"scriptId" help:"Script ID"`
|
||||
Function string `arg:"" name:"function" help:"Function name to run"`
|
||||
Params string `name:"params" help:"JSON array of function parameters" default:"[]"`
|
||||
DevMode bool `name:"dev-mode" help:"Run latest saved code if you own the script"`
|
||||
}
|
||||
|
||||
func (c *AppScriptRunCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scriptID := strings.TrimSpace(normalizeGoogleID(c.ScriptID))
|
||||
if scriptID == "" {
|
||||
return usage("empty scriptId")
|
||||
}
|
||||
function := strings.TrimSpace(c.Function)
|
||||
if function == "" {
|
||||
return usage("empty function")
|
||||
}
|
||||
|
||||
params, err := parseJSONArray(c.Params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := newAppScriptService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op, err := svc.Scripts.Run(scriptID, &scriptapi.ExecutionRequest{
|
||||
Function: function,
|
||||
Parameters: params,
|
||||
DevMode: c.DevMode,
|
||||
}).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"operation": op,
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
u.Out().Printf("done\t%t", op.Done)
|
||||
|
||||
if op.Error != nil {
|
||||
if op.Error.Code != 0 {
|
||||
u.Out().Printf("error_code\t%d", op.Error.Code)
|
||||
}
|
||||
if op.Error.Message != "" {
|
||||
u.Out().Printf("error\t%s", op.Error.Message)
|
||||
}
|
||||
if detail := parseExecutionError(op.Error); detail != nil {
|
||||
if detail.ErrorType != "" {
|
||||
u.Out().Printf("error_type\t%s", detail.ErrorType)
|
||||
}
|
||||
if detail.ErrorMessage != "" {
|
||||
u.Out().Printf("error_message\t%s", detail.ErrorMessage)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(op.Response) > 0 {
|
||||
var execResp scriptapi.ExecutionResponse
|
||||
if err := json.Unmarshal(op.Response, &execResp); err == nil && execResp.Result != nil {
|
||||
if b, marshalErr := json.Marshal(execResp.Result); marshalErr == nil {
|
||||
u.Out().Printf("result\t%s", string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AppScriptCreateCmd struct {
|
||||
Title string `name:"title" help:"Project title" required:""`
|
||||
ParentID string `name:"parent-id" help:"Optional Drive file ID to bind to"`
|
||||
}
|
||||
|
||||
func (c *AppScriptCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := strings.TrimSpace(c.Title)
|
||||
if title == "" {
|
||||
return usage("empty --title")
|
||||
}
|
||||
parentID := strings.TrimSpace(normalizeGoogleID(c.ParentID))
|
||||
|
||||
if dryRunErr := dryRunExit(ctx, flags, "appscript.create", map[string]any{
|
||||
"title": title,
|
||||
"parent_id": parentID,
|
||||
}); dryRunErr != nil {
|
||||
return dryRunErr
|
||||
}
|
||||
|
||||
svc, err := newAppScriptService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := svc.Projects.Create(&scriptapi.CreateProjectRequest{
|
||||
Title: title,
|
||||
ParentId: parentID,
|
||||
}).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"created": true,
|
||||
"project": project,
|
||||
"editor_url": appScriptEditURL(project.ScriptId),
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
u.Out().Printf("created\ttrue")
|
||||
u.Out().Printf("script_id\t%s", project.ScriptId)
|
||||
if project.Title != "" {
|
||||
u.Out().Printf("title\t%s", project.Title)
|
||||
}
|
||||
if project.ParentId != "" {
|
||||
u.Out().Printf("parent_id\t%s", project.ParentId)
|
||||
}
|
||||
u.Out().Printf("editor_url\t%s", appScriptEditURL(project.ScriptId))
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJSONArray(raw string) ([]interface{}, error) {
|
||||
val := strings.TrimSpace(raw)
|
||||
if val == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var out []interface{}
|
||||
if err := json.Unmarshal([]byte(val), &out); err != nil {
|
||||
return nil, usagef("invalid --params JSON array: %v", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseExecutionError(status *scriptapi.Status) *scriptapi.ExecutionError {
|
||||
if status == nil || len(status.Details) == 0 {
|
||||
return nil
|
||||
}
|
||||
var detail scriptapi.ExecutionError
|
||||
if err := json.Unmarshal(status.Details[0], &detail); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &detail
|
||||
}
|
||||
|
||||
func appScriptEditURL(scriptID string) string {
|
||||
scriptID = strings.TrimSpace(scriptID)
|
||||
if scriptID == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://script.google.com/d/" + scriptID + "/edit"
|
||||
}
|
||||
328
internal/cmd/execute_forms_appscript_test.go
Normal file
328
internal/cmd/execute_forms_appscript_test.go
Normal file
@ -0,0 +1,328 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
formsapi "google.golang.org/api/forms/v1"
|
||||
"google.golang.org/api/option"
|
||||
scriptapi "google.golang.org/api/script/v1"
|
||||
)
|
||||
|
||||
func TestExecute_FormsGet_Text(t *testing.T) {
|
||||
origNew := newFormsService
|
||||
t.Cleanup(func() { newFormsService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !(strings.Contains(r.URL.Path, "/forms/form123") && r.Method == http.MethodGet) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"formId": "form123",
|
||||
"responderUri": "https://docs.google.com/forms/d/e/resp",
|
||||
"info": map[string]any{
|
||||
"title": "Survey",
|
||||
"description": "Weekly check-in",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := formsapi.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newFormsService = func(context.Context, string) (*formsapi.Service, error) { return svc, nil }
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "forms", "get", "form123"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "id\tform123") || !strings.Contains(out, "title\tSurvey") || !strings.Contains(out, "edit_url\thttps://docs.google.com/forms/d/form123/edit") {
|
||||
t.Fatalf("unexpected out=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_FormsResponsesList_JSON(t *testing.T) {
|
||||
origNew := newFormsService
|
||||
t.Cleanup(func() { newFormsService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !(strings.Contains(r.URL.Path, "/forms/form123/responses") && r.Method == http.MethodGet) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"responses": []map[string]any{
|
||||
{
|
||||
"responseId": "r1",
|
||||
"lastSubmittedTime": "2026-02-14T00:00:00Z",
|
||||
"respondentEmail": "user@example.com",
|
||||
},
|
||||
},
|
||||
"nextPageToken": "next123",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := formsapi.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newFormsService = func(context.Context, string) (*formsapi.Service, error) { return svc, nil }
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "forms", "responses", "list", "form123", "--max", "1"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var parsed struct {
|
||||
FormID string `json:"form_id"`
|
||||
Responses []struct {
|
||||
ResponseID string `json:"responseId"`
|
||||
} `json:"responses"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if parsed.FormID != "form123" || len(parsed.Responses) != 1 || parsed.Responses[0].ResponseID != "r1" || parsed.NextPageToken != "next123" {
|
||||
t.Fatalf("unexpected payload: %#v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_FormsResponsesList_RejectsNonPositiveMax(t *testing.T) {
|
||||
origNew := newFormsService
|
||||
t.Cleanup(func() { newFormsService = origNew })
|
||||
newFormsService = func(context.Context, string) (*formsapi.Service, error) {
|
||||
t.Fatalf("expected validation to fail before creating forms service")
|
||||
return nil, errors.New("unexpected forms service call")
|
||||
}
|
||||
|
||||
_ = captureStderr(t, func() {
|
||||
err := Execute([]string{"--account", "a@b.com", "forms", "responses", "list", "form123", "--max", "0"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--max must be > 0") {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecute_FormsCreate_DryRun_JSON(t *testing.T) {
|
||||
origNew := newFormsService
|
||||
t.Cleanup(func() { newFormsService = origNew })
|
||||
newFormsService = func(context.Context, string) (*formsapi.Service, error) {
|
||||
t.Fatalf("dry-run should not create forms service")
|
||||
return nil, errors.New("unexpected forms service call")
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{
|
||||
"--json",
|
||||
"--dry-run",
|
||||
"--account", "a@b.com",
|
||||
"forms", "create",
|
||||
"--title", "Weekly Check-in",
|
||||
"--description", "Friday async update",
|
||||
}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var parsed struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Op string `json:"op"`
|
||||
Request struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
} `json:"request"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !parsed.DryRun || parsed.Op != "forms.create" {
|
||||
t.Fatalf("unexpected dry-run payload: %#v", parsed)
|
||||
}
|
||||
if parsed.Request.Title != "Weekly Check-in" || parsed.Request.Description != "Friday async update" {
|
||||
t.Fatalf("unexpected request payload: %#v", parsed.Request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_AppScriptRun_JSON(t *testing.T) {
|
||||
origNew := newAppScriptService
|
||||
t.Cleanup(func() { newAppScriptService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !(strings.Contains(r.URL.Path, "/scripts/script123:run") && r.Method == http.MethodPost) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if req["function"] != "myFunc" {
|
||||
t.Fatalf("unexpected function: %#v", req["function"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"done": true,
|
||||
"response": map[string]any{
|
||||
"result": map[string]any{
|
||||
"ok": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := scriptapi.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newAppScriptService = func(context.Context, string) (*scriptapi.Service, error) { return svc, nil }
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--json", "--account", "a@b.com", "appscript", "run", "script123", "myFunc", "--params", "[\"x\"]"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
op, ok := parsed["operation"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing operation: %#v", parsed)
|
||||
}
|
||||
if done, _ := op["done"].(bool); !done {
|
||||
t.Fatalf("expected done=true: %#v", op)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_AppScriptCreate_DryRun_Text(t *testing.T) {
|
||||
origNew := newAppScriptService
|
||||
t.Cleanup(func() { newAppScriptService = origNew })
|
||||
newAppScriptService = func(context.Context, string) (*scriptapi.Service, error) {
|
||||
t.Fatalf("dry-run should not create appscript service")
|
||||
return nil, errors.New("unexpected appscript service call")
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{
|
||||
"--dry-run",
|
||||
"--account", "a@b.com",
|
||||
"appscript", "create",
|
||||
"--title", "Automation Helpers",
|
||||
"--parent-id", "drive123",
|
||||
}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "Dry run: would appscript.create") ||
|
||||
!strings.Contains(out, `"title": "Automation Helpers"`) ||
|
||||
!strings.Contains(out, `"parent_id": "drive123"`) {
|
||||
t.Fatalf("unexpected dry-run out=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_AppScriptRun_RejectsNonArrayParams(t *testing.T) {
|
||||
origNew := newAppScriptService
|
||||
t.Cleanup(func() { newAppScriptService = origNew })
|
||||
newAppScriptService = func(context.Context, string) (*scriptapi.Service, error) {
|
||||
t.Fatalf("expected params validation to fail before creating appscript service")
|
||||
return nil, errors.New("unexpected appscript service call")
|
||||
}
|
||||
|
||||
_ = captureStderr(t, func() {
|
||||
err := Execute([]string{"--account", "a@b.com", "appscript", "run", "script123", "myFunc", "--params", `{"x":1}`})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --params JSON array") {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecute_AppScriptRun_TextErrorDetails(t *testing.T) {
|
||||
origNew := newAppScriptService
|
||||
t.Cleanup(func() { newAppScriptService = origNew })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !(strings.Contains(r.URL.Path, "/scripts/script123:run") && r.Method == http.MethodPost) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"done": true,
|
||||
"error": map[string]any{
|
||||
"code": 3,
|
||||
"message": "Script execution failed",
|
||||
"details": []map[string]any{
|
||||
{
|
||||
"@type": "type.googleapis.com/google.apps.script.type.ExecutionError",
|
||||
"errorType": "TypeError",
|
||||
"errorMessage": "boom",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, err := scriptapi.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newAppScriptService = func(context.Context, string) (*scriptapi.Service, error) { return svc, nil }
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
_ = captureStderr(t, func() {
|
||||
if err := Execute([]string{"--account", "a@b.com", "appscript", "run", "script123", "myFunc"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "done\ttrue") ||
|
||||
!strings.Contains(out, "error_code\t3") ||
|
||||
!strings.Contains(out, "error\tScript execution failed") ||
|
||||
!strings.Contains(out, "error_type\tTypeError") ||
|
||||
!strings.Contains(out, "error_message\tboom") {
|
||||
t.Fatalf("unexpected out=%q", out)
|
||||
}
|
||||
}
|
||||
262
internal/cmd/forms.go
Normal file
262
internal/cmd/forms.go
Normal file
@ -0,0 +1,262 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
formsapi "google.golang.org/api/forms/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
var newFormsService = googleapi.NewForms
|
||||
|
||||
type FormsCmd struct {
|
||||
Get FormsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a form"`
|
||||
Create FormsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a form"`
|
||||
Responses FormsResponsesCmd `cmd:"" name:"responses" help:"Form responses"`
|
||||
}
|
||||
|
||||
type FormsResponsesCmd struct {
|
||||
List FormsResponsesListCmd `cmd:"" name:"list" aliases:"ls" help:"List form responses"`
|
||||
Get FormsResponseGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a form response"`
|
||||
}
|
||||
|
||||
type FormsGetCmd struct {
|
||||
FormID string `arg:"" name:"formId" help:"Form ID"`
|
||||
}
|
||||
|
||||
func (c *FormsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
|
||||
if formID == "" {
|
||||
return usage("empty formId")
|
||||
}
|
||||
|
||||
svc, err := newFormsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form, err := svc.Forms.Get(formID).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"form": form,
|
||||
"edit_url": formEditURL(formID),
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
printFormSummary(u, form, formID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type FormsCreateCmd struct {
|
||||
Title string `name:"title" help:"Form title" required:""`
|
||||
Description string `name:"description" help:"Form description"`
|
||||
}
|
||||
|
||||
func (c *FormsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := strings.TrimSpace(c.Title)
|
||||
if title == "" {
|
||||
return usage("empty --title")
|
||||
}
|
||||
description := strings.TrimSpace(c.Description)
|
||||
|
||||
if dryRunErr := dryRunExit(ctx, flags, "forms.create", map[string]any{
|
||||
"title": title,
|
||||
"description": description,
|
||||
}); dryRunErr != nil {
|
||||
return dryRunErr
|
||||
}
|
||||
|
||||
svc, err := newFormsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := &formsapi.Form{Info: &formsapi.Info{
|
||||
Title: title,
|
||||
Description: description,
|
||||
}}
|
||||
form, err := svc.Forms.Create(req).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
formID := strings.TrimSpace(form.FormId)
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"created": true,
|
||||
"form": form,
|
||||
"edit_url": formEditURL(formID),
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
u.Out().Printf("created\ttrue")
|
||||
printFormSummary(u, form, formID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type FormsResponsesListCmd struct {
|
||||
FormID string `arg:"" name:"formId" help:"Form ID"`
|
||||
Max int `name:"max" help:"Maximum responses" default:"20"`
|
||||
Page string `name:"page" help:"Page token"`
|
||||
Filter string `name:"filter" help:"Filter expression"`
|
||||
}
|
||||
|
||||
func (c *FormsResponsesListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
|
||||
if formID == "" {
|
||||
return usage("empty formId")
|
||||
}
|
||||
if c.Max <= 0 {
|
||||
return usage("--max must be > 0")
|
||||
}
|
||||
|
||||
svc, err := newFormsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
call := svc.Forms.Responses.List(formID).PageSize(int64(c.Max)).Context(ctx)
|
||||
if page := strings.TrimSpace(c.Page); page != "" {
|
||||
call = call.PageToken(page)
|
||||
}
|
||||
if filter := strings.TrimSpace(c.Filter); filter != "" {
|
||||
call = call.Filter(filter)
|
||||
}
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"form_id": formID,
|
||||
"responses": resp.Responses,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
u.Out().Println("RESPONSE_ID\tSUBMITTED\tEMAIL")
|
||||
for _, item := range resp.Responses {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
submitted := firstFormTime(item.LastSubmittedTime, item.CreateTime)
|
||||
u.Out().Printf("%s\t%s\t%s", item.ResponseId, submitted, item.RespondentEmail)
|
||||
}
|
||||
if next := strings.TrimSpace(resp.NextPageToken); next != "" {
|
||||
u.Err().Println("# Next page: --page " + next)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FormsResponseGetCmd struct {
|
||||
FormID string `arg:"" name:"formId" help:"Form ID"`
|
||||
ResponseID string `arg:"" name:"responseId" help:"Response ID"`
|
||||
}
|
||||
|
||||
func (c *FormsResponseGetCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
|
||||
if formID == "" {
|
||||
return usage("empty formId")
|
||||
}
|
||||
responseID := strings.TrimSpace(c.ResponseID)
|
||||
if responseID == "" {
|
||||
return usage("empty responseId")
|
||||
}
|
||||
|
||||
svc, err := newFormsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := svc.Forms.Responses.Get(formID, responseID).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"response": resp,
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
u.Out().Printf("response_id\t%s", resp.ResponseId)
|
||||
u.Out().Printf("submitted\t%s", firstFormTime(resp.LastSubmittedTime, resp.CreateTime))
|
||||
if resp.RespondentEmail != "" {
|
||||
u.Out().Printf("email\t%s", resp.RespondentEmail)
|
||||
}
|
||||
u.Out().Printf("answers\t%d", len(resp.Answers))
|
||||
if resp.TotalScore != 0 {
|
||||
u.Out().Printf("total_score\t%s", strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", resp.TotalScore), "0"), "."))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printFormSummary(u *ui.UI, form *formsapi.Form, fallbackID string) {
|
||||
if u == nil || form == nil {
|
||||
return
|
||||
}
|
||||
formID := strings.TrimSpace(form.FormId)
|
||||
if formID == "" {
|
||||
formID = strings.TrimSpace(fallbackID)
|
||||
}
|
||||
u.Out().Printf("id\t%s", formID)
|
||||
if form.Info != nil {
|
||||
if form.Info.Title != "" {
|
||||
u.Out().Printf("title\t%s", form.Info.Title)
|
||||
}
|
||||
if form.Info.Description != "" {
|
||||
u.Out().Printf("description\t%s", form.Info.Description)
|
||||
}
|
||||
}
|
||||
if form.ResponderUri != "" {
|
||||
u.Out().Printf("responder_uri\t%s", form.ResponderUri)
|
||||
}
|
||||
u.Out().Printf("edit_url\t%s", formEditURL(formID))
|
||||
}
|
||||
|
||||
func formEditURL(formID string) string {
|
||||
formID = strings.TrimSpace(formID)
|
||||
if formID == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://docs.google.com/forms/d/" + formID + "/edit"
|
||||
}
|
||||
|
||||
func firstFormTime(values ...string) string {
|
||||
for _, v := range values {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -29,7 +29,7 @@ const (
|
||||
|
||||
type RootFlags struct {
|
||||
Color string `help:"Color output: auto|always|never" default:"${color}"`
|
||||
Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets)" aliases:"acct" short:"a"`
|
||||
Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript)" aliases:"acct" short:"a"`
|
||||
Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"`
|
||||
EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"`
|
||||
JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}" aliases:"machine" short:"j"`
|
||||
@ -75,6 +75,8 @@ type CLI struct {
|
||||
People PeopleCmd `cmd:"" aliases:"person" help:"Google People"`
|
||||
Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"`
|
||||
Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"`
|
||||
Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"`
|
||||
AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"`
|
||||
Config ConfigCmd `cmd:"" help:"Manage configuration"`
|
||||
ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"`
|
||||
Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"`
|
||||
@ -325,7 +327,7 @@ func newParser(description string) (*kong.Kong, *CLI, error) {
|
||||
}
|
||||
|
||||
func baseDescription() string {
|
||||
return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People"
|
||||
return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script"
|
||||
}
|
||||
|
||||
func helpDescription() string {
|
||||
|
||||
20
internal/googleapi/appscript.go
Normal file
20
internal/googleapi/appscript.go
Normal file
@ -0,0 +1,20 @@
|
||||
package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/script/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
)
|
||||
|
||||
func NewAppScript(ctx context.Context, email string) (*script.Service, error) {
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceAppScript, email); err != nil {
|
||||
return nil, fmt.Errorf("appscript options: %w", err)
|
||||
} else if svc, err := script.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create appscript service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
20
internal/googleapi/forms.go
Normal file
20
internal/googleapi/forms.go
Normal file
@ -0,0 +1,20 @@
|
||||
package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/forms/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
)
|
||||
|
||||
func NewForms(ctx context.Context, email string) (*forms.Service, error) {
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceForms, email); err != nil {
|
||||
return nil, fmt.Errorf("forms options: %w", err)
|
||||
} else if svc, err := forms.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create forms service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,8 @@ const (
|
||||
ServiceTasks Service = "tasks"
|
||||
ServicePeople Service = "people"
|
||||
ServiceSheets Service = "sheets"
|
||||
ServiceForms Service = "forms"
|
||||
ServiceAppScript Service = "appscript"
|
||||
ServiceGroups Service = "groups"
|
||||
ServiceKeep Service = "keep"
|
||||
)
|
||||
@ -68,6 +70,8 @@ var serviceOrder = []Service{
|
||||
ServiceTasks,
|
||||
ServiceSheets,
|
||||
ServicePeople,
|
||||
ServiceForms,
|
||||
ServiceAppScript,
|
||||
ServiceGroups,
|
||||
ServiceKeep,
|
||||
}
|
||||
@ -170,6 +174,23 @@ var serviceInfoByService = map[Service]serviceInfo{
|
||||
apis: []string{"Sheets API", "Drive API"},
|
||||
note: "Export via Drive",
|
||||
},
|
||||
ServiceForms: {
|
||||
scopes: []string{
|
||||
"https://www.googleapis.com/auth/forms.body",
|
||||
"https://www.googleapis.com/auth/forms.responses.readonly",
|
||||
},
|
||||
user: true,
|
||||
apis: []string{"Forms API"},
|
||||
},
|
||||
ServiceAppScript: {
|
||||
scopes: []string{
|
||||
"https://www.googleapis.com/auth/script.projects",
|
||||
"https://www.googleapis.com/auth/script.deployments",
|
||||
"https://www.googleapis.com/auth/script.processes",
|
||||
},
|
||||
user: true,
|
||||
apis: []string{"Apps Script API"},
|
||||
},
|
||||
ServiceGroups: {
|
||||
scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
|
||||
user: false,
|
||||
@ -478,6 +499,25 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string,
|
||||
}
|
||||
|
||||
return []string{driveScopeValue(), sheetsScope}, nil
|
||||
case ServiceForms:
|
||||
formBodyScope := "https://www.googleapis.com/auth/forms.body"
|
||||
if opts.Readonly {
|
||||
formBodyScope = "https://www.googleapis.com/auth/forms.body.readonly"
|
||||
}
|
||||
|
||||
return []string{
|
||||
formBodyScope,
|
||||
"https://www.googleapis.com/auth/forms.responses.readonly",
|
||||
}, nil
|
||||
case ServiceAppScript:
|
||||
if opts.Readonly {
|
||||
return []string{
|
||||
"https://www.googleapis.com/auth/script.projects.readonly",
|
||||
"https://www.googleapis.com/auth/script.deployments.readonly",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return Scopes(service)
|
||||
case ServiceGroups:
|
||||
return Scopes(service)
|
||||
case ServiceKeep:
|
||||
|
||||
@ -19,6 +19,8 @@ func TestParseService(t *testing.T) {
|
||||
{"tasks", ServiceTasks},
|
||||
{"people", ServicePeople},
|
||||
{"sheets", ServiceSheets},
|
||||
{"forms", ServiceForms},
|
||||
{"appscript", ServiceAppScript},
|
||||
{"groups", ServiceGroups},
|
||||
{"keep", ServiceKeep},
|
||||
}
|
||||
@ -63,7 +65,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
|
||||
|
||||
func TestAllServices(t *testing.T) {
|
||||
svcs := AllServices()
|
||||
if len(svcs) != 13 {
|
||||
if len(svcs) != 15 {
|
||||
t.Fatalf("unexpected: %v", svcs)
|
||||
}
|
||||
seen := make(map[Service]bool)
|
||||
@ -72,7 +74,7 @@ func TestAllServices(t *testing.T) {
|
||||
seen[s] = true
|
||||
}
|
||||
|
||||
for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceGroups, ServiceKeep} {
|
||||
for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
@ -81,7 +83,7 @@ func TestAllServices(t *testing.T) {
|
||||
|
||||
func TestUserServices(t *testing.T) {
|
||||
svcs := UserServices()
|
||||
if len(svcs) != 11 {
|
||||
if len(svcs) != 13 {
|
||||
t.Fatalf("unexpected: %v", svcs)
|
||||
}
|
||||
|
||||
@ -94,6 +96,8 @@ func TestUserServices(t *testing.T) {
|
||||
seenDocs = true
|
||||
case ServiceSlides:
|
||||
seenSlides = true
|
||||
case ServiceForms, ServiceAppScript:
|
||||
// expected user services
|
||||
case ServiceKeep:
|
||||
t.Fatalf("unexpected keep in user services")
|
||||
}
|
||||
@ -109,7 +113,7 @@ func TestUserServices(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUserServiceCSV(t *testing.T) {
|
||||
want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people"
|
||||
want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript"
|
||||
if got := UserServiceCSV(); got != want {
|
||||
t.Fatalf("unexpected user services csv: %q", got)
|
||||
}
|
||||
@ -225,7 +229,7 @@ func TestScopesForServices_UnionSorted(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScopesForManageWithOptions_Readonly(t *testing.T) {
|
||||
scopes, err := ScopesForManageWithOptions([]Service{ServiceGmail, ServiceDrive, ServiceCalendar, ServiceContacts, ServiceTasks, ServiceSheets, ServiceDocs, ServicePeople}, ScopeOptions{
|
||||
scopes, err := ScopesForManageWithOptions([]Service{ServiceGmail, ServiceDrive, ServiceCalendar, ServiceContacts, ServiceTasks, ServiceSheets, ServiceDocs, ServicePeople, ServiceForms, ServiceAppScript}, ScopeOptions{
|
||||
Readonly: true,
|
||||
DriveScope: DriveScopeFull,
|
||||
})
|
||||
@ -244,6 +248,10 @@ func TestScopesForManageWithOptions_Readonly(t *testing.T) {
|
||||
"https://www.googleapis.com/auth/tasks.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
||||
"https://www.googleapis.com/auth/documents.readonly",
|
||||
"https://www.googleapis.com/auth/forms.body.readonly",
|
||||
"https://www.googleapis.com/auth/forms.responses.readonly",
|
||||
"https://www.googleapis.com/auth/script.projects.readonly",
|
||||
"https://www.googleapis.com/auth/script.deployments.readonly",
|
||||
"profile",
|
||||
}
|
||||
for _, w := range want {
|
||||
@ -428,6 +436,40 @@ func TestScopes_GmailIncludesSettingsSharing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopes_FormsIncludesBodyAndResponses(t *testing.T) {
|
||||
scopes, err := Scopes(ServiceForms)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
if !containsScope(scopes, "https://www.googleapis.com/auth/forms.body") {
|
||||
t.Fatalf("missing forms.body in %v", scopes)
|
||||
}
|
||||
|
||||
if !containsScope(scopes, "https://www.googleapis.com/auth/forms.responses.readonly") {
|
||||
t.Fatalf("missing forms.responses.readonly in %v", scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopesForServiceWithOptions_AppScriptReadonly(t *testing.T) {
|
||||
scopes, err := scopesForServiceWithOptions(ServiceAppScript, ScopeOptions{Readonly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
if !containsScope(scopes, "https://www.googleapis.com/auth/script.projects.readonly") {
|
||||
t.Fatalf("missing script.projects.readonly in %v", scopes)
|
||||
}
|
||||
|
||||
if !containsScope(scopes, "https://www.googleapis.com/auth/script.deployments.readonly") {
|
||||
t.Fatalf("missing script.deployments.readonly in %v", scopes)
|
||||
}
|
||||
|
||||
if containsScope(scopes, "https://www.googleapis.com/auth/script.projects") {
|
||||
t.Fatalf("unexpected script.projects in %v", scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopes_UnknownService(t *testing.T) {
|
||||
if _, err := Scopes(Service("nope")); err == nil {
|
||||
t.Fatalf("expected error")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user