Files
k3nny f5f8546bcf
ci / vet, staticcheck, test, build (push) Successful in 2m16s
release / Build and publish release (push) Successful in 1m9s
feat(cli): output formats, GL034-GL041 lint rules, include inputs and cache
Bundles three patch releases (v0.2.16–v0.2.18):

v0.2.18 — output formats (--format flag on glint check):
- json: stable JSON report (schema_version: 1, findings array, summary)
- sarif: SARIF 2.1.0 for GitHub Code Scanning / GitLab SAST
- junit: JUnit XML for CI test-report artifacts (artifacts:reports:junit)
- github: GitHub Actions ::error:: / ::warning:: annotation lines
- Unknown --format value exits 2 with a helpful error message
- Summary line routed to stderr in structured formats; context suppressed

v0.2.17 — include resolution improvements:
- Recursive include depth capped at 100 (matches GitLab's own limit)
- project: and component: includes tracked in visited set (cycle detection)
- $[[ inputs.KEY ]] / $[[ inputs.KEY | default(…) ]] substituted from with:
- --cache-dir: persist fetched remote templates to disk (SHA-256 keyed)
- --offline: serve from cache only; defaults to ~/.cache/glint

v0.2.16 — new lint rules (GL034–GL041):
- GL034: services map form requires name; alias must be valid DNS label
- GL035: rules:changes / rules:exists absolute path detection
- GL036: timeout format validation (job-level + default.timeout)
- GL037: id_tokens entries must have an aud key
- GL038: secrets entries must declare a provider (vault / gcp / azure)
- GL039: pages: keyword + artifacts.paths consistency
- GL040: duplicate stage names in stages: list
- GL041: cache.key.files must be exact paths, not globs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 10:09:16 +02:00

196 lines
5.9 KiB
Go

// Package fetcher retrieves remote GitLab CI YAML files via the GitLab REST API.
package fetcher
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)
// TokenSource describes where a token was found, which determines the correct
// authentication header to use with the GitLab API.
type TokenSource int
const (
TokenNone TokenSource = iota
TokenPrivate // GITLAB_TOKEN or GITLAB_PRIVATE_TOKEN → PRIVATE-TOKEN header
TokenJobToken // CI_JOB_TOKEN → JOB-TOKEN header
)
// GitLabConfig holds everything needed to reach a GitLab instance.
type GitLabConfig struct {
BaseURL string
Token string
Source TokenSource
CacheDir string // local cache directory; empty = caching disabled
Offline bool // when true, return an error instead of making network calls
}
// AutoConfig builds a GitLabConfig from environment variables.
//
// Base URL resolution order: CI_SERVER_URL → GITLAB_URL → https://gitlab.com
// Token resolution order: GITLAB_TOKEN → CI_JOB_TOKEN → GITLAB_PRIVATE_TOKEN
func AutoConfig() GitLabConfig {
baseURL := firstNonEmpty(
os.Getenv("CI_SERVER_URL"),
os.Getenv("GITLAB_URL"),
"https://gitlab.com",
)
cfg := GitLabConfig{BaseURL: strings.TrimRight(baseURL, "/")}
if t := os.Getenv("GITLAB_TOKEN"); t != "" {
cfg.Token = t
cfg.Source = TokenPrivate
} else if t := os.Getenv("CI_JOB_TOKEN"); t != "" {
cfg.Token = t
cfg.Source = TokenJobToken
} else if t := os.Getenv("GITLAB_PRIVATE_TOKEN"); t != "" {
cfg.Token = t
cfg.Source = TokenPrivate
}
return cfg
}
// WithOverrides returns a copy of cfg with the provided overrides applied.
// Non-empty strings overwrite the corresponding field; booleans are always
// applied (so offline: false explicitly clears offline mode).
// cacheDir: empty string disables caching.
func (cfg GitLabConfig) WithOverrides(baseURL, token, cacheDir string, offline bool) GitLabConfig {
if baseURL != "" {
cfg.BaseURL = strings.TrimRight(baseURL, "/")
}
if token != "" {
cfg.Token = token
cfg.Source = TokenPrivate
}
cfg.CacheDir = cacheDir
cfg.Offline = offline
return cfg
}
// ForHost returns a copy of cfg with BaseURL set to https://<host>.
// Used when a CI/CD component reference specifies a different GitLab host.
func (cfg GitLabConfig) ForHost(host string) GitLabConfig {
cfg.BaseURL = "https://" + host
return cfg
}
// HasToken reports whether the config carries a usable token.
func (cfg GitLabConfig) HasToken() bool { return cfg.Token != "" }
// FetchFile downloads a single file from a GitLab project repository.
// If no token is configured the request is sent unauthenticated (works for
// public projects and GitLab CI/CD catalog components).
//
// - project — namespace/project path, e.g. "my-group/my-project"
// - filePath — repository file path, e.g. "/templates/ci.yml"
// - ref — branch, tag, or commit SHA; empty string defaults to HEAD
func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) {
if ref == "" {
ref = "HEAD"
}
cKey := cfg.BaseURL + "|" + project + "|" + filePath + "|" + ref
if data, ok := cacheRead(cfg.CacheDir, cKey); ok {
return data, nil
}
if cfg.Offline {
return nil, fmt.Errorf("offline mode: %s:%s@%s is not in the local cache (run without --offline first to populate the cache)", project, filePath, ref)
}
encodedProject := url.PathEscape(project)
encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/"))
apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw",
cfg.BaseURL, encodedProject, encodedFile)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
q := req.URL.Query()
q.Set("ref", ref)
req.URL.RawQuery = q.Encode()
// Add auth header only when a token is available.
if cfg.Token != "" {
switch cfg.Source {
case TokenJobToken:
req.Header.Set("JOB-TOKEN", cfg.Token)
default:
req.Header.Set("PRIVATE-TOKEN", cfg.Token)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", apiURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(body))
switch resp.StatusCode {
case http.StatusUnauthorized:
if cfg.Token == "" {
return nil, fmt.Errorf("authentication required (401) — set GITLAB_TOKEN or CI_JOB_TOKEN")
}
return nil, fmt.Errorf("authentication failed (401) — check your token")
case http.StatusNotFound:
return nil, fmt.Errorf("not found (404): %s@%s in project %s — check the project path, file path, and token permissions", filePath, ref, project)
default:
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, msg)
}
}
cacheWrite(cfg.CacheDir, cKey, body)
return body, nil
}
// FetchURL downloads the content at a plain HTTPS URL without authentication.
// Used for include: remote: entries which are public by definition.
// Responses are read from and written to the local cache when CacheDir is set.
func (cfg GitLabConfig) FetchURL(rawURL string) ([]byte, error) {
if data, ok := cacheRead(cfg.CacheDir, rawURL); ok {
return data, nil
}
if cfg.Offline {
return nil, fmt.Errorf("offline mode: %s is not in the local cache (run without --offline first to populate the cache)", rawURL)
}
resp, err := http.Get(rawURL) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("GET %s: %w", rawURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode)
}
cacheWrite(cfg.CacheDir, rawURL, body)
return body, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}