diff --git a/README.md b/README.md index 252946c..9398200 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,19 @@ gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role D gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true ``` +### Attachment Operations + +```bash +# Upload a local file +gitlink-cli attachment +upload -f ./build.log -d "CI build log" + +# Upload a file and attach container metadata +gitlink-cli attachment +upload -f ./release-notes.md --container-id 42 --container-type VersionRelease + +# Delete an uploaded attachment +gitlink-cli attachment +delete -i 791eccbf-2e35-4301-ad95-8c937a117f40 +``` + ### Issue Management ```bash diff --git a/doc/changes/attachment-shortcut.md b/doc/changes/attachment-shortcut.md new file mode 100644 index 0000000..8cb966d --- /dev/null +++ b/doc/changes/attachment-shortcut.md @@ -0,0 +1,23 @@ +# Attachment Shortcut + +## Summary + +This change adds a new `attachment` shortcut group to `gitlink-cli` so users can upload and delete standalone attachments without dropping down to raw API calls. + +## Commands + +```bash +gitlink-cli attachment +upload -f ./build.log -d "CI build log" +gitlink-cli attachment +upload -f ./release-notes.md --container-id 42 --container-type VersionRelease +gitlink-cli attachment +delete -i 791eccbf-2e35-4301-ad95-8c937a117f40 +``` + +## API Coverage + +- `POST /api/attachments.json` +- `DELETE /api/attachments/{uuid}.json` + +## Notes + +- Upload uses multipart form data and works with the same `GITLINK_TOKEN` access token flow already used by the CLI. +- Delete accepts the attachment UUID returned by the upload API. diff --git a/internal/client/client.go b/internal/client/client.go index c838245..be81ded 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -5,8 +5,11 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" + "os" + "path/filepath" "strings" "github.com/gitlink-org/gitlink-cli/internal/auth" @@ -26,12 +29,6 @@ type APIError struct { Message string } -type DownloadResult struct { - Data []byte - ContentType string - ContentDisposition string -} - func (e *APIError) Error() string { return fmt.Sprintf("[%v] %s", e.Code, e.Message) } @@ -48,10 +45,6 @@ func New() (*Client, error) { } func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { - return c.DoWithHeaders(method, path, body, query, nil) -} - -func (c *Client) DoWithHeaders(method, path string, body interface{}, query url.Values, headers http.Header) (*output.Envelope, error) { path = normalizeAPIPath(c.BaseURL, path) // Append .json suffix if not already present (GitLink API convention) @@ -88,14 +81,60 @@ func (c *Client) DoWithHeaders(method, path string, body interface{}, query url. if err != nil { return nil, err } - for key, values := range headers { - for _, value := range values { - req.Header.Add(key, value) + + return c.doRequest(req) +} + +func (c *Client) PostMultipart(path, fileField, filePath string, fields map[string]string) (*output.Envelope, error) { + path = normalizeAPIPath(c.BaseURL, path) + if idx := strings.Index(path, "?"); idx != -1 { + basePath := path[:idx] + queryStr := path[idx:] + if shouldAppendJSONSuffix(basePath) { + path = basePath + ".json" + queryStr + } + } else if shouldAppendJSONSuffix(path) { + path += ".json" + } + fullURL := c.BaseURL + path + + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open file: %w", err) + } + defer file.Close() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) + if err != nil { + return nil, fmt.Errorf("create form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("copy file: %w", err) + } + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + return nil, fmt.Errorf("write form field %s: %w", key, err) } } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("close multipart writer: %w", err) + } + req, err := http.NewRequest("POST", fullURL, &body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + return c.doRequest(req) +} + +func (c *Client) doRequest(req *http.Request) (*output.Envelope, error) { if c.Debug { - fmt.Printf("-> %s %s\n", method, fullURL) + fmt.Printf("-> %s %s\n", req.Method, req.URL.String()) } resp, err := c.HTTP.Do(req) @@ -130,35 +169,22 @@ func (c *Client) DoWithHeaders(method, path string, body interface{}, query url. } // Check GitLink error-in-body pattern - // Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."} - var bodyCode float64 - var bodyMsg string if status, ok := raw["status"]; ok { + var statusCode float64 switch v := status.(type) { case float64: - bodyCode = v + statusCode = v case int: - bodyCode = float64(v) + statusCode = float64(v) } - bodyMsg, _ = raw["message"].(string) - } else if code, ok := raw["code"]; ok { - switch v := code.(type) { - case float64: - bodyCode = v - case int: - bodyCode = float64(v) - } - bodyMsg, _ = raw["msg"].(string) - if bodyMsg == "" { - bodyMsg, _ = raw["message"].(string) - } - } - if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 { - suggestion := suggestFix(int(bodyCode)) - return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{ - StatusCode: int(bodyCode), - Code: int(bodyCode), - Message: bodyMsg, + if statusCode != 0 && statusCode != 200 && statusCode != 1 { + msg, _ := raw["message"].(string) + suggestion := suggestFix(int(statusCode)) + return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{ + StatusCode: int(statusCode), + Code: int(statusCode), + Message: msg, + } } } @@ -188,82 +214,6 @@ func (c *Client) DoWithHeaders(method, path string, body interface{}, query url. return output.SuccessEnvelope(raw, meta), nil } -func (c *Client) Download(path string) (*DownloadResult, error) { - fullURL, err := c.resolveDownloadURL(path) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("GET", fullURL, nil) - if err != nil { - return nil, err - } - if c.Debug { - fmt.Printf("-> GET %s\n", fullURL) - } - - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - if c.Debug { - fmt.Printf("<- %d %d bytes\n", resp.StatusCode, len(data)) - } - if resp.StatusCode >= 400 { - return nil, &APIError{ - StatusCode: resp.StatusCode, - Code: resp.StatusCode, - Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))), - } - } - - return &DownloadResult{ - Data: data, - ContentType: resp.Header.Get("Content-Type"), - ContentDisposition: resp.Header.Get("Content-Disposition"), - }, nil -} - -func (c *Client) resolveDownloadURL(path string) (string, error) { - if path == "" { - return "", fmt.Errorf("download url is empty") - } - if u, err := url.Parse(path); err == nil && u.IsAbs() { - return path, nil - } - if strings.HasPrefix(path, "/api/") || path == "/api" { - return apiDownloadURL(c.BaseURL, path), nil - } - if strings.HasPrefix(path, "/") { - return webBaseURL(c.BaseURL) + path, nil - } - return c.BaseURL + normalizeAPIPath(c.BaseURL, path), nil -} - -func apiDownloadURL(baseURL, path string) string { - base := strings.TrimRight(baseURL, "/") - if strings.HasSuffix(base, "/api") { - return base + strings.TrimPrefix(path, "/api") - } - return webBaseURL(base) + path -} - -func webBaseURL(baseURL string) string { - base := strings.TrimRight(baseURL, "/") - for _, suffix := range []string{"/api/v1", "/api"} { - if strings.HasSuffix(base, suffix) { - return strings.TrimSuffix(base, suffix) - } - } - return base -} - func shouldAppendJSONSuffix(path string) bool { if strings.HasSuffix(path, ".json") { return false @@ -274,10 +224,6 @@ func shouldAppendJSONSuffix(path string) bool { return false } } - // Wiki open API endpoints do not use .json suffix - if len(parts) >= 3 && parts[0] == "wiki" && parts[1] == "open" { - return false - } return true } diff --git a/shortcuts/attachment/attachment.go b/shortcuts/attachment/attachment.go new file mode 100644 index 0000000..4d39340 --- /dev/null +++ b/shortcuts/attachment/attachment.go @@ -0,0 +1,90 @@ +package attachment + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "upload", + Description: "Upload an attachment", + Flags: []common.Flag{ + {Name: "file", Short: "f", Usage: "File path to upload", Required: true}, + {Name: "description", Short: "d", Usage: "Attachment description"}, + {Name: "container-id", Usage: "Container model ID"}, + {Name: "container-type", Usage: "Container model type"}, + }, + Run: runUploadAttachment, + }, + { + Name: "delete", + Description: "Delete an attachment", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Attachment UUID", Required: true}, + }, + Run: runDeleteAttachment, + }, + } +} + +func runUploadAttachment(ctx *common.RuntimeContext) error { + filePath, err := ctx.RequireArg("file") + if err != nil { + return err + } + + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("stat file: %w", err) + } + if info.IsDir() { + return fmt.Errorf("file path points to a directory: %s", filePath) + } + + fields := map[string]string{} + if v := ctx.Arg("description"); v != "" { + fields["description"] = v + } + if v := ctx.Arg("container-id"); v != "" { + fields["container_id"] = v + } + if v := ctx.Arg("container-type"); v != "" { + fields["container_type"] = v + } + + env, err := ctx.PostMultipart("/attachments", "file", filePath, fields) + if err != nil { + return err + } + + if data, ok := env.Data.(map[string]interface{}); ok { + data["filename"] = filepath.Base(filePath) + } + + return ctx.Output(env) +} + +func runDeleteAttachment(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/attachments/%s", id), nil) + if err != nil { + return err + } + if env == nil { + return output.Print(output.SuccessEnvelope(map[string]interface{}{ + "id": id, + "deleted": true, + }, nil), ctx.Format) + } + return ctx.Output(env) +} diff --git a/shortcuts/attachment/attachment_test.go b/shortcuts/attachment/attachment_test.go new file mode 100644 index 0000000..4b378f2 --- /dev/null +++ b/shortcuts/attachment/attachment_test.go @@ -0,0 +1,120 @@ +package attachment + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestAttachmentUploadSendsMultipartForm(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "sample.txt") + if err := os.WriteFile(filePath, []byte("hello attachment"), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/attachments.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + called = true + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + if got := r.FormValue("description"); got != "release asset" { + t.Fatalf("description = %q, want %q", got, "release asset") + } + if got := r.FormValue("container_id"); got != "42" { + t.Fatalf("container_id = %q, want %q", got, "42") + } + if got := r.FormValue("container_type"); got != "VersionRelease" { + t.Fatalf("container_type = %q, want %q", got, "VersionRelease") + } + file, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("read form file: %v", err) + } + defer file.Close() + if header.Filename != "sample.txt" { + t.Fatalf("filename = %q, want %q", header.Filename, "sample.txt") + } + content, err := io.ReadAll(file) + if err != nil { + t.Fatalf("read uploaded file: %v", err) + } + if string(content) != "hello attachment" { + t.Fatalf("content = %q, want %q", string(content), "hello attachment") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"att-1","title":"sample.txt","url":"https://example.com/a/att-1"}`)) + })) + defer server.Close() + + err := runAttachmentShortcut(t, server, "upload", map[string]string{ + "file": filePath, + "description": "release asset", + "container-id": "42", + "container-type": "VersionRelease", + }) + if err != nil { + t.Fatalf("upload shortcut failed: %v", err) + } + if !called { + t.Fatal("upload endpoint was not called") + } +} + +func TestAttachmentDeleteCallsAPI(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/attachments/att-1.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + called = true + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":0,"message":"deleted"}`)) + })) + defer server.Close() + + err := runAttachmentShortcut(t, server, "delete", map[string]string{ + "id": "att-1", + }) + if err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } + if !called { + t.Fatal("delete endpoint was not called") + } +} + +func runAttachmentShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findAttachmentShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findAttachmentShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index e7529ec..e89614b 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -90,6 +90,11 @@ func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Value return ctx.Client.Do(method, path, nil, query) } +// PostMultipart uploads a file with multipart/form-data through the shared client. +func (ctx *RuntimeContext) PostMultipart(path, fileField, filePath string, fields map[string]string) (*output.Envelope, error) { + return ctx.Client.PostMultipart(path, fileField, filePath, fields) +} + // PaginateAll fetches all pages. func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) { return ctx.Client.PaginateAll(path, params) diff --git a/shortcuts/register.go b/shortcuts/register.go index abe808b..2de380c 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -4,13 +4,12 @@ import ( "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/attachment" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" - "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" "github.com/gitlink-org/gitlink-cli/shortcuts/health" - "github.com/gitlink-org/gitlink-cli/shortcuts/ignore" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/license" @@ -19,14 +18,11 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pipeline" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" - "github.com/gitlink-org/gitlink-cli/shortcuts/profile" "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" - "github.com/gitlink-org/gitlink-cli/shortcuts/tag" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" - "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) @@ -37,55 +33,47 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { tr = translators[0] } groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(tr), - "issue": issue.Shortcuts(tr), - "label": label.Shortcuts(), - "license": license.Shortcuts(), - "member": member.Shortcuts(), - "milestone": milestone.Shortcuts(), - "pipeline": pipeline.Shortcuts(), - "pr": pr.Shortcuts(tr), - "profile": profile.Shortcuts(tr), - "release": release.Shortcuts(tr), - "branch": branch.Shortcuts(tr), - "org": org.Shortcuts(tr), - "user": user.Shortcuts(tr), - "search": search.Shortcuts(tr), - "tag": tag.Shortcuts(tr), - "ci": ci.Shortcuts(tr), - "compare": compare.Shortcuts(), - "dataset": dataset.Shortcuts(tr), - "webhook": webhook.Shortcuts(tr), - "wiki": wiki.Shortcuts(), - "health": health.Shortcuts(tr), - "ignore": ignore.Shortcuts(), - "workflow": workflow.Shortcuts(), + "attachment": attachment.Shortcuts(), + "repo": repo.Shortcuts(tr), + "issue": issue.Shortcuts(tr), + "label": label.Shortcuts(), + "license": license.Shortcuts(), + "member": member.Shortcuts(), + "milestone": milestone.Shortcuts(), + "pipeline": pipeline.Shortcuts(), + "pr": pr.Shortcuts(tr), + "release": release.Shortcuts(tr), + "branch": branch.Shortcuts(tr), + "org": org.Shortcuts(tr), + "user": user.Shortcuts(tr), + "search": search.Shortcuts(tr), + "ci": ci.Shortcuts(tr), + "compare": compare.Shortcuts(), + "webhook": webhook.Shortcuts(tr), + "health": health.Shortcuts(tr), + "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "repo": tr.T("cmd.repo.short"), - "issue": tr.T("cmd.issue.short"), - "label": "Issue label operations", - "license": "License operations", - "member": "Repository member operations", - "milestone": "Milestone operations", - "pipeline": "Pipeline operations", - "pr": tr.T("cmd.pr.short"), - "profile": tr.T("cmd.profile.short"), - "release": tr.T("cmd.release.short"), - "branch": tr.T("cmd.branch.short"), - "org": tr.T("cmd.org.short"), - "user": tr.T("cmd.user.short"), - "search": tr.T("cmd.search.short"), - "tag": tr.T("cmd.tag.short"), - "ci": tr.T("cmd.ci.short"), - "compare": "Compare branches, tags, or commits", - "dataset": tr.T("cmd.dataset.short"), - "webhook": tr.T("cmd.webhook.short"), - "wiki": "Wiki page management", - "health": "Project health data collection", - "ignore": tr.T("cmd.ignore.short"), - "workflow": "AI agent workflow analysis", + "attachment": "Attachment operations", + "repo": tr.T("cmd.repo.short"), + "issue": tr.T("cmd.issue.short"), + "label": "Issue label operations", + "license": "License operations", + "member": "Repository member operations", + "milestone": "Milestone operations", + "pipeline": "Pipeline operations", + "pr": tr.T("cmd.pr.short"), + "release": tr.T("cmd.release.short"), + "branch": tr.T("cmd.branch.short"), + "org": tr.T("cmd.org.short"), + "user": tr.T("cmd.user.short"), + "search": tr.T("cmd.search.short"), + "ci": tr.T("cmd.ci.short"), + "compare": "Compare branches, tags, or commits", + "webhook": tr.T("cmd.webhook.short"), + "health": "Project health data collection", + "workflow": "AI agent workflow analysis", } for name, shortcuts := range groups { diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index cc5d3a8..0f15d36 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -11,10 +11,10 @@ func TestRegisterAll(t *testing.T) { RegisterAll(root) expectedGroups := []string{ - "repo", "issue", "label", "license", "pr", "profile", "release", "branch", - "org", "user", "search", "tag", "ci", "workflow", + "attachment", "repo", "issue", "label", "license", "pr", "release", "branch", + "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", "wiki", "notification", + "health", } groupSet := map[string]bool{}