Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
commit
58b29a7f84
70 changed files with 12994 additions and 0 deletions
401
internal/api/client.go
Normal file
401
internal/api/client.go
Normal 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
390
internal/api/client_test.go
Normal 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
163
internal/api/config.go
Normal 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 ""
|
||||
}
|
||||
97
internal/api/config_test.go
Normal file
97
internal/api/config_test.go
Normal 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())
|
||||
}
|
||||
44
internal/api/contract_value_test.go
Normal file
44
internal/api/contract_value_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
65
internal/api/device_auth.go
Normal file
65
internal/api/device_auth.go
Normal 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)
|
||||
}
|
||||
30
internal/api/device_auth_test.go
Normal file
30
internal/api/device_auth_test.go
Normal 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
17
internal/api/run_id.go
Normal 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
344
internal/api/types.go
Normal 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"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue