Skip to content

Commit 71c0de0

Browse files
authored
Merge pull request #1 from sourceant/feat/read-the-graph-and-install-a-core
feat: Read the graph and install a core from the command line
2 parents 3b24be0 + f5b1594 commit 71c0de0

19 files changed

Lines changed: 1391 additions & 0 deletions

File tree

‎.github/workflows/checks.yml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Checks
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
checks:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: actions/setup-go@v5
15+
with:
16+
go-version-file: go.mod
17+
cache: true
18+
19+
- name: Formatting
20+
run: make fmt-check
21+
22+
- name: Vet
23+
run: make vet
24+
25+
- name: Tests
26+
run: make test-race
27+
28+
- name: Build
29+
run: make build

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Anchored, or it also matches cmd/sourceant and the entry point is
2+
# never committed.
3+
/sourceant
4+
coverage.out
5+
coverage.html

‎Makefile‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
.PHONY: help deps build test test-race test-coverage fmt fmt-check vet lint lint-install clean qa
2+
3+
BINARY_NAME=sourceant
4+
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
5+
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
6+
GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
7+
LDFLAGS=-ldflags "-X github.com/sourceant/cli/internal/command.Version=$(VERSION) -X github.com/sourceant/cli/internal/command.BuildTime=$(BUILD_TIME) -X github.com/sourceant/cli/internal/command.GitCommit=$(GIT_COMMIT)"
8+
9+
help:
10+
@echo "SourceAnt CLI - build commands"
11+
@echo ""
12+
@echo "make deps - Download dependencies"
13+
@echo "make build - Build the CLI binary"
14+
@echo "make test - Run unit tests"
15+
@echo "make test-race - Run unit tests under the race detector"
16+
@echo "make test-coverage - Run tests with a coverage report"
17+
@echo "make fmt - Format code with gofmt"
18+
@echo "make fmt-check - Check gofmt formatting"
19+
@echo "make vet - Run go vet"
20+
@echo "make lint - Run golangci-lint"
21+
@echo "make qa - Run fmt-check, vet, lint, and tests"
22+
@echo "make clean - Clean build artifacts"
23+
24+
deps:
25+
go mod download
26+
go mod tidy
27+
28+
build:
29+
go build $(LDFLAGS) -o $(BINARY_NAME) ./cmd/sourceant
30+
31+
test:
32+
go test ./...
33+
34+
test-race:
35+
go test -race ./...
36+
37+
test-coverage:
38+
go test -coverprofile=coverage.out ./...
39+
go tool cover -html=coverage.out -o coverage.html
40+
@echo "Coverage report generated: coverage.html"
41+
42+
fmt:
43+
go fmt ./...
44+
45+
fmt-check:
46+
@test -z "$$(gofmt -l .)" || (echo "Run gofmt on:" && gofmt -l . && exit 1)
47+
48+
vet:
49+
go vet ./...
50+
51+
lint:
52+
@command -v golangci-lint > /dev/null 2>&1 && golangci-lint run ./... || \
53+
(test -x "$$(go env GOPATH)/bin/golangci-lint" && "$$(go env GOPATH)/bin/golangci-lint" run ./... || \
54+
(echo "golangci-lint not found. Run 'make lint-install' first." && exit 1))
55+
56+
lint-install:
57+
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
58+
59+
qa: fmt-check vet lint test
60+
61+
clean:
62+
rm -f $(BINARY_NAME)
63+
rm -f coverage.out coverage.html
64+
go clean

‎README.md‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# SourceAnt CLI
2+
3+
The command a person types. It reads the code graph the SourceAnt agent keeps on this machine.
4+
5+
```
6+
$ sourceant repos
7+
REPOSITORY PATH
8+
acme/billing /home/you/work/billing
9+
10+
$ sourceant graph acme/billing
11+
2215 nodes, 2006 links
12+
13+
KIND COUNT
14+
function 895
15+
import 854
16+
class 257
17+
python 179
18+
19+
EDGE COUNT
20+
defines 1152
21+
imports 854
22+
```
23+
24+
## How the pieces fit
25+
26+
Three processes, each with one job:
27+
28+
| | |
29+
|---|---|
30+
| `sourceant` | this CLI, which talks only to the agent |
31+
| `sourceant-agent` | always running: supervises the indexer, keeps the graph current, serves it |
32+
| the SourceAnt core | Python, owns the grammars and the graph |
33+
34+
The CLI never reaches past the agent. The agent is the process that is always up and the one that knows where the core is listening; going around it would mean learning both.
35+
36+
## Installing
37+
38+
```bash
39+
make build
40+
./sourceant install
41+
```
42+
43+
`install` puts a core on this machine and writes down which one, so the agent knows what to start.
44+
45+
Two ways to have it. `--runtime docker` pulls the published image, and is what works today. `--runtime python` builds a virtual environment and pip installs the core, for when the core is published as a package; until then it says so rather than recording something that will not start.
46+
47+
Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not matter which one indexed it. The container runs as whoever installed, so what it writes there belongs to them.
48+
49+
Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent).
50+
51+
| Variable | Default | Meaning |
52+
|---|---|---|
53+
| `SOURCEANT_AGENT_URL` | `http://127.0.0.1:8930` | The agent to talk to |
54+
55+
`--agent`, `--timeout` and `--json` override it per command.
56+
57+
## Commands
58+
59+
| Command | What it does |
60+
|---|---|
61+
| `sourceant install` | Put a core on this machine |
62+
| `sourceant status` | Whether the agent and the indexer are running |
63+
| `sourceant repos` | Repositories indexed on this machine |
64+
| `sourceant graph <repository>` | What the indexer found in one of them |
65+
| `sourceant ui` | Open the graph in a browser |
66+
| `sourceant version` | What this build is |
67+
68+
`--json` prints the agent's own answer, for anything that wants to read it rather than look at it.
69+
70+
## Building
71+
72+
```bash
73+
make qa # fmt-check, vet, lint, test
74+
make build
75+
```
76+
77+
## Licence
78+
79+
MIT.

‎VERSION‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0.1.0

‎cmd/sourceant/main.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Command sourceant is the SourceAnt command line client.
2+
package main
3+
4+
import (
5+
"os"
6+
7+
"github.com/sourceant/cli/internal/command"
8+
)
9+
10+
func main() {
11+
os.Exit(command.Run(os.Args[1:], os.Stdout, os.Stderr))
12+
}

‎go.mod‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module github.com/sourceant/cli
2+
3+
go 1.26.1
4+
5+
require github.com/spf13/cobra v1.10.2
6+
7+
require (
8+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
9+
github.com/spf13/pflag v1.0.9 // indirect
10+
)

‎go.sum‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
6+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
7+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
8+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
9+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

‎internal/agent/client.go‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Package agent talks to the SourceAnt agent running on this machine.
2+
//
3+
// The CLI never reaches past the agent to the Python core. The agent is the
4+
// process that is always up and the one that knows where the core landed;
5+
// going around it would mean learning both.
6+
package agent
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"net/url"
15+
"strconv"
16+
"strings"
17+
"time"
18+
)
19+
20+
// Status is what the agent says about itself.
21+
type Status struct {
22+
Version string `json:"version"`
23+
CoreURL string `json:"core_url"`
24+
CoreUp bool `json:"core_up"`
25+
CoreStarts int `json:"core_starts"`
26+
LastExit string `json:"last_exit,omitempty"`
27+
}
28+
29+
// Repository is one repository indexed on this machine.
30+
type Repository struct {
31+
Name string `json:"name"`
32+
Path string `json:"path"`
33+
}
34+
35+
// Node is one file, import or symbol.
36+
type Node struct {
37+
ID string `json:"id"`
38+
Name string `json:"name"`
39+
Kind string `json:"kind"`
40+
Labels []string `json:"labels"`
41+
Path string `json:"path"`
42+
}
43+
44+
// Link is a typed edge between two nodes.
45+
type Link struct {
46+
Source string `json:"source"`
47+
Target string `json:"target"`
48+
Type string `json:"type"`
49+
}
50+
51+
// Graph is one repository's whole scope.
52+
type Graph struct {
53+
Nodes []Node `json:"nodes"`
54+
Links []Link `json:"links"`
55+
Truncated bool `json:"truncated"`
56+
}
57+
58+
// Error is a non-2xx answer from the agent.
59+
type Error struct {
60+
StatusCode int
61+
Detail string
62+
}
63+
64+
func (e *Error) Error() string {
65+
if e.Detail == "" {
66+
return fmt.Sprintf("the agent returned %d", e.StatusCode)
67+
}
68+
return e.Detail
69+
}
70+
71+
// Unreachable says the agent is not running, or not where we looked.
72+
type Unreachable struct {
73+
BaseURL string
74+
Cause error
75+
}
76+
77+
func (e *Unreachable) Error() string {
78+
return fmt.Sprintf("no agent answering at %s: %v", e.BaseURL, e.Cause)
79+
}
80+
81+
func (e *Unreachable) Unwrap() error { return e.Cause }
82+
83+
// Client talks to one agent.
84+
type Client struct {
85+
baseURL string
86+
http *http.Client
87+
}
88+
89+
// New builds a client for the agent at baseURL.
90+
func New(baseURL string, timeout time.Duration) *Client {
91+
return &Client{
92+
baseURL: strings.TrimRight(baseURL, "/"),
93+
http: &http.Client{Timeout: timeout},
94+
}
95+
}
96+
97+
// BaseURL is the agent this client talks to.
98+
func (c *Client) BaseURL() string { return c.baseURL }
99+
100+
// Status asks the agent how it and the core are doing.
101+
func (c *Client) Status(ctx context.Context) (Status, error) {
102+
return get[Status](ctx, c, "/health", nil)
103+
}
104+
105+
// Repositories lists what is indexed on this machine.
106+
func (c *Client) Repositories(ctx context.Context) ([]Repository, error) {
107+
return get[[]Repository](ctx, c, "/api/repositories", nil)
108+
}
109+
110+
// GraphOptions narrows what a drawing covers.
111+
type GraphOptions struct {
112+
PathPrefix string
113+
IncludeTests bool
114+
NodeLimit int
115+
}
116+
117+
// Graph reads one repository's whole scope.
118+
func (c *Client) Graph(ctx context.Context, repository string, opts GraphOptions) (Graph, error) {
119+
query := url.Values{"repository": {repository}}
120+
if opts.PathPrefix != "" {
121+
query.Set("path_prefix", opts.PathPrefix)
122+
}
123+
if opts.IncludeTests {
124+
query.Set("include_tests", "true")
125+
}
126+
if opts.NodeLimit > 0 {
127+
query.Set("node_limit", strconv.Itoa(opts.NodeLimit))
128+
}
129+
return get[Graph](ctx, c, "/api/graph", query)
130+
}
131+
132+
func get[T any](ctx context.Context, c *Client, path string, query url.Values) (T, error) {
133+
var zero T
134+
target := c.baseURL + path
135+
if len(query) > 0 {
136+
target += "?" + query.Encode()
137+
}
138+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
139+
if err != nil {
140+
return zero, err
141+
}
142+
req.Header.Set("Accept", "application/json")
143+
144+
resp, err := c.http.Do(req)
145+
if err != nil {
146+
return zero, &Unreachable{BaseURL: c.baseURL, Cause: err}
147+
}
148+
defer func() { _ = resp.Body.Close() }()
149+
150+
body, err := io.ReadAll(resp.Body)
151+
if err != nil {
152+
return zero, err
153+
}
154+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
155+
return zero, &Error{StatusCode: resp.StatusCode, Detail: detail(body)}
156+
}
157+
if err := json.Unmarshal(body, &zero); err != nil {
158+
return zero, fmt.Errorf("the agent answered %s with something other than JSON: %w", path, err)
159+
}
160+
return zero, nil
161+
}
162+
163+
func detail(body []byte) string {
164+
var parsed struct {
165+
Error string `json:"error"`
166+
}
167+
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error != "" {
168+
return parsed.Error
169+
}
170+
return strings.TrimSpace(string(body))
171+
}

0 commit comments

Comments
 (0)