forked from Gitlink/gitlink-cli
Merge PR #222: feat(attachment): 新增附件上传与删除快捷命令
# Conflicts: # internal/client/client.go # shortcuts/register.go # shortcuts/register_test.go
This commit is contained in:
commit
d5ffff930e
13
README.md
13
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
|
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
|
### Issue Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -5,8 +5,11 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/auth"
|
"github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||||
|
|
@ -26,12 +29,6 @@ type APIError struct {
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResult struct {
|
|
||||||
Data []byte
|
|
||||||
ContentType string
|
|
||||||
ContentDisposition string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
func (e *APIError) Error() string {
|
||||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
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) {
|
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)
|
path = normalizeAPIPath(c.BaseURL, path)
|
||||||
|
|
||||||
// Append .json suffix if not already present (GitLink API convention)
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for key, values := range headers {
|
|
||||||
for _, value := range values {
|
return c.doRequest(req)
|
||||||
req.Header.Add(key, value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
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)
|
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
|
// 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 {
|
if status, ok := raw["status"]; ok {
|
||||||
|
var statusCode float64
|
||||||
switch v := status.(type) {
|
switch v := status.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
bodyCode = v
|
statusCode = v
|
||||||
case int:
|
case int:
|
||||||
bodyCode = float64(v)
|
statusCode = float64(v)
|
||||||
}
|
}
|
||||||
bodyMsg, _ = raw["message"].(string)
|
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
|
||||||
} else if code, ok := raw["code"]; ok {
|
msg, _ := raw["message"].(string)
|
||||||
switch v := code.(type) {
|
suggestion := suggestFix(int(statusCode))
|
||||||
case float64:
|
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
|
||||||
bodyCode = v
|
StatusCode: int(statusCode),
|
||||||
case int:
|
Code: int(statusCode),
|
||||||
bodyCode = float64(v)
|
Message: msg,
|
||||||
}
|
}
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,82 +214,6 @@ func (c *Client) DoWithHeaders(method, path string, body interface{}, query url.
|
||||||
return output.SuccessEnvelope(raw, meta), nil
|
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 {
|
func shouldAppendJSONSuffix(path string) bool {
|
||||||
if strings.HasSuffix(path, ".json") {
|
if strings.HasSuffix(path, ".json") {
|
||||||
return false
|
return false
|
||||||
|
|
@ -274,10 +224,6 @@ func shouldAppendJSONSuffix(path string) bool {
|
||||||
return false
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -90,6 +90,11 @@ func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Value
|
||||||
return ctx.Client.Do(method, path, nil, query)
|
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.
|
// PaginateAll fetches all pages.
|
||||||
func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
|
func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
|
||||||
return ctx.Client.PaginateAll(path, params)
|
return ctx.Client.PaginateAll(path, params)
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,12 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
"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/branch"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
|
"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/health"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ignore"
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
|
"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/org"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
|
"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/release"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
|
"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/user"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
|
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,6 +33,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
|
||||||
tr = translators[0]
|
tr = translators[0]
|
||||||
}
|
}
|
||||||
groups := map[string][]*common.Shortcut{
|
groups := map[string][]*common.Shortcut{
|
||||||
|
"attachment": attachment.Shortcuts(),
|
||||||
"repo": repo.Shortcuts(tr),
|
"repo": repo.Shortcuts(tr),
|
||||||
"issue": issue.Shortcuts(tr),
|
"issue": issue.Shortcuts(tr),
|
||||||
"label": label.Shortcuts(),
|
"label": label.Shortcuts(),
|
||||||
|
|
@ -45,24 +42,20 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
|
||||||
"milestone": milestone.Shortcuts(),
|
"milestone": milestone.Shortcuts(),
|
||||||
"pipeline": pipeline.Shortcuts(),
|
"pipeline": pipeline.Shortcuts(),
|
||||||
"pr": pr.Shortcuts(tr),
|
"pr": pr.Shortcuts(tr),
|
||||||
"profile": profile.Shortcuts(tr),
|
|
||||||
"release": release.Shortcuts(tr),
|
"release": release.Shortcuts(tr),
|
||||||
"branch": branch.Shortcuts(tr),
|
"branch": branch.Shortcuts(tr),
|
||||||
"org": org.Shortcuts(tr),
|
"org": org.Shortcuts(tr),
|
||||||
"user": user.Shortcuts(tr),
|
"user": user.Shortcuts(tr),
|
||||||
"search": search.Shortcuts(tr),
|
"search": search.Shortcuts(tr),
|
||||||
"tag": tag.Shortcuts(tr),
|
|
||||||
"ci": ci.Shortcuts(tr),
|
"ci": ci.Shortcuts(tr),
|
||||||
"compare": compare.Shortcuts(),
|
"compare": compare.Shortcuts(),
|
||||||
"dataset": dataset.Shortcuts(tr),
|
|
||||||
"webhook": webhook.Shortcuts(tr),
|
"webhook": webhook.Shortcuts(tr),
|
||||||
"wiki": wiki.Shortcuts(),
|
|
||||||
"health": health.Shortcuts(tr),
|
"health": health.Shortcuts(tr),
|
||||||
"ignore": ignore.Shortcuts(),
|
|
||||||
"workflow": workflow.Shortcuts(),
|
"workflow": workflow.Shortcuts(),
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptions := map[string]string{
|
descriptions := map[string]string{
|
||||||
|
"attachment": "Attachment operations",
|
||||||
"repo": tr.T("cmd.repo.short"),
|
"repo": tr.T("cmd.repo.short"),
|
||||||
"issue": tr.T("cmd.issue.short"),
|
"issue": tr.T("cmd.issue.short"),
|
||||||
"label": "Issue label operations",
|
"label": "Issue label operations",
|
||||||
|
|
@ -71,20 +64,15 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
|
||||||
"milestone": "Milestone operations",
|
"milestone": "Milestone operations",
|
||||||
"pipeline": "Pipeline operations",
|
"pipeline": "Pipeline operations",
|
||||||
"pr": tr.T("cmd.pr.short"),
|
"pr": tr.T("cmd.pr.short"),
|
||||||
"profile": tr.T("cmd.profile.short"),
|
|
||||||
"release": tr.T("cmd.release.short"),
|
"release": tr.T("cmd.release.short"),
|
||||||
"branch": tr.T("cmd.branch.short"),
|
"branch": tr.T("cmd.branch.short"),
|
||||||
"org": tr.T("cmd.org.short"),
|
"org": tr.T("cmd.org.short"),
|
||||||
"user": tr.T("cmd.user.short"),
|
"user": tr.T("cmd.user.short"),
|
||||||
"search": tr.T("cmd.search.short"),
|
"search": tr.T("cmd.search.short"),
|
||||||
"tag": tr.T("cmd.tag.short"),
|
|
||||||
"ci": tr.T("cmd.ci.short"),
|
"ci": tr.T("cmd.ci.short"),
|
||||||
"compare": "Compare branches, tags, or commits",
|
"compare": "Compare branches, tags, or commits",
|
||||||
"dataset": tr.T("cmd.dataset.short"),
|
|
||||||
"webhook": tr.T("cmd.webhook.short"),
|
"webhook": tr.T("cmd.webhook.short"),
|
||||||
"wiki": "Wiki page management",
|
|
||||||
"health": "Project health data collection",
|
"health": "Project health data collection",
|
||||||
"ignore": tr.T("cmd.ignore.short"),
|
|
||||||
"workflow": "AI agent workflow analysis",
|
"workflow": "AI agent workflow analysis",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ func TestRegisterAll(t *testing.T) {
|
||||||
RegisterAll(root)
|
RegisterAll(root)
|
||||||
|
|
||||||
expectedGroups := []string{
|
expectedGroups := []string{
|
||||||
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
|
"attachment", "repo", "issue", "label", "license", "pr", "release", "branch",
|
||||||
"org", "user", "search", "tag", "ci", "workflow",
|
"org", "user", "search", "ci", "workflow",
|
||||||
"compare", "member", "milestone", "pipeline", "webhook",
|
"compare", "member", "milestone", "pipeline", "webhook",
|
||||||
"dataset", "health", "ignore", "wiki", "notification",
|
"health",
|
||||||
}
|
}
|
||||||
|
|
||||||
groupSet := map[string]bool{}
|
groupSet := map[string]bool{}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue