- internal/gitea:用戶端支援寫入動詞(DoJSON/DoRaw,共用 do 核心) - internal/dailyops:七組日常操作的 API 層(issues/pulls/labels/milestones/releases/repos/api) - internal/cli:子命令接線;寫入類一律 --yes 閘門(未確認不發請求、exit 2) - teai pulls 相容 #6 工作流用法(無子命令);有子命令走日常操作 - 修正 dispatch:命令旗標(如 --has-work、--comments)不再被誤判為未知全域選項 - teai api 支援 path 內含查詢字串 - 單元測試(httptest 假伺服器)+真實站點交叉驗證(讀取命令輸出對照 Gitea API;寫入回路 create→comment→close 於 alex/.profile 驗證後清理)
229 lines
7.2 KiB
Go
229 lines
7.2 KiB
Go
// 套件 gitea 提供存取 Gitea API 的 HTTP 用戶端基礎:
|
||
// 認證、分頁、逾時與錯誤映射。
|
||
//
|
||
// 僅使用標準庫(net/http 等),離線可建置。token 由呼叫端注入,
|
||
// 本套件不負責認證來源的解析(見 auth.go),也不會把 token 寫進
|
||
// 任何輸出或錯誤訊息。
|
||
package gitea
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"reflect"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ErrAPI 表示與 Gitea API 溝通失敗(連線、認證、權限或伺服器錯誤)。
|
||
// 行為上對應結束碼 3(API 錯誤)。
|
||
type ErrAPI struct {
|
||
// StatusCode 是 HTTP 回應狀態碼;0 表示請求未完成(連線失敗等)。
|
||
StatusCode int
|
||
// Op 是失敗的步驟描述(如 "GET /api/v1/user"),僅供診斷。
|
||
Op string
|
||
// Err 是底層錯誤。
|
||
Err error
|
||
}
|
||
|
||
// Error 實作 error 介面;不包含 token 或完整回應內容。
|
||
func (e *ErrAPI) Error() string {
|
||
if e.StatusCode == 0 {
|
||
return fmt.Sprintf("gitea: %s: %v", e.Op, e.Err)
|
||
}
|
||
return fmt.Sprintf("gitea: %s: HTTP %d", e.Op, e.StatusCode)
|
||
}
|
||
|
||
// Unwrap 支援 errors.Is / errors.As。
|
||
func (e *ErrAPI) Unwrap() error { return e.Err }
|
||
|
||
// Client 是 Gitea API 的最小用戶端。零值不可用,請用 New。
|
||
type Client struct {
|
||
// baseURL 是 API 根(如 https://gitea.alterminal.com/api/v1),
|
||
// 不帶尾斜線。
|
||
baseURL *url.URL
|
||
// token 是 API token;空字串表示匿名存取(公開端點)。
|
||
token string
|
||
// hc 是底層 HTTP 用戶端(帶逾時)。
|
||
hc *http.Client
|
||
}
|
||
|
||
// DefaultTimeout 是未指定逾時時的預設值。
|
||
const DefaultTimeout = 30 * time.Second
|
||
|
||
// New 建立用戶端。baseURL 為站點網址(如 https://gitea.alterminal.com),
|
||
// 函式內部自行補上 /api/v1 路徑。timeout <= 0 時採 DefaultTimeout。
|
||
func New(baseURL, token string, timeout time.Duration) (*Client, error) {
|
||
u, err := url.Parse(strings.TrimSuffix(baseURL, "/"))
|
||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||
return nil, fmt.Errorf("gitea: invalid base URL %q", baseURL)
|
||
}
|
||
u = u.JoinPath("api", "v1")
|
||
if timeout <= 0 {
|
||
timeout = DefaultTimeout
|
||
}
|
||
return &Client{
|
||
baseURL: u,
|
||
token: token,
|
||
hc: &http.Client{Timeout: timeout},
|
||
}, nil
|
||
}
|
||
|
||
// MaxLimit 是 Gitea 清單端點單次回傳上限(服務端硬上限 50)。
|
||
const MaxLimit = 50
|
||
|
||
// getJSON 對 path(相對 baseURL,以 / 開頭)發 GET,把 JSON 回應解到 out。
|
||
// 失敗時回傳 *ErrAPI。rawQuery 為組好的查詢字串(見 ListOptions.query)。
|
||
func (c *Client) getJSON(ctx context.Context, path, rawQuery string, out any) error {
|
||
_, err := c.do(ctx, http.MethodGet, path, rawQuery, "", nil, out)
|
||
return err
|
||
}
|
||
|
||
// DoJSON 對 path 發 method 請求(POST/PATCH/PUT/DELETE…),body 為可 JSON
|
||
// 化的值(nil 表示不帶 body),2xx 時把回應解到 out(out 非 nil 且回應
|
||
// 有內容時)。供寫入類操作(issues create、pulls merge…)使用。
|
||
func (c *Client) DoJSON(ctx context.Context, method, path, rawQuery string, body any, out any) error {
|
||
var payload []byte
|
||
if body != nil {
|
||
b, err := json.Marshal(body)
|
||
if err != nil {
|
||
return fmt.Errorf("gitea: marshal request body: %w", err)
|
||
}
|
||
payload = b
|
||
}
|
||
_, err := c.do(ctx, method, path, rawQuery, "application/json", payload, out)
|
||
return err
|
||
}
|
||
|
||
// DoRaw 以原樣 body 送出請求並回傳原始回應內容(teai api 用)。
|
||
// 非 2xx 回 *ErrAPI。
|
||
func (c *Client) DoRaw(ctx context.Context, method, path, rawQuery string, body []byte) ([]byte, error) {
|
||
contentType := ""
|
||
if body != nil {
|
||
contentType = "application/json"
|
||
}
|
||
return c.do(ctx, method, path, rawQuery, contentType, body, nil)
|
||
}
|
||
|
||
// do 是所有請求的共同核心:組 URL、帶認證表頭、送出、檢查狀態碼、
|
||
// 解析回應。2xx 時回傳原始回應位元組;out 非 nil 時另解到 out。
|
||
// 錯誤訊息不含 token 或回應內容。
|
||
func (c *Client) do(ctx context.Context, method, path, rawQuery, contentType string, payload []byte, out any) ([]byte, error) {
|
||
u := *c.baseURL
|
||
u.Path += path
|
||
u.RawQuery = rawQuery
|
||
op := method + " " + u.Path
|
||
|
||
var body io.Reader
|
||
if payload != nil {
|
||
body = bytes.NewReader(payload)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
||
if err != nil {
|
||
return nil, &ErrAPI{Op: op, Err: err}
|
||
}
|
||
req.Header.Set("Accept", "application/json")
|
||
if payload != nil && contentType != "" {
|
||
req.Header.Set("Content-Type", contentType)
|
||
}
|
||
if c.token != "" {
|
||
req.Header.Set("Authorization", "token "+c.token)
|
||
}
|
||
resp, err := c.hc.Do(req)
|
||
if err != nil {
|
||
return nil, &ErrAPI{Op: op, Err: err}
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||
// 把內容讀掉再丟棄,讓連線可重用;錯誤訊息不含回應內容。
|
||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||
return nil, &ErrAPI{Op: op, StatusCode: resp.StatusCode}
|
||
}
|
||
data, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, &ErrAPI{Op: op, Err: err}
|
||
}
|
||
if out != nil && len(data) > 0 {
|
||
if err := json.Unmarshal(data, out); err != nil {
|
||
return nil, &ErrAPI{Op: op, Err: fmt.Errorf("decode response: %w", err)}
|
||
}
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
// GetJSON 是 getJSON 的公開介面,供非清單端點(如 /user)使用。
|
||
func (c *Client) GetJSON(ctx context.Context, path string, out any) error {
|
||
return c.getJSON(ctx, path, "", out)
|
||
}
|
||
|
||
// ListOptions 描述清單端點的分頁參數。
|
||
type ListOptions struct {
|
||
// Limit 是單頁筆數;<= 0 或 > MaxLimit 時設為 MaxLimit。
|
||
Limit int
|
||
// Page 是頁碼(1 起);僅供單頁抓取,ListAll 一律從第 1 頁開始。
|
||
Page int
|
||
// Extra 是額外查詢參數(如 state、type)。
|
||
Extra url.Values
|
||
}
|
||
|
||
// query 把分頁參數組成查詢字串。
|
||
func (o ListOptions) query() string {
|
||
q := url.Values{}
|
||
limit := o.Limit
|
||
if limit <= 0 || limit > MaxLimit {
|
||
limit = MaxLimit
|
||
}
|
||
q.Set("limit", strconv.Itoa(limit))
|
||
if o.Page > 1 {
|
||
q.Set("page", strconv.Itoa(o.Page))
|
||
}
|
||
for k, vs := range o.Extra {
|
||
for _, v := range vs {
|
||
q.Set(k, v)
|
||
}
|
||
}
|
||
return q.Encode()
|
||
}
|
||
|
||
// ListAll 逐頁抓取清單端點直到抓完,把結果存進 out(指向 slice 的指標,
|
||
// 初始須為空 slice)。每頁固定使用 limit=MaxLimit(Gitea 上限 50);
|
||
// opts.Extra 照常帶入。頁數在服務端回傳未滿頁或空頁時結束。
|
||
func (c *Client) ListAll(ctx context.Context, path string, opts ListOptions, out any) error {
|
||
rv := reflect.ValueOf(out)
|
||
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
||
return fmt.Errorf("gitea: ListAll: out must be non-nil pointer to slice")
|
||
}
|
||
sv := rv.Elem()
|
||
if sv.Kind() != reflect.Slice {
|
||
return fmt.Errorf("gitea: ListAll: out must be non-nil pointer to slice")
|
||
}
|
||
if sv.Len() != 0 {
|
||
return fmt.Errorf("gitea: ListAll: out slice must be empty")
|
||
}
|
||
for page := 1; ; page++ {
|
||
opts := opts
|
||
opts.Limit = MaxLimit
|
||
opts.Page = page
|
||
pageOut := reflect.MakeSlice(sv.Type(), 0, MaxLimit)
|
||
pp := reflect.New(sv.Type())
|
||
pp.Elem().Set(pageOut)
|
||
if err := c.getJSON(ctx, path, opts.query(), pp.Interface()); err != nil {
|
||
return err
|
||
}
|
||
got := pp.Elem()
|
||
n := got.Len()
|
||
if n == 0 {
|
||
return nil
|
||
}
|
||
sv.Set(reflect.AppendSlice(sv, got))
|
||
if n < MaxLimit {
|
||
return nil
|
||
}
|
||
}
|
||
}
|