Skip to content

Feature: File uploads and attachments - #117

Open
CFDan wants to merge 3 commits into
mainfrom
feature/file-attachments
Open

Feature: File uploads and attachments#117
CFDan wants to merge 3 commits into
mainfrom
feature/file-attachments

Conversation

@CFDan

@CFDan CFDan commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Lets callers upload a file and attach it to a task (at creation or afterwards), a comment, or a message. This is the gap behind a customer request for attachment support in the MCP server — LLMs generate plans and specs and currently have no way to put them on a task.

API

// Upload once, get an opaque reference
uploaded, _ := projects.PendingFileCreate(ctx, engine,
    projects.NewPendingFileCreateRequestFromBytes("plan.md", data))

// Attach it while creating a task, or to one that already exists
req := projects.NewTaskCreateRequest(tasklistID, "Ship it")
req.Attachments = projects.TaskAttachments{
    PendingFiles: []projects.TaskAttachmentPendingFile{{Reference: uploaded.PendingFile.Ref}},
}

Comments and messages take PendingFileAttachments []PendingFileRef. Messages also take Attachments LegacyNumericList for files that already exist. FileCreate/FileDelete cover the project's files area, which is the only way to get an identifier reusable across several tasks, since a pending reference is consumed the first time it is attached.

Notes for review

Upload is one multipart request, not the presigned S3 flow the docs describe. POST /projects/api/v1/pendingfiles.json is authenticated, goes to the installation, and has explicit middleware carve-outs in projectsapigo (the request timeout is disabled for that exact path). The presigned flow needs three requests, bakes the exact content length into the signature so the body cannot be streamed, and signs X-Amz-Acl only outside staging — an SDK taking that route would have to know which environment its installation is in.

attachmentOptions is deliberately not modelled. Its only field defaults to false, and on task update the server seeds the "keep these" list from the task's current attachments, so it removes nothing. Attaching is always additive. Modelling it would advertise a capability that does not work.

Comments stay on v1. v3 would drop four of the five parent types, break CommentCreateResponse.ID for existing callers, and the v3 comment attachments path panics server side (streamlinkQuery has no comment case) — I will raise that separately.

json:"-" plus omitzero on TaskAttachments, so existing callers' payloads are unchanged rather than gaining an empty attachments: {}.

FileCreateRequest has NotifyCurrentUser but not the group Notify. The API returns 201 for a bool, "ALL" and an ID list alike, but that only proves acceptance, not that notifications fire, so I left it unmodelled rather than guess at a notifier interface.

Verification

Run live against a real installation, in a throwaway project since deleted: upload, task create with attachment, attach to an existing task, comment, message, project file create, reusing that file identifier on a second task, file delete, and an update with no attachments to confirm the key is omitted. All passed.

go build, go vet, go test ./... and go generate ./... are clean, with no sparse fieldset drift.

Note gofmt/golangci-lint flag ~50 pre-existing files — the checkout is CRLF (core.autocrlf=true, no .gitattributes) while the tools want LF. That predates this branch and none of the files here are affected; worth a separate conversation.

Adds the ability to upload a file and attach it to a task, a comment or a
message, plus a project's files area.

Uploading is a single authenticated multipart request to
POST /projects/api/v1/pendingfiles.json, which returns an opaque reference. The
reference is then passed to whichever entity should own the file. The presigned
S3 flow the public docs describe was not used: it needs three requests, bakes
the exact content length into the signature, and signs the ACL header only
outside staging, so an SDK would have to know which environment its
installation is in.

Tasks take the v3 attachments object, which is a sibling of the task in the
request body, in the same way predecessors already are. Comments and messages
take the v1 pendingFileAttachments list. The API accepts either a JSON array or
a comma separated string there, so no custom encoding type was needed.

attachmentOptions is deliberately not modelled. Its only field defaults to
false, and on task update the API seeds the "keep these" list from the task's
current attachments, so it removes nothing. Attaching is always additive.

Notebooks, milestones, links and message replies have no attachment support in
the API, and the v3 comment attachments route is broken server side, which is
why comments continue to be written through v1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CFDan
CFDan requested a review from a team as a code owner August 15, 2026 11:45
@CFDan
CFDan requested a review from rafaeljusto August 15, 2026 11:45
@CFDan CFDan added the enhancement New feature or request label Aug 15, 2026
Contents is now a byte slice rather than an io.Reader. A multipart body has to
be assembled in full before it is sent, so a reader was being drained into a
buffer anyway, and holding the bytes keeps the request re-executable: running
the same request value twice used to upload an empty file the second time. It
also removes the double buffering and the panic on a typed-nil reader.

FileCreateResponse decodes the documented "id" instead of the "fileId" alias,
matching every other v1 create response here, and FileCreateRequest validates
its required fields the way PendingFileCreateRequest already did.

Adds attachments_test.go, which drives HTTPRequest and asserts the encoded body.
The integration tests only assert that a write was accepted, and the API answers
2xx whether or not it understood the attachment, so a wrong key or a field
nested in the wrong place went unnoticed. It covers both multi-reference forms,
attaching an existing file by identifier with a category, and the absence of the
attachments key when none was asked for. These run without a configured engine.

The attachment integration tests attached a pending file directly, which makes
the API create a project file the test has no way to find, so every CI run left
files behind in the shared test project. They now add the file to the project
first and delete it in cleanup, which also covers attaching by identifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CFDan

CFDan commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Review pass done — pushed d48d296.

Fixed

  • Contents is now []byte rather than io.Reader. The request was single-use: a multipart body has to be assembled in full anyway, so the reader was drained into a buffer, and re-running the same request value uploaded an empty file the second time. This also removes the double buffering and the typed-nil panic.
  • FileCreateResponse decodes the documented id rather than the fileId alias, matching every other v1 create response here.
  • FileCreateRequest validates its required fields, as PendingFileCreateRequest already did.
  • The attachment integration tests attached a pending file directly, which makes the API create a project file the test has no way to find, so every CI run left files behind in the shared test project. They now add the file to the project first and delete it in cleanup — which also covers attaching by identifier.
  • Dropped the engine skip from the validation tests; they need no network and now always run.
  • New attachments_test.go drives HTTPRequest and asserts the encoded body. This is the real gap the review found: the integration tests only assert that a write was accepted, and the API answers 2xx whether or not it understood the attachment, so a wrong key or wrong nesting went unnoticed. It covers multi-reference attaches, attaching by identifier with a category, and the absence of the key when nothing was asked for.

Checked against the API rather than changed

Four findings assumed behaviour that turned out not to hold. I tested each on a live installation:

  • pendingFileAttachments as a JSON array is correct, including for several references. Two refs in one array produced two attachments. The docs do say comma-separated, and the handler does accept that, but it branches on IsArray first. The array needs no encoding type of its own, so it stays.
  • Comment update is additive: a comment with two files, updated with one new reference, ended with three.
  • Message update with attachments did not replace. A message with two files, updated with one of the two identifiers, still had two.
  • TaskAttachments.Files and the message Attachments field were verified end to end, though the review was right that nothing in the repo covered them — attachments_test.go does now.

Not changed

The io.Reader streaming suggestion conflicts with making the request re-executable, and []byte already removes the double buffering that motivated it. Streaming can be added later as its own request type without breaking this one.

FileCreateResponse exposes a single ID again; the fileId fallback is decoded
through a local struct in HandleHTTPResponse rather than a second exported
field that mirrors the first. Documents that the v1 message-update attachments
field is additive, verified against a live installation, matching its
pending-file sibling. Presizes the multipart upload buffer, since the file
length is known. Adds a clarifying comment on the pending-file upload path,
which is the only v1 route in the package under /projects/api/v1/. Guards the
request-body test helper against a nil body and indexes the subset matcher's
array path; TestFileDelete now provisions its file through the createFile
helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant