From 89117f039f5188fea94edc6061cfaaa25d6cb4bb Mon Sep 17 00:00:00 2001 From: Caio Pizzol <33255434+caiopizzol@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:27:47 -0300 Subject: [PATCH 1/2] feat: add provider connection command --- cmd/connect_provider.go | 105 +++++++++++++++++++++++++++++++++++ cmd/connect_provider_test.go | 97 ++++++++++++++++++++++++++++++++ request/api.go | 21 +++++++ request/oauth_test.go | 63 +++++++++++++++++++++ request/request.go | 17 +++++- request/types.go | 8 +++ 6 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 cmd/connect_provider.go create mode 100644 cmd/connect_provider_test.go create mode 100644 request/oauth_test.go diff --git a/cmd/connect_provider.go b/cmd/connect_provider.go new file mode 100644 index 0000000..4a42842 --- /dev/null +++ b/cmd/connect_provider.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "context" + "errors" + + "github.com/amp-labs/cli/clerk" + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/request" + "github.com/spf13/cobra" +) + +type oauthClient interface { + GenerateOAuthAuthorizationURL( + ctx context.Context, + params *request.OAuthAuthorizationURLParams, + ) (string, error) +} + +type oauthClientFactory func(projectId string, apiKey string) oauthClient + +type connectProjectResolver func() string + +type browserDetector func() bool + +type browserOpener func(url string) + +func newConnectProviderCmd( + newClient oauthClientFactory, + getProjectId connectProjectResolver, + hasBrowser browserDetector, + openURL browserOpener, +) *cobra.Command { + var ( + groupRef string + consumerRef string + providerAppId string + ) + + cmd := &cobra.Command{ + Use: "connect:provider ", + Short: "Connect an account to a provider", + Long: "Generate an OAuth authorization URL. Open it in a browser when available; " + + "otherwise, print it.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + projectId := getProjectId() + client := newClient(projectId, flags.GetAPIKey()) + + url, err := client.GenerateOAuthAuthorizationURL( + cmd.Context(), + &request.OAuthAuthorizationURLParams{ + ProjectIdOrName: projectId, + Provider: args[0], + GroupRef: groupRef, + ConsumerRef: consumerRef, + ProviderAppId: providerAppId, + }, + ) + if err != nil { + if errors.Is(err, clerk.ErrNoSessions) { + logger.FatalErr("Authenticated session has expired, please log in using amp login", err) + } else { + logger.FatalErr("Unable to generate OAuth authorization URL", err) + } + } + + if hasBrowser() { + openURL(url) + logger.Info("Opened the authorization URL in your browser.") + + return + } + + logger.Infof("Authorization URL: %s", url) + }, + } + + cmd.Flags().StringVar(&groupRef, "group-ref", "", "Identifier for the organization or workspace") + cmd.Flags().StringVar(&consumerRef, "consumer-ref", "", "Identifier for the user authorizing the connection") + cmd.Flags().StringVar(&providerAppId, "provider-app", "", "Provider app ID (uses the project default if omitted)") + + for _, flag := range []string{"group-ref", "consumer-ref"} { + err := cmd.MarkFlagRequired(flag) + if err != nil { + logger.FatalErr("unable to require "+flag+" flag", err) + } + } + + return cmd +} + +var connectProviderCmd = newConnectProviderCmd( //nolint:gochecknoglobals + func(projectId string, apiKey string) oauthClient { + return request.NewAPIClient(projectId, &apiKey) + }, + flags.GetProjectOrFail, + canOpenBrowser, + openBrowser, +) + +func init() { + rootCmd.AddCommand(connectProviderCmd) +} diff --git a/cmd/connect_provider_test.go b/cmd/connect_provider_test.go new file mode 100644 index 0000000..331e204 --- /dev/null +++ b/cmd/connect_provider_test.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/amp-labs/cli/request" +) + +const ( + testAuthorizationURL = "https://provider.example/authorize" + testOAuthProject = "test-project" +) + +type fakeOAuthClient struct { + requestedWith *request.OAuthAuthorizationURLParams +} + +func (f *fakeOAuthClient) GenerateOAuthAuthorizationURL( + _ context.Context, + params *request.OAuthAuthorizationURLParams, +) (string, error) { + f.requestedWith = params + + return testAuthorizationURL, nil +} + +func TestConnectProviderCommandGeneratesAndOpensAuthorizationURL(t *testing.T) { + t.Parallel() + + client := &fakeOAuthClient{} + openedURL := "" + cmd := newConnectProviderCmd( + func(projectId string, _ string) oauthClient { + if projectId != testOAuthProject { + t.Fatalf("project ID = %q, want test-project", projectId) + } + + return client + }, + func() string { return testOAuthProject }, + func() bool { return true }, + func(url string) { openedURL = url }, + ) + cmd.SetArgs([]string{ + "asana", + "--group-ref", "test-group", + "--consumer-ref", "test-consumer", + "--provider-app", "provider-app-id", + }) + + err := cmd.Execute() + if err != nil { + t.Fatalf("execute connect provider command: %v", err) + } + + want := request.OAuthAuthorizationURLParams{ + ProjectIdOrName: testOAuthProject, + Provider: "asana", + GroupRef: "test-group", + ConsumerRef: "test-consumer", + ProviderAppId: "provider-app-id", + } + if client.requestedWith == nil || *client.requestedWith != want { + t.Fatalf("authorization params = %#v, want %#v", client.requestedWith, want) + } + + if openedURL != testAuthorizationURL { + t.Fatalf("opened URL = %q, want %q", openedURL, testAuthorizationURL) + } +} + +func TestConnectProviderCommandDoesNotOpenBrowserWhenUnavailable(t *testing.T) { + t.Parallel() + + client := &fakeOAuthClient{} + cmd := newConnectProviderCmd( + func(_ string, _ string) oauthClient { return client }, + func() string { return testOAuthProject }, + func() bool { return false }, + func(url string) { t.Fatalf("opened URL in headless mode: %s", url) }, + ) + cmd.SetArgs([]string{ + "asana", + "--group-ref", "test-group", + "--consumer-ref", "test-consumer", + }) + + err := cmd.Execute() + if err != nil { + t.Fatalf("execute connect provider command: %v", err) + } + + if client.requestedWith == nil { + t.Fatal("authorization URL was not requested") + } +} diff --git a/request/api.go b/request/api.go index e7a9ecd..cc1d6cf 100644 --- a/request/api.go +++ b/request/api.go @@ -129,6 +129,27 @@ func (c *APIClient) GetMyInfo(ctx context.Context) (map[string]any, error) { return myInfo, nil } +func (c *APIClient) GenerateOAuthAuthorizationURL( + ctx context.Context, + params *OAuthAuthorizationURLParams, +) (string, error) { + oauthURL := c.Root + "/oauth-connect" + + auth, err := c.getAuthHeader(ctx) + if err != nil { + return "", err + } + + var authorizationURL string + + _, err = c.Client.Post(ctx, oauthURL, params, &authorizationURL, auth) //nolint:bodyclose + if err != nil { + return "", err + } + + return authorizationURL, nil +} + func (c *APIClient) DeleteIntegration(ctx context.Context, integrationId string) error { delURL := fmt.Sprintf("%s/projects/%s/integrations/%s", c.Root, c.ProjectId, integrationId) diff --git a/request/oauth_test.go b/request/oauth_test.go new file mode 100644 index 0000000..b11daf4 --- /dev/null +++ b/request/oauth_test.go @@ -0,0 +1,63 @@ +package request + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGenerateOAuthAuthorizationURL(t *testing.T) { + t.Parallel() + + wantParams := OAuthAuthorizationURLParams{ + ProjectIdOrName: "test-project", + Provider: "asana", + GroupRef: "test-group", + ConsumerRef: "test-consumer", + ProviderAppId: "provider-app-id", + } + wantURL := "https://provider.example/authorize" + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost || req.URL.Path != "/v1/oauth-connect" { + t.Errorf("request = %s %s, want POST /v1/oauth-connect", req.Method, req.URL.Path) + } + + if req.Header.Get("X-Api-Key") != "test-api-key" { + t.Errorf("X-Api-Key = %q, want test-api-key", req.Header.Get("X-Api-Key")) + } + + var params OAuthAuthorizationURLParams + + err := json.NewDecoder(req.Body).Decode(¶ms) + if err != nil { + t.Errorf("decode request: %v", err) + } + + if params != wantParams { + t.Errorf("request body = %#v, want %#v", params, wantParams) + } + + writer.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = writer.Write([]byte(wantURL)) + })) + defer server.Close() + + apiKey := "test-api-key" + client := &APIClient{ + Root: server.URL + "/v1", + ProjectId: "test-project", + APIKey: &apiKey, + Client: &Client{Client: server.Client()}, + } + + url, err := client.GenerateOAuthAuthorizationURL(t.Context(), &wantParams) + if err != nil { + t.Fatalf("generate OAuth authorization URL: %v", err) + } + + if url != wantURL { + t.Fatalf("authorization URL = %q, want %q", url, wantURL) + } +} diff --git a/request/request.go b/request/request.go index 86935ac..2b87f80 100644 --- a/request/request.go +++ b/request/request.go @@ -195,8 +195,9 @@ func (c *Client) Delete(ctx context.Context, } var ( - ErrNon200Status = errors.New("error response from API") - ErrNotFound = errors.New("HTTP Status 404") + ErrNon200Status = errors.New("error response from API") + ErrNotFound = errors.New("HTTP Status 404") + errPlainTextResultTarget = errors.New("plain text response requires a string result") ) func (c *Client) makeRequestAndParseJSONResult(req *http.Request, result any) (*http.Response, error) { //nolint:cyclop @@ -235,6 +236,18 @@ func (c *Client) makeRequestAndParseJSONResult(req *http.Request, result any) (* } } + contentType, _, contentTypeErr := mime.ParseMediaType(res.Header.Get("Content-Type")) + if contentTypeErr == nil && contentType == "text/plain" { + textResult, ok := result.(*string) + if !ok { + return nil, errPlainTextResultTarget + } + + *textResult = string(payload) + + return res, nil + } + err = json.Unmarshal(payload, result) if err != nil { return nil, err diff --git a/request/types.go b/request/types.go index 540388c..b86bbcd 100644 --- a/request/types.go +++ b/request/types.go @@ -55,6 +55,14 @@ type ProviderApp struct { ProjectId string `json:"projectId"` } +type OAuthAuthorizationURLParams struct { + ProjectIdOrName string `json:"projectIdOrName"` + Provider string `json:"provider"` + GroupRef string `json:"groupRef"` + ConsumerRef string `json:"consumerRef"` + ProviderAppId string `json:"providerAppId,omitempty"` +} + type Config struct { Id string `json:"id"` RevisionId string `json:"revisionId"` From 83a6fe0fae78fe09e095be599d8c7a6dc2475c72 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <33255434+caiopizzol@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:29:00 -0300 Subject: [PATCH 2/2] docs: clarify provider connection help --- cmd/connect_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/connect_provider.go b/cmd/connect_provider.go index 4a42842..8786415 100644 --- a/cmd/connect_provider.go +++ b/cmd/connect_provider.go @@ -40,7 +40,7 @@ func newConnectProviderCmd( cmd := &cobra.Command{ Use: "connect:provider ", - Short: "Connect an account to a provider", + Short: "Start a provider OAuth connection", Long: "Generate an OAuth authorization URL. Open it in a browser when available; " + "otherwise, print it.", Args: cobra.ExactArgs(1),