Skip to content
Draft
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
203 changes: 203 additions & 0 deletions .surface

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ The **Since** column tags each row with the Basecamp version that introduced its
| search | 2 | `search` | ✅ | BC4 | - | Full-text search + metadata. Filters: `--project`/`--in`, `--type`, `--creator`, `--since` (BC5-only), `--file-type`, `--exclude-chat`. Metadata lists recording/file search types |
| recordings | 4 | `recordings` | ✅ | BC4 | - | Browse by type/status, trash/archive/restore |
| **Files & Documents** |
| uploads | 8 | `files`, `uploads` | ✅ | BC4 | - | list, show, create. Create supports `--visible-to-clients` (root vault only) |
| uploads | 8 | `files`, `uploads` | ✅ | BC4 | - | list, show, create, update, download, versions (`files versions <id>`). Create supports `--visible-to-clients` (root vault only); trash/archive/restore go through `recordings` |
| vaults | 8 | `files`, `vaults` | ✅ | BC4 | - | list, show, create |
| documents | 8 | `files`, `docs` | ✅ | BC4 | - | list, show, create, update. Create supports `--subscribe`/`--no-subscribe`, `--visible-to-clients` (root vault only) |
| attachments | 1 | `uploads`, `attachments` | ✅ | BC4 | - | Upload via `attach`; list embedded attachments via `attachments list` (parses `<bc-attachment>` from content) |
Expand Down
8 changes: 8 additions & 0 deletions e2e/smoke/smoke_files_read.bats
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ setup_file() {
assert_json_value '.ok' 'true'
}

@test "files versions lists an upload's versions" {
ensure_upload || mark_unverifiable "No upload in project"

run_smoke basecamp files versions "$QA_UPLOAD" -p "$QA_PROJECT" --json
assert_success
assert_json_value '.ok' 'true'
}

@test "files download downloads a file" {
ensure_upload || return 0

Expand Down
8 changes: 8 additions & 0 deletions e2e/smoke/smoke_lifecycle.bats
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ load smoke_helper
mark_out_of_scope "Shares implementation with files group (tested)"
}

@test "docs versions is out of scope" {
mark_out_of_scope "Shares implementation with files group (tested)"
}

@test "docs uploads create is out of scope" {
mark_out_of_scope "Shares implementation with files group (tested)"
}
Expand Down Expand Up @@ -201,6 +205,10 @@ load smoke_helper
mark_out_of_scope "Shares implementation with files group (tested)"
}

@test "vaults versions is out of scope" {
mark_out_of_scope "Shares implementation with files group (tested)"
}

@test "vaults uploads create is out of scope" {
mark_out_of_scope "Shares implementation with files group (tested)"
}
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func CommandCategories() []CommandCategory {
{Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "publish", "pin", "unpin", "trash", "archive", "restore"}},
{Name: "chat", Category: "core", Description: "Chat in real-time", Actions: []string{"list", "messages", "post", "upload", "line", "update", "delete"}},
{Name: "cards", Category: "core", Description: "Manage Kanban cards", Actions: []string{"list", "show", "create", "update", "move", "done", "columns", "wormholes", "steps", "trash", "archive", "restore"}},
{Name: "files", Category: "core", Description: "Manage files, documents, and folders", Actions: []string{"list", "show", "download", "update", "trash", "archive", "restore"}},
{Name: "files", Category: "core", Description: "Manage files, documents, and folders", Actions: []string{"list", "show", "versions", "download", "update", "trash", "archive", "restore"}},
{Name: "checkins", Category: "core", Description: "View automatic check-ins", Actions: []string{"questions", "question", "answers", "answer", "reminders"}},
{Name: "schedule", Category: "core", Description: "Manage schedule entries", Actions: []string{"show", "entries", "create", "update"}},
},
Expand Down
89 changes: 89 additions & 0 deletions internal/commands/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Each project has a root folder containing documents, uploads, and subfolders.`,
newUploadsCmd(&project, &vaultID),
newDocsCmd(&project, &vaultID),
newFilesShowCmd(&project),
newFilesVersionsCmd(),
newFilesUpdateCmd(&project),
newFilesDownloadCmd(&project),
newRecordableTrashCmd("file"),
Expand Down Expand Up @@ -1540,6 +1541,94 @@ You can pass either an item ID or a Basecamp URL:
return cmd
}

func newFilesVersionsCmd() *cobra.Command {
var limit int
var page int
var all bool

cmd := &cobra.Command{
Use: "versions <upload_id|url>",
Short: "List an upload's versions",
Long: `List every version of an uploaded file.

Replacing a file in Basecamp keeps the earlier copies as versions of the same
upload, so the upload ID stays stable while its contents change.

You can pass either an upload ID or a Basecamp URL:
basecamp files versions 789 --in my-project
basecamp files versions https://3.basecamp.com/123/buckets/456/uploads/789`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

// Validate flag combinations
if all && limit > 0 {
return output.ErrUsage("--all and --limit are mutually exclusive")
}
if page > 0 && (all || limit > 0) {
return output.ErrUsage("--page cannot be combined with --all or --limit")
}
if page > 1 {
return output.ErrUsage("only --page 1 is supported; use --all to fetch everything")
}

if err := ensureAccount(cmd, app); err != nil {
return err
}

uploadIDStr := extractID(args[0])
uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64)
if err != nil {
return output.ErrUsage("Invalid upload ID")
}

// Build pagination options. The SDK treats Limit 0 as "every
// version", which is also this command's default.
opts := &basecamp.UploadVersionListOptions{}
if limit > 0 {
opts.Limit = limit
}
if page > 0 {
opts.Page = page
}

versionsResult, err := app.Account().Uploads().ListVersions(cmd.Context(), uploadID, opts)
if err != nil {
return convertSDKError(err)
}
versions := versionsResult.Versions

respOpts := []output.ResponseOption{
output.WithSummary(fmt.Sprintf("%d versions of upload #%s", len(versions), uploadIDStr)),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "show",
Cmd: fmt.Sprintf("basecamp files show %s", uploadIDStr),
Description: "Show file details",
},
output.Breadcrumb{
Action: "download",
Cmd: fmt.Sprintf("basecamp files download %s", uploadIDStr),
Description: "Download the current version",
},
),
}

if notice := output.TruncationNoticeWithTotal(len(versions), versionsResult.Meta.TotalCount); notice != "" {
respOpts = append(respOpts, output.WithNotice(notice))
}

return app.OK(versions, respOpts...)
},
}

cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of versions to fetch (0 = all)")
cmd.Flags().BoolVar(&all, "all", false, "Fetch all versions (no limit)")
cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)")

return cmd
}

func newFilesUpdateCmd(project *string) *cobra.Command {
var title string
var content string
Expand Down
72 changes: 72 additions & 0 deletions internal/commands/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1236,3 +1236,75 @@ func TestFilesGroupSpellingsGetHonestAccountWideSemantics(t *testing.T) {
assert.NotContains(t, transport.last(t).Query, "kind=")
})
}

// mockUploadVersionsTransport serves the versions listing for upload 789 and
// records every path it is asked for, so a stray call to the wrong endpoint
// fails the test instead of passing on a lucky response.
type mockUploadVersionsTransport struct {
requests []string
}

func (t *mockUploadVersionsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.requests = append(t.requests, req.Method+" "+req.URL.Path)

header := make(http.Header)
header.Set("Content-Type", "application/json")

if req.Method != http.MethodGet || !strings.HasSuffix(req.URL.Path, "/uploads/789/versions.json") {
return nil, fmt.Errorf("unexpected request: %s %s", req.Method, req.URL.Path)
}

return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(
`[{"id":790,"title":"report.pdf","filename":"report.pdf","status":"active"}]`,
)),
Header: header,
}, nil
}

// TestFilesVersionsListsUploadVersions verifies the command reaches the
// account-level versions endpoint — no project scope, no extra lookups.
func TestFilesVersionsListsUploadVersions(t *testing.T) {
transport := &mockUploadVersionsTransport{}
app := showTestApp(t, transport)

cmd := NewFilesCmd()
err := executeMessagesCommand(cmd, app, "versions", "789")
require.NoError(t, err)

assert.Equal(t, []string{"GET /99999/uploads/789/versions.json"}, transport.requests)
}

// TestFilesVersionsAcceptsURL verifies a pasted upload URL resolves to the
// same request as the bare ID.
func TestFilesVersionsAcceptsURL(t *testing.T) {
transport := &mockUploadVersionsTransport{}
app := showTestApp(t, transport)

cmd := NewFilesCmd()
err := executeMessagesCommand(cmd, app, "versions", "https://3.basecamp.com/99999/buckets/456/uploads/789")
require.NoError(t, err)

assert.Equal(t, []string{"GET /99999/uploads/789/versions.json"}, transport.requests)
}

// TestFilesVersionsRejectsConflictingPagination pins the same pagination
// contract the other bounded listings use: --page disables the walk, so it
// cannot be combined with --all or --limit, and only page 1 is reachable.
func TestFilesVersionsRejectsConflictingPagination(t *testing.T) {
for name, args := range map[string][]string{
"--all with --limit": {"versions", "789", "--all", "--limit", "5"},
"--page with --all": {"versions", "789", "--page", "1", "--all"},
"--page beyond 1": {"versions", "789", "--page", "2"},
} {
t.Run(name, func(t *testing.T) {
transport := &mockUploadVersionsTransport{}
app := showTestApp(t, transport)

err := executeMessagesCommand(NewFilesCmd(), app, args...)
require.Error(t, err)
assert.Empty(t, transport.requests, "must refuse before any request")
})
}
}
7 changes: 7 additions & 0 deletions skills/basecamp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,8 @@ basecamp files list --all-projects --limit 500 # Walk pages until 500 c
basecamp files list --all-projects --page 2 # Exactly page 2
basecamp files list --all-projects --all # Every page (slow on big accounts)
basecamp files show <id> --in <project> # Show item (auto-detects type)
basecamp files versions <upload_id> --json # Every version of an uploaded file
basecamp files versions <upload_id> --limit 5 --json # Cap results (default: all)
basecamp files download <id> --in <project> # Download file
basecamp files download <id> --out ./dir # Download to specific dir
basecamp files download "https://storage.../download/f" # Download from storage URL
Expand Down Expand Up @@ -777,6 +779,11 @@ server default; as with Messages, a **client-authenticated caller always creates
client-visible records** regardless. `recordings visibility` is **not** a
remediation for nested docs/uploads.

**Upload versions:** replacing a file keeps the earlier copies under the same
upload ID, so `basecamp files versions <upload_id>` is how you see the history of
one file. A file that was never replaced returns its single current version, not
an error. Only `--page 1` is accepted; use `--all` to walk every page.

**Subcommands:** `folders`, `uploads`, `documents` (each with pagination flags)

### Schedule
Expand Down
Loading