diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 09a1ac1..5e0329c 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -41,6 +41,7 @@ export default defineConfig({ { label: 'Memberships', translations: { 'zh-CN': '成员关系' }, slug: 'commands/memberships' }, { label: 'Versions', translations: { 'zh-CN': '版本' }, slug: 'commands/versions' }, { label: 'Files', translations: { 'zh-CN': '项目文件' }, slug: 'commands/files' }, + { label: 'Attachments', translations: { 'zh-CN': '附件' }, slug: 'commands/attachments' }, { label: 'Time Entries', translations: { 'zh-CN': '工时记录' }, slug: 'commands/time' }, { label: 'Users', translations: { 'zh-CN': '用户' }, slug: 'commands/users' }, { label: 'My Account', translations: { 'zh-CN': '我的账户' }, slug: 'commands/my_account' }, diff --git a/docs/src/content/docs/commands/attachments.mdx b/docs/src/content/docs/commands/attachments.mdx new file mode 100644 index 0000000..efba7ac --- /dev/null +++ b/docs/src/content/docs/commands/attachments.mdx @@ -0,0 +1,106 @@ +--- +title: Attachments +description: Inspect attachment metadata and download attachment files without curl or manual API-key handling. +--- + +import { CardGrid, LinkCard, Tabs, TabItem, Aside, Badge } from '@astrojs/starlight/components'; + +The `attachments` command (aliases `attachment`, `a`) surfaces Redmine's [Attachments API](https://www.redmine.org/projects/redmine/wiki/Rest_api#Attachments) so you can inspect an attachment's metadata and download its bytes. Downloads reuse the active profile's server and API key, streaming straight to disk - no `curl`, no manually extracted key. + + + + + + + + +## get + +```bash +redmine attachments get [flags] +``` + +Aliases: `show`, `view`, `metadata`. Fetches the attachment's ID, filename, size, content type, description, author, and creation time via `GET /attachments/:id.json`. Table output also shows the download URL (when present); `json` returns every field, including `content_url`. + +| Flag | Description | +|------|-------------| +| `-o, --output` | Output format: `table`, `json`, `csv` | + + + + ```bash + redmine attachments get 42 + ``` + + + ```bash + redmine attachments get 42 -o json + ``` + + + ```bash + redmine attachments get 42 -o csv + ``` + + + +## download + +```bash +redmine attachments download [flags] +``` + +Aliases: `dl`, `get-file`. Streams the attachment's raw bytes to disk (or stdout), authenticating with the active profile. The response is streamed, never buffered entirely in memory, so large files are safe. + +| Flag | Description | +|------|-------------| +| `-d, --dir` | Directory to save into, using the attachment's real filename (created if missing) | +| `--path` | Exact destination file path. Use `-` to stream to stdout | +| `-o, --output` | Confirmation format: `json` prints a result object; otherwise a human line is written to stderr | + +With no destination flag, the file is saved as `./` in the current directory. + + + + + + ```bash + # Saves ./ + redmine attachments download 42 + ``` + + + ```bash + redmine attachments download 42 --dir ./downloads + ``` + + + ```bash + redmine attachments download 42 --path ./logo.png + ``` + + + ```bash + redmine attachments download 42 --path - | file - + ``` + + + ```bash + redmine attachments download 42 --dir ./downloads -o json + ``` + + + + diff --git a/docs/src/content/docs/commands/issues.mdx b/docs/src/content/docs/commands/issues.mdx index 785da4c..80deb28 100644 --- a/docs/src/content/docs/commands/issues.mdx +++ b/docs/src/content/docs/commands/issues.mdx @@ -91,11 +91,34 @@ Aliases: `show`, `view`. | Flag | Description | |------|-------------| -| `--include` | Include related data: `journals`, `children`, `relations` | -| `--journals` | Shorthand for `--include journals` | -| `--children` | Shorthand for `--include children` | -| `--relations` | Shorthand for `--include relations` | -| `-o, --output` | Output format | +| `--include` | Include related data: `journals`, `children`, `relations`, `attachments` | +| `--journals` | Shorthand for `--include journals` | +| `--children` | Shorthand for `--include children` | +| `--relations` | Shorthand for `--include relations` | +| `--attachments` | List the issue's attachments (id, filename, size, content type) | +| `--download-attachments` | Download every attachment of the issue into the given directory | +| `-o, --output` | Output format | + + + + ```bash + # List attachment IDs so you can download one + redmine issues get 123 --attachments + ``` + + + ```bash + # Pull every attachment into ./issue-123 + redmine issues get 123 --download-attachments ./issue-123 + ``` + + + + ## create diff --git a/docs/src/content/docs/zh-cn/commands/attachments.mdx b/docs/src/content/docs/zh-cn/commands/attachments.mdx new file mode 100644 index 0000000..4421ace --- /dev/null +++ b/docs/src/content/docs/zh-cn/commands/attachments.mdx @@ -0,0 +1,105 @@ +--- +title: 附件 +description: 查看附件元数据并下载附件文件,无需 curl,也无需手动处理 API 密钥。 +--- + +import { CardGrid, LinkCard, Tabs, TabItem, Aside, Badge } from '@astrojs/starlight/components'; + +`attachments` 命令(别名 `attachment`、`a`)封装了 Redmine 的[附件 API](https://www.redmine.org/projects/redmine/wiki/Rest_api#Attachments),让你既能查看附件的元数据,也能下载其字节内容。下载会复用当前 profile 的服务器地址和 API 密钥,并直接流式写入磁盘 - 无需 `curl`,也无需手动提取密钥。 + + + + + + + + +## get + +```bash +redmine attachments get [flags] +``` + +别名:`show`、`view`、`metadata`。通过 `GET /attachments/:id.json` 获取附件的 ID、文件名、大小、内容类型、描述、作者和创建时间。表格输出还会显示下载 URL(若存在);`json` 会返回所有字段,包括 `content_url`。 + +| 标志 | 描述 | +|------|------| +| `-o, --output` | 输出格式:`table`、`json`、`csv` | + + + + ```bash + redmine attachments get 42 + ``` + + + ```bash + redmine attachments get 42 -o json + ``` + + + ```bash + redmine attachments get 42 -o csv + ``` + + + +## download + +```bash +redmine attachments download [flags] +``` + +别名:`dl`、`get-file`。使用当前 profile 进行认证,将附件的原始字节流式写入磁盘(或 stdout)。响应是流式处理的,不会整体缓存在内存中,因此下载大文件也很安全。 + +| 标志 | 描述 | +|------|------| +| `-d, --dir` | 保存到的目录,使用附件的真实文件名(目录不存在时自动创建) | +| `--path` | 精确的目标文件路径。使用 `-` 可流式输出到 stdout | +| `-o, --output` | 确认信息的格式:`json` 打印结果对象;否则向 stderr 写入一行可读信息 | + +若不指定目标标志,文件会以 `./<文件名>` 保存到当前目录。 + + + + + + ```bash + # 保存为 ./<文件名> + redmine attachments download 42 + ``` + + + ```bash + redmine attachments download 42 --dir ./downloads + ``` + + + ```bash + redmine attachments download 42 --path ./logo.png + ``` + + + ```bash + redmine attachments download 42 --path - | file - + ``` + + + ```bash + redmine attachments download 42 --dir ./downloads -o json + ``` + + + + diff --git a/docs/src/content/docs/zh-cn/commands/issues.mdx b/docs/src/content/docs/zh-cn/commands/issues.mdx index d1d7071..e96f152 100644 --- a/docs/src/content/docs/zh-cn/commands/issues.mdx +++ b/docs/src/content/docs/zh-cn/commands/issues.mdx @@ -91,11 +91,34 @@ redmine issues get [flags] | 标志 | 描述 | |------|------| -| `--include` | 包含关联数据:`journals`、`children`、`relations` | -| `--journals` | `--include journals` 的简写 | -| `--children` | `--include children` 的简写 | -| `--relations` | `--include relations` 的简写 | -| `-o, --output` | 输出格式 | +| `--include` | 包含关联数据:`journals`、`children`、`relations`、`attachments` | +| `--journals` | `--include journals` 的简写 | +| `--children` | `--include children` 的简写 | +| `--relations` | `--include relations` 的简写 | +| `--attachments` | 列出工单的附件(id、文件名、大小、内容类型) | +| `--download-attachments` | 将工单的所有附件下载到指定目录 | +| `-o, --output` | 输出格式 | + + + + ```bash + # 列出附件 ID,以便下载某个附件 + redmine issues get 123 --attachments + ``` + + + ```bash + # 将所有附件下载到 ./issue-123 + redmine issues get 123 --download-attachments ./issue-123 + ``` + + + + ## create diff --git a/e2e/attachments_test.go b/e2e/attachments_test.go new file mode 100644 index 0000000..f62f742 --- /dev/null +++ b/e2e/attachments_test.go @@ -0,0 +1,169 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "os" + "path/filepath" + "strconv" + "testing" + "time" +) + +// pngFixture returns a small payload that begins with the real PNG signature +// and contains NUL and high bytes, so a byte-identical round-trip proves +// binary integrity (no text decoding / newline mangling). +func pngFixture(uniq string) []byte { + return []byte("\x89PNG\r\n\x1a\n\x00\x01\x02\x03\xff\xfe redmine-cli e2e " + uniq + "\n") +} + +// createIssueWithAttachment creates an issue carrying a single attachment and +// returns the issue ID, the on-disk filename, and the exact bytes uploaded. +func createIssueWithAttachment(t *testing.T, r *cliRunner, projectIdentifier string) (issueID int, filename string, payload []byte) { + t.Helper() + uniq := strconv.FormatInt(time.Now().UnixNano(), 36) + filename = "logo-" + uniq + ".png" + payload = pngFixture(uniq) + path := filepath.Join(t.TempDir(), filename) + if err := os.WriteFile(path, payload, 0o600); err != nil { + t.Fatalf("write attach file: %v", err) + } + + var created struct { + ID int `json:"id"` + } + r.runJSON(t, &created, "issues", "create", + "--project", projectIdentifier, + "--tracker", firstTrackerName(t, r), + "--subject", "E2E attachment "+uniq, + "--attach", path) + if created.ID == 0 { + t.Fatal("issues create returned no ID") + } + return created.ID, filename, payload +} + +type attachmentEntry struct { + ID int `json:"id"` + Filename string `json:"filename"` + Filesize int64 `json:"filesize"` + ContentType string `json:"content_type"` +} + +// findIssueAttachmentID discovers the attachment ID via the new +// `issues get --attachments` flag, asserting the attachment surfaces in the +// typed JSON output. +func findIssueAttachmentID(t *testing.T, r *cliRunner, issueID int, filename string) attachmentEntry { + t.Helper() + var resp struct { + Attachments []attachmentEntry `json:"attachments"` + } + r.runJSON(t, &resp, "issues", "get", issueIDArg(issueID), "--attachments") + for _, att := range resp.Attachments { + if att.Filename == filename { + return att + } + } + t.Fatalf("attachment %q not surfaced by `issues get --attachments`: %+v", filename, resp.Attachments) + return attachmentEntry{} +} + +// TestAttachments_Lifecycle exercises the full discover -> metadata -> download +// flow and asserts binary integrity of the downloaded file. +func TestAttachments_Lifecycle(t *testing.T) { + requireE2E(t) + r := newCLIRunner(t, e2eBaseURL(), e2eAPIKey()) + proj := createTestProject(t, r) + + issueID, filename, payload := createIssueWithAttachment(t, r, proj.Identifier) + + att := findIssueAttachmentID(t, r, issueID, filename) + if att.ID == 0 { + t.Fatal("discovered attachment has zero ID") + } + if att.Filesize != int64(len(payload)) { + t.Errorf("filesize = %d, want %d", att.Filesize, len(payload)) + } + + // Metadata via `attachments get`. + var meta attachmentEntry + r.runJSON(t, &meta, "attachments", "get", strconv.Itoa(att.ID)) + if meta.Filename != filename || meta.Filesize != int64(len(payload)) { + t.Errorf("attachments get = %+v, want filename=%q size=%d", meta, filename, len(payload)) + } + + // Download into a directory using the real filename. + dir := t.TempDir() + var dl struct { + ID int `json:"id"` + Filename string `json:"filename"` + Path string `json:"path"` + Bytes int64 `json:"bytes"` + } + r.runJSON(t, &dl, "attachments", "download", strconv.Itoa(att.ID), "--dir", dir) + if dl.Path != filepath.Join(dir, filename) { + t.Errorf("download path = %q, want %q", dl.Path, filepath.Join(dir, filename)) + } + if dl.Bytes != int64(len(payload)) { + t.Errorf("downloaded bytes = %d, want %d", dl.Bytes, len(payload)) + } + + got, err := os.ReadFile(dl.Path) + if err != nil { + t.Fatalf("read downloaded file: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("downloaded file not byte-identical to the uploaded PNG\nwant %v\ngot %v", payload, got) + } +} + +// TestAttachments_DownloadToStdout verifies `--path -` streams raw bytes to +// stdout without corruption, even though the runner injects --output json. +func TestAttachments_DownloadToStdout(t *testing.T) { + requireE2E(t) + r := newCLIRunner(t, e2eBaseURL(), e2eAPIKey()) + proj := createTestProject(t, r) + + issueID, filename, payload := createIssueWithAttachment(t, r, proj.Identifier) + att := findIssueAttachmentID(t, r, issueID, filename) + + stdout, _, err := r.runRaw("attachments", "download", strconv.Itoa(att.ID), "--path", "-") + if err != nil { + t.Fatalf("download to stdout failed: %v", err) + } + if !bytes.Equal(stdout, payload) { + t.Fatalf("stdout not byte-identical to the uploaded PNG\nwant %v\ngot %v", payload, stdout) + } +} + +// TestAttachments_IssueDownloadAll covers `issues get --download-attachments`. +func TestAttachments_IssueDownloadAll(t *testing.T) { + requireE2E(t) + r := newCLIRunner(t, e2eBaseURL(), e2eAPIKey()) + proj := createTestProject(t, r) + + issueID, filename, payload := createIssueWithAttachment(t, r, proj.Identifier) + + dir := filepath.Join(t.TempDir(), "issue-attachments") + // The issue JSON still lands on stdout; the download is a stderr-reported + // side effect, so we only need a successful exit here. + r.run(t, "issues", "get", issueIDArg(issueID), "--download-attachments", dir) + + got, err := os.ReadFile(filepath.Join(dir, filename)) + if err != nil { + t.Fatalf("expected downloaded attachment in %s: %v", dir, err) + } + if !bytes.Equal(got, payload) { + t.Fatal("issue-downloaded attachment not byte-identical to source") + } +} + +// TestAttachments_GetMissing asserts a clear error for an unknown attachment. +func TestAttachments_GetMissing(t *testing.T) { + requireE2E(t) + r := newCLIRunner(t, e2eBaseURL(), e2eAPIKey()) + + stdout, _ := r.runExpectError(t, "attachments", "get", "999999999") + requireErrorEnvelopeMessage(t, stdout) +} diff --git a/e2e/issues_test.go b/e2e/issues_test.go index 3d754cf..d8dd358 100644 --- a/e2e/issues_test.go +++ b/e2e/issues_test.go @@ -160,9 +160,9 @@ func TestIssues_Assign(t *testing.T) { } // TestIssues_Attachment verifies the multipart upload path: create an issue -// with --attach, then fetch via the raw api with ?include=attachments (the -// typed Issue model doesn't surface attachments) and assert the file is -// present. +// with --attach, then fetch via the raw api with ?include=attachments and +// assert the file is present. (The typed `issues get --attachments` flow is +// covered separately in attachments_test.go.) func TestIssues_Attachment(t *testing.T) { requireE2E(t) r := newCLIRunner(t, e2eBaseURL(), e2eAPIKey()) diff --git a/e2e/testdata/mcp_tools_readonly.golden.json b/e2e/testdata/mcp_tools_readonly.golden.json index 6b7855e..62ac551 100644 --- a/e2e/testdata/mcp_tools_readonly.golden.json +++ b/e2e/testdata/mcp_tools_readonly.golden.json @@ -1,4 +1,8 @@ [ + { + "name": "get_attachment", + "description": "Fetch metadata for a Redmine attachment by ID: filename, size, content type, description, author, and download URL. Discover attachment IDs via get_issue with includes=[\"attachments\"]." + }, { "name": "get_custom_field", "description": "Fetch a single custom field definition by ID. Admin-only endpoint." diff --git a/internal/api/attachments.go b/internal/api/attachments.go index 626e1fc..25f5dc3 100644 --- a/internal/api/attachments.go +++ b/internal/api/attachments.go @@ -7,16 +7,81 @@ import ( "io" "net/http" "net/url" + "strconv" + "strings" "time" "github.com/aarondpn/redmine-cli/v2/internal/debug" + "github.com/aarondpn/redmine-cli/v2/internal/models" ) -// AttachmentService handles file upload API calls. +// AttachmentService handles attachment metadata, upload, and download API +// calls. type AttachmentService struct { client *Client } +// Get retrieves the metadata for a single attachment by ID via +// GET /attachments/:id.json (filename, content type, size, author, +// content_url, ...). +func (s *AttachmentService) Get(ctx context.Context, id int) (*models.Attachment, error) { + var resp struct { + Attachment models.Attachment `json:"attachment"` + } + if err := s.client.Get(ctx, fmt.Sprintf("/attachments/%d.json", id), nil, &resp); err != nil { + return nil, err + } + return &resp.Attachment, nil +} + +// Download streams the raw bytes of an attachment to w without buffering the +// whole file in memory. It returns the number of bytes written. +// +// The download URL is resolved from the attachment's content_url when that URL +// is hosted on the configured Redmine server (so subdirectory installs keep +// working), and otherwise falls back to the canonical +// /attachments/download/:id/:filename path built from the base URL. This guard +// ensures the API key (added to every request by authTransport) is never sent +// to a host other than the configured server. +func (s *AttachmentService) Download(ctx context.Context, att *models.Attachment, w io.Writer) (int64, error) { + u := s.downloadURL(att) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return 0, err + } + + start := time.Now() + resp, err := s.client.httpClient.Do(req) + duration := time.Since(start) + if err != nil { + s.client.debugLog.Printf("HTTP %s %s -> error (%s): %v", req.Method, debug.ScrubURL(req.URL.String()), duration.Round(time.Millisecond), err) + return 0, fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + s.client.debugLog.Printf("HTTP %s %s -> %d (%s)", req.Method, debug.ScrubURL(req.URL.String()), resp.StatusCode, duration.Round(time.Millisecond)) + + if resp.StatusCode >= 400 { + return 0, parseErrorResponse(resp) + } + + n, err := io.Copy(w, resp.Body) + if err != nil { + return n, fmt.Errorf("streaming attachment %d: %w", att.ID, err) + } + return n, nil +} + +// downloadURL returns the absolute URL to stream the attachment bytes from. +// See Download for the host-matching rationale. +func (s *AttachmentService) downloadURL(att *models.Attachment) string { + if att.ContentURL != "" && strings.HasPrefix(att.ContentURL, s.client.baseURL+"/") { + return att.ContentURL + } + return s.client.baseURL + "/attachments/download/" + strconv.Itoa(att.ID) + "/" + url.PathEscape(att.Filename) +} + // Upload streams body (of known size) to Redmine's /uploads.json endpoint and // returns the upload token. filename is sent as a query parameter per the // Redmine REST docs so the server records the original name. diff --git a/internal/api/attachments_download_test.go b/internal/api/attachments_download_test.go new file mode 100644 index 0000000..901b08a --- /dev/null +++ b/internal/api/attachments_download_test.go @@ -0,0 +1,176 @@ +package api + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aarondpn/redmine-cli/v2/internal/models" +) + +func TestAttachmentGet_Success(t *testing.T) { + var gotPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"attachment":{"id":7,"filename":"diagram.png","filesize":2048,"content_type":"image/png","description":"arch","content_url":"http://example/attachments/download/7/diagram.png","author":{"id":3,"name":"Ada"},"created_on":"2026-01-02T03:04:05Z"}}`)) + })) + defer ts.Close() + + c := newTestClient(ts) + c.Attachments = &AttachmentService{client: c} + + att, err := c.Attachments.Get(context.Background(), 7) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/attachments/7.json" { + t.Errorf("path = %q, want /attachments/7.json", gotPath) + } + if att.ID != 7 || att.Filename != "diagram.png" || att.Filesize != 2048 { + t.Errorf("attachment = %+v, want id=7 filename=diagram.png size=2048", att) + } + if att.ContentType != "image/png" || att.Author.Name != "Ada" { + t.Errorf("attachment metadata = %+v, want content_type/image author Ada", att) + } +} + +func TestAttachmentGet_NotFound(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + c := newTestClient(ts) + c.Attachments = &AttachmentService{client: c} + + _, err := c.Attachments.Get(context.Background(), 999) + var apiErr *APIError + if !errors.As(err, &apiErr) || !apiErr.IsNotFound() { + t.Fatalf("err = %v, want APIError 404", err) + } +} + +func TestAttachmentDownload_StreamsFromContentURL(t *testing.T) { + want := []byte("\x89PNG\r\n\x1a\n raw binary \x00\x01\x02 bytes") + var gotPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(want) + })) + defer ts.Close() + + c := newTestClient(ts) + c.Attachments = &AttachmentService{client: c} + + att := &models.Attachment{ + ID: 7, + Filename: "diagram.png", + ContentURL: ts.URL + "/attachments/download/7/diagram.png", + } + + var buf bytes.Buffer + n, err := c.Attachments.Download(context.Background(), att, &buf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/attachments/download/7/diagram.png" { + t.Errorf("download path = %q, want /attachments/download/7/diagram.png", gotPath) + } + if n != int64(len(want)) { + t.Errorf("bytes = %d, want %d", n, len(want)) + } + if !bytes.Equal(buf.Bytes(), want) { + t.Errorf("body = %v, want %v (binary integrity)", buf.Bytes(), want) + } +} + +func TestAttachmentDownload_FallsBackToCanonicalPath(t *testing.T) { + want := []byte("plain content") + var gotPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write(want) + })) + defer ts.Close() + + c := newTestClient(ts) + c.Attachments = &AttachmentService{client: c} + + // No content_url: Download must build /attachments/download/:id/:filename + // against the base URL and URL-escape the filename. + att := &models.Attachment{ID: 9, Filename: "release notes.txt"} + + var buf bytes.Buffer + if _, err := c.Attachments.Download(context.Background(), att, &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/attachments/download/9/release notes.txt" { + t.Errorf("download path = %q, want decoded /attachments/download/9/release notes.txt", gotPath) + } + if !bytes.Equal(buf.Bytes(), want) { + t.Errorf("body = %q, want %q", buf.Bytes(), want) + } +} + +func TestAttachmentDownload_ErrorStatus(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer ts.Close() + + c := newTestClient(ts) + c.Attachments = &AttachmentService{client: c} + + att := &models.Attachment{ID: 1, Filename: "x.bin", ContentURL: ts.URL + "/attachments/download/1/x.bin"} + _, err := c.Attachments.Download(context.Background(), att, &bytes.Buffer{}) + var apiErr *APIError + if !errors.As(err, &apiErr) || !apiErr.IsForbidden() { + t.Fatalf("err = %v, want APIError 403", err) + } +} + +// TestAttachmentDownloadURL exercises the host-matching guard that keeps the +// API key (added to every request by authTransport) from being sent to a host +// other than the configured Redmine server. +func TestAttachmentDownloadURL(t *testing.T) { + s := &AttachmentService{client: &Client{baseURL: "https://redmine.example.com/redmine"}} + + cases := []struct { + name string + att *models.Attachment + want string + }{ + { + name: "content_url on configured server is used verbatim", + att: &models.Attachment{ID: 1, Filename: "a.png", ContentURL: "https://redmine.example.com/redmine/attachments/download/1/a.png"}, + want: "https://redmine.example.com/redmine/attachments/download/1/a.png", + }, + { + name: "foreign content_url falls back to canonical base-URL path", + att: &models.Attachment{ID: 2, Filename: "b.png", ContentURL: "https://evil.example.net/steal"}, + want: "https://redmine.example.com/redmine/attachments/download/2/b.png", + }, + { + name: "empty content_url builds canonical path", + att: &models.Attachment{ID: 3, Filename: "c.png"}, + want: "https://redmine.example.com/redmine/attachments/download/3/c.png", + }, + { + name: "filename is URL-escaped in the built path", + att: &models.Attachment{ID: 4, Filename: "my file (1).png"}, + want: "https://redmine.example.com/redmine/attachments/download/4/my%20file%20%281%29.png", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := s.downloadURL(tc.att); got != tc.want { + t.Errorf("downloadURL = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/api/auth_transport_test.go b/internal/api/auth_transport_test.go new file mode 100644 index 0000000..aa7f35c --- /dev/null +++ b/internal/api/auth_transport_test.go @@ -0,0 +1,132 @@ +package api + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/aarondpn/redmine-cli/v2/internal/debug" + "github.com/aarondpn/redmine-cli/v2/internal/models" +) + +func hostOf(t *testing.T, raw string) string { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u.Host +} + +func TestAuthTransport_AttachesKeyOnConfiguredHost(t *testing.T) { + var gotKey string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Redmine-API-Key") + _, _ = w.Write([]byte("{}")) + })) + defer ts.Close() + + client := &http.Client{Transport: &authTransport{ + base: http.DefaultTransport, authMethod: "apikey", apiKey: "K", host: hostOf(t, ts.URL), + }} + req, _ := http.NewRequest(http.MethodGet, ts.URL, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + resp.Body.Close() + if gotKey != "K" { + t.Errorf("X-Redmine-API-Key = %q, want K (configured host must receive auth)", gotKey) + } +} + +func TestAuthTransport_StripsKeyOnForeignHost(t *testing.T) { + var gotKey string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Redmine-API-Key") + _, _ = w.Write([]byte("{}")) + })) + defer ts.Close() + + // Configured host is deliberately different from the server we call. + client := &http.Client{Transport: &authTransport{ + base: http.DefaultTransport, authMethod: "apikey", apiKey: "K", host: "redmine.internal:9999", + }} + req, _ := http.NewRequest(http.MethodGet, ts.URL, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + resp.Body.Close() + if gotKey != "" { + t.Errorf("X-Redmine-API-Key = %q, want empty (must not leak to a foreign host)", gotKey) + } +} + +func TestAuthTransport_StripsBasicAuthOnForeignHost(t *testing.T) { + var gotUser string + var hadAuth bool + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _, hadAuth = r.BasicAuth() + _, _ = w.Write([]byte("{}")) + })) + defer ts.Close() + + client := &http.Client{Transport: &authTransport{ + base: http.DefaultTransport, authMethod: "basic", username: "u", password: "p", host: "redmine.internal:9999", + }} + req, _ := http.NewRequest(http.MethodGet, ts.URL, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + resp.Body.Close() + if hadAuth { + t.Errorf("basic auth leaked to foreign host (user=%q)", gotUser) + } +} + +// TestAttachmentDownload_CrossHostRedirectDoesNotLeakKey reproduces the +// realistic case where an attachment's content_url 302-redirects to external +// object storage. The API key must reach the configured server but never the +// off-host redirect target. +func TestAttachmentDownload_CrossHostRedirectDoesNotLeakKey(t *testing.T) { + var evilGotKey string + evil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + evilGotKey = r.Header.Get("X-Redmine-API-Key") + _, _ = w.Write([]byte("payload")) + })) + defer evil.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Redmine-API-Key") != "SECRET" { + t.Errorf("configured host missing API key: %q", r.Header.Get("X-Redmine-API-Key")) + } + http.Redirect(w, r, evil.URL+"/object-storage", http.StatusFound) + })) + defer srv.Close() + + c := &Client{ + httpClient: &http.Client{Transport: &authTransport{ + base: http.DefaultTransport, authMethod: "apikey", apiKey: "SECRET", host: hostOf(t, srv.URL), + }}, + baseURL: srv.URL, + debugLog: debug.New(nil), + } + c.Attachments = &AttachmentService{client: c} + + att := &models.Attachment{ID: 1, Filename: "x.bin", ContentURL: srv.URL + "/attachments/download/1/x.bin"} + var buf bytes.Buffer + if _, err := c.Attachments.Download(context.Background(), att, &buf); err != nil { + t.Fatalf("download failed: %v", err) + } + if evilGotKey != "" { + t.Fatalf("API key leaked to off-host redirect target: %q", evilGotKey) + } + if buf.String() != "payload" { + t.Errorf("body = %q, want payload (redirect must still be followed)", buf.String()) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 1abe1e2..bb43be6 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -63,6 +63,13 @@ type authTransport struct { apiKey string username string password string + // host is the configured server host (host[:port]). Credentials are only + // attached when the request targets this host, so a redirect off-site + // (e.g. an attachment content_url that 302s to external object storage) + // never receives the API key or basic-auth credentials. An empty host + // means "always attach" (used by tests that construct the transport + // directly without a configured server). + host string } func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { @@ -71,11 +78,16 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("Content-Type", "application/json") } - switch t.authMethod { - case "basic": - req.SetBasicAuth(t.username, t.password) - default: - req.Header.Set("X-Redmine-API-Key", t.apiKey) + // Only attach credentials to the configured server's host. This keeps the + // API key (and basic-auth) from leaking to a third party if the server, or + // an attachment's content_url, redirects to an off-site host. + if t.host == "" || strings.EqualFold(req.URL.Host, t.host) { + switch t.authMethod { + case "basic": + req.SetBasicAuth(t.username, t.password) + default: + req.Header.Set("X-Redmine-API-Key", t.apiKey) + } } base := t.base @@ -93,12 +105,18 @@ func NewClient(cfg *config.Config, log *debug.Logger) (*Client, error) { baseURL := strings.TrimRight(cfg.Server, "/") + var host string + if u, err := url.Parse(baseURL); err == nil { + host = u.Host + } + transport := &authTransport{ base: http.DefaultTransport, authMethod: cfg.AuthMethod, apiKey: cfg.APIKey, username: cfg.Username, password: cfg.Password, + host: host, } c := &Client{ diff --git a/internal/cmd/attachment/attachment.go b/internal/cmd/attachment/attachment.go new file mode 100644 index 0000000..0784d56 --- /dev/null +++ b/internal/cmd/attachment/attachment.go @@ -0,0 +1,30 @@ +// Package attachment implements the `redmine attachments` command group, which +// surfaces the Redmine REST Attachments API so users (and agents) can inspect +// attachment metadata and download the raw bytes without dropping down to +// `redmine api` plus a manual `curl` with a hand-extracted API key. +package attachment + +import ( + "github.com/spf13/cobra" + + "github.com/aarondpn/redmine-cli/v2/internal/cmdutil" +) + +// NewCmdAttachments creates the parent attachments command. +func NewCmdAttachments(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "attachments", + Aliases: []string{"attachment", "a"}, + Short: "Inspect and download Redmine attachments", + Long: "Inspect attachment metadata and download attachment files.\n\n" + + "Discover attachment IDs with `redmine issues get --attachments`, then\n" + + "fetch metadata with `redmine attachments get ` or download the file with\n" + + "`redmine attachments download `. Downloads reuse the active profile's\n" + + "server and API key, so there is no need to extract the key or shell out to curl.", + } + + cmd.AddCommand(newCmdGet(f)) + cmd.AddCommand(newCmdDownload(f)) + + return cmd +} diff --git a/internal/cmd/attachment/attachment_test.go b/internal/cmd/attachment/attachment_test.go new file mode 100644 index 0000000..3b27455 --- /dev/null +++ b/internal/cmd/attachment/attachment_test.go @@ -0,0 +1,182 @@ +package attachment + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aarondpn/redmine-cli/v2/internal/testutil" +) + +// pngBytes is a tiny payload containing NUL and other non-text bytes so the +// tests double as a binary-integrity check. +var pngBytes = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0x01, 0x02, 0xff} + +// attachmentServer serves metadata at /attachments/.json and raw bytes at +// the content_url it advertises. id 55 -> logo.png. +func attachmentServer(t *testing.T) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/attachments/55.json": + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"attachment":{"id":55,"filename":"logo.png","filesize":%d,"content_type":"image/png","description":"the logo","content_url":%q,"author":{"id":1,"name":"Ada"},"created_on":"2026-01-01T00:00:00Z"}}`, + len(pngBytes), srv.URL+"/attachments/download/55/logo.png") + case "/attachments/download/55/logo.png": + _, _ = w.Write(pngBytes) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestCmdAttachmentGet_JSON(t *testing.T) { + srv := attachmentServer(t) + f := testutil.NewFactory(t, srv.URL) + cmd := newCmdGet(f) + cmd.SetArgs([]string{"55", "--output", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + out := testutil.Stdout(f) + if !strings.Contains(out, `"filename": "logo.png"`) || !strings.Contains(out, `"content_type": "image/png"`) { + t.Fatalf("json stdout = %q, want filename and content_type", out) + } +} + +func TestCmdAttachmentGet_TableAndCSV(t *testing.T) { + for _, format := range []string{"table", "csv"} { + t.Run(format, func(t *testing.T) { + srv := attachmentServer(t) + f := testutil.NewFactory(t, srv.URL) + cmd := newCmdGet(f) + cmd.SetArgs([]string{"55", "--output", format}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if out := testutil.Stdout(f); !strings.Contains(out, "logo.png") { + t.Fatalf("%s stdout = %q, want logo.png", format, out) + } + }) + } +} + +func TestCmdAttachmentGet_InvalidID(t *testing.T) { + f := testutil.NewFactory(t, "http://127.0.0.1:0") + cmd := newCmdGet(f) + cmd.SetArgs([]string{"not-a-number"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for non-numeric id") + } +} + +func TestCmdAttachmentDownload_ToDirJSON(t *testing.T) { + srv := attachmentServer(t) + f := testutil.NewFactory(t, srv.URL) + dir := t.TempDir() + cmd := newCmdDownload(f) + cmd.SetArgs([]string{"55", "--dir", dir, "--output", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + + var res struct { + ID int `json:"id"` + Filename string `json:"filename"` + Path string `json:"path"` + Bytes int64 `json:"bytes"` + } + if err := json.Unmarshal([]byte(testutil.Stdout(f)), &res); err != nil { + t.Fatalf("decode result: %v\nstdout: %s", err, testutil.Stdout(f)) + } + wantPath := filepath.Join(dir, "logo.png") + if res.Path != wantPath || res.Bytes != int64(len(pngBytes)) || res.Filename != "logo.png" { + t.Fatalf("result = %+v, want path=%q bytes=%d", res, wantPath, len(pngBytes)) + } + got, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("read downloaded file: %v", err) + } + if string(got) != string(pngBytes) { + t.Errorf("downloaded bytes differ from source (binary integrity)") + } +} + +func TestCmdAttachmentDownload_DefaultsToCurrentDir(t *testing.T) { + srv := attachmentServer(t) + f := testutil.NewFactory(t, srv.URL) + t.Chdir(t.TempDir()) // bare `download ` saves into the working directory + + cmd := newCmdDownload(f) + cmd.SetArgs([]string{"55"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile("logo.png") + if err != nil { + t.Fatalf("expected ./logo.png in the working directory: %v", err) + } + if string(got) != string(pngBytes) { + t.Error("downloaded bytes differ from source") + } +} + +func TestCmdAttachmentDownload_ToExplicitPath(t *testing.T) { + srv := attachmentServer(t) + f := testutil.NewFactory(t, srv.URL) + dest := filepath.Join(t.TempDir(), "renamed.png") + cmd := newCmdDownload(f) + cmd.SetArgs([]string{"55", "--path", dest}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read downloaded file: %v", err) + } + if string(got) != string(pngBytes) { + t.Error("downloaded bytes differ from source") + } + // Default (table) output writes the confirmation to stderr, never stdout. + if testutil.Stdout(f) != "" { + t.Errorf("stdout = %q, want empty (confirmation goes to stderr)", testutil.Stdout(f)) + } + if !strings.Contains(testutil.Stderr(f), "renamed.png") { + t.Errorf("stderr = %q, want confirmation mentioning the path", testutil.Stderr(f)) + } +} + +func TestCmdAttachmentDownload_ToStdout(t *testing.T) { + srv := attachmentServer(t) + f := testutil.NewFactory(t, srv.URL) + cmd := newCmdDownload(f) + // --output json must NOT corrupt the piped binary on stdout. + cmd.SetArgs([]string{"55", "--path", "-", "--output", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if testutil.Stdout(f) != string(pngBytes) { + t.Errorf("stdout did not receive the raw bytes verbatim") + } + if !strings.Contains(testutil.Stderr(f), "stdout") { + t.Errorf("stderr = %q, want confirmation mentioning stdout", testutil.Stderr(f)) + } +} + +func TestCmdAttachmentDownload_DirAndPathMutuallyExclusive(t *testing.T) { + f := testutil.NewFactory(t, "http://127.0.0.1:0") + cmd := newCmdDownload(f) + cmd.SetArgs([]string{"55", "--dir", "/tmp/x", "--path", "/tmp/y"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when --dir and --path are combined") + } +} diff --git a/internal/cmd/attachment/download.go b/internal/cmd/attachment/download.go new file mode 100644 index 0000000..92258b7 --- /dev/null +++ b/internal/cmd/attachment/download.go @@ -0,0 +1,133 @@ +package attachment + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/aarondpn/redmine-cli/v2/internal/cmdutil" + "github.com/aarondpn/redmine-cli/v2/internal/ops" + "github.com/aarondpn/redmine-cli/v2/internal/output" +) + +// downloadResult is the JSON payload emitted (with -o json) after a successful +// download to a file or directory. +type downloadResult struct { + ID int `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"content_type,omitempty"` + Path string `json:"path"` + Bytes int64 `json:"bytes"` +} + +func newCmdDownload(f *cmdutil.Factory) *cobra.Command { + var ( + dir string + path string + format string + ) + + cmd := &cobra.Command{ + Use: "download ", + Aliases: []string{"dl", "get-file"}, + Short: "Download an attachment file", + Long: "Download the raw bytes of an attachment, authenticating with the active\n" + + "profile's server and API key (no manual key handling, no curl).\n\n" + + "By default the file is saved in the current directory under its real\n" + + "filename. Use --dir to choose a directory, --path to choose an exact file\n" + + "path, or `--path -` to stream the bytes to stdout for piping. The file is\n" + + "streamed to disk, never buffered entirely in memory.\n\n" + + "Note: for this command -o/--output controls the confirmation output\n" + + "(json prints a result object; otherwise a human-readable line is written to\n" + + "stderr). It does NOT change where the bytes go; use --path/--dir for that.", + Example: ` # Save to ./ in the current directory + redmine attachments download 42 + + # Save into a directory, keeping the real filename + redmine attachments download 42 --dir ./downloads + + # Save to an explicit path + redmine attachments download 42 --path ./logo.png + + # Stream to stdout (pipe into another tool) + redmine attachments download 42 --path - > logo.png + + # Print a JSON result describing the saved file + redmine attachments download 42 --dir ./downloads -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid attachment ID: %s", args[0]) + } + + client, err := f.ApiClient() + if err != nil { + return err + } + printer := f.Printer(format) + + ctx := context.Background() + stop := printer.Spinner("Downloading attachment...") + att, err := ops.GetAttachment(ctx, client, ops.GetAttachmentInput{ID: id}) + if err != nil { + stop() + return fmt.Errorf("failed to get attachment %d: %w", id, err) + } + + // Stream to stdout: stdout carries the raw bytes, so the + // confirmation always goes to stderr and no JSON envelope is + // emitted (it would corrupt the piped output). + if path == "-" { + n, err := client.Attachments.Download(ctx, att, f.IOStreams.Out) + stop() + if err != nil { + return fmt.Errorf("failed to download attachment %d: %w", id, err) + } + fmt.Fprintf(f.IOStreams.ErrOut, "Downloaded %q (%d bytes) to stdout\n", att.Filename, n) + return nil + } + + var ( + dest string + bytes int64 + ) + switch { + case path != "": + dest = path + bytes, err = cmdutil.SaveAttachmentToFile(ctx, client, att, path) + case dir != "": + dest, bytes, err = cmdutil.SaveAttachmentToDir(ctx, client, att, dir) + default: + dest, bytes, err = cmdutil.SaveAttachmentToDir(ctx, client, att, ".") + } + stop() + if err != nil { + return fmt.Errorf("failed to download attachment %d: %w", id, err) + } + + result := downloadResult{ + ID: att.ID, + Filename: att.Filename, + ContentType: att.ContentType, + Path: dest, + Bytes: bytes, + } + if printer.Format() == output.FormatJSON { + printer.JSON(result) + return nil + } + printer.Success(fmt.Sprintf("Downloaded %q (%d bytes) to %s", att.Filename, bytes, dest)) + return nil + }, + } + + cmd.Flags().StringVarP(&dir, "dir", "d", "", "Directory to save into, using the attachment's real filename") + cmd.Flags().StringVar(&path, "path", "", "Exact destination file path (use - for stdout)") + cmd.MarkFlagsMutuallyExclusive("dir", "path") + cmdutil.AddOutputFlag(cmd, &format) + + return cmd +} diff --git a/internal/cmd/attachment/get.go b/internal/cmd/attachment/get.go new file mode 100644 index 0000000..6a2b771 --- /dev/null +++ b/internal/cmd/attachment/get.go @@ -0,0 +1,101 @@ +package attachment + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/aarondpn/redmine-cli/v2/internal/cmdutil" + "github.com/aarondpn/redmine-cli/v2/internal/models" + "github.com/aarondpn/redmine-cli/v2/internal/ops" + "github.com/aarondpn/redmine-cli/v2/internal/output" +) + +func newCmdGet(f *cmdutil.Factory) *cobra.Command { + var format string + + cmd := &cobra.Command{ + Use: "get ", + Aliases: []string{"show", "view", "metadata"}, + Short: "Show attachment metadata", + Long: "Display metadata for a single attachment: filename, size, content type, " + + "description, author, creation time, and download URL.\n\n" + + "Use `redmine attachments download ` to fetch the file itself.", + Example: ` # Show metadata in a table + redmine attachments get 42 + + # JSON output (for scripting) + redmine attachments get 42 -o json + + # CSV output + redmine attachments get 42 -o csv`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid attachment ID: %s", args[0]) + } + + client, err := f.ApiClient() + if err != nil { + return err + } + printer := f.Printer(format) + + stop := printer.Spinner("Fetching attachment...") + att, err := ops.GetAttachment(context.Background(), client, ops.GetAttachmentInput{ID: id}) + stop() + if err != nil { + return fmt.Errorf("failed to get attachment %d: %w", id, err) + } + + switch printer.Format() { + case output.FormatJSON: + printer.JSON(att) + case output.FormatCSV: + printer.CSV(attachmentHeaders, [][]string{attachmentRow(att)}) + default: + renderAttachmentDetail(printer, att) + } + return nil + }, + } + + cmdutil.AddOutputFlag(cmd, &format) + + return cmd +} + +var attachmentHeaders = []string{"ID", "Filename", "Size", "Content-Type", "Author", "Created", "Description"} + +func attachmentRow(att *models.Attachment) []string { + return []string{ + strconv.Itoa(att.ID), + att.Filename, + strconv.FormatInt(att.Filesize, 10), + att.ContentType, + att.Author.Name, + att.CreatedOn, + att.Description, + } +} + +func renderAttachmentDetail(printer output.Printer, att *models.Attachment) { + pairs := []output.KeyValue{ + {Key: "ID", Value: output.StyleID.Render(strconv.Itoa(att.ID))}, + {Key: "Filename", Value: att.Filename}, + {Key: "Size", Value: fmt.Sprintf("%d bytes", att.Filesize)}, + {Key: "Content-Type", Value: att.ContentType}, + {Key: "Author", Value: att.Author.Name}, + {Key: "Created", Value: att.CreatedOn}, + } + if att.Description != "" { + pairs = append(pairs, output.KeyValue{Key: "Description", Value: att.Description}) + } + if att.ContentURL != "" { + pairs = append(pairs, output.KeyValue{Key: "Content URL", Value: att.ContentURL}) + } + printer.Detail(pairs) +} diff --git a/internal/cmd/issue/get.go b/internal/cmd/issue/get.go index ba093aa..0e5a0e5 100644 --- a/internal/cmd/issue/get.go +++ b/internal/cmd/issue/get.go @@ -8,7 +8,9 @@ import ( "github.com/spf13/cobra" + "github.com/aarondpn/redmine-cli/v2/internal/api" "github.com/aarondpn/redmine-cli/v2/internal/cmdutil" + "github.com/aarondpn/redmine-cli/v2/internal/models" "github.com/aarondpn/redmine-cli/v2/internal/ops" "github.com/aarondpn/redmine-cli/v2/internal/output" ) @@ -16,20 +18,38 @@ import ( // NewCmdGet creates the issues get command. func NewCmdGet(f *cmdutil.Factory) *cobra.Command { var ( - include string - journals bool - children bool - relations bool - format string + include string + journals bool + children bool + relations bool + attachments bool + downloadAttachments string + format string ) cmd := &cobra.Command{ Use: "get ", Aliases: []string{"show", "view"}, Short: "Get issue details", - Long: "Display detailed information about a specific issue.", - Args: cobra.ExactArgs(1), + Long: "Display detailed information about a specific issue.\n\n" + + "Use --attachments to list the issue's attachments (with their IDs, so you can\n" + + "download one via `redmine attachments download `), or --download-attachments\n" + + " to download every attachment of the issue into in one step.", + Example: ` # Show an issue + redmine issues get 123 + + # Include comments/history + redmine issues get 123 --journals + + # List the issue's attachments (id, filename, size, type) + redmine issues get 123 --attachments + + # Download every attachment into ./issue-123 + redmine issues get 123 --download-attachments ./issue-123`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + id, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("invalid issue ID: %s", args[0]) @@ -53,15 +73,26 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { if relations { includes = append(includes, "relations") } + if attachments || downloadAttachments != "" { + includes = append(includes, "attachments") + } printer := f.Printer(format) stop := printer.Spinner("Fetching issue...") - issue, err := ops.GetIssue(context.Background(), client, ops.GetIssueInput{ID: id, Includes: includes}) + issue, err := ops.GetIssue(ctx, client, ops.GetIssueInput{ID: id, Includes: includes}) stop() if err != nil { return fmt.Errorf("failed to get issue %s: %w", fmt.Sprintf("#%d", id), err) } + // Side-effect: download every attachment into the target dir. + // Confirmations go to stderr so JSON stdout stays the issue body. + if downloadAttachments != "" { + if err := downloadIssueAttachments(ctx, client, printer, issue, downloadAttachments); err != nil { + return err + } + } + if printer.Format() == output.FormatJSON { printer.JSON(issue) return nil @@ -111,15 +142,51 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { } } + if len(issue.Attachments) > 0 { + fmt.Println() + fmt.Println("Attachments:") + rows := make([][]string, len(issue.Attachments)) + for i, a := range issue.Attachments { + rows[i] = []string{ + strconv.Itoa(a.ID), + a.Filename, + strconv.FormatInt(a.Filesize, 10), + a.ContentType, + } + } + printer.Table([]string{"ID", "Filename", "Size", "Content-Type"}, rows) + } + return nil }, } - cmd.Flags().StringVar(&include, "include", "", "Include related data: journals,children,relations") + cmd.Flags().StringVar(&include, "include", "", "Include related data: journals,children,relations,attachments") cmd.Flags().BoolVar(&journals, "journals", false, "Include issue history/comments (shorthand for --include journals)") cmd.Flags().BoolVar(&children, "children", false, "Include child issues (shorthand for --include children)") cmd.Flags().BoolVar(&relations, "relations", false, "Include issue relations (shorthand for --include relations)") + cmd.Flags().BoolVar(&attachments, "attachments", false, "Include the issue's attachments (shorthand for --include attachments)") + cmd.Flags().StringVar(&downloadAttachments, "download-attachments", "", "Download every attachment of the issue into the given directory") cmdutil.AddOutputFlag(cmd, &format) return cmd } + +// downloadIssueAttachments streams every attachment of the issue into dir, +// reporting progress to stderr (via the printer) so it never pollutes JSON +// output on stdout. +func downloadIssueAttachments(ctx context.Context, client *api.Client, printer output.Printer, issue *models.Issue, dir string) error { + if len(issue.Attachments) == 0 { + printer.Warning(fmt.Sprintf("Issue #%d has no attachments to download", issue.ID)) + return nil + } + for i := range issue.Attachments { + att := &issue.Attachments[i] + path, n, err := cmdutil.SaveAttachmentToDir(ctx, client, att, dir) + if err != nil { + return fmt.Errorf("downloading attachment %d (%s): %w", att.ID, att.Filename, err) + } + printer.Success(fmt.Sprintf("Downloaded %q (%d bytes) to %s", att.Filename, n, path)) + } + return nil +} diff --git a/internal/cmd/issue/get_attachments_test.go b/internal/cmd/issue/get_attachments_test.go new file mode 100644 index 0000000..9720b25 --- /dev/null +++ b/internal/cmd/issue/get_attachments_test.go @@ -0,0 +1,109 @@ +package issue + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aarondpn/redmine-cli/v2/internal/testutil" +) + +var issuePNG = []byte{0x89, 'P', 'N', 'G', 0x00, 0x01, 0x02, 0xff} + +// issueWithAttachmentsServer serves issue #1 with one attachment (when +// include=attachments is requested) plus the raw bytes at the content_url. +func issueWithAttachmentsServer(t *testing.T) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/issues/1.json": + if !strings.Contains(r.URL.RawQuery, "attachments") { + t.Errorf("expected include=attachments, got query %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"issue":{"id":1,"project":{"id":1,"name":"P"},"tracker":{"id":1,"name":"T"},"status":{"id":1,"name":"New"},"priority":{"id":1,"name":"Normal"},"author":{"id":1,"name":"A"},"subject":"s","attachments":[{"id":55,"filename":"shot.png","filesize":%d,"content_type":"image/png","content_url":%q,"author":{"id":1,"name":"A"},"created_on":"2026-01-01T00:00:00Z"}]}}`, + len(issuePNG), srv.URL+"/attachments/download/55/shot.png") + case "/attachments/download/55/shot.png": + _, _ = w.Write(issuePNG) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestCmdIssueGet_AttachmentsIncludedInJSON(t *testing.T) { + srv := issueWithAttachmentsServer(t) + f := testutil.NewFactory(t, srv.URL) + cmd := NewCmdGet(f) + cmd.SetArgs([]string{"1", "--attachments", "--output", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + out := testutil.Stdout(f) + if !strings.Contains(out, `"filename": "shot.png"`) || !strings.Contains(out, `"id": 55`) { + t.Fatalf("json stdout = %q, want attachment shot.png id 55", out) + } +} + +func TestCmdIssueGet_DownloadAttachments_NoAttachments(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/issues/2.json" { + t.Errorf("unexpected request %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issue":{"id":2,"project":{"id":1,"name":"P"},"tracker":{"id":1,"name":"T"},"status":{"id":1,"name":"New"},"priority":{"id":1,"name":"Normal"},"author":{"id":1,"name":"A"},"subject":"s","attachments":[]}}`)) + })) + defer srv.Close() + + f := testutil.NewFactory(t, srv.URL) + dir := t.TempDir() + cmd := NewCmdGet(f) + cmd.SetArgs([]string{"2", "--download-attachments", dir, "--output", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("expected success for an issue with no attachments: %v", err) + } + if !strings.Contains(testutil.Stderr(f), "no attachments to download") { + t.Errorf("stderr = %q, want a no-attachments warning", testutil.Stderr(f)) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("expected no files written, got %d", len(entries)) + } +} + +func TestCmdIssueGet_DownloadAttachments(t *testing.T) { + srv := issueWithAttachmentsServer(t) + f := testutil.NewFactory(t, srv.URL) + dir := t.TempDir() + cmd := NewCmdGet(f) + cmd.SetArgs([]string{"1", "--download-attachments", dir, "--output", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(dir, "shot.png")) + if err != nil { + t.Fatalf("expected downloaded attachment: %v", err) + } + if string(got) != string(issuePNG) { + t.Error("downloaded attachment bytes differ from source") + } + // The issue JSON still lands on stdout; download confirmation on stderr. + if !strings.Contains(testutil.Stdout(f), `"subject": "s"`) { + t.Errorf("stdout should still carry the issue JSON, got %q", testutil.Stdout(f)) + } + if !strings.Contains(testutil.Stderr(f), "shot.png") { + t.Errorf("stderr = %q, want download confirmation", testutil.Stderr(f)) + } +} diff --git a/internal/cmd/output_contract_test.go b/internal/cmd/output_contract_test.go index 3ec7b4a..020dda4 100644 --- a/internal/cmd/output_contract_test.go +++ b/internal/cmd/output_contract_test.go @@ -29,6 +29,8 @@ type commandContract struct { // failure instead of a review surprise. var commandOutputContracts = map[string]commandContract{ "redmine api": {Mode: outputContractStructured}, + "redmine attachments download": {Mode: outputContractStructured}, + "redmine attachments get": {Mode: outputContractStructured}, "redmine auth list": {Mode: outputContractStructured}, "redmine auth login": {Mode: outputContractInteractive, Reason: "runs an interactive Huh form to collect credentials"}, "redmine auth logout": {Mode: outputContractInteractive, Reason: "may prompt for interactive confirmation before deleting a profile"}, diff --git a/internal/cmd/root.go b/internal/cmd/root.go index cf3948d..f2d2362 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" apicmd "github.com/aarondpn/redmine-cli/v2/internal/cmd/api" + "github.com/aarondpn/redmine-cli/v2/internal/cmd/attachment" "github.com/aarondpn/redmine-cli/v2/internal/cmd/auth" "github.com/aarondpn/redmine-cli/v2/internal/cmd/category" "github.com/aarondpn/redmine-cli/v2/internal/cmd/completion" @@ -94,6 +95,7 @@ func NewRootCmdWithFactory(version string) (*cobra.Command, *cmdutil.Factory) { cmd.AddCommand(apicmd.NewCmdAPI(f)) cmd.AddCommand(auth.NewCmdAuth(f)) cmd.AddCommand(issue.NewCmdIssue(f)) + cmd.AddCommand(attachment.NewCmdAttachments(f)) cmd.AddCommand(group.NewCmdGroup(f)) cmd.AddCommand(membership.NewCmdMemberships(f)) cmd.AddCommand(project.NewCmdProject(f)) diff --git a/internal/cmdutil/downloads.go b/internal/cmdutil/downloads.go new file mode 100644 index 0000000..4abc474 --- /dev/null +++ b/internal/cmdutil/downloads.go @@ -0,0 +1,54 @@ +package cmdutil + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/aarondpn/redmine-cli/v2/internal/api" + "github.com/aarondpn/redmine-cli/v2/internal/models" +) + +// SaveAttachmentToDir streams an attachment into dir using its real filename +// and returns the path written and the number of bytes streamed. The directory +// is created if it does not exist. The server-supplied filename is reduced to +// its base component so a malicious or malformed name (e.g. one containing +// "../") cannot escape dir. +func SaveAttachmentToDir(ctx context.Context, client *api.Client, att *models.Attachment, dir string) (string, int64, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", 0, fmt.Errorf("creating directory %s: %w", dir, err) + } + name := filepath.Base(att.Filename) + // filepath.Base can only yield ".", "..", the separator, or "" for a + // degenerate name; all of them would write outside dir (or to dir itself), + // so fall back to a synthetic, contained name. + if name == "." || name == ".." || name == string(os.PathSeparator) || name == "" { + name = fmt.Sprintf("attachment-%d", att.ID) + } + path := filepath.Join(dir, name) + n, err := SaveAttachmentToFile(ctx, client, att, path) + return path, n, err +} + +// SaveAttachmentToFile streams an attachment to the exact path given and +// returns the number of bytes written. The parent directory must already +// exist. The file is created (or truncated) with 0o644 permissions. +func SaveAttachmentToFile(ctx context.Context, client *api.Client, att *models.Attachment, path string) (int64, error) { + f, err := os.Create(path) + if err != nil { + return 0, fmt.Errorf("creating %s: %w", path, err) + } + + n, err := client.Attachments.Download(ctx, att, f) + if closeErr := f.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("closing %s: %w", path, closeErr) + } + if err != nil { + // Best-effort cleanup of the partial file so a failed download does + // not leave a truncated artifact behind. + _ = os.Remove(path) + return n, err + } + return n, nil +} diff --git a/internal/cmdutil/downloads_test.go b/internal/cmdutil/downloads_test.go new file mode 100644 index 0000000..c23ce69 --- /dev/null +++ b/internal/cmdutil/downloads_test.go @@ -0,0 +1,131 @@ +package cmdutil + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/aarondpn/redmine-cli/v2/internal/api" + "github.com/aarondpn/redmine-cli/v2/internal/debug" + "github.com/aarondpn/redmine-cli/v2/internal/models" +) + +func newDownloadClient(t *testing.T, body []byte, status int) *api.Client { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if status != 0 && status != http.StatusOK { + w.WriteHeader(status) + return + } + _, _ = w.Write(body) + })) + t.Cleanup(ts.Close) + return api.NewTestClient(ts.Client(), ts.URL, debug.New(nil)) +} + +func attFor(client *api.Client, filename string) *models.Attachment { + // content_url is left empty so Download builds the canonical path against + // the test client's base URL (any path resolves to the stub handler). + return &models.Attachment{ID: 1, Filename: filename} +} + +func TestSaveAttachmentToDir_WritesRealFilename(t *testing.T) { + want := []byte("file body bytes") + client := newDownloadClient(t, want, http.StatusOK) + dir := t.TempDir() + + path, n, err := SaveAttachmentToDir(context.Background(), client, attFor(client, "report.pdf"), dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := filepath.Join(dir, "report.pdf"); path != got { + t.Errorf("path = %q, want %q", path, got) + } + if n != int64(len(want)) { + t.Errorf("bytes = %d, want %d", n, len(want)) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read written file: %v", err) + } + if string(got) != string(want) { + t.Errorf("content = %q, want %q", got, want) + } +} + +// TestSaveAttachmentToDir_SanitizesFilename guards against a server-supplied +// filename escaping the destination directory via path traversal. +func TestSaveAttachmentToDir_SanitizesFilename(t *testing.T) { + client := newDownloadClient(t, []byte("x"), http.StatusOK) + dir := t.TempDir() + + path, _, err := SaveAttachmentToDir(context.Background(), client, attFor(client, "../../../etc/evil"), dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if filepath.Dir(path) != filepath.Clean(dir) { + t.Errorf("written outside dir: path=%q dir=%q", path, dir) + } + if filepath.Base(path) != "evil" { + t.Errorf("base = %q, want sanitized to evil", filepath.Base(path)) + } +} + +// TestSaveAttachmentToDir_FallsBackForDegenerateNames covers names that +// filepath.Base reduces to a value that would escape dir or name the directory +// itself ("..", "/", ""); each must fall back to a contained synthetic name. +func TestSaveAttachmentToDir_FallsBackForDegenerateNames(t *testing.T) { + for _, name := range []string{"..", "../..", "/", ""} { + t.Run("name="+name, func(t *testing.T) { + client := newDownloadClient(t, []byte("x"), http.StatusOK) + dir := t.TempDir() + + path, _, err := SaveAttachmentToDir(context.Background(), client, attFor(client, name), dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if filepath.Dir(path) != filepath.Clean(dir) { + t.Errorf("written outside dir: path=%q dir=%q", path, dir) + } + if filepath.Base(path) != "attachment-1" { + t.Errorf("base = %q, want synthetic attachment-1", filepath.Base(path)) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file at %q: %v", path, err) + } + }) + } +} + +// TestSaveAttachmentToDir_CreatesDir confirms the destination directory is +// created when missing (used by both `attachments download --dir` and +// `issues get --download-attachments`). +func TestSaveAttachmentToDir_CreatesDir(t *testing.T) { + client := newDownloadClient(t, []byte("data"), http.StatusOK) + dir := filepath.Join(t.TempDir(), "nested", "created") + + path, _, err := SaveAttachmentToDir(context.Background(), client, attFor(client, "a.bin"), dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file at %q: %v", path, err) + } +} + +// TestSaveAttachmentToFile_CleansUpPartialOnError verifies a failed download +// does not leave a truncated artifact behind. +func TestSaveAttachmentToFile_CleansUpPartialOnError(t *testing.T) { + client := newDownloadClient(t, nil, http.StatusForbidden) + path := filepath.Join(t.TempDir(), "partial.bin") + + if _, err := SaveAttachmentToFile(context.Background(), client, attFor(client, "partial.bin"), path); err == nil { + t.Fatal("expected error on 403 download") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("partial file should have been removed, stat err = %v", err) + } +} diff --git a/internal/mcpserver/zz_generated_tools.go b/internal/mcpserver/zz_generated_tools.go index 1b88d4f..e9cd5dd 100644 --- a/internal/mcpserver/zz_generated_tools.go +++ b/internal/mcpserver/zz_generated_tools.go @@ -106,6 +106,12 @@ func registerGeneratedTools(s *mcp.Server, client *api.Client, opts Options) { Writes: true, Call: ops.DeleteIssueRelation, }) + registerToolSpec(s, client, opts, toolSpec[ops.GetAttachmentInput, *models.Attachment]{ + Name: "get_attachment", + Description: "Fetch metadata for a Redmine attachment by ID: filename, size, content type, description, author, and download URL. Discover attachment IDs via get_issue with includes=[\"attachments\"].", + Category: "issues", + Call: ops.GetAttachment, + }) registerToolSpec(s, client, opts, toolSpec[ops.GetIssueInput, *models.Issue]{ Name: "get_issue", Description: "Fetch a single Redmine issue by ID.", @@ -487,6 +493,7 @@ func generatedToolDescriptors() []ToolDescriptor { {Name: "create_issue_relation", Description: "Create a relation between two issues. Requires --enable-writes.", Group: "issues", Writes: true}, {Name: "delete_issue", Description: "Delete a Redmine issue. Destructive. Requires --enable-writes.", Group: "issues", Writes: true}, {Name: "delete_issue_relation", Description: "Delete an issue relation by its relation ID. Requires --enable-writes.", Group: "issues", Writes: true}, + {Name: "get_attachment", Description: "Fetch metadata for a Redmine attachment by ID: filename, size, content type, description, author, and download URL. Discover attachment IDs via get_issue with includes=[\"attachments\"].", Group: "issues", Writes: false}, {Name: "get_issue", Description: "Fetch a single Redmine issue by ID.", Group: "issues", Writes: false}, {Name: "get_issue_relation", Description: "Fetch a single issue relation by its relation ID.", Group: "issues", Writes: false}, {Name: "list_issue_relations", Description: "List relations attached to an issue.", Group: "issues", Writes: false}, diff --git a/internal/models/attachment.go b/internal/models/attachment.go new file mode 100644 index 0000000..70dff71 --- /dev/null +++ b/internal/models/attachment.go @@ -0,0 +1,16 @@ +package models + +// Attachment represents a file attached to a Redmine resource (issue, wiki +// page, ...). It is returned by the /attachments/:id.json endpoint and by the +// attachments[] array on issues and wiki pages (include=attachments). +type Attachment struct { + ID int `json:"id"` + Filename string `json:"filename"` + Filesize int64 `json:"filesize"` + ContentType string `json:"content_type"` + Description string `json:"description"` + ContentURL string `json:"content_url"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + Author IDName `json:"author"` + CreatedOn string `json:"created_on"` +} diff --git a/internal/models/issue.go b/internal/models/issue.go index 70d5c08..6af4e4c 100644 --- a/internal/models/issue.go +++ b/internal/models/issue.go @@ -34,6 +34,7 @@ type Issue struct { Children []IDRef `json:"children,omitempty"` Watchers []IDName `json:"watchers,omitempty"` Relations []IssueRelation `json:"relations,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` } // IssueRelation represents a relation between two issues. Returned both via diff --git a/internal/models/wiki.go b/internal/models/wiki.go index 3c427fa..4d4a7f2 100644 --- a/internal/models/wiki.go +++ b/internal/models/wiki.go @@ -18,18 +18,6 @@ type WikiPageTitle struct { Title string `json:"title"` } -// Attachment represents a file attached to a wiki page. -type Attachment struct { - ID int `json:"id"` - Filename string `json:"filename"` - Filesize int64 `json:"filesize"` - ContentType string `json:"content_type"` - Description string `json:"description"` - ContentURL string `json:"content_url"` - Author IDName `json:"author"` - CreatedOn string `json:"created_on"` -} - // WikiPageIndex represents a wiki page entry in the index listing. type WikiPageIndex struct { Title string `json:"title"` diff --git a/internal/ops/attachments.go b/internal/ops/attachments.go new file mode 100644 index 0000000..c3478a1 --- /dev/null +++ b/internal/ops/attachments.go @@ -0,0 +1,19 @@ +package ops + +import ( + "context" + + "github.com/aarondpn/redmine-cli/v2/internal/api" + "github.com/aarondpn/redmine-cli/v2/internal/models" +) + +type GetAttachmentInput struct { + ID int `json:"id" jsonschema:"Numeric attachment ID. Discover IDs via get_issue with includes=[\"attachments\"]."` +} + +//mcpgen:tool get_attachment +//mcpgen:description Fetch metadata for a Redmine attachment by ID: filename, size, content type, description, author, and download URL. Discover attachment IDs via get_issue with includes=["attachments"]. +//mcpgen:category issues +func GetAttachment(ctx context.Context, client *api.Client, input GetAttachmentInput) (*models.Attachment, error) { + return client.Attachments.Get(ctx, input.ID) +} diff --git a/internal/ops/attachments_test.go b/internal/ops/attachments_test.go new file mode 100644 index 0000000..cbe9d6d --- /dev/null +++ b/internal/ops/attachments_test.go @@ -0,0 +1,34 @@ +package ops + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aarondpn/redmine-cli/v2/internal/api" + "github.com/aarondpn/redmine-cli/v2/internal/debug" +) + +func TestGetAttachment_DecodesResponse(t *testing.T) { + var gotPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"attachment":{"id":42,"filename":"screenshot.png","filesize":1234,"content_type":"image/png","author":{"id":1,"name":"Grace"},"created_on":"2026-05-01T00:00:00Z"}}`)) + })) + defer ts.Close() + + client := api.NewTestClient(ts.Client(), ts.URL, debug.New(nil)) + + att, err := GetAttachment(context.Background(), client, GetAttachmentInput{ID: 42}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/attachments/42.json" { + t.Errorf("path = %q, want /attachments/42.json", gotPath) + } + if att.ID != 42 || att.Filename != "screenshot.png" || att.ContentType != "image/png" { + t.Errorf("attachment = %+v, want id=42 screenshot.png image/png", att) + } +} diff --git a/skills/redmine-cli/SKILL.md b/skills/redmine-cli/SKILL.md index 8a6d40f..9296a5c 100644 --- a/skills/redmine-cli/SKILL.md +++ b/skills/redmine-cli/SKILL.md @@ -14,6 +14,7 @@ Only these top-level commands exist. Do NOT invent subcommands that aren't liste | Command | Purpose | |---------|---------| | `issues` | Create, list, get, update, close, reopen, assign, comment, delete, search, browse issues; manage watchers and relations (`issues watchers …`, `issues relations …`) | +| `attachments` | Inspect attachment metadata (`attachments get `) and download attachment files (`attachments download `) using the active profile's auth | | `queries` | List Redmine saved queries; reuse them via `issues list --query` / `--query-id` | | `projects` | List, get, create, update, archive, unarchive, delete projects; list project members. `--include` on list/get exposes trackers, modules, categories, custom fields, and time-entry activities (Redmine 5.0+ for archive). | | `time` | Log, list, get, update, delete, summarize time entries | @@ -90,10 +91,32 @@ When you create an issue, project, user, or other resource, the CLI returns the Get the server URL from `redmine config` (or from the JSON output's hints). Always mention the URL or the `open` command after a successful create so the user can quickly navigate to the new resource. +## Attachments: Always Download and Inspect Them + +Issues often carry attachments (screenshots, diagrams, logs, PDFs) that contain +information not present in the text. **Whenever an issue has attachments, +download them and inspect their contents before answering** - especially +images, which frequently hold the actual error, mockup, or detail the ticket is +about. + +1. **Discover attachment IDs**: `redmine issues get --attachments` lists each + attachment's `id`, `filename`, `size`, and `content_type`. With `-o json` the + issue's `attachments[]` array is included in the output. +2. **Download one file**: `redmine attachments download -d ` saves it + under its real filename (or `--path ` for an exact path, `--path -` to + stream to stdout). No `curl`, no manual API-key handling - it reuses the active + profile's auth. +3. **Download everything at once**: `redmine issues get --download-attachments ` + pulls every attachment of the issue into `` in one step. +4. **Inspect metadata only** (no download): `redmine attachments get `. + +After downloading an image, open/read it and use what it shows. Do not answer a +question about a ticket with attachments without first looking at them. + ## Non-Obvious Behaviors - `redmine issues list` defaults to `--status open`. Use `--status closed`, `--status "*"`, or a specific status name. -- `redmine issues get --journals` includes comments/history. Also available: `--children`, `--relations`. +- `redmine issues get --journals` includes comments/history. Also available: `--children`, `--relations`, `--attachments`. - `redmine issues update` only sends flags you explicitly pass — omitted flags are not changed. - If `--project` is omitted, the configured default project is used (set via `redmine auth login`). - Projects can accumulate hundreds of versions, most of them closed or locked. When you need a version for a new issue, time entry, or similar workflow, always start from `redmine versions list --open` so the shortlist stays small and you don't pick a version that can no longer accept work.