tarakan-client/internal/api/client.go
Maxfield Luke 58b29a7f84
Some checks failed
CI and releases / Test and vet (push) Failing after 1s
CI and releases / Build release binaries (push) Has been skipped
CI and releases / Publish GitHub release (push) Has been skipped
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at
https://git.elektrine.com/elektrine/tarakan-client.
2026-07-29 04:56:36 -04:00

401 lines
13 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
const (
DefaultBaseURL = "https://tarakan.lol"
maxResponseSize = 2 << 20
)
var ErrTokenRequired = errors.New("API token is required (run tarakan login, pass --token, or set TARAKAN_API_TOKEN)")
type Client struct {
baseURL string
token string
httpClient *http.Client
}
type APIError struct {
StatusCode int
Message string
Errors map[string][]string
}
func (e *APIError) Error() string {
message := e.Message
if message == "" {
message = http.StatusText(e.StatusCode)
}
if len(e.Errors) == 0 {
return fmt.Sprintf("Tarakan API returned %d: %s", e.StatusCode, message)
}
return fmt.Sprintf("Tarakan API returned %d: %s (%s)", e.StatusCode, message, formatValidationErrors(e.Errors))
}
// New builds a client. baseURL may come from --url/--host or TARAKAN_URL;
// token from --token or TARAKAN_API_TOKEN. The local Phoenix development
// server is the only HTTP exception; remote tokens require TLS so a production
// token is never sent in clear text by mistake.
func New(baseURL, token string, httpClient *http.Client) (*Client, error) {
return newClient(baseURL, token, httpClient, true)
}
// NewPublic builds a client for the unauthenticated browser-login endpoints.
// It applies the same HTTPS and redirect protections as an authenticated client.
func NewPublic(baseURL string, httpClient *http.Client) (*Client, error) {
return newClient(baseURL, "", httpClient, false)
}
func newClient(baseURL, token string, httpClient *http.Client, requireToken bool) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("host URL must be an absolute HTTP(S) URL (pass --url or TARAKAN_URL)")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && !(scheme == "http" && isLoopbackHost(parsed.Hostname())) {
return nil, errors.New("host URL must use HTTPS except for a loopback development server")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("host URL must not contain credentials, a query, or a fragment")
}
token = strings.TrimSpace(token)
if requireToken && token == "" {
return nil, ErrTokenRequired
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
clientCopy := *httpClient
clientCopy.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
return &Client{baseURL: baseURL, token: token, httpClient: &clientCopy}, nil
}
// BaseURL returns the configured Tarakan origin (no trailing slash). Used when
// cloning Tarakan-hosted repositories: git is served at {BaseURL}/{owner}/{name}.git.
func (c *Client) BaseURL() string {
if c == nil {
return ""
}
return c.baseURL
}
func (c *Client) ListTasks(ctx context.Context, owner, name string) ([]Task, error) {
var response struct {
Jobs []Task `json:"jobs"`
Tasks []Task `json:"tasks"`
}
path := "/api/github.com/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/jobs"
if err := c.do(ctx, http.MethodGet, path, nil, &response); err != nil {
return nil, err
}
if len(response.Jobs) > 0 {
return response.Jobs, nil
}
return response.Tasks, nil
}
// ListOpenJobs returns claimable Jobs from the global queue (all listed repos).
// Optional filter may narrow by min stars, primary language, or job kind.
func (c *Client) ListOpenJobs(ctx context.Context, filter ...QueueFilter) ([]Task, error) {
path := "/api/jobs"
if len(filter) > 0 {
if q := filter[0].Query().Encode(); q != "" {
path += "?" + q
}
}
var response struct {
Jobs []Task `json:"jobs"`
Tasks []Task `json:"tasks"`
Requests []Task `json:"requests"`
}
if err := c.do(ctx, http.MethodGet, path, nil, &response); err != nil {
return nil, err
}
switch {
case len(response.Jobs) > 0:
return response.Jobs, nil
case len(response.Requests) > 0:
return response.Requests, nil
default:
return response.Tasks, nil
}
}
func (c *Client) GetTask(ctx context.Context, id int64) (Task, error) {
return c.taskRequest(ctx, http.MethodGet, taskPath(id), nil)
}
func (c *Client) ClaimTask(ctx context.Context, id int64) (Task, error) {
return c.taskRequest(ctx, http.MethodPost, taskPath(id)+"/claim", struct{}{})
}
func (c *Client) ReleaseTask(ctx context.Context, id int64) (Task, error) {
return c.taskRequest(ctx, http.MethodDelete, taskPath(id)+"/claim", nil)
}
// RenewTaskClaim extends an active lease held by this credential's account.
func (c *Client) RenewTaskClaim(ctx context.Context, id int64) (Task, error) {
return c.taskRequest(ctx, http.MethodPost, taskPath(id)+"/claim/renew", struct{}{})
}
func (c *Client) SubmitTask(ctx context.Context, id int64, submission Submission) (Task, error) {
return c.taskRequest(ctx, http.MethodPost, taskPath(id)+"/complete", submission)
}
// CompleteTask is retained for source compatibility. The server transition is
// a restricted submission and never marks the contribution accepted.
func (c *Client) CompleteTask(ctx context.Context, id int64, completion Completion) (Task, error) {
return c.SubmitTask(ctx, id, completion)
}
// ListReviewableRepositories returns the review queue. status may be empty or
// one of "unscanned"/"findings"/"reviewed"/"clear". Optional filter narrows by
// min stars and primary language.
func (c *Client) ListReviewableRepositories(ctx context.Context, status string, filter ...QueueFilter) ([]QueueRepository, error) {
values := url.Values{}
if status != "" {
values.Set("status", status)
}
if len(filter) > 0 {
for key, vals := range filter[0].Query() {
for _, v := range vals {
values.Set(key, v)
}
}
}
path := "/api/repositories"
if encoded := values.Encode(); encoded != "" {
path += "?" + encoded
}
var response struct {
Repositories []QueueRepository `json:"repositories"`
}
if err := c.do(ctx, http.MethodGet, path, nil, &response); err != nil {
return nil, err
}
return response.Repositories, nil
}
// RegisterRepository adds a public GitHub repository to Tarakan by owner/name
// or URL. Idempotent: already-registered repos return successfully.
func (c *Client) RegisterRepository(ctx context.Context, urlOrSlug string) (QueueRepository, error) {
urlOrSlug = strings.TrimSpace(urlOrSlug)
if urlOrSlug == "" {
return QueueRepository{}, errors.New("repository url is required")
}
var response struct {
Repository QueueRepository `json:"repository"`
}
if err := c.do(ctx, http.MethodPost, "/api/repositories", map[string]string{
"url": urlOrSlug,
}, &response); err != nil {
return QueueRepository{}, err
}
return response.Repository, nil
}
// ListScans returns the reviews of a repository visible to the caller. A
// reviewer-tier credential with reviews:read sees restricted findings.
func (c *Client) ListScans(ctx context.Context, owner, name string) ([]Scan, error) {
return c.ListScansForHost(ctx, "github", owner, name)
}
func (c *Client) ListScansForHost(ctx context.Context, host, owner, name string) ([]Scan, error) {
var response struct {
Scans []Scan `json:"scans"`
}
if err := c.do(ctx, http.MethodGet, scanBasePathForHost(host, owner, name), nil, &response); err != nil {
return nil, err
}
return response.Scans, nil
}
// GetRepositoryMemory returns prompt-safe canonical findings for reconciliation.
func (c *Client) GetRepositoryMemory(ctx context.Context, owner, name, commitSHA string) (RepositoryMemory, error) {
return c.GetRepositoryMemoryForHost(ctx, "github", owner, name, commitSHA)
}
func (c *Client) GetRepositoryMemoryForHost(ctx context.Context, host, owner, name, commitSHA string) (RepositoryMemory, error) {
path := repositoryBasePath(host, owner, name) + "/memory"
if commitSHA != "" {
path += "?commit_sha=" + url.QueryEscape(commitSHA)
}
var memory RepositoryMemory
if err := c.do(ctx, http.MethodGet, path, nil, &memory); err != nil {
return RepositoryMemory{}, err
}
return memory, nil
}
// SubmitFindingVerdict records an independent check on one canonical finding.
func (c *Client) SubmitFindingVerdict(ctx context.Context, owner, name, publicID string, verdict FindingVerdict) error {
return c.SubmitFindingVerdictForHost(ctx, "github", owner, name, publicID, verdict)
}
func (c *Client) SubmitFindingVerdictForHost(ctx context.Context, host, owner, name, publicID string, verdict FindingVerdict) error {
path := repositoryBasePath(host, owner, name) +
"/findings/" + url.PathEscape(publicID) + "/check"
var response json.RawMessage
return c.do(ctx, http.MethodPost, path, verdict, &response)
}
// SubmitScan submits a review in Tarakan Scan Format v1 and returns the
// quarantined scan.
func (c *Client) SubmitScan(ctx context.Context, owner, name string, submission ScanSubmission) (Scan, error) {
return c.SubmitScanForHost(ctx, "github", owner, name, submission)
}
func (c *Client) SubmitScanForHost(ctx context.Context, host, owner, name string, submission ScanSubmission) (Scan, error) {
var scan Scan
if err := c.do(ctx, http.MethodPost, scanBasePathForHost(host, owner, name), submission, &scan); err != nil {
return Scan{}, err
}
return scan, nil
}
// SubmitVerdict records a verdict (and optional proof-of-concept) on a review.
// The caller must be an independent qualified reviewer.
func (c *Client) SubmitVerdict(ctx context.Context, owner, name string, scanID int64, verdict Verdict) (Scan, error) {
return c.SubmitVerdictForHost(ctx, "github", owner, name, scanID, verdict)
}
func (c *Client) SubmitVerdictForHost(ctx context.Context, host, owner, name string, scanID int64, verdict Verdict) (Scan, error) {
path := scanBasePathForHost(host, owner, name) + "/" + strconv.FormatInt(scanID, 10) + "/verdict"
var scan Scan
if err := c.do(ctx, http.MethodPost, path, verdict, &scan); err != nil {
return Scan{}, err
}
return scan, nil
}
func (c *Client) taskRequest(ctx context.Context, method, path string, input any) (Task, error) {
// The canonical contract returns the task directly. The wrapper field keeps
// the client tolerant of older development builds without weakening types.
var raw json.RawMessage
if err := c.do(ctx, method, path, input, &raw); err != nil {
return Task{}, err
}
var wrapped struct {
Task json.RawMessage `json:"task"`
}
if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Task) != 0 {
raw = wrapped.Task
}
var task Task
if err := json.Unmarshal(raw, &task); err != nil {
return Task{}, fmt.Errorf("decode Tarakan task: %w", err)
}
return task, nil
}
func (c *Client) do(ctx context.Context, method, path string, input, output any) error {
var body io.Reader
if input != nil {
encoded, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("encode Tarakan request: %w", err)
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return fmt.Errorf("create Tarakan request: %w", err)
}
request.Header.Set("Accept", "application/json")
if c.token != "" {
request.Header.Set("Authorization", "Bearer "+c.token)
}
request.Header.Set("User-Agent", "tarakan-client")
if input != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("contact Tarakan API: %w", err)
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxResponseSize+1)
data, err := io.ReadAll(limited)
if err != nil {
return fmt.Errorf("read Tarakan response: %w", err)
}
if len(data) > maxResponseSize {
return errors.New("Tarakan API response is too large")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return decodeAPIError(response.StatusCode, data)
}
if output == nil || len(bytes.TrimSpace(data)) == 0 {
return nil
}
if err := json.Unmarshal(data, output); err != nil {
return fmt.Errorf("decode Tarakan response: %w", err)
}
return nil
}
func decodeAPIError(status int, data []byte) error {
response := struct {
Error string `json:"error"`
Errors map[string][]string `json:"errors"`
}{}
_ = json.Unmarshal(data, &response)
return &APIError{StatusCode: status, Message: response.Error, Errors: response.Errors}
}
func formatValidationErrors(errorsByField map[string][]string) string {
parts := make([]string, 0, len(errorsByField))
for field, messages := range errorsByField {
parts = append(parts, field+": "+strings.Join(messages, ", "))
}
sort.Strings(parts)
return strings.Join(parts, "; ")
}
func taskPath(id int64) string {
return "/api/jobs/" + strconv.FormatInt(id, 10)
}
func scanBasePath(owner, name string) string {
return scanBasePathForHost("github", owner, name)
}
func scanBasePathForHost(host, owner, name string) string {
return repositoryBasePath(host, owner, name) + "/reports"
}
func repositoryBasePath(host, owner, name string) string {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
host = "github.com"
}
return "/api/" + url.PathEscape(host) + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name)
}
func isLoopbackHost(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
address := net.ParseIP(host)
return address != nil && address.IsLoopback()
}