Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Docs: preserve paragraph-separating blank lines when replacing a single tab from Markdown. (#644)
- Docs: add `docs cell-update` for non-destructive table-cell content replacement by table, row, and column. (#646)
- Gmail: pause watch push Gmail API fetches per account while a 429 Retry-After circuit is open. (#643)
- YouTube: let `videos list` and `comments list` use OAuth when `--account` is supplied, preserving the API-key fallback for unauthenticated public reads. (#664)
- Docs: update the bundled `gog` agent skill to preserve broad user OAuth scopes during reauth and rely on command guards for scoped execution.

## 0.19.0 - 2026-05-22
Expand Down
58 changes: 55 additions & 3 deletions internal/cmd/youtube.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import (

youtube "google.golang.org/api/youtube/v3"

"github.com/steipete/gogcli/internal/errfmt"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)

const youtubeCommentsOAuthScope = "https://www.googleapis.com/auth/youtube.force-ssl"

type YouTubeCmd struct {
Activities YouTubeActivitiesCmd `cmd:"" name:"activities" aliases:"activity" help:"List channel activities"`
Videos YouTubeVideosCmd `cmd:"" name:"videos" aliases:"video" help:"List or get videos"`
Expand Down Expand Up @@ -133,7 +136,7 @@ func (c *YouTubeVideosListCmd) Run(ctx context.Context, flags *RootFlags) error
return usage("--chart mostPopular requires --region (e.g. US)")
}

svc, err := getYouTubeServiceWithAPIKey(ctx)
svc, err := getYouTubeReadService(ctx, flags)
if err != nil {
return err
}
Expand Down Expand Up @@ -289,7 +292,7 @@ func (c *YouTubeCommentsListCmd) Run(ctx context.Context, flags *RootFlags) erro
return usage("use either --video-id or --channel-id, not both")
}

svc, err := getYouTubeServiceWithAPIKey(ctx)
svc, err := getYouTubeCommentsService(ctx, flags)
if err != nil {
return err
}
Expand All @@ -304,7 +307,7 @@ func (c *YouTubeCommentsListCmd) Run(ctx context.Context, flags *RootFlags) erro
}
resp, err := call.Do()
if err != nil {
return err
return wrapYouTubeCommentsError(err, flags)
}

if outfmt.IsJSON(ctx) {
Expand Down Expand Up @@ -434,3 +437,52 @@ func validateYouTubeMax(limit int64) error {
}
return nil
}

func getYouTubeReadService(ctx context.Context, flags *RootFlags) (*youtube.Service, error) {
if youtubeAccountSelectorPresent(flags) {
account, err := requireAccount(flags)
if err != nil {
return nil, err
}
return getYouTubeServiceForAccount(ctx, account)
}
return getYouTubeServiceWithAPIKey(ctx)
}

func getYouTubeCommentsService(ctx context.Context, flags *RootFlags) (*youtube.Service, error) {
if youtubeAccountSelectorPresent(flags) {
account, err := requireAccount(flags)
if err != nil {
return nil, err
}
return getYouTubeCommentsServiceForAccount(ctx, account)
}
return getYouTubeServiceWithAPIKey(ctx)
}

func youtubeAccountSelectorPresent(flags *RootFlags) bool {
return flagAccount(flags) != "" || strings.TrimSpace(os.Getenv("GOG_ACCOUNT")) != "" || hasDirectAccessToken(flags)
}

func wrapYouTubeCommentsError(err error, flags *RootFlags) error {
if err == nil {
return nil
}
errText := err.Error()
if !strings.Contains(errText, "insufficientPermissions") &&
!strings.Contains(errText, "insufficient authentication scopes") &&
!strings.Contains(errText, "ACCESS_TOKEN_SCOPE_INSUFFICIENT") {
return err
}
if !youtubeAccountSelectorPresent(flags) {
return err
}
account, accountErr := requireAccount(flags)
if accountErr != nil {
return err
}
return errfmt.NewUserFacingError(
fmt.Sprintf("youtube comments OAuth requires %s; re-authenticate with: gog auth add %s --services youtube --extra-scopes %s --force-consent", youtubeCommentsOAuthScope, account, youtubeCommentsOAuthScope),
err,
)
}
9 changes: 7 additions & 2 deletions internal/cmd/youtube_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import (
)

var (
newYouTubeWithAPIKey = googleapi.NewYouTubeWithAPIKey
newYouTubeForAccount = googleapi.NewYouTubeForAccount
newYouTubeWithAPIKey = googleapi.NewYouTubeWithAPIKey
newYouTubeForAccount = googleapi.NewYouTubeForAccount
newYouTubeCommentsForAccount = googleapi.NewYouTubeCommentsForAccount
)

func getYouTubeAPIKey() (string, error) {
Expand All @@ -37,3 +38,7 @@ func getYouTubeServiceWithAPIKey(ctx context.Context) (*youtube.Service, error)
func getYouTubeServiceForAccount(ctx context.Context, account string) (*youtube.Service, error) {
return newYouTubeForAccount(ctx, account)
}

func getYouTubeCommentsServiceForAccount(ctx context.Context, account string) (*youtube.Service, error) {
return newYouTubeCommentsForAccount(ctx, account)
}
122 changes: 122 additions & 0 deletions internal/cmd/youtube_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

youtube "google.golang.org/api/youtube/v3"

"github.com/steipete/gogcli/internal/secrets"
)

func TestYouTubeChannelsListWithAPIKey(t *testing.T) {
Expand Down Expand Up @@ -102,6 +105,125 @@ func TestYouTubeMineUsesOAuthService(t *testing.T) {
}
}

func TestYouTubeVideosListWithAccountUsesOAuthService(t *testing.T) {
origOAuth := newYouTubeForAccount
origAPIKey := newYouTubeWithAPIKey
t.Cleanup(func() {
newYouTubeForAccount = origOAuth
newYouTubeWithAPIKey = origAPIKey
})

var gotAccount string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/youtube/v3/videos" {
t.Fatalf("path = %s", r.URL.Path)
}
if got := r.URL.Query().Get("id"); got != "dQw4w9WgXcQ" {
t.Fatalf("id = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
}))
defer srv.Close()

svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
newYouTubeForAccount = func(_ context.Context, account string) (*youtube.Service, error) {
gotAccount = account
return svc, nil
}
newYouTubeWithAPIKey = func(context.Context, string) (*youtube.Service, error) {
t.Fatal("API key service should not be used when account is configured")
return nil, errors.New("unexpected API key service")
}

err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "dQw4w9WgXcQ", "--max", "1"}, newQuietUIContext(t), &RootFlags{Account: "me@example.com"})
if err != nil {
t.Fatalf("runKong: %v", err)
}
if gotAccount != "me@example.com" {
t.Fatalf("account = %q", gotAccount)
}
}

func TestYouTubeCommentsListWithAccountUsesOAuthService(t *testing.T) {
origOAuth := newYouTubeCommentsForAccount
origAPIKey := newYouTubeWithAPIKey
t.Cleanup(func() {
newYouTubeCommentsForAccount = origOAuth
newYouTubeWithAPIKey = origAPIKey
})

var gotAccount string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/youtube/v3/commentThreads" {
t.Fatalf("path = %s", r.URL.Path)
}
if got := r.URL.Query().Get("videoId"); got != "dQw4w9WgXcQ" {
t.Fatalf("videoId = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
}))
defer srv.Close()

svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
newYouTubeCommentsForAccount = func(_ context.Context, account string) (*youtube.Service, error) {
gotAccount = account
return svc, nil
}
newYouTubeWithAPIKey = func(context.Context, string) (*youtube.Service, error) {
t.Fatal("API key service should not be used when account is configured")
return nil, errors.New("unexpected API key service")
}

err := runKong(t, &YouTubeCommentsListCmd{}, []string{"--video-id", "dQw4w9WgXcQ", "--max", "1"}, newQuietUIContext(t), &RootFlags{Account: "me@example.com"})
if err != nil {
t.Fatalf("runKong: %v", err)
}
if gotAccount != "me@example.com" {
t.Fatalf("account = %q", gotAccount)
}
}

func TestYouTubeVideosListWithAutoAccountUsesOAuthService(t *testing.T) {
origOAuth := newYouTubeForAccount
origAPIKey := newYouTubeWithAPIKey
origStore := openSecretsStoreForAccount
t.Cleanup(func() {
newYouTubeForAccount = origOAuth
newYouTubeWithAPIKey = origAPIKey
openSecretsStoreForAccount = origStore
})
openSecretsStoreForAccount = func() (secrets.Store, error) {
return &fakeSecretsStore{defaultAccount: "default@example.com"}, nil
}

var gotAccount string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/youtube/v3/videos" {
t.Fatalf("path = %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{}})
}))
defer srv.Close()

svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", youtube.NewService)
newYouTubeForAccount = func(_ context.Context, account string) (*youtube.Service, error) {
gotAccount = account
return svc, nil
}
newYouTubeWithAPIKey = func(context.Context, string) (*youtube.Service, error) {
t.Fatal("API key service should not be used when --account auto is configured")
return nil, errors.New("unexpected API key service")
}

err := runKong(t, &YouTubeVideosListCmd{}, []string{"--id", "dQw4w9WgXcQ", "--max", "1"}, newQuietUIContext(t), &RootFlags{Account: "auto"})
if err != nil {
t.Fatalf("runKong: %v", err)
}
if gotAccount != "default@example.com" {
t.Fatalf("account = %q", gotAccount)
}
}

func TestYouTubeValidation(t *testing.T) {
err := runKong(t, &YouTubeChannelsListCmd{}, []string{"--id", "UC123", "--max", "51"}, newQuietUIContext(t), &RootFlags{})
if err == nil || !strings.Contains(err.Error(), "--max must be between 1 and 50") {
Expand Down
18 changes: 18 additions & 0 deletions internal/googleapi/youtube.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (

var errYouTubeAPIKeyRequired = errors.New("youtube: API key required (config set youtube_api_key KEY or GOG_YOUTUBE_API_KEY)")

const scopeYouTubeForceSSL = "https://www.googleapis.com/auth/youtube.force-ssl"

// NewYouTubeWithAPIKey creates a YouTube Data API v3 service client using an API key.
// Use for public data: list by channelId, videoId, playlistId, etc.
// API key can be set via config (youtube_api_key) or GOG_YOUTUBE_API_KEY.
Expand Down Expand Up @@ -60,3 +62,19 @@ func NewYouTubeForAccount(ctx context.Context, email string) (*youtube.Service,

return svc, nil
}

// NewYouTubeCommentsForAccount creates a YouTube Data API v3 client for comment reads.
// Google requires youtube.force-ssl for commentThreads.list; youtube.readonly is insufficient.
func NewYouTubeCommentsForAccount(ctx context.Context, email string) (*youtube.Service, error) {
opts, err := optionsForAccountScopes(ctx, string(googleauth.ServiceYouTube), email, []string{scopeYouTubeForceSSL})
if err != nil {
return nil, fmt.Errorf("youtube comments OAuth options: %w", err)
}

svc, err := youtube.NewService(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("youtube comments service for account: %w", err)
}

return svc, nil
}
Loading