Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
401 lines
13 KiB
Go
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()
|
|
}
|