Files
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

268 lines
7.9 KiB
Go

package gitlab
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/datasource"
)
type client struct {
baseURL, token string
http *http.Client
}
type apiError struct {
endpoint string
status int
}
func (e *apiError) Error() string {
return fmt.Sprintf("gitlab API %s: status %d", e.endpoint, e.status)
}
type project struct {
ID int64 `json:"id"`
PathWithNamespace string `json:"path_with_namespace"`
Name string `json:"name"`
WebURL string `json:"web_url"`
DefaultBranch string `json:"default_branch"`
Namespace struct {
ID int64 `json:"id"`
} `json:"namespace"`
}
type treeEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"`
}
type comparison struct {
Diffs []struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
NewFile bool `json:"new_file"`
DeletedFile bool `json:"deleted_file"`
RenamedFile bool `json:"renamed_file"`
} `json:"diffs"`
CompareTimeout bool `json:"compare_timeout"`
CompareSameRef bool `json:"compare_same_ref"`
}
func newClient(baseURL, token string) (*client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" || strings.TrimSpace(token) == "" {
return nil, fmt.Errorf("GitLab platform configuration is missing")
}
if err := datasource.ValidateConnectorBaseURL(baseURL); err != nil {
return nil, err
}
if !strings.Contains(baseURL, "://") {
baseURL = "https://" + baseURL
}
if !strings.HasSuffix(baseURL, "/api/v4") {
baseURL += "/api/v4"
}
return &client{baseURL: baseURL, token: token, http: datasource.NewConnectorHTTPClient(30 * time.Second)}, nil
}
func (c *client) get(ctx context.Context, endpoint string, out interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, nil)
if err != nil {
return err
}
req.Header.Set("PRIVATE-TOKEN", c.token)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &apiError{endpoint: endpoint, status: resp.StatusCode}
}
return json.Unmarshal(body, out)
}
func (c *client) getRaw(ctx context.Context, endpoint string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("PRIVATE-TOKEN", c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &apiError{endpoint: endpoint, status: resp.StatusCode}
}
return io.ReadAll(resp.Body)
}
// projectPath encodes a GitLab project identifier for URL path segments.
// Numeric IDs are used verbatim. Namespace paths accept either "group/project"
// or a once-encoded "group%2Fproject" without double-encoding percent signs.
func projectPath(id string) string {
id = strings.TrimSpace(id)
if id == "" {
return ""
}
if _, err := strconv.ParseInt(id, 10, 64); err == nil {
return id
}
decoded := id
if strings.Contains(id, "%") {
if unescaped, err := url.PathUnescape(id); err == nil {
decoded = unescaped
}
}
if strings.Contains(decoded, "/") {
parts := strings.Split(decoded, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "%2F")
}
return url.PathEscape(decoded)
}
func (c *client) project(ctx context.Context, id string) (*project, error) {
var p project
err := c.get(ctx, "/projects/"+projectPath(id), &p)
return &p, err
}
func (c *client) projects(ctx context.Context) ([]project, error) {
var p []project
err := c.get(ctx, "/projects?membership=true&per_page=100&order_by=path_with_namespace&sort=asc", &p)
return p, err
}
// ping verifies that the supplied private token is accepted by this GitLab
// instance. It avoids project-list ordering parameters that older GitLab
// deployments may reject even when the token is valid.
func (c *client) ping(ctx context.Context) error {
var user struct {
ID int64 `json:"id"`
}
return c.get(ctx, "/user", &user)
}
func (c *client) commitSHA(ctx context.Context, id, ref string) (string, error) {
var v struct {
ID string `json:"id"`
}
err := c.get(ctx, "/projects/"+projectPath(id)+"/repository/commits/"+url.PathEscape(ref), &v)
return v.ID, err
}
func (c *client) tree(ctx context.Context, id, ref, dir string) ([]treeEntry, error) {
q := url.Values{"ref": {ref}, "per_page": {"100"}, "page": {"1"}}
if dir != "" {
q.Set("path", dir)
}
endpoint := "/projects/" + projectPath(id) + "/repository/tree"
var all []treeEntry
for {
var page []treeEntry
nextPage, err := c.getTreePage(ctx, endpoint, q, &page)
if err != nil {
return nil, err
}
all = append(all, page...)
if nextPage == "" {
return all, nil
}
q.Set("page", nextPage)
}
}
func (c *client) getTreePage(ctx context.Context, endpoint string, query url.Values, out interface{}) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint+"?"+query.Encode(), nil)
if err != nil {
return "", err
}
req.Header.Set("PRIVATE-TOKEN", c.token)
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", &apiError{endpoint: endpoint, status: resp.StatusCode}
}
if err := json.Unmarshal(body, out); err != nil {
return "", err
}
return resp.Header.Get("X-Next-Page"), nil
}
func (c *client) raw(ctx context.Context, id, ref, file string) ([]byte, error) {
q := url.Values{"ref": {ref}}
encodedFile := gitlabFilePathEscape(file)
rawEndpoint := "/projects/" + projectPath(id) + "/repository/files/" + encodedFile + "/raw?" + q.Encode()
content, err := c.getRaw(ctx, rawEndpoint)
if err == nil {
return content, nil
}
var apiErr *apiError
if !errors.As(err, &apiErr) || apiErr.status != http.StatusNotFound {
return nil, fmt.Errorf("gitlab raw file: %w", err)
}
// Some GitLab deployments expose the file detail endpoint but return 404
// for the otherwise standard /raw route. The detail response contains the
// same content as base64 and provides a compatible fallback.
var detail struct {
Encoding string `json:"encoding"`
Content string `json:"content"`
}
detailEndpoint := "/projects/" + projectPath(id) + "/repository/files/" + encodedFile + "?" + q.Encode()
if err := c.get(ctx, detailEndpoint, &detail); err != nil {
return nil, fmt.Errorf("gitlab file content: %w", err)
}
if detail.Encoding != "base64" {
return nil, fmt.Errorf("gitlab file content: unsupported encoding %q", detail.Encoding)
}
content, err = base64.StdEncoding.DecodeString(detail.Content)
if err != nil {
return nil, fmt.Errorf("gitlab file content: decode base64: %w", err)
}
return content, nil
}
// gitlabFilePathEscape mirrors the company GitLab raw-file route: only ASCII
// letters, digits, hyphen and underscore remain literal. In particular dots,
// path separators and UTF-8 bytes must be percent-encoded.
func gitlabFilePathEscape(file string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
b.Grow(len(file) * 3)
for _, c := range []byte(file) {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0x0f])
}
return b.String()
}
func (c *client) compare(ctx context.Context, id, from, to string) (*comparison, error) {
q := url.Values{"from": {from}, "to": {to}}
var v comparison
err := c.get(ctx, "/projects/"+projectPath(id)+"/repository/compare?"+q.Encode(), &v)
return &v, err
}