Compare commits

...

1 Commits

Author SHA1 Message Date
co63oc 3a11458132 feat(shortcut): add repo upload 2026-07-08 16:22:22 +08:00
10 changed files with 572 additions and 29 deletions

View File

@ -255,6 +255,12 @@ gitlink-cli repo +create -n my-project -d "Project description"
# Fork a repository
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

View File

@ -266,6 +266,12 @@ gitlink-cli repo +create -n my-project -d "项目描述"
# Fork 仓库
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 管理

View File

@ -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`

View File

@ -3,8 +3,10 @@ package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
@ -14,6 +16,20 @@ import (
"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 {
HTTP *http.Client
BaseURL string
@ -99,12 +115,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
}
// Check HTTP-level errors
if resp.StatusCode >= 400 {
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
}
if err := httpError(respData, resp.StatusCode); err != nil {
return nil, err
}
// 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
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
var bodyCode float64
var bodyMsg string
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 {
bodyCode, bodyMsg := extractBodyErrorCode(raw)
if bodyCode != 0 && !isSuccessCode(bodyCode) {
suggestion := suggestFix(int(bodyCode))
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
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)
}
// 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 {
switch code {
case 401:

View File

@ -2,11 +2,14 @@ package client
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"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)
}
})
}

View File

@ -88,6 +88,8 @@
"cmd.repo.list.short": "List repositories for a user or organization",
"cmd.repo.short": "Repository operations",
"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.short": "GitLink CLI - command-line tool for GitLink",
"cmd.search.repos.short": "Search repositories",
@ -214,6 +216,10 @@
"flag.repo.private": "Make repository private (true/false)",
"flag.repo.tree.path": "Directory path to list (default: repository root)",
"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.sort_by": "Sort field",
"flag.sort_direction": "Sort direction: asc, desc",

View File

@ -214,6 +214,10 @@
"flag.repo.private": "设为私有仓库true/false",
"flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)",
"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.sort_by": "排序字段",
"flag.sort_direction": "排序方向asc、desc",

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
@ -126,3 +127,9 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
}
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)
}

View File

@ -3,9 +3,12 @@ package repo
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -253,9 +256,55 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
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 {
if len(translators) > 0 && translators[0] != nil {
return translators[0]

View File

@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"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) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeText(t, w, http.StatusInternalServerError, "server error")