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
115 changes: 115 additions & 0 deletions .surface

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ func NewRootCmd() *cobra.Command {

// Context flags
cmd.PersistentFlags().StringVarP(&flags.Project, "project", "p", "", "Project ID or name")
cmd.PersistentFlags().StringVar(&flags.Project, "in", "", "Project ID or name (alias for --project)")
Comment thread
robzolkos marked this conversation as resolved.
cmd.PersistentFlags().StringVarP(&flags.Account, "account", "a", "", "Account ID")
Comment thread
robzolkos marked this conversation as resolved.
Comment thread
robzolkos marked this conversation as resolved.
cmd.PersistentFlags().StringVar(&flags.Todolist, "todolist", "", "Todolist ID or name")
cmd.PersistentFlags().StringVarP(&flags.Profile, "profile", "P", "", "Named profile")
Expand All @@ -202,6 +203,7 @@ func NewRootCmd() *cobra.Command {
// DefaultCacheDirFunc checks --cache-dir flag, then app context, then env vars.
completer := completion.NewCompleter(nil)
_ = cmd.RegisterFlagCompletionFunc("project", completer.ProjectNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("in", completer.ProjectNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("account", completer.AccountCompletion())
_ = cmd.RegisterFlagCompletionFunc("profile", completer.ProfileCompletion())

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@ Without --project, an interactive picker is shown.`,
}

// Mode 1: explicit --project flag — non-interactive setter
if f := cmd.Flags().Lookup("project"); f != nil && f.Changed {
if projectFlagChanged(cmd) {
projectArg := app.Flags.Project
resolvedID, projectName, err := app.Names.ResolveProject(cmd.Context(), projectArg)
if err != nil {
Expand Down
28 changes: 28 additions & 0 deletions internal/commands/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ func setupConfigProjectTestApp(t *testing.T) (*appctx.App, *bytes.Buffer, *httpt
func executeConfigProjectCmd(app *appctx.App, extraArgs ...string) error {
cmd := NewConfigCmd()
cmd.PersistentFlags().StringVarP(&app.Flags.Project, "project", "p", "", "Project ID or name")
cmd.PersistentFlags().StringVar(&app.Flags.Project, "in", "", "Project ID or name (alias for --project)")
args := append([]string{"project"}, extraArgs...)
cmd.SetArgs(args)
ctx := appctx.WithApp(context.Background(), app)
Expand Down Expand Up @@ -473,6 +474,33 @@ func TestConfigProject_ExplicitFlag(t *testing.T) {
assert.Equal(t, "12345", saved["project_id"])
}

func TestConfigProject_InAlias(t *testing.T) {
app, buf, _ := setupConfigProjectTestApp(t)

tmpDir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(tmpDir))
defer os.Chdir(origDir)

err = executeConfigProjectCmd(app, "--in", "12345")
require.NoError(t, err)

var result map[string]any
parseEnvelopeData(t, buf, &result)
assert.Equal(t, "12345", result["project_id"])
assert.Equal(t, "Project Alpha", result["project_name"])
assert.Equal(t, "set", result["status"])

// Verify config file was written
data, err := os.ReadFile(filepath.Join(tmpDir, ".basecamp", "config.json"))
require.NoError(t, err)
var saved map[string]any
require.NoError(t, json.Unmarshal(data, &saved))
assert.Equal(t, "12345", saved["project_id"])
}

func TestConfigProject_ExplicitFlag_InvalidID(t *testing.T) {
app, _, _ := setupConfigProjectTestApp(t)

Expand Down
12 changes: 12 additions & 0 deletions internal/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,3 +506,15 @@ func resolveMentions(ctx context.Context, resolver *names.Resolver, html string)
},
)
}

// projectFlagChanged reports whether the user explicitly passed --project or
// its --in alias on the command line.
func projectFlagChanged(cmd *cobra.Command) bool {
if f := cmd.Flags().Lookup("project"); f != nil && f.Changed {
return true
}
if f := cmd.Flags().Lookup("in"); f != nil && f.Changed {
return true
}
return false
}
32 changes: 30 additions & 2 deletions internal/commands/people.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,17 +168,25 @@ func newPeopleListCmd() *cobra.Command {
Short: "List people",
Long: "List all people in your Basecamp account, or in a specific project.",
RunE: func(cmd *cobra.Command, args []string) error {
if projectID == "" {
projectID = appctx.FromContext(cmd.Context()).Flags.Project
}
return runPeopleList(cmd, projectID, limit, page, all, sortField, reverse)
},
}

cmd.Flags().StringVarP(&projectID, "project", "p", "", "List people in a specific project")
cmd.Flags().StringVar(&projectID, "in", "", "List people in a specific project (alias for --project)")
cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of people to fetch (0 = all)")
cmd.Flags().BoolVar(&all, "all", false, "Fetch all people (no limit)")
cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)")
cmd.Flags().StringVar(&sortField, "sort", "", "Sort by field (name)")
cmd.Flags().BoolVar(&reverse, "reverse", false, "Reverse sort order")

completer := completion.NewCompleter(nil)
_ = cmd.RegisterFlagCompletionFunc("project", completer.ProjectNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("in", completer.ProjectNameCompletion())

return cmd
}

Expand Down Expand Up @@ -394,12 +402,22 @@ func newPeopleAddCmd() *cobra.Command {
if len(args) == 0 {
return missingArg(cmd, "<person-id>...")
}
if projectID == "" {
projectID = appctx.FromContext(cmd.Context()).Flags.Project
}
if projectID == "" {
return output.ErrUsage("--project (or --in) is required")
}
return runPeopleAdd(cmd, args, projectID)
},
}

cmd.Flags().StringVarP(&projectID, "project", "p", "", "Project to add people to (required)")
_ = cmd.MarkFlagRequired("project")
cmd.Flags().StringVar(&projectID, "in", "", "Project to add people to (alias for --project)")

completer := completion.NewCompleter(nil)
_ = cmd.RegisterFlagCompletionFunc("project", completer.ProjectNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("in", completer.ProjectNameCompletion())

return cmd
}
Expand Down Expand Up @@ -468,12 +486,22 @@ func newPeopleRemoveCmd() *cobra.Command {
if len(args) == 0 {
return missingArg(cmd, "<person-id>...")
}
if projectID == "" {
projectID = appctx.FromContext(cmd.Context()).Flags.Project
}
if projectID == "" {
return output.ErrUsage("--project (or --in) is required")
}
return runPeopleRemove(cmd, args, projectID)
},
}

cmd.Flags().StringVarP(&projectID, "project", "p", "", "Project to remove people from (required)")
_ = cmd.MarkFlagRequired("project")
cmd.Flags().StringVar(&projectID, "in", "", "Project to remove people from (alias for --project)")

completer := completion.NewCompleter(nil)
_ = cmd.RegisterFlagCompletionFunc("project", completer.ProjectNameCompletion())
_ = cmd.RegisterFlagCompletionFunc("in", completer.ProjectNameCompletion())

return cmd
}
Expand Down
210 changes: 210 additions & 0 deletions internal/commands/people_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -509,3 +510,212 @@ func TestMeWithBC3TokenOverridingStaleLaunchpadCreds(t *testing.T) {
require.NoError(t, json.Unmarshal(buf.Bytes(), &result), "failed to parse output: %s", buf.String())
assert.Equal(t, "mixed@example.com", result.Data.Identity.EmailAddress)
}

// setupPeopleMockServer creates a mock server that routes people endpoints.
// It serves distinct payloads for account-wide vs project-scoped list,
// and handles the UpdateProjectAccess (grant/revoke) endpoint.
func setupPeopleMockServer(t *testing.T, accountID string, projectID int64) *httptest.Server {
t.Helper()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

projectsPath := fmt.Sprintf("/%s/projects.json", accountID)
projectPeoplePath := fmt.Sprintf("/%s/projects/%d/people.json", accountID, projectID)
accountPeoplePath := fmt.Sprintf("/%s/people.json", accountID)
accessPath := fmt.Sprintf("/%s/projects/%d/people/users.json", accountID, projectID)

switch {
case r.URL.Path == projectsPath && r.Method == http.MethodGet:
// Projects list — used by name resolver
json.NewEncoder(w).Encode([]map[string]any{
{"id": projectID, "name": "Test Project"},
})
case r.URL.Path == accountPeoplePath && r.Method == http.MethodGet:
// Account-wide people list — also used by name resolver for person IDs
json.NewEncoder(w).Encode([]map[string]any{
{"id": 1001, "name": "Alice Test", "email_address": "alice@example.com"},
{"id": 2001, "name": "Account Bob", "title": "PM", "employee": true, "admin": true, "email_address": "bob@example.com"},
{"id": 2002, "name": "Account Carol", "title": "Design", "employee": true, "admin": false, "email_address": "carol@example.com"},
})
case r.URL.Path == projectPeoplePath && r.Method == http.MethodGet:
// Project-scoped people list — return a distinct set
json.NewEncoder(w).Encode([]map[string]any{
{"id": 1001, "name": "Project Alice", "title": "Dev", "employee": true, "admin": false, "email_address": "alice@example.com"},
})
case r.URL.Path == accessPath && r.Method == http.MethodPut:
// UpdateProjectAccess — echo back granted/revoked
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("bad request body: %v", err), http.StatusBadRequest)
return
}
resp := map[string]any{"granted": []any{}, "revoked": []any{}}
if ids, ok := req["grant"].([]any); ok {
for _, id := range ids {
resp["granted"] = append(resp["granted"].([]any), map[string]any{
"id": id, "name": fmt.Sprintf("Person %v", id),
})
}
}
if ids, ok := req["revoke"].([]any); ok {
for _, id := range ids {
resp["revoked"] = append(resp["revoked"].([]any), map[string]any{
"id": id, "name": fmt.Sprintf("Person %v", id),
})
}
Comment thread
jeremy marked this conversation as resolved.
}
json.NewEncoder(w).Encode(resp)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
return server
}

// setupPeopleMockApp creates a test app wired to the mock people server.
func setupPeopleMockApp(t *testing.T, server *httptest.Server) (*appctx.App, *bytes.Buffer) {
t.Helper()

t.Setenv("BASECAMP_NO_KEYRING", "1")

buf := &bytes.Buffer{}
cfg := &config.Config{
AccountID: "99999",
CacheDir: t.TempDir(),
}

sdkClient := basecamp.NewClient(
&basecamp.Config{BaseURL: server.URL},
&peopleTestTokenProvider{},
)

app := &appctx.App{
Config: cfg,
SDK: sdkClient,
Names: names.NewResolver(sdkClient, nil, "99999"),
Output: output.New(output.Options{
Format: output.FormatJSON,
Writer: buf,
}),
Flags: appctx.GlobalFlags{Hints: true},
}
return app, buf
}

// TestPeopleListIn verifies that --in takes the project-scoped path and
// returns project-specific people, not the account-wide list.
func TestPeopleListIn(t *testing.T) {
server := setupPeopleMockServer(t, "99999", 55555)
app, buf := setupPeopleMockApp(t, server)

cmd := NewPeopleCmd()
err := executePeopleCommand(cmd, app, "list", "--in", "55555")
require.NoError(t, err)

var result struct {
Data []struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &result), "output: %s", buf.String())

// Should return the project-scoped person (Alice), not the account-wide set
require.Len(t, result.Data, 1)
assert.Equal(t, int64(1001), result.Data[0].ID)
assert.Equal(t, "Project Alice", result.Data[0].Name)
}

// TestPeopleListWithoutIn returns account-wide list as a control.
func TestPeopleListWithoutIn(t *testing.T) {
server := setupPeopleMockServer(t, "99999", 55555)
app, buf := setupPeopleMockApp(t, server)

cmd := NewPeopleCmd()
err := executePeopleCommand(cmd, app, "list")
require.NoError(t, err)

var result struct {
Data []struct {
ID int64 `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &result), "output: %s", buf.String())

// Should return the account-wide people (Alice + Bob + Carol)
assert.Len(t, result.Data, 3)
}

// TestPeopleAddIn verifies that --in is accepted and routed to the
// correct project access endpoint (grant succeeds).
func TestPeopleAddIn(t *testing.T) {
server := setupPeopleMockServer(t, "99999", 55555)
app, buf := setupPeopleMockApp(t, server)

cmd := NewPeopleCmd()
err := executePeopleCommand(cmd, app, "add", "--in", "55555", "1001")
require.NoError(t, err)

var result struct {
Data struct {
Granted []struct {
ID int64 `json:"id"`
} `json:"granted"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &result), "output: %s", buf.String())
require.Len(t, result.Data.Granted, 1)
assert.Equal(t, int64(1001), result.Data.Granted[0].ID)
}

// TestPeopleRemoveIn verifies that --in is accepted and routed to the
// correct project access endpoint (revoke succeeds).
func TestPeopleRemoveIn(t *testing.T) {
server := setupPeopleMockServer(t, "99999", 55555)
app, buf := setupPeopleMockApp(t, server)

cmd := NewPeopleCmd()
err := executePeopleCommand(cmd, app, "remove", "--in", "55555", "1001")
require.NoError(t, err)

var result struct {
Data struct {
Revoked []struct {
ID int64 `json:"id"`
} `json:"revoked"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &result), "output: %s", buf.String())
require.Len(t, result.Data.Revoked, 1)
assert.Equal(t, int64(1001), result.Data.Revoked[0].ID)
}

// TestPeopleAddNoProject verifies that omitting --project/--in returns a usage error.
func TestPeopleAddNoProject(t *testing.T) {
app, _ := setupPeopleTestApp(t)

cmd := NewPeopleCmd()
err := executePeopleCommand(cmd, app, "add", "1001")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Equal(t, output.CodeUsage, e.Code)
assert.Contains(t, e.Message, "--project (or --in) is required")
}

// TestPeopleRemoveNoProject verifies that omitting --project/--in returns a usage error.
func TestPeopleRemoveNoProject(t *testing.T) {
app, _ := setupPeopleTestApp(t)

cmd := NewPeopleCmd()
err := executePeopleCommand(cmd, app, "remove", "1001")
require.Error(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Equal(t, output.CodeUsage, e.Code)
assert.Contains(t, e.Message, "--project (or --in) is required")
}
Loading