Initial commit on Forgejo
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

Fresh repository history for elektrine/tarakan-client hosted at
https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
Maxfield Luke 2026-07-29 04:56:36 -04:00
commit 58b29a7f84
70 changed files with 12994 additions and 0 deletions

401
internal/api/client.go Normal file
View file

@ -0,0 +1,401 @@
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()
}

390
internal/api/client_test.go Normal file
View file

@ -0,0 +1,390 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const testToken = "test-secret-that-must-never-be-logged"
func TestListOpenJobsUsesGlobalQueueRoute(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || request.URL.Path != "/api/jobs" {
t.Fatalf("unexpected request %s %s", request.Method, request.URL.Path)
}
if got := request.Header.Get("Authorization"); got != "Bearer secret-token" {
t.Fatalf("authorization = %q", got)
}
_, _ = response.Write([]byte(`{"jobs":[{"id":3,"status":"open","kind":"code_review","capability":"agent","title":"x","repository":{"owner":"a","name":"b"}}],"tasks":[{"id":3}],"requests":[{"id":3}]}`))
}))
defer server.Close()
client := &Client{baseURL: server.URL, token: "secret-token", httpClient: server.Client()}
jobs, err := client.ListOpenJobs(context.Background())
if err != nil {
t.Fatal(err)
}
if len(jobs) != 1 || jobs[0].ID != 3 || jobs[0].Repository.Slug() != "a/b" {
t.Fatalf("jobs = %#v", jobs)
}
}
func TestListTasksUsesRepositoryRouteAndBearerToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || request.URL.Path != "/api/github.com/openai/codex/jobs" {
t.Fatalf("request = %s %s", request.Method, request.URL.Path)
}
if got := request.Header.Get("Authorization"); got != "Bearer "+testToken {
t.Fatalf("authorization header = %q", got)
}
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"jobs":[{"id":7,"repository":{"owner":"openai","name":"codex"},"commit_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","capability":"agent","status":"open"}]}`))
}))
defer server.Close()
client, err := New(server.URL, testToken, server.Client())
if err != nil {
t.Fatal(err)
}
tasks, err := client.ListTasks(context.Background(), "openai", "codex")
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || tasks[0].ID != 7 || tasks[0].Repository.Slug() != "openai/codex" {
t.Fatalf("tasks = %#v", tasks)
}
}
func TestTaskMutationsMatchContract(t *testing.T) {
tests := []struct {
name string
method string
path string
call func(*Client) (Task, error)
body map[string]string
}{
{name: "show", method: http.MethodGet, path: "/api/jobs/9", call: func(client *Client) (Task, error) { return client.GetTask(context.Background(), 9) }},
{name: "claim", method: http.MethodPost, path: "/api/jobs/9/claim", call: func(client *Client) (Task, error) { return client.ClaimTask(context.Background(), 9) }},
{name: "release", method: http.MethodDelete, path: "/api/jobs/9/claim", call: func(client *Client) (Task, error) { return client.ReleaseTask(context.Background(), 9) }},
{name: "renew", method: http.MethodPost, path: "/api/jobs/9/claim/renew", call: func(client *Client) (Task, error) { return client.RenewTaskClaim(context.Background(), 9) }},
{name: "submit", method: http.MethodPost, path: "/api/jobs/9/complete", body: map[string]string{"provenance": "human", "summary": "confirmed", "evidence": "test output"}, call: func(client *Client) (Task, error) {
return client.SubmitTask(context.Background(), 9, Submission{Provenance: "human", Summary: "confirmed", Evidence: "test output"})
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != test.method || request.URL.Path != test.path {
t.Fatalf("request = %s %s", request.Method, request.URL.Path)
}
if test.body != nil {
var body map[string]string
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
for key, want := range test.body {
if body[key] != want {
t.Fatalf("body[%q] = %q, want %q", key, body[key], want)
}
}
}
_, _ = response.Write([]byte(`{"id":9,"status":"claimed","repository":{"owner":"openai","name":"codex"}}`))
}))
defer server.Close()
client, err := New(server.URL, testToken, server.Client())
if err != nil {
t.Fatal(err)
}
task, err := test.call(client)
if err != nil || task.ID != 9 {
t.Fatalf("task = %#v, err = %v", task, err)
}
})
}
}
func TestTaskResponseAcceptsDevelopmentWrapper(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_, _ = response.Write([]byte(`{"task":{"id":12,"status":"open"}}`))
}))
defer server.Close()
client, _ := New(server.URL, testToken, server.Client())
task, err := client.GetTask(context.Background(), 12)
if err != nil || task.ID != 12 {
t.Fatalf("task = %#v, err = %v", task, err)
}
}
func TestTaskResponseDecodesWebContract(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_, _ = response.Write([]byte(`{
"id":42,
"kind":"privacy_review",
"capability":"hybrid",
"title":"Map deletion",
"description":"Trace retained data",
"status":"submitted",
"visibility":"restricted",
"commit_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"commit_committed_at":"2026-07-10T12:00:00Z",
"repository":{"id":3,"host":"github","owner":"openai","name":"codex","canonical_url":"https://github.com/openai/codex","participation_mode":"maintainer_verified","record_url":"https://tarakan.lol/github/openai/codex"},
"creator":{"id":8,"handle":"finder"},
"claimant":{"id":9,"handle":"reviewer"},
"lease":{"claimed_at":"2026-07-10T12:01:00Z","expires_at":"2026-07-10T14:01:00Z","active":false},
"contribution":{"id":6,"provenance":"hybrid","summary":"Confirmed","evidence":"steps","contributor":{"id":9,"handle":"reviewer"},"submitted_at":"2026-07-10T12:30:00Z"},
"completed_at":"2026-07-10T12:30:00Z",
"inserted_at":"2026-07-10T12:00:00Z",
"updated_at":"2026-07-10T12:30:00Z",
"task_url":"https://tarakan.lol/work/42"
}`))
}))
defer server.Close()
client, _ := New(server.URL, testToken, server.Client())
task, err := client.GetTask(context.Background(), 42)
if err != nil {
t.Fatal(err)
}
if task.Repository.Host != "github" || task.Repository.CanonicalURL != "https://github.com/openai/codex" {
t.Fatalf("repository = %#v", task.Repository)
}
if task.Repository.ParticipationMode != "maintainer_verified" || task.Status != "submitted" || task.Visibility != "restricted" {
t.Fatalf("repository/status contract = %#v / %q", task.Repository, task.Status)
}
if task.Creator == nil || task.Creator.ID != 8 || task.Creator.Handle != "finder" {
t.Fatalf("creator = %#v", task.Creator)
}
if task.Lease == nil || task.Lease.Active {
t.Fatalf("lease = %#v", task.Lease)
}
if task.Contribution == nil || task.Contribution.SubmittedAt == "" || task.Contribution.Contributor.Handle != "reviewer" {
t.Fatalf("contribution = %#v", task.Contribution)
}
}
func TestAPIErrorDoesNotExposeToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusUnauthorized)
_, _ = response.Write([]byte(`{"error":"missing or invalid API token"}`))
}))
defer server.Close()
client, _ := New(server.URL, testToken, server.Client())
_, err := client.GetTask(context.Background(), 1)
if err == nil || strings.Contains(err.Error(), testToken) {
t.Fatalf("unsafe error = %v", err)
}
var apiError *APIError
if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusUnauthorized {
t.Fatalf("error = %#v", err)
}
}
func TestNewRequiresTokenAndTLSAwayFromLoopback(t *testing.T) {
if _, err := New(DefaultBaseURL, "", nil); !errors.Is(err, ErrTokenRequired) {
t.Fatalf("missing token error = %v", err)
}
if _, err := New("http://tarakan.lol", testToken, nil); err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("insecure URL error = %v", err)
}
}
func TestClientDoesNotFollowRedirectsWithAuthorization(t *testing.T) {
redirected := false
destination := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
redirected = true
}))
defer destination.Close()
source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Redirect(response, nil, destination.URL, http.StatusTemporaryRedirect)
}))
defer source.Close()
client, _ := New(source.URL, testToken, source.Client())
_, err := client.GetTask(context.Background(), 1)
if err == nil || redirected {
t.Fatalf("err = %v, redirected = %v", err, redirected)
}
}
func TestListReviewableRepositoriesQueriesStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/repositories" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("status"); got != "unscanned" {
t.Fatalf("status = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"repositories":[{"host":"github.com","owner":"snyk-labs","name":"nodejs-goof","status":"unscanned"}]}`))
}))
defer server.Close()
client, err := New(server.URL, testToken, server.Client())
if err != nil {
t.Fatal(err)
}
repos, err := client.ListReviewableRepositories(context.Background(), "unscanned")
if err != nil {
t.Fatal(err)
}
if len(repos) != 1 || repos[0].Slug() != "snyk-labs/nodejs-goof" || repos[0].Status != "unscanned" {
t.Fatalf("repos = %#v", repos)
}
}
func TestListScansSurfacesFindings(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/github/snyk-labs/nodejs-goof/reports" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"scans":[{"id":15,"commit_sha":"add14ba","review_status":"quarantined","verified":false,"details_visible":true,"findings_count":1,"submitter":"modela","findings":[{"file":"app.js","line_start":83,"severity":"high","title":"Hardcoded secret"}]}]}`))
}))
defer server.Close()
client, _ := New(server.URL, testToken, server.Client())
scans, err := client.ListScans(context.Background(), "snyk-labs", "nodejs-goof")
if err != nil {
t.Fatal(err)
}
if len(scans) != 1 || scans[0].ID != 15 || len(scans[0].Findings) != 1 || scans[0].Findings[0].File != "app.js" {
t.Fatalf("scans = %#v", scans)
}
}
func TestSubmitScanAndVerdictSendExpectedBodies(t *testing.T) {
t.Run("scan", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/github/snyk-labs/nodejs-goof/reports" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
var body ScanSubmission
_ = json.NewDecoder(r.Body).Decode(&body)
if body.CommitSHA != "add14ba" || body.Document.Format != 1 || len(body.Document.Findings) != 1 {
t.Fatalf("body = %#v", body)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":16,"review_status":"quarantined"}`))
}))
defer server.Close()
client, _ := New(server.URL, testToken, server.Client())
scan, err := client.SubmitScan(context.Background(), "snyk-labs", "nodejs-goof", ScanSubmission{
CommitSHA: "add14ba", Provenance: "agent", ReviewKind: "code_review",
Document: ScanDocument{Format: 1, Findings: []ScanFinding{{File: "app.js", Severity: "high", Title: "x", Description: "y"}}},
})
if err != nil || scan.ID != 16 {
t.Fatalf("scan = %#v err = %v", scan, err)
}
})
t.Run("verdict", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/github/snyk-labs/nodejs-goof/reports/15/verdict" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
var body Verdict
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Verdict != "confirmed" || body.Provenance != "hybrid" || body.Evidence == "" {
t.Fatalf("body = %#v", body)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":15,"verified":true,"confirmations":[{"verdict":"confirmed","provenance":"hybrid"}]}`))
}))
defer server.Close()
client, _ := New(server.URL, testToken, server.Client())
scan, err := client.SubmitVerdict(context.Background(), "snyk-labs", "nodejs-goof", 15, Verdict{
Verdict: "confirmed", Provenance: "hybrid", Notes: "confirmed the finding", Evidence: "poc here",
})
if err != nil || !scan.Verified {
t.Fatalf("scan = %#v err = %v", scan, err)
}
})
}
func TestHostAwareReportPathsSupportTarakanHostedRepositories(t *testing.T) {
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/memory"):
_ = json.NewEncoder(w).Encode(RepositoryMemory{Findings: []CanonicalFindingMemory{}})
case r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode(map[string]any{"scans": []Scan{}})
default:
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(Scan{ID: 1})
}
}))
defer server.Close()
client, err := New(server.URL, testToken, nil)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
_, _ = client.GetRepositoryMemoryForHost(ctx, "tarakan.lol", "alice", "demo", "abc")
_, _ = client.ListScansForHost(ctx, "tarakan.lol", "alice", "demo")
_, _ = client.SubmitScanForHost(ctx, "tarakan.lol", "alice", "demo", ScanSubmission{})
for _, got := range paths {
if !strings.HasPrefix(got, "/api/tarakan.lol/alice/demo/") {
t.Fatalf("host-aware path = %q", got)
}
}
}
func TestRepositoryMemoryAndFindingCheckContracts(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/github/openai/codex/memory":
if r.URL.Query().Get("commit_sha") != "abc" {
t.Fatalf("commit_sha = %q", r.URL.Query().Get("commit_sha"))
}
_, _ = w.Write([]byte(`{"repository":"openai/codex","findings":[{"public_id":"finding-1","status":"open","file_path":"auth.go","detections_count":7}]}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/github/openai/codex/findings/finding-1/check":
var body FindingVerdict
_ = json.NewDecoder(r.Body).Decode(&body)
if body.CommitSHA != "abc" || body.Verdict != "confirmed" {
t.Fatalf("body = %#v", body)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"public_id":"finding-1","status":"open"}`))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client, _ := New(server.URL, testToken, server.Client())
memory, err := client.GetRepositoryMemory(context.Background(), "openai", "codex", "abc")
if err != nil || len(memory.Findings) != 1 || memory.Findings[0].DetectionsCount != 7 {
t.Fatalf("memory = %#v, err = %v", memory, err)
}
err = client.SubmitFindingVerdict(context.Background(), "openai", "codex", "finding-1", FindingVerdict{
CommitSHA: "abc", Verdict: "confirmed", Provenance: "human", Notes: "independent evidence",
})
if err != nil {
t.Fatal(err)
}
}
func TestNewRunIDIsUnique(t *testing.T) {
first, err := NewRunID()
if err != nil {
t.Fatal(err)
}
second, err := NewRunID()
if err != nil {
t.Fatal(err)
}
if first == second || !strings.HasPrefix(first, "run_") {
t.Fatalf("run ids = %q, %q", first, second)
}
}

163
internal/api/config.go Normal file
View file

@ -0,0 +1,163 @@
package api
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
)
// Config is how the client finds the Tarakan host and authenticates.
// CLI flags and interactive /url /token override environment variables and
// the config saved by `tarakan login`.
type Config struct {
BaseURL string `json:"base_url"`
Token string `json:"token"`
}
// LoadConfig builds config in this precedence order: explicit values,
// environment variables, values saved by `tarakan login`, then defaults.
func LoadConfig(url, token string) Config {
saved, _ := LoadSavedConfig()
return Config{
BaseURL: firstNonEmpty(url, os.Getenv("TARAKAN_URL"), saved.BaseURL, DefaultBaseURL),
Token: firstNonEmpty(token, os.Getenv("TARAKAN_API_TOKEN"), saved.Token),
}.normalized()
}
// SavedConfigPath returns the per-user file used by `tarakan login`.
func SavedConfigPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "tarakan", "config.json"), nil
}
// LoadSavedConfig reads the persisted login, if one exists.
func LoadSavedConfig() (Config, error) {
path, err := SavedConfigPath()
if err != nil {
return Config{}, err
}
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return Config{}, nil
}
if err != nil {
return Config{}, err
}
var config Config
if err := json.Unmarshal(raw, &config); err != nil {
return Config{}, err
}
return config.normalized(), nil
}
// SaveConfig persists a login in a user-only file. The containing directory
// and file permissions are tightened even when they already exist.
func SaveConfig(config Config) (string, error) {
config = config.normalized()
path, err := SavedConfigPath()
if err != nil {
return "", err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
if err := os.Chmod(dir, 0o700); err != nil {
return "", err
}
raw, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", err
}
raw = append(raw, '\n')
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return "", err
}
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return "", err
}
if _, err := file.Write(raw); err != nil {
_ = file.Close()
return "", err
}
if err := file.Close(); err != nil {
return "", err
}
return path, nil
}
// RemoveSavedConfig logs the client out. It is idempotent.
func RemoveSavedConfig() error {
path, err := SavedConfigPath()
if err != nil {
return err
}
err = os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// FromEnv is LoadConfig with no explicit overrides (env / defaults only).
func FromEnv() (*Client, error) {
return LoadConfig("", "").Client()
}
// WithOverrides returns a copy with non-empty url/token applied.
func (c Config) WithOverrides(url, token string) Config {
if strings.TrimSpace(url) != "" {
c.BaseURL = url
}
if strings.TrimSpace(token) != "" {
c.Token = token
}
return c.normalized()
}
// Client builds an HTTP client from this config.
func (c Config) Client() (*Client, error) {
return New(c.BaseURL, c.Token, nil)
}
// MaskedToken is safe to show in UI (never the full secret).
func (c Config) MaskedToken() string {
t := strings.TrimSpace(c.Token)
if t == "" {
return "(not set)"
}
if len(t) <= 8 {
return "****"
}
return t[:4] + "…" + t[len(t)-4:]
}
// Summary is a one-line status for the interactive UI.
func (c Config) Summary() string {
return "url " + c.BaseURL + " token " + c.MaskedToken()
}
func (c Config) normalized() Config {
c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
c.Token = strings.TrimSpace(c.Token)
if c.BaseURL == "" {
c.BaseURL = DefaultBaseURL
}
return c
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}

View file

@ -0,0 +1,97 @@
package api
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigPrefersExplicitOverEnv(t *testing.T) {
isolateSavedConfig(t)
t.Setenv("TARAKAN_URL", "https://env.example")
t.Setenv("TARAKAN_API_TOKEN", "env-token")
cfg := LoadConfig("https://cli.example", "cli-token")
if cfg.BaseURL != "https://cli.example" || cfg.Token != "cli-token" {
t.Fatalf("cfg = %#v", cfg)
}
cfg = LoadConfig("", "")
if cfg.BaseURL != "https://env.example" || cfg.Token != "env-token" {
t.Fatalf("env cfg = %#v", cfg)
}
}
func TestLoadConfigDefaultsURL(t *testing.T) {
isolateSavedConfig(t)
t.Setenv("TARAKAN_URL", "")
t.Setenv("TARAKAN_API_TOKEN", "t")
cfg := LoadConfig("", "t")
if cfg.BaseURL != "https://tarakan.lol" {
t.Fatalf("BaseURL = %q", cfg.BaseURL)
}
}
func TestConfigWithOverrides(t *testing.T) {
isolateSavedConfig(t)
cfg := LoadConfig("https://a.example", "one").WithOverrides("https://b.example", "")
if cfg.BaseURL != "https://b.example" || cfg.Token != "one" {
t.Fatalf("cfg = %#v", cfg)
}
}
func TestMaskedToken(t *testing.T) {
if got := (Config{}).MaskedToken(); got != "(not set)" {
t.Fatalf("empty = %q", got)
}
if got := (Config{Token: "abcdefghijklmnop"}).MaskedToken(); got != "abcd…mnop" {
t.Fatalf("masked = %q", got)
}
}
func TestSavedConfigIsLoadedAndProtected(t *testing.T) {
isolateSavedConfig(t)
t.Setenv("TARAKAN_URL", "")
t.Setenv("TARAKAN_API_TOKEN", "")
path, err := SaveConfig(Config{BaseURL: "https://saved.example/", Token: "saved-token"})
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "tarakan", "config.json"); path != want {
t.Fatalf("path = %q, want %q", path, want)
}
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("config mode = %v, err = %v", info.Mode().Perm(), err)
}
if info, err := os.Stat(filepath.Dir(path)); err != nil || info.Mode().Perm() != 0o700 {
t.Fatalf("config dir mode = %v, err = %v", info.Mode().Perm(), err)
}
cfg := LoadConfig("", "")
if cfg.BaseURL != "https://saved.example" || cfg.Token != "saved-token" {
t.Fatalf("saved cfg = %#v", cfg)
}
t.Setenv("TARAKAN_URL", "https://env.example")
t.Setenv("TARAKAN_API_TOKEN", "env-token")
cfg = LoadConfig("https://explicit.example", "explicit-token")
if cfg.BaseURL != "https://explicit.example" || cfg.Token != "explicit-token" {
t.Fatalf("precedence cfg = %#v", cfg)
}
if err := RemoveSavedConfig(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("saved config still exists: %v", err)
}
if err := RemoveSavedConfig(); err != nil {
t.Fatalf("second removal should be harmless: %v", err)
}
}
func isolateSavedConfig(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
}

View file

@ -0,0 +1,44 @@
package api
import "testing"
func TestContractValueLabel(t *testing.T) {
cases := []struct {
name string
value *ContractValue
want string
}{
{"nil is silent", nil, ""},
{"nothing on offer", &ContractValue{}, ""},
{"cash only", &ContractValue{Cents: 25_000, Count: 1}, "$250"},
{"credits only", &ContractValue{Credits: 500, Count: 1}, "500 credits"},
{"both", &ContractValue{Cents: 10_000, Credits: 50, Count: 2}, "$100 + 50 credits"},
// A contract can exist with no value recorded yet; say nothing rather
// than print a misleading "$0".
{"counted but valueless", &ContractValue{Count: 1}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.value.Label(); got != tc.want {
t.Fatalf("Label() = %q, want %q", got, tc.want)
}
})
}
}
func TestSuppressionsTotal(t *testing.T) {
var empty Suppressions
if empty.Total() != 0 {
t.Fatalf("empty total = %d", empty.Total())
}
s := Suppressions{
Repository: []Suppression{{Title: "a"}, {Title: "b"}},
Patterns: []Suppression{{Title: "c"}},
}
if s.Total() != 3 {
t.Fatalf("total = %d, want 3", s.Total())
}
}

View file

@ -0,0 +1,65 @@
package api
import (
"context"
"errors"
"net/http"
"time"
)
var (
ErrAuthorizationPending = errors.New("browser authorization is still pending")
ErrAccessDenied = errors.New("browser authorization was denied")
ErrDeviceCodeExpired = errors.New("browser authorization expired")
)
type DeviceAuthorization struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int64 `json:"expires_in"`
Interval int64 `json:"interval"`
}
type DeviceCredential struct {
Token string `json:"token"`
TokenType string `json:"token_type"`
ExpiresAt time.Time `json:"expires_at"`
Scopes []string `json:"scopes"`
}
func (c *Client) StartDeviceAuthorization(ctx context.Context, clientName string) (DeviceAuthorization, error) {
var authorization DeviceAuthorization
err := c.do(ctx, http.MethodPost, "/api/client-auth/start", map[string]string{
"client_name": clientName,
}, &authorization)
return authorization, err
}
func (c *Client) ExchangeDeviceAuthorization(ctx context.Context, deviceCode string) (DeviceCredential, error) {
var credential DeviceCredential
err := c.do(ctx, http.MethodPost, "/api/client-auth/exchange", map[string]string{
"device_code": deviceCode,
}, &credential)
if err == nil {
return credential, nil
}
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.Message {
case "authorization_pending":
return DeviceCredential{}, ErrAuthorizationPending
case "access_denied":
return DeviceCredential{}, ErrAccessDenied
case "expired_token":
return DeviceCredential{}, ErrDeviceCodeExpired
}
}
return DeviceCredential{}, err
}
// RevokeCurrentCredential revokes the bearer token used by this client.
func (c *Client) RevokeCurrentCredential(ctx context.Context) error {
return c.do(ctx, http.MethodDelete, "/api/client-auth/session", nil, nil)
}

View file

@ -0,0 +1,30 @@
package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestDeviceAuthorizationMapsPendingResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
t.Fatalf("public request included an Authorization header")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"authorization_pending"}`))
}))
defer server.Close()
client, err := NewPublic(server.URL, nil)
if err != nil {
t.Fatal(err)
}
_, err = client.ExchangeDeviceAuthorization(context.Background(), "device-code")
if !errors.Is(err, ErrAuthorizationPending) {
t.Fatalf("err = %v, want ErrAuthorizationPending", err)
}
}

17
internal/api/run_id.go Normal file
View file

@ -0,0 +1,17 @@
package api
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// NewRunID identifies one agent execution so network retries are idempotent
// without treating independent executions as duplicates.
func NewRunID() (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate run id: %w", err)
}
return "run_" + hex.EncodeToString(raw), nil
}

344
internal/api/types.go Normal file
View file

@ -0,0 +1,344 @@
package api
import (
"fmt"
"net/url"
"strconv"
"strings"
)
// Repository is the canonical repository identity returned by Tarakan. Client
// code must compare this identity with the local origin before running a task.
type Repository struct {
ID int64 `json:"id,omitempty"`
Host string `json:"host,omitempty"`
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name,omitempty"`
CanonicalURL string `json:"canonical_url,omitempty"`
ParticipationMode string `json:"participation_mode,omitempty"`
PrimaryLanguage string `json:"primary_language,omitempty"`
StarsCount int64 `json:"stars_count,omitempty"`
RecordURL string `json:"record_url,omitempty"`
}
func (r Repository) Slug() string {
if r.FullName != "" {
return r.FullName
}
if r.Owner == "" || r.Name == "" {
return ""
}
return r.Owner + "/" + r.Name
}
// Actor is the minimal public contributor identity returned by Tarakan.
type Actor struct {
ID int64 `json:"id,omitempty"`
Handle string `json:"handle,omitempty"`
}
type Lease struct {
ClaimedAt string `json:"claimed_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Active bool `json:"active"`
}
type Contribution struct {
ID int64 `json:"id,omitempty"`
Version int64 `json:"version,omitempty"`
Provenance string `json:"provenance"`
Summary string `json:"summary"`
Evidence string `json:"evidence,omitempty"`
Contributor *Actor `json:"contributor,omitempty"`
SubmittedAt string `json:"submitted_at,omitempty"`
}
type ReviewDecision struct {
ID int64 `json:"id,omitempty"`
Action string `json:"action"`
Reason string `json:"reason,omitempty"`
Evidence string `json:"evidence,omitempty"`
Reviewer *Actor `json:"reviewer,omitempty"`
DecidedAt string `json:"decided_at,omitempty"`
}
// Task is one immutable, commit-pinned unit of collaborative security work.
// ContractValue is what is currently escrowed against a job's repository. It
// is the answer to "is anyone paying for this?" at the moment the operator
// decides where to spend tokens.
type ContractValue struct {
Cents int64 `json:"cents"`
Credits int64 `json:"credits"`
Count int64 `json:"count"`
}
// Label renders the value for a queue listing, or "" when nothing is on offer.
func (c *ContractValue) Label() string {
if c == nil || c.Count == 0 {
return ""
}
switch {
case c.Cents > 0 && c.Credits > 0:
return fmt.Sprintf("$%d + %d credits", c.Cents/100, c.Credits)
case c.Cents > 0:
return fmt.Sprintf("$%d", c.Cents/100)
case c.Credits > 0:
return fmt.Sprintf("%d credits", c.Credits)
default:
return ""
}
}
type Task struct {
ID int64 `json:"id"`
Repository Repository `json:"repository"`
Contract *ContractValue `json:"contract,omitempty"`
CommitSHA string `json:"commit_sha"`
CommitCommittedAt string `json:"commit_committed_at,omitempty"`
Kind string `json:"kind"`
Capability string `json:"capability"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Visibility string `json:"visibility,omitempty"`
Creator *Actor `json:"creator,omitempty"`
Claimant *Actor `json:"claimant,omitempty"`
Reviewer *Actor `json:"reviewer,omitempty"`
Lease *Lease `json:"lease,omitempty"`
Contribution *Contribution `json:"contribution,omitempty"`
Contributions []Contribution `json:"contributions,omitempty"`
Decisions []ReviewDecision `json:"decisions,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
SubmittedAt string `json:"submitted_at,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
InsertedAt string `json:"inserted_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
DisclosedAt string `json:"disclosed_at,omitempty"`
Discloser *Actor `json:"discloser,omitempty"`
SensitiveReviewed bool `json:"sensitive_data_reviewed,omitempty"`
TaskURL string `json:"task_url,omitempty"`
RequestURL string `json:"request_url,omitempty"`
LinkedReviewID *int64 `json:"linked_review_id,omitempty"`
LinkedReview *LinkedReview `json:"linked_review,omitempty"`
TargetReviewID *int64 `json:"target_review_id,omitempty"`
TargetReview *LinkedReview `json:"target_review,omitempty"`
}
// LinkedReview is the structured Review created when completing a Request with
// Tarakan Review/Scan Format document.
type LinkedReview struct {
ID int64 `json:"id"`
ReviewStatus string `json:"review_status,omitempty"`
Visibility string `json:"visibility,omitempty"`
FindingsCount int64 `json:"findings_count,omitempty"`
Provenance string `json:"provenance,omitempty"`
ReviewKind string `json:"review_kind,omitempty"`
Model string `json:"model,omitempty"`
PromptVersion string `json:"prompt_version,omitempty"`
CommitSHA string `json:"commit_sha,omitempty"`
SourceRequestID *int64 `json:"source_request_id,omitempty"`
Findings []Finding `json:"findings,omitempty"`
}
// Submission completes a Request. Prefer Document (Review Format) for
// finding-producing kinds so Tarakan records Findings; Evidence is legacy prose.
// For verify_findings with target_review_id, set Verdict + Notes (or Summary).
type Submission struct {
Provenance string `json:"provenance"`
Summary string `json:"summary,omitempty"`
Evidence string `json:"evidence,omitempty"`
Model string `json:"model,omitempty"`
PromptVersion string `json:"prompt_version,omitempty"`
Document *ScanDocument `json:"document,omitempty"`
Verdict string `json:"verdict,omitempty"`
Notes string `json:"notes,omitempty"`
}
type Completion = Submission
// QueueRepository is a repository in the review queue returned by
// GET /api/repositories. It is the work a scanning client picks up.
type QueueRepository struct {
Host string `json:"host"`
Owner string `json:"owner"`
Name string `json:"name"`
Status string `json:"status"`
DefaultBranch string `json:"default_branch,omitempty"`
PrimaryLanguage string `json:"primary_language,omitempty"`
StarsCount int64 `json:"stars_count,omitempty"`
ScanCount int64 `json:"scan_count"`
LastScannedAt string `json:"last_scanned_at,omitempty"`
RegisteredAt string `json:"registered_at,omitempty"`
RecordURL string `json:"record_url,omitempty"`
}
// QueueFilter narrows jobs and repository discovery (stars, language, kind).
type QueueFilter struct {
MinStars int
Language string
Kind string
}
func (f QueueFilter) Empty() bool {
return f.MinStars <= 0 && strings.TrimSpace(f.Language) == "" && strings.TrimSpace(f.Kind) == ""
}
func (f QueueFilter) Query() url.Values {
values := url.Values{}
if f.MinStars > 0 {
values.Set("min_stars", strconv.FormatInt(int64(f.MinStars), 10))
}
if lang := strings.TrimSpace(f.Language); lang != "" {
values.Set("language", lang)
}
if kind := strings.TrimSpace(f.Kind); kind != "" {
values.Set("kind", kind)
}
return values
}
func (r QueueRepository) Slug() string {
if r.Owner == "" || r.Name == "" {
return ""
}
return r.Owner + "/" + r.Name
}
// Finding is one issue inside a review, visible only when the caller is
// authorized to see restricted evidence.
type Finding struct {
PublicID string `json:"public_id,omitempty"`
CanonicalFindingID string `json:"canonical_finding_id,omitempty"`
Disposition string `json:"disposition,omitempty"`
File string `json:"file"`
LineStart int64 `json:"line_start,omitempty"`
LineEnd int64 `json:"line_end,omitempty"`
Severity string `json:"severity"`
Title string `json:"title"`
Description string `json:"description"`
}
// ScanConfirmation is a recorded verdict on a review.
type ScanConfirmation struct {
Verdict string `json:"verdict"`
Provenance string `json:"provenance"`
Verifier string `json:"verifier,omitempty"`
}
// Scan is one submitted review of a repository at an exact commit.
type Scan struct {
ID int64 `json:"id"`
CommitSHA string `json:"commit_sha"`
Provenance string `json:"provenance"`
ReviewKind string `json:"review_kind"`
Model string `json:"model,omitempty"`
PromptVersion string `json:"prompt_version,omitempty"`
RunID string `json:"run_id,omitempty"`
ReviewStatus string `json:"review_status"`
Visibility string `json:"visibility"`
Verified bool `json:"verified"`
FindingsCount int64 `json:"findings_count"`
DetailsVisible bool `json:"details_visible"`
Submitter string `json:"submitter,omitempty"`
Findings []Finding `json:"findings,omitempty"`
Confirmations []ScanConfirmation `json:"confirmations,omitempty"`
}
// ScanDocument is the Tarakan Scan Format v1 body of a review submission.
type ScanDocument struct {
Format int64 `json:"tarakan_scan_format"`
Findings []ScanFinding `json:"findings"`
}
// ScanFinding is one finding inside a submitted ScanDocument.
type ScanFinding struct {
File string `json:"file"`
LineStart int64 `json:"line_start,omitempty"`
LineEnd int64 `json:"line_end,omitempty"`
Severity string `json:"severity"`
Title string `json:"title"`
Description string `json:"description"`
Disposition string `json:"disposition,omitempty"`
ExistingFindingID string `json:"existing_finding_id,omitempty"`
}
// ScanSubmission is the request body for POST .../scans.
type ScanSubmission struct {
CommitSHA string `json:"commit_sha"`
Provenance string `json:"provenance"`
ReviewKind string `json:"review_kind"`
Model string `json:"model,omitempty"`
PromptVersion string `json:"prompt_version,omitempty"`
RunID string `json:"run_id,omitempty"`
Document ScanDocument `json:"document"`
}
// RepositoryMemory is the compact canonical issue index used only after an
// agent has completed a blind discovery pass.
type RepositoryMemory struct {
Repository string `json:"repository"`
TargetCommitSHA string `json:"target_commit_sha,omitempty"`
Findings []CanonicalFindingMemory `json:"findings"`
Suppressions Suppressions `json:"suppressions"`
}
// Suppressions are findings the record already judged non-bugs. Reporting one
// again costs the operator tokens for a verdict that is already settled, so
// they are handed to the agent as things not to spend the budget rediscovering.
type Suppressions struct {
Note string `json:"note"`
Repository []Suppression `json:"repository"`
Patterns []Suppression `json:"patterns"`
}
type Suppression struct {
PublicID string `json:"public_id,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
PatternKey string `json:"pattern_key,omitempty"`
File string `json:"file_path,omitempty"`
LineStart int64 `json:"line_start,omitempty"`
Title string `json:"title"`
DisputesCount int64 `json:"disputes_count,omitempty"`
DisputedRepositories int64 `json:"disputed_repositories,omitempty"`
Scope string `json:"scope"`
}
// Total is how many settled non-bugs this scan does not have to rediscover.
func (s Suppressions) Total() int { return len(s.Repository) + len(s.Patterns) }
type CanonicalFindingMemory struct {
PublicID string `json:"public_id"`
Status string `json:"status"`
File string `json:"file_path"`
LineStart int64 `json:"line_start,omitempty"`
LineEnd int64 `json:"line_end,omitempty"`
Severity string `json:"severity"`
Title string `json:"title"`
Description string `json:"description"`
FirstSeenCommitSHA string `json:"first_seen_commit_sha"`
LastSeenCommitSHA string `json:"last_seen_commit_sha"`
SameCommit bool `json:"same_commit"`
DetectionsCount int64 `json:"detections_count"`
DistinctSubmittersCount int64 `json:"distinct_submitters_count"`
DistinctModelsCount int64 `json:"distinct_models_count"`
ConfirmationsCount int64 `json:"confirmations_count"`
DisputesCount int64 `json:"disputes_count"`
}
type FindingVerdict struct {
CommitSHA string `json:"commit_sha"`
Verdict string `json:"verdict"`
Provenance string `json:"provenance"`
Notes string `json:"notes"`
Evidence string `json:"evidence,omitempty"`
}
// Verdict is the request body for POST .../scans/:id/verdict.
type Verdict struct {
Verdict string `json:"verdict"`
Provenance string `json:"provenance"`
Notes string `json:"notes"`
Evidence string `json:"evidence,omitempty"`
}