Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
3a11458132 |
|
|
@ -255,6 +255,12 @@ gitlink-cli repo +create -n my-project -d "Project description"
|
||||||
|
|
||||||
# Fork a repository
|
# Fork a repository
|
||||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||||
|
|
||||||
|
# Upload a file attachment
|
||||||
|
gitlink-cli repo +upload --owner Gitlink --repo forgeplus -f ./document.pdf -d "Project documentation"
|
||||||
|
|
||||||
|
# Upload a file with container metadata
|
||||||
|
gitlink-cli repo +upload -f ./image.png --container-id 12345 --container-type Project
|
||||||
```
|
```
|
||||||
|
|
||||||
### Webhook Management
|
### Webhook Management
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,12 @@ gitlink-cli repo +create -n my-project -d "项目描述"
|
||||||
|
|
||||||
# Fork 仓库
|
# Fork 仓库
|
||||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||||
|
|
||||||
|
# 上传文件附件
|
||||||
|
gitlink-cli repo +upload --owner Gitlink --repo forgeplus -f ./document.pdf -d "项目文档"
|
||||||
|
|
||||||
|
# 上传文件并指定归属模型
|
||||||
|
gitlink-cli repo +upload -f ./image.png --container-id 12345 --container-type Project
|
||||||
```
|
```
|
||||||
|
|
||||||
### Webhook 管理
|
### Webhook 管理
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Repo Upload Shortcut
|
||||||
|
|
||||||
|
## 中文说明
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
|
||||||
|
- 新增 `repo +upload` 命令,支持通过 `multipart/form-data` 上传文件到 `/api/attachments.json`。
|
||||||
|
- Client 层新增 `Upload()` 方法,支持文件字段和额外表单字段。
|
||||||
|
- RuntimeContext 新增 `UploadFile()` 便捷方法。
|
||||||
|
- 支持 `--file`(必需)、`--description`、`--container-id`、`--container-type` 参数。
|
||||||
|
- 更新 README、README.zh-CN 和变更说明。
|
||||||
|
|
||||||
|
### 命令
|
||||||
|
|
||||||
|
| 命令 | 用途 |
|
||||||
|
|---------|---------|
|
||||||
|
| `gitlink-cli repo +upload` | 上传文件附件 |
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 简写 | 必需 | 说明 |
|
||||||
|
|------|-------|----------|-------------|
|
||||||
|
| `--file` | `-f` | 是 | 要上传的文件路径 |
|
||||||
|
| `--description` | `-d` | 否 | 上传文件的描述 |
|
||||||
|
| `--container-id` | | 否 | 上传文件归属模型 ID |
|
||||||
|
| `--container-type` | | 否 | 上传文件归属模型类型 |
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 上传文件
|
||||||
|
gitlink-cli repo +upload --file ./example.txt
|
||||||
|
|
||||||
|
# 上传文件并添加描述
|
||||||
|
gitlink-cli repo +upload -f ./document.pdf -d "项目文档"
|
||||||
|
|
||||||
|
# 上传文件并指定归属
|
||||||
|
gitlink-cli repo +upload -f ./image.png \
|
||||||
|
--container-id 12345 \
|
||||||
|
--container-type Project
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "f5838d8f-451b-4793-a0f2-0278430e8207",
|
||||||
|
"title": "example.txt",
|
||||||
|
"filesize": "12 B",
|
||||||
|
"is_pdf": false,
|
||||||
|
"url": "http://example.com/file.txt",
|
||||||
|
"created_on": "2026-06-23 14:40",
|
||||||
|
"content_type": "text/plain"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- `TestRepoUploadShortcut`: 验证 multipart 请求、文件字段和 description。
|
||||||
|
- `TestRepoUploadWithContainerFields`: 验证 container_id 和 container_type 字段。
|
||||||
|
- `TestRepoUploadMissingFile`: 验证文件不存在时的错误处理。
|
||||||
|
- `TestRepoUploadHTTPError`: 验证 HTTP 500 错误传播。
|
||||||
|
- `TestClientUploadMultipart`: 验证 Client.Upload() 的 multipart 构建。
|
||||||
|
- `TestClientUploadHTTPError`: 验证 HTTP 403 时返回 APIError。
|
||||||
|
- `TestClientUploadJSONSuffix`: 验证 `.json` 后缀自动追加。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- `go build ./...`
|
||||||
|
- `go test ./shortcuts/repo/ ./internal/client/`
|
||||||
|
- `go run . repo +upload --help`
|
||||||
|
|
@ -3,8 +3,10 @@ package client
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -14,6 +16,20 @@ import (
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultMaxUploadSize is the default maximum file size (in bytes) accepted
|
||||||
|
// by Upload. Multipart data is fully buffered in memory; exceeding this
|
||||||
|
// limit returns ErrUploadTooLarge instead of risking an OOM.
|
||||||
|
defaultMaxUploadSize = 50 << 20 // 50 MB
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// MaxUploadSize is the maximum file size for Upload. Overridable in tests.
|
||||||
|
MaxUploadSize = int64(defaultMaxUploadSize)
|
||||||
|
|
||||||
|
ErrUploadTooLarge = errors.New("file too large to upload")
|
||||||
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HTTP *http.Client
|
HTTP *http.Client
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
|
@ -99,12 +115,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check HTTP-level errors
|
// Check HTTP-level errors
|
||||||
if resp.StatusCode >= 400 {
|
if err := httpError(respData, resp.StatusCode); err != nil {
|
||||||
return nil, &APIError{
|
return nil, err
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Code: resp.StatusCode,
|
|
||||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON
|
// Parse JSON
|
||||||
|
|
@ -116,29 +128,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
||||||
|
|
||||||
// Check GitLink error-in-body pattern
|
// Check GitLink error-in-body pattern
|
||||||
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
|
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
|
||||||
var bodyCode float64
|
bodyCode, bodyMsg := extractBodyErrorCode(raw)
|
||||||
var bodyMsg string
|
if bodyCode != 0 && !isSuccessCode(bodyCode) {
|
||||||
if status, ok := raw["status"]; ok {
|
|
||||||
switch v := status.(type) {
|
|
||||||
case float64:
|
|
||||||
bodyCode = v
|
|
||||||
case int:
|
|
||||||
bodyCode = 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))
|
suggestion := suggestFix(int(bodyCode))
|
||||||
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
|
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
|
||||||
StatusCode: int(bodyCode),
|
StatusCode: int(bodyCode),
|
||||||
|
|
@ -218,6 +209,144 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
|
||||||
return c.Do("DELETE", path, nil, query)
|
return c.Do("DELETE", path, nil, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload sends a multipart/form-data POST request (file upload).
|
||||||
|
// fields may be nil.
|
||||||
|
// Files larger than MaxUploadSize (50 MB) are rejected to avoid OOM.
|
||||||
|
func (c *Client) Upload(path string, fieldName, fileName string, fileReader io.Reader, fields map[string]string) (*output.Envelope, error) {
|
||||||
|
path = normalizeAPIPath(c.BaseURL, path)
|
||||||
|
if shouldAppendJSONSuffix(path) {
|
||||||
|
path += ".json"
|
||||||
|
}
|
||||||
|
fullURL := c.BaseURL + path
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
|
||||||
|
// File part must be added first when fields are present so the server
|
||||||
|
// sees the file under the expected part name.
|
||||||
|
if fileReader != nil && fieldName != "" {
|
||||||
|
part, err := mw.CreateFormFile(fieldName, fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, err := io.Copy(part, io.LimitReader(fileReader, MaxUploadSize+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n > MaxUploadSize {
|
||||||
|
return nil, fmt.Errorf("%w: %d bytes exceeds maximum %d MB", ErrUploadTooLarge, n, MaxUploadSize>>20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range fields {
|
||||||
|
if err := mw.WriteField(k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", fullURL, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
|
||||||
|
if c.Debug {
|
||||||
|
fmt.Printf("→ POST %s (multipart)\n", fullURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respData, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Debug {
|
||||||
|
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := httpError(respData, resp.StatusCode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(respData, &raw); err != nil {
|
||||||
|
return output.SuccessEnvelope(string(respData), nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyCode, bodyMsg := extractBodyErrorCode(raw)
|
||||||
|
if bodyCode != 0 && !isSuccessCode(bodyCode) {
|
||||||
|
suggestion := suggestFix(int(bodyCode))
|
||||||
|
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
|
||||||
|
StatusCode: int(bodyCode),
|
||||||
|
Code: int(bodyCode),
|
||||||
|
Message: bodyMsg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.SuccessEnvelope(raw, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBodyErrorCode extracts a numeric error code and message from a parsed JSON
|
||||||
|
// response body. It understands both {"status":N, "message":"..."} (GitLink core API)
|
||||||
|
// and {"code":N, "msg":"..."} (gateway API) patterns. Returns (0, "") if no error
|
||||||
|
// code is present in the body or the parsed code is not a number.
|
||||||
|
func extractBodyErrorCode(raw map[string]interface{}) (code float64, msg string) {
|
||||||
|
if status, ok := raw["status"]; ok {
|
||||||
|
switch v := status.(type) {
|
||||||
|
case float64:
|
||||||
|
code = v
|
||||||
|
case int:
|
||||||
|
code = float64(v)
|
||||||
|
case json.Number:
|
||||||
|
code, _ = v.Float64()
|
||||||
|
}
|
||||||
|
msg, _ = raw["message"].(string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if gateCode, ok := raw["code"]; ok {
|
||||||
|
switch v := gateCode.(type) {
|
||||||
|
case float64:
|
||||||
|
code = v
|
||||||
|
case int:
|
||||||
|
code = float64(v)
|
||||||
|
case json.Number:
|
||||||
|
code, _ = v.Float64()
|
||||||
|
}
|
||||||
|
msg, _ = raw["msg"].(string)
|
||||||
|
if msg == "" {
|
||||||
|
msg, _ = raw["message"].(string)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSuccessCode reports whether a body-level status/code value represents a
|
||||||
|
// successful response (i.e. it should NOT be treated as an API error).
|
||||||
|
func isSuccessCode(code float64) bool {
|
||||||
|
return code == 0 || code == 200 || code == 201 || code == 204 || code == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpError builds an APIError for non-2xx HTTP responses. Returns nil when
|
||||||
|
// the status code does not indicate an error.
|
||||||
|
func httpError(body []byte, statusCode int) *APIError {
|
||||||
|
if statusCode < 400 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Code: statusCode,
|
||||||
|
Message: fmt.Sprintf("HTTP %d: %s", statusCode, strings.TrimSpace(string(body))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func suggestFix(code int) string {
|
func suggestFix(code int) string {
|
||||||
switch code {
|
switch code {
|
||||||
case 401:
|
case 401:
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -579,3 +582,140 @@ func TestShouldAppendJSONSuffixSkipsWikiOpenPaths(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientUploadMultipart(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Fatalf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/attachments.json" {
|
||||||
|
t.Fatalf("expected /api/attachments.json, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "multipart/form-data") {
|
||||||
|
t.Fatalf("expected multipart/form-data, got %s", ct)
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
t.Fatalf("parse multipart: %v", err)
|
||||||
|
}
|
||||||
|
files := r.MultipartForm.File["file"]
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("expected 1 file, got %d", len(files))
|
||||||
|
}
|
||||||
|
if files[0].Filename != "hello.txt" {
|
||||||
|
t.Fatalf("expected filename hello.txt, got %s", files[0].Filename)
|
||||||
|
}
|
||||||
|
fh, err := files[0].Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open uploaded file: %v", err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
data, _ := io.ReadAll(fh)
|
||||||
|
if string(data) != "hello world" {
|
||||||
|
t.Fatalf("file content = %q, want %q", string(data), "hello world")
|
||||||
|
}
|
||||||
|
if desc := r.FormValue("description"); desc != "test file" {
|
||||||
|
t.Fatalf("description = %q, want %q", desc, "test file")
|
||||||
|
}
|
||||||
|
if cid := r.FormValue("container_id"); cid != "42" {
|
||||||
|
t.Fatalf("container_id = %q, want %q", cid, "42")
|
||||||
|
}
|
||||||
|
if ctype := r.FormValue("container_type"); ctype != "Project" {
|
||||||
|
t.Fatalf("container_type = %q, want %q", ctype, "Project")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"id":"abc-123","title":"hello.txt","filesize":"11 B","is_pdf":false,"url":"http://example.com/hello.txt","created_on":"2026-06-23 14:40","content_type":"text/plain"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
reader := strings.NewReader("hello world")
|
||||||
|
fields := map[string]string{
|
||||||
|
"description": "test file",
|
||||||
|
"container_id": "42",
|
||||||
|
"container_type": "Project",
|
||||||
|
}
|
||||||
|
env, err := c.Upload("/api/attachments", "file", "hello.txt", reader, fields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload failed: %v", err)
|
||||||
|
}
|
||||||
|
if !env.OK {
|
||||||
|
t.Fatal("expected OK=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUploadHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"status":403,"message":"forbidden"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
reader := strings.NewReader("data")
|
||||||
|
_, err := c.Upload("/api/attachments", "file", "test.txt", reader, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 403")
|
||||||
|
}
|
||||||
|
apiErr, ok := err.(*APIError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected APIError, got %T", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 403 {
|
||||||
|
t.Fatalf("expected status 403, got %d", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUploadJSONSuffix(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/attachments.json" {
|
||||||
|
t.Fatalf("expected .json suffix, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"id":"ok"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
reader := strings.NewReader("data")
|
||||||
|
_, err := c.Upload("/api/attachments", "file", "test.txt", reader, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUploadSizeLimit(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"id":"ok"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Files within the limit should succeed.
|
||||||
|
origSize := MaxUploadSize
|
||||||
|
MaxUploadSize = 100
|
||||||
|
defer func() { MaxUploadSize = origSize }()
|
||||||
|
|
||||||
|
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||||
|
|
||||||
|
// 50 bytes — within limit
|
||||||
|
t.Run("within limit", func(t *testing.T) {
|
||||||
|
reader := strings.NewReader(strings.Repeat("x", 50))
|
||||||
|
_, err := c.Upload("/api/attachments", "file", "small.txt", reader, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("small file should succeed, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 150 bytes — exceeds limit
|
||||||
|
t.Run("exceeds limit", func(t *testing.T) {
|
||||||
|
reader := strings.NewReader(strings.Repeat("x", 150))
|
||||||
|
_, err := c.Upload("/api/attachments", "file", "large.txt", reader, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for oversized file")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUploadTooLarge) {
|
||||||
|
t.Fatalf("expected ErrUploadTooLarge, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@
|
||||||
"cmd.repo.list.short": "List repositories for a user or organization",
|
"cmd.repo.list.short": "List repositories for a user or organization",
|
||||||
"cmd.repo.short": "Repository operations",
|
"cmd.repo.short": "Repository operations",
|
||||||
"cmd.repo.tree.short": "List repository files and directories",
|
"cmd.repo.tree.short": "List repository files and directories",
|
||||||
|
"cmd.repo.upload.short": "Upload a file attachment",
|
||||||
|
"cmd.repo.upload.long": "Upload a file to the repository as an attachment. Optional fields: description, container-id, container-type.",
|
||||||
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
|
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
|
||||||
"cmd.root.short": "GitLink CLI - command-line tool for GitLink",
|
"cmd.root.short": "GitLink CLI - command-line tool for GitLink",
|
||||||
"cmd.search.repos.short": "Search repositories",
|
"cmd.search.repos.short": "Search repositories",
|
||||||
|
|
@ -214,6 +216,10 @@
|
||||||
"flag.repo.private": "Make repository private (true/false)",
|
"flag.repo.private": "Make repository private (true/false)",
|
||||||
"flag.repo.tree.path": "Directory path to list (default: repository root)",
|
"flag.repo.tree.path": "Directory path to list (default: repository root)",
|
||||||
"flag.repo.tree.ref": "Branch, tag, or commit ref",
|
"flag.repo.tree.ref": "Branch, tag, or commit ref",
|
||||||
|
"flag.repo.upload.file": "Path to the file to upload",
|
||||||
|
"flag.repo.upload.description": "Description for the uploaded file",
|
||||||
|
"flag.repo.upload.container_id": "ID of the model the file belongs to",
|
||||||
|
"flag.repo.upload.container_type": "Type of the model the file belongs to",
|
||||||
"flag.search.keyword": "Search keyword",
|
"flag.search.keyword": "Search keyword",
|
||||||
"flag.sort_by": "Sort field",
|
"flag.sort_by": "Sort field",
|
||||||
"flag.sort_direction": "Sort direction: asc, desc",
|
"flag.sort_direction": "Sort direction: asc, desc",
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,10 @@
|
||||||
"flag.repo.private": "设为私有仓库(true/false)",
|
"flag.repo.private": "设为私有仓库(true/false)",
|
||||||
"flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)",
|
"flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)",
|
||||||
"flag.repo.tree.ref": "分支、标签或提交引用",
|
"flag.repo.tree.ref": "分支、标签或提交引用",
|
||||||
|
"flag.repo.upload.file": "要上传的文件路径",
|
||||||
|
"flag.repo.upload.description": "上传文件的描述",
|
||||||
|
"flag.repo.upload.container_id": "上传文件归属模型 ID",
|
||||||
|
"flag.repo.upload.container_type": "上传文件归属模型类型",
|
||||||
"flag.search.keyword": "搜索关键词",
|
"flag.search.keyword": "搜索关键词",
|
||||||
"flag.sort_by": "排序字段",
|
"flag.sort_by": "排序字段",
|
||||||
"flag.sort_direction": "排序方向:asc、desc",
|
"flag.sort_direction": "排序方向:asc、desc",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||||
|
|
@ -126,3 +127,9 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
||||||
}
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadFile uploads a file via multipart/form-data POST.
|
||||||
|
// fields may be nil.
|
||||||
|
func (ctx *RuntimeContext) UploadFile(path, fieldName, fileName string, fileReader io.Reader, fields map[string]string) (*output.Envelope, error) {
|
||||||
|
return ctx.Client.Upload(path, fieldName, fileName, fileReader, fields)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ package repo
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
@ -253,9 +256,55 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
||||||
return ctx.Output(env)
|
return ctx.Output(env)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "upload",
|
||||||
|
Description: tr.T("cmd.repo.upload.short"),
|
||||||
|
Long: tr.T("cmd.repo.upload.long"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "file", Short: "f", Usage: tr.T("flag.repo.upload.file"), Required: true},
|
||||||
|
{Name: "description", Short: "d", Usage: tr.T("flag.repo.upload.description")},
|
||||||
|
{Name: "container-id", Usage: tr.T("flag.repo.upload.container_id")},
|
||||||
|
{Name: "container-type", Usage: tr.T("flag.repo.upload.container_type")},
|
||||||
|
},
|
||||||
|
Run: runRepoUpload,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runRepoUpload(ctx *common.RuntimeContext) error {
|
||||||
|
filePath := ctx.Arg("file")
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Check file size upfront to avoid buffering huge files in memory.
|
||||||
|
if fi, err := f.Stat(); err == nil {
|
||||||
|
if fi.Size() > client.MaxUploadSize {
|
||||||
|
return fmt.Errorf("%w: %d bytes exceeds maximum %d MB",
|
||||||
|
client.ErrUploadTooLarge, fi.Size(), client.MaxUploadSize>>20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.UploadFile("/api/attachments", "file", filepath.Base(filePath), f, fields)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Output(env)
|
||||||
|
}
|
||||||
|
|
||||||
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||||
if len(translators) > 0 && translators[0] != nil {
|
if len(translators) > 0 && translators[0] != nil {
|
||||||
return translators[0]
|
return translators[0]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
|
@ -597,6 +599,129 @@ func TestRepoDeleteHTTPError(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- upload ---
|
||||||
|
|
||||||
|
func TestRepoUploadShortcut(t *testing.T) {
|
||||||
|
tmpFile, err := os.CreateTemp("", "upload-test-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
if _, err := tmpFile.WriteString("test content"); err != nil {
|
||||||
|
t.Fatalf("write temp file: %v", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Fatalf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/attachments.json" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(contentType, "multipart/form-data") {
|
||||||
|
t.Fatalf("expected multipart/form-data, got %s", contentType)
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
t.Fatalf("parse multipart: %v", err)
|
||||||
|
}
|
||||||
|
if files := r.MultipartForm.File["file"]; len(files) == 0 {
|
||||||
|
t.Fatal("missing file field")
|
||||||
|
}
|
||||||
|
if desc := r.FormValue("description"); desc != "test description" {
|
||||||
|
t.Fatalf("description = %q, want %q", desc, "test description")
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"id": "f5838d8f-451b-4793-a0f2-0278430e8207",
|
||||||
|
"title": "upload-test.txt",
|
||||||
|
"filesize": "12 B",
|
||||||
|
"is_pdf": false,
|
||||||
|
"url": "http://example.com/file.txt",
|
||||||
|
"created_on": "2026-06-23 14:40",
|
||||||
|
"content_type": "text/plain",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err = runShortcut(t, server, "upload", map[string]string{
|
||||||
|
"file": tmpFile.Name(),
|
||||||
|
"description": "test description",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoUploadWithContainerFields(t *testing.T) {
|
||||||
|
tmpFile, err := os.CreateTemp("", "upload-container-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
tmpFile.WriteString("content")
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
t.Fatalf("parse multipart: %v", err)
|
||||||
|
}
|
||||||
|
if cid := r.FormValue("container_id"); cid != "12345" {
|
||||||
|
t.Fatalf("container_id = %q, want %q", cid, "12345")
|
||||||
|
}
|
||||||
|
if ctype := r.FormValue("container_type"); ctype != "Project" {
|
||||||
|
t.Fatalf("container_type = %q, want %q", ctype, "Project")
|
||||||
|
}
|
||||||
|
writeJSON(t, w, map[string]interface{}{"id": "test-id"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err = runShortcut(t, server, "upload", map[string]string{
|
||||||
|
"file": tmpFile.Name(),
|
||||||
|
"container-id": "12345",
|
||||||
|
"container-type": "Project",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload with container fields failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoUploadMissingFile(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("should not call API when file is missing")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runShortcut(t, server, "upload", map[string]string{
|
||||||
|
"file": "/nonexistent/file.txt",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoUploadHTTPError(t *testing.T) {
|
||||||
|
tmpFile, err := os.CreateTemp("", "upload-error-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
tmpFile.WriteString("content")
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeText(t, w, http.StatusInternalServerError, "server error")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err = runShortcut(t, server, "upload", map[string]string{
|
||||||
|
"file": tmpFile.Name(),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepoCreateGetUserHTTPError(t *testing.T) {
|
func TestRepoCreateGetUserHTTPError(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeText(t, w, http.StatusInternalServerError, "server error")
|
writeText(t, w, http.StatusInternalServerError, "server error")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue