package registry import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // giteaPackageVersion represents a single version entry from the Gitea // packages API response. type giteaPackageVersion struct { ID int64 `json:"id"` Version string `json:"version"` Creator struct { Login string `json:"login"` } `json:"creator"` CreatedAt time.Time `json:"created_at"` } // GiteaClient implements Client for Gitea container registries. type GiteaClient struct { baseURL string token string httpClient *http.Client } // NewGiteaClient creates a new Gitea registry client. // baseURL should be the Gitea instance URL (e.g., "https://git.example.com"). // token is a personal access token with package read permissions. func NewGiteaClient(baseURL, token string) *GiteaClient { return &GiteaClient{ baseURL: strings.TrimRight(baseURL, "/"), token: token, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } // ListImages returns all container images (packages) for the given owner. // It queries GET /api/v1/packages/{owner}?type=container and paginates // through all results, returning a RegistryImage for each unique package. func (c *GiteaClient) ListImages(ctx context.Context, owner string) ([]RegistryImage, error) { if owner == "" { return nil, fmt.Errorf("owner is required for listing images") } // Extract the registry host from baseURL to build full references. host := c.baseURL for _, prefix := range []string{"https://", "http://"} { host = strings.TrimPrefix(host, prefix) } host = strings.TrimRight(host, "/") var images []RegistryImage seen := make(map[string]bool) page := 1 limit := 50 for { url := fmt.Sprintf("%s/api/v1/packages/%s?type=container&page=%d&limit=%d", c.baseURL, owner, page, limit) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if c.token != "" { req.Header.Set("Authorization", "token "+c.token) } req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) } var packages []giteaPackageListEntry if err := json.Unmarshal(body, &packages); err != nil { return nil, fmt.Errorf("decode package list: %w", err) } for _, p := range packages { if !seen[p.Name] { seen[p.Name] = true images = append(images, RegistryImage{ Name: p.Name, Owner: owner, FullRef: fmt.Sprintf("%s/%s/%s", host, owner, p.Name), }) } } if len(packages) < limit { break } page++ } return images, nil } // ListTags returns all available tags for the given container image. // The image should be in the format "owner/package-name" or // "registry-host/owner/package-name" (the registry host prefix is stripped). func (c *GiteaClient) ListTags(ctx context.Context, image string) ([]string, error) { owner, pkg := parseImage(image) if owner == "" || pkg == "" { return nil, fmt.Errorf("invalid image format %q: expected owner/package", image) } versions, err := c.listPackageVersions(ctx, owner, pkg) if err != nil { return nil, fmt.Errorf("list tags for %s/%s: %w", owner, pkg, err) } tags := make([]string, 0, len(versions)) for _, v := range versions { tags = append(tags, v.Version) } return tags, nil } // GetLatestTag returns the most recently created tag matching the given glob // pattern. Returns empty string if no tags match. func (c *GiteaClient) GetLatestTag(ctx context.Context, image string, pattern string) (string, error) { tags, err := c.ListTags(ctx, image) if err != nil { return "", err } return LatestTag(tags, pattern) } // listPackageVersions fetches all container package versions from the Gitea API. // Endpoint: GET /api/v1/packages/{owner}?type=container&q={package} // Gitea paginates results; this function fetches all pages. func (c *GiteaClient) listPackageVersions(ctx context.Context, owner, pkg string) ([]giteaPackageVersion, error) { var allVersions []giteaPackageVersion page := 1 limit := 50 for { url := fmt.Sprintf("%s/api/v1/packages/%s?type=container&q=%s&page=%d&limit=%d", c.baseURL, owner, pkg, page, limit) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if c.token != "" { req.Header.Set("Authorization", "token "+c.token) } req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) } var packages []giteaPackageListEntry if err := json.Unmarshal(body, &packages); err != nil { return nil, fmt.Errorf("decode package list: %w", err) } // Filter for exact package name match and collect versions. for _, p := range packages { if p.Name == pkg { versions, err := c.fetchPackageVersions(ctx, owner, pkg) if err != nil { return nil, err } return versions, nil } } // If we got fewer results than the limit, we've reached the last page. if len(packages) < limit { break } page++ } return allVersions, nil } // giteaPackageListEntry represents a package in the Gitea packages list response. type giteaPackageListEntry struct { ID int64 `json:"id"` Name string `json:"name"` Type string `json:"type"` Version string `json:"version"` } // fetchPackageVersions fetches all versions of a specific container package. // Endpoint: GET /api/v1/packages/{owner}/container/{name} func (c *GiteaClient) fetchPackageVersions(ctx context.Context, owner, pkg string) ([]giteaPackageVersion, error) { var allVersions []giteaPackageVersion page := 1 limit := 50 for { url := fmt.Sprintf("%s/api/v1/packages/%s/container/%s?page=%d&limit=%d", c.baseURL, owner, pkg, page, limit) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if c.token != "" { req.Header.Set("Authorization", "token "+c.token) } req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) } var versions []giteaPackageVersion if err := json.Unmarshal(body, &versions); err != nil { return nil, fmt.Errorf("decode versions: %w", err) } allVersions = append(allVersions, versions...) if len(versions) < limit { break } page++ } return allVersions, nil } // parseImage extracts the owner and package name from an image string. // Supported formats: // - "owner/package" // - "registry.example.com/owner/package" // // Returns empty strings if the format is invalid. func parseImage(image string) (owner, pkg string) { parts := strings.Split(image, "/") switch len(parts) { case 2: // owner/package return parts[0], parts[1] case 3: // registry.example.com/owner/package return parts[1], parts[2] default: return "", "" } }