Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.0-beta.4] - 2026-09-27

### Added

- `sourceant review` reads the work in a checkout against the branch it would be
proposed to, and prints a link to the answer. It exits 2 when a skill blocks
the change, so a shell script can use it
- `sourceant repos` says when each repository was last read, or that it is being
read now

### Fixed

- The line printed when no agent answers names a command that is on the path
- A review asked for from the terminal is named, so a list of reviews says where
each came from

## [1.0.0-beta.3]

### Added
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
# SourceAnt CLI

The command a person types. It reads the code graph the SourceAnt agent keeps on this machine.
The command a person types. It reads the work in a checkout, and the code graph the SourceAnt agent keeps on this machine.

```
$ sourceant review
http://127.0.0.1:8930/reviews/8c2b18b67f4c41efb61efd7d2967ccfd

feat/subtract against main (bd51156), 1 file changed, 2 commits

blocking calc.py:5 The function 'subtract' has no docstring on its first line.
blocking calc.py:9 The function 'times' has no docstring on its first line.

Not ready. 2 blocking.

$ sourceant repos
REPOSITORY PATH
acme/billing /home/you/work/billing
REPOSITORY READ PATH
acme/billing 3 minutes ago /home/you/work/billing
acme/shipping reading /home/you/work/shipping

$ sourceant graph acme/billing
2215 nodes, 2006 links
Expand Down Expand Up @@ -74,6 +85,7 @@ Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not

| Command | What it does |
|---|---|
| `sourceant review [path]` | Read what a checkout has that its default branch does not |
| `sourceant setup` | Put the agent and a core on this machine |
| `sourceant stop` | Stop the agent and its Python core or Docker container |
| `sourceant status` | Whether the agent and the indexer are running |
Expand All @@ -82,6 +94,8 @@ Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not
| `sourceant ui` | Open the graph in a browser |
| `sourceant version` | What this build is |

`review` reads the folder you are standing in, committed or not, against the branch the repository defaults to. It exits 2 when a skill blocks the change, so a shell script can use it. `--against <ref>` compares against something else, `--no-model` says what changed without judging it, `--no-wait` prints the link and leaves it running, and `--title` and `--skill` name the change and the skills to read it against.

`--json` prints the agent's own answer, for anything that wants to read it rather than look at it.

## Building
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0-beta.3
1.0.0-beta.4
9 changes: 7 additions & 2 deletions internal/agent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ type Status struct {
}

// Repository is one repository indexed on this machine.
//
// IndexedAt is empty until it has been read, which is not the same as nothing
// having changed since, and Reading says a read is under way now.
type Repository struct {
Name string `json:"name"`
Path string `json:"path"`
Name string `json:"name"`
Path string `json:"path"`
IndexedAt string `json:"indexed_at"`
Reading bool `json:"reading"`
}

// Node is one file, import or symbol.
Expand Down
190 changes: 190 additions & 0 deletions internal/agent/reviews.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package agent

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)

// Ask is what to review and how.
type Ask struct {
Repository string `json:"repository"`
Against string `json:"against"`
Title string `json:"title"`
Description string `json:"description"`
Skills []string `json:"skills"`
UseModel bool `json:"use_model"`
}

// Finding is one thing a skill says is wrong with a change.
type Finding struct {
Detail string `json:"detail"`
Severity string `json:"severity"`
Path string `json:"path"`
Line *int `json:"line"`
}

// Verdict is what one skill made of a change.
type Verdict struct {
Skill string `json:"skill"`
Passed bool `json:"passed"`
Note string `json:"note"`
Findings []Finding `json:"findings"`
}

// ChangedFile is one file the work touches, and what changed in it.
type ChangedFile struct {
Path string `json:"path"`
Change string `json:"change"`
Patch string `json:"patch"`
}

// Commit is one commit the branch has that its base does not.
type Commit struct {
SHA string `json:"sha"`
Author string `json:"author"`
At string `json:"at"`
Subject string `json:"subject"`
Body string `json:"body"`
}

// Skill is one rule the review read the change against.
type Skill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Origin string `json:"origin"`
Path string `json:"path"`
Paths []string `json:"paths"`
Reviews *bool `json:"reviews"`
Automatic bool `json:"automatic"`
}

// Recorded is one thing known about the repository being reviewed.
type Recorded struct {
ID string `json:"id"`
Kind string `json:"kind"`
Summary string `json:"summary"`
}

// Where is the checkout the review read, and what it was compared against.
type Where struct {
Path string `json:"path"`
Branch string `json:"branch"`
Against string `json:"against"`
Base string `json:"base"`
Commits int `json:"commits"`
}

// Suggestion is one thing to change, and the code to put there.
type Suggestion struct {
Path string `json:"path"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Side string `json:"side"`
Comment string `json:"comment"`
Category string `json:"category"`
ExistingCode string `json:"existing_code"`
SuggestedCode string `json:"suggested_code"`
}

// Summary is the review in the order a person reads it.
type Summary struct {
Overview string `json:"overview"`
KeyImprovements []string `json:"key_improvements"`
MinorSuggestions []string `json:"minor_suggestions"`
CriticalIssues []string `json:"critical_issues"`
}

// Read is the review proper, from the same generator a pull request gets.
type Read struct {
Verdict string `json:"verdict"`
Summary Summary `json:"summary"`
Suggestions []Suggestion `json:"suggestions"`
Notes map[string]string `json:"notes"`
}

// Review is whether a checkout's work is ready to be proposed to anyone.
type Review struct {
Ready bool `json:"ready"`
Note string `json:"note"`
Base string `json:"base"`
Where Where `json:"where"`
Changed []ChangedFile `json:"changed"`
Commits []Commit `json:"commits"`
Skills []Skill `json:"skills"`
Knowledge []Recorded `json:"knowledge"`
Verdicts []Verdict `json:"verdicts"`
Read Read `json:"review"`
}

// Reading is one review, whether it has finished or not.
type Reading struct {
ID string `json:"id"`
Repository string `json:"repository"`
Status string `json:"status"`
Title string `json:"title"`
Error string `json:"error"`
Started string `json:"started"`
Finished string `json:"finished"`
Review Review `json:"review"`
// Where to send somebody who wants to read it.
Path string `json:"path"`
}

// What a review's status can be.
const (
Running = "running"
Done = "done"
Failed = "failed"
)

// Review asks for a review and answers with where to find it, before the
// reading is done.
func (c *Client) Review(ctx context.Context, ask Ask) (Reading, error) {
if ask.Skills == nil {
ask.Skills = []string{}
}
return post[Reading](ctx, c, "/api/reviews", ask)
}

// Reviewed is one review by name, however long ago it ran.
func (c *Client) Reviewed(ctx context.Context, id string) (Reading, error) {
return get[Reading](ctx, c, "/api/reviews/"+url.PathEscape(id), nil)
}

func post[T any](ctx context.Context, c *Client, path string, body any) (T, error) {
var zero T
encoded, err := json.Marshal(body)
if err != nil {
return zero, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(encoded))
if err != nil {
return zero, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")

resp, err := c.http.Do(req)
if err != nil {
return zero, &Unreachable{BaseURL: c.baseURL, Cause: err}
}
defer func() { _ = resp.Body.Close() }()

answer, err := io.ReadAll(resp.Body)
if err != nil {
return zero, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return zero, &Error{StatusCode: resp.StatusCode, Detail: detail(answer)}
}
if err := json.Unmarshal(answer, &zero); err != nil {
return zero, fmt.Errorf("the agent answered %s with something other than JSON: %w", path, err)
}
return zero, nil
}
Loading
Loading