From 7e50fe95a402fb1afbdc6bef7aba93da58a18cbe Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Tue, 5 May 2026 17:39:59 -0700 Subject: [PATCH] feat(forms): add publish settings command --- README.md | 1 + docs/commands.generated.md | 1 + docs/commands/README.md | 3 +- docs/commands/gog-forms-publish.md | 44 +++++ docs/commands/gog-forms.md | 1 + internal/cmd/forms.go | 1 + internal/cmd/forms_publish.go | 112 ++++++++++++ internal/cmd/forms_publish_test.go | 283 +++++++++++++++++++++++++++++ safety-profiles/agent-safe.yaml | 1 + safety-profiles/readonly.yaml | 1 + 10 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 docs/commands/gog-forms-publish.md create mode 100644 internal/cmd/forms_publish.go create mode 100644 internal/cmd/forms_publish_test.go diff --git a/README.md b/README.md index f85186385..8eb438594 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ gog sheets banding set 'Sheet1!A1:D100' ```bash gog slides create-from-markdown "Weekly update" --content-file slides.md gog slides insert-text "New text" +gog forms publish gog forms responses list --json gog forms raw --pretty ``` diff --git a/docs/commands.generated.md b/docs/commands.generated.md index f9bcc5104..fea8a1ad9 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -268,6 +268,7 @@ Generated from `gog schema --json`. - [`gog forms (form) delete-question (delete-q,dq,rm-q) `](commands/gog-forms-delete-question.md) - Delete a question by index - [`gog forms (form) get (info,show) `](commands/gog-forms-get.md) - Get a form - [`gog forms (form) move-question (move-q,mq) `](commands/gog-forms-move-question.md) - Move a question to a new position + - [`gog forms (form) publish [flags]`](commands/gog-forms-publish.md) - Publish or unpublish a form - [`gog forms (form) raw [flags]`](commands/gog-forms-raw.md) - Dump raw Google Forms API response as JSON (Forms.Get; lossless; for scripting and LLM consumption) - [`gog forms (form) responses `](commands/gog-forms-responses.md) - Form responses - [`gog forms (form) responses get (info,show) `](commands/gog-forms-responses-get.md) - Get a form response diff --git a/docs/commands/README.md b/docs/commands/README.md index 310d5dd1a..271c7b19f 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 470. +Generated pages: 471. ## Top-level Commands @@ -311,6 +311,7 @@ Generated pages: 470. - [gog forms delete-question](gog-forms-delete-question.md) - Delete a question by index - [gog forms get](gog-forms-get.md) - Get a form - [gog forms move-question](gog-forms-move-question.md) - Move a question to a new position + - [gog forms publish](gog-forms-publish.md) - Publish or unpublish a form - [gog forms raw](gog-forms-raw.md) - Dump raw Google Forms API response as JSON (Forms.Get; lossless; for scripting and LLM consumption) - [gog forms responses](gog-forms-responses.md) - Form responses - [gog forms responses get](gog-forms-responses-get.md) - Get a form response diff --git a/docs/commands/gog-forms-publish.md b/docs/commands/gog-forms-publish.md new file mode 100644 index 000000000..e7c8cad18 --- /dev/null +++ b/docs/commands/gog-forms-publish.md @@ -0,0 +1,44 @@ +# `gog forms publish` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Publish or unpublish a form + +## Usage + +```bash +gog forms (form) publish [flags] +``` + +## Parent + +- [gog forms](gog-forms.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--accepting-responses` | `bool` | true | Whether a published form accepts responses | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--unpublish` | `bool` | | Unpublish the form instead of publishing it | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog forms](gog-forms.md) +- [Command index](README.md) diff --git a/docs/commands/gog-forms.md b/docs/commands/gog-forms.md index 0241cda53..a0780bd60 100644 --- a/docs/commands/gog-forms.md +++ b/docs/commands/gog-forms.md @@ -21,6 +21,7 @@ gog forms (form) [flags] - [gog forms delete-question](gog-forms-delete-question.md) - Delete a question by index - [gog forms get](gog-forms-get.md) - Get a form - [gog forms move-question](gog-forms-move-question.md) - Move a question to a new position +- [gog forms publish](gog-forms-publish.md) - Publish or unpublish a form - [gog forms raw](gog-forms-raw.md) - Dump raw Google Forms API response as JSON (Forms.Get; lossless; for scripting and LLM consumption) - [gog forms responses](gog-forms-responses.md) - Form responses - [gog forms update](gog-forms-update.md) - Update form title, description, or settings diff --git a/internal/cmd/forms.go b/internal/cmd/forms.go index b42ceb7e1..77f4c77ef 100644 --- a/internal/cmd/forms.go +++ b/internal/cmd/forms.go @@ -19,6 +19,7 @@ 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"` Update FormsUpdateCmd `cmd:"" name:"update" aliases:"edit" help:"Update form title, description, or settings"` + Publish FormsPublishCmd `cmd:"" name:"publish" help:"Publish or unpublish a form"` AddQuestion FormsAddQuestionCmd `cmd:"" name:"add-question" aliases:"add-q,aq" help:"Add a question to a form"` DeleteQuestion FormsDeleteQuestionCmd `cmd:"" name:"delete-question" aliases:"delete-q,dq,rm-q" help:"Delete a question by index"` MoveQuestion FormsMoveQuestionCmd `cmd:"" name:"move-question" aliases:"move-q,mq" help:"Move a question to a new position"` diff --git a/internal/cmd/forms_publish.go b/internal/cmd/forms_publish.go new file mode 100644 index 000000000..39fed634e --- /dev/null +++ b/internal/cmd/forms_publish.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "context" + "os" + "strings" + + formsapi "google.golang.org/api/forms/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// FormsPublishCmd publishes a form via forms.setPublishSettings. +type FormsPublishCmd struct { + FormID string `arg:"" name:"formId" help:"Form ID"` + Unpublish bool `name:"unpublish" help:"Unpublish the form instead of publishing it"` + AcceptingResponses bool `name:"accepting-responses" help:"Whether a published form accepts responses" default:"true"` +} + +func (c *FormsPublishCmd) Run(ctx context.Context, flags *RootFlags) error { + published := !c.Unpublish + acceptingResponses := c.AcceptingResponses + if !published { + acceptingResponses = false + } + operation := "forms.publish" + if !published { + operation = "forms.unpublish" + } + + return setFormPublishState(ctx, flags, formPublishStateRequest{ + FormID: c.FormID, + Published: published, + AcceptingResponses: acceptingResponses, + Operation: operation, + }) +} + +type formPublishStateRequest struct { + FormID string + Published bool + AcceptingResponses bool + Operation string +} + +func setFormPublishState(ctx context.Context, flags *RootFlags, publishReq formPublishStateRequest) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + formID := strings.TrimSpace(normalizeGoogleID(publishReq.FormID)) + if formID == "" { + return usage("empty formId") + } + + if dryRunErr := dryRunExit(ctx, flags, publishReq.Operation, map[string]any{ + "form_id": formID, + "published": publishReq.Published, + "accepting_responses": publishReq.AcceptingResponses, + }); dryRunErr != nil { + return dryRunErr + } + + svc, err := newFormsService(ctx, account) + if err != nil { + return err + } + + req := &formsapi.SetPublishSettingsRequest{ + UpdateMask: "publish_state", + PublishSettings: &formsapi.PublishSettings{ + PublishState: &formsapi.PublishState{ + IsPublished: publishReq.Published, + IsAcceptingResponses: publishReq.AcceptingResponses, + ForceSendFields: []string{"IsPublished", "IsAcceptingResponses"}, + }, + }, + } + resp, err := svc.Forms.SetPublishSettings(formID, req).Context(ctx).Do() + if err != nil { + return err + } + + form, err := svc.Forms.Get(formID).Context(ctx).Do() + if err != nil { + return err + } + + responderURI := strings.TrimSpace(form.ResponderUri) + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "published": publishReq.Published, + "accepting_responses": publishReq.AcceptingResponses, + "form_id": formID, + "responder_uri": responderURI, + "edit_url": formEditURL(formID), + "publish_settings": resp.PublishSettings, + "form": form, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("published\t%t", publishReq.Published) + u.Out().Printf("accepting_responses\t%t", publishReq.AcceptingResponses) + u.Out().Printf("form_id\t%s", formID) + if responderURI != "" { + u.Out().Printf("responder_uri\t%s", responderURI) + } + u.Out().Printf("edit_url\t%s", formEditURL(formID)) + return nil +} diff --git a/internal/cmd/forms_publish_test.go b/internal/cmd/forms_publish_test.go new file mode 100644 index 000000000..6baaeea74 --- /dev/null +++ b/internal/cmd/forms_publish_test.go @@ -0,0 +1,283 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + formsapi "google.golang.org/api/forms/v1" +) + +func TestFormsPublishCmd(t *testing.T) { + origNew := newFormsService + t.Cleanup(func() { newFormsService = origNew }) + + var gotPublish formsapi.SetPublishSettingsRequest + var gotPublishJSON map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/forms/form1:setPublishSettings"): + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read setPublishSettings: %v", err) + } + if err := json.Unmarshal(body, &gotPublish); err != nil { + t.Fatalf("decode setPublishSettings: %v", err) + } + if err := json.Unmarshal(body, &gotPublishJSON); err != nil { + t.Fatalf("decode setPublishSettings JSON: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "publishSettings": map[string]any{ + "publishState": map[string]any{ + "isPublished": true, + "isAcceptingResponses": true, + }, + }, + }) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/forms/form1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form1", + "responderUri": "https://docs.google.com/forms/d/e/form1/viewform", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + newFormsService = func(ctx context.Context, account string) (*formsapi.Service, error) { + return newFormsTestService(t, ctx, srv), nil + } + + err := runKong(t, &FormsPublishCmd{}, []string{"form1"}, newQuietUIContext(t), &RootFlags{Account: "a@b.com"}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + + if gotPublish.UpdateMask != "publish_state" { + t.Fatalf("UpdateMask = %q, want publish_state", gotPublish.UpdateMask) + } + if gotPublish.PublishSettings == nil || gotPublish.PublishSettings.PublishState == nil { + t.Fatalf("missing publish state: %#v", gotPublish.PublishSettings) + } + state := gotPublish.PublishSettings.PublishState + if !state.IsPublished || !state.IsAcceptingResponses { + t.Fatalf("unexpected publish state: %#v", state) + } + assertPublishStateJSON(t, gotPublishJSON, true, true) +} + +func TestFormsPublishCmdUnpublish(t *testing.T) { + origNew := newFormsService + t.Cleanup(func() { newFormsService = origNew }) + + var gotPublish formsapi.SetPublishSettingsRequest + var gotPublishJSON map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/forms/form1:setPublishSettings"): + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read setPublishSettings: %v", err) + } + if err := json.Unmarshal(body, &gotPublish); err != nil { + t.Fatalf("decode setPublishSettings: %v", err) + } + if err := json.Unmarshal(body, &gotPublishJSON); err != nil { + t.Fatalf("decode setPublishSettings JSON: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "publishSettings": map[string]any{ + "publishState": map[string]any{ + "isPublished": false, + "isAcceptingResponses": false, + }, + }, + }) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/forms/form1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form1", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + newFormsService = func(ctx context.Context, account string) (*formsapi.Service, error) { + return newFormsTestService(t, ctx, srv), nil + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + err := Execute([]string{"--json", "--account", "a@b.com", "forms", "publish", "form1", "--unpublish"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if gotPublish.UpdateMask != "publish_state" { + t.Fatalf("UpdateMask = %q, want publish_state", gotPublish.UpdateMask) + } + if gotPublish.PublishSettings == nil || gotPublish.PublishSettings.PublishState == nil { + t.Fatalf("missing publish state: %#v", gotPublish.PublishSettings) + } + state := gotPublish.PublishSettings.PublishState + if state.IsPublished || state.IsAcceptingResponses { + t.Fatalf("unexpected publish state: %#v", state) + } + assertPublishStateJSON(t, gotPublishJSON, false, false) + + var parsed struct { + Published bool `json:"published"` + AcceptingResponses bool `json:"accepting_responses"` + FormID string `json:"form_id"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out) + } + if parsed.Published || parsed.AcceptingResponses || parsed.FormID != "form1" { + t.Fatalf("unexpected JSON payload: %#v", parsed) + } +} + +func TestFormsPublishCmdAcceptingResponsesFalseJSON(t *testing.T) { + origNew := newFormsService + t.Cleanup(func() { newFormsService = origNew }) + + var gotPublish formsapi.SetPublishSettingsRequest + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1/forms/form1:setPublishSettings"): + if err := json.NewDecoder(r.Body).Decode(&gotPublish); err != nil { + t.Fatalf("decode setPublishSettings: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "publishSettings": map[string]any{ + "publishState": map[string]any{ + "isPublished": true, + "isAcceptingResponses": false, + }, + }, + }) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/forms/form1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "formId": "form1", + "responderUri": "https://docs.google.com/forms/d/e/form1/viewform", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + newFormsService = func(ctx context.Context, account string) (*formsapi.Service, error) { + return newFormsTestService(t, ctx, srv), nil + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + err := Execute([]string{ + "--json", + "--account", "a@b.com", + "forms", "publish", "form1", + "--accepting-responses=false", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if gotPublish.PublishSettings == nil || + gotPublish.PublishSettings.PublishState == nil || + !gotPublish.PublishSettings.PublishState.IsPublished || + gotPublish.PublishSettings.PublishState.IsAcceptingResponses { + t.Fatalf("unexpected publish state: %#v", gotPublish.PublishSettings) + } + + var parsed struct { + Published bool `json:"published"` + AcceptingResponses bool `json:"accepting_responses"` + FormID string `json:"form_id"` + ResponderURI string `json:"responder_uri"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out) + } + if !parsed.Published || parsed.AcceptingResponses || parsed.FormID != "form1" { + t.Fatalf("unexpected JSON payload: %#v", parsed) + } + if parsed.ResponderURI != "https://docs.google.com/forms/d/e/form1/viewform" { + t.Fatalf("responder_uri = %q", parsed.ResponderURI) + } +} + +func TestFormsPublishCmdDryRun(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, nil + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + err := Execute([]string{ + "--json", + "--dry-run", + "--account", "a@b.com", + "forms", "publish", "form1", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + Request struct { + FormID string `json:"form_id"` + Published bool `json:"published"` + AcceptingResponses bool `json:"accepting_responses"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out) + } + if !parsed.DryRun || parsed.Op != "forms.publish" { + t.Fatalf("unexpected dry-run payload: %#v", parsed) + } + if parsed.Request.FormID != "form1" || !parsed.Request.Published || !parsed.Request.AcceptingResponses { + t.Fatalf("unexpected request payload: %#v", parsed.Request) + } +} + +func assertPublishStateJSON(t *testing.T, payload map[string]any, wantPublished, wantAccepting bool) { + t.Helper() + settings, _ := payload["publishSettings"].(map[string]any) + state, _ := settings["publishState"].(map[string]any) + if got, ok := state["isPublished"].(bool); !ok || got != wantPublished { + t.Fatalf("JSON isPublished = %v (%t), want %t; payload=%#v", state["isPublished"], ok, wantPublished, payload) + } + if got, ok := state["isAcceptingResponses"].(bool); !ok || got != wantAccepting { + t.Fatalf("JSON isAcceptingResponses = %v (%t), want %t; payload=%#v", state["isAcceptingResponses"], ok, wantAccepting, payload) + } +} diff --git a/safety-profiles/agent-safe.yaml b/safety-profiles/agent-safe.yaml index 4f096681b..84fb731db 100644 --- a/safety-profiles/agent-safe.yaml +++ b/safety-profiles/agent-safe.yaml @@ -230,6 +230,7 @@ forms: get: true create: false update: false + publish: false add-question: false delete-question: false move-question: true diff --git a/safety-profiles/readonly.yaml b/safety-profiles/readonly.yaml index 9cb6d1f77..14ef8ee0a 100644 --- a/safety-profiles/readonly.yaml +++ b/safety-profiles/readonly.yaml @@ -235,6 +235,7 @@ forms: get: true create: false update: false + publish: false add-question: false delete-question: false move-question: false