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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ sting --author mfacenet --scope repos --repos skaphos/sting --window 7d --diffs
```

Run `sting --help` (or `sting <command> --help`) for the full flag list.
Queries return at most 100 commits by default to avoid routine rate-limit
pressure; pass `--max-commits 0` only when you intentionally want an exhaustive
scan.

### Evidence depth

Expand All @@ -192,7 +195,9 @@ flags when you want an agent to explain the actual code changes:
GitHub fetches this evidence from per-commit detail calls. GitLab uses
`with_stats` for line stats and commit diff calls for file evidence. Keep full
diffs explicit because they cost extra API calls and can be token-heavy for an
LLM context.
LLM context. The MCP `get_commits` tool therefore treats an omitted
`include_diffs` argument as `false`, even when the server config enables diffs;
callers must explicitly pass `include_diffs: true` to request patch text.

### Scopes

Expand Down Expand Up @@ -272,7 +277,7 @@ directory, or pointed at explicitly with `--config path.yaml`.
| `gitlab_token` | `STING_GITLAB_TOKEN` | `--gitlab-token` | — | dedicated GitLab PAT |
| `gitlab_base_url` | `STING_GITLAB_BASE_URL` | `--gitlab-base-url` | GitLab.com | GitLab API v4 root |
| `per_page` | `STING_PER_PAGE` | `--per-page` | `100` | API page size (1–100) |
| `max_commits` | `STING_MAX_COMMITS` | `--max-commits` | `0` | cap on returned commits (0 = unlimited) |
| `max_commits` | `STING_MAX_COMMITS` | `--max-commits` | `100` | cap on returned commits (0 = unlimited) |
| `default_scope` | `STING_DEFAULT_SCOPE` | (`--scope`) | `search` | scope when unspecified |
| `default_window` | `STING_DEFAULT_WINDOW` | (`--window`) | `7d` | look-back when `since` unspecified |
| `default_repos` | `STING_DEFAULT_REPOS` | (`--repos`) | — | repo/project list for `repos` scope |
Expand Down
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ default_org: "" # org/group used by scope=org
default_format: markdown # markdown | json

per_page: 100 # API page size (1-100)
max_commits: 0 # cap on returned commits (0 = unlimited)
max_commits: 100 # cap on returned commits (0 = unlimited)
include_stats: false # fetch per-commit additions/deletions
include_files: false # fetch per-file change summaries
include_diffs: false # fetch bounded patch text; implies include_files
Expand Down
9 changes: 8 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import (
"github.com/skaphos/sting/model"
)

// DefaultMaxCommits bounds routine queries to the providers' maximum page size.
// Users can still set max_commits to 0 for an exhaustive scan.
const DefaultMaxCommits = 100

// Config holds all tunable settings. The mapstructure keys are the canonical
// configuration keys: they are the YAML/JSON config-file keys, the viper keys
// bound to flags, and (uppercased, STING_-prefixed) the environment variables.
Expand Down Expand Up @@ -77,7 +81,7 @@ func Default() Config {
DefaultRepos: []string{},
DefaultFormat: "markdown",
PerPage: 100,
MaxCommits: 0,
MaxCommits: DefaultMaxCommits,
IncludeStats: false,
IncludeFiles: false,
IncludeDiffs: false,
Expand Down Expand Up @@ -126,6 +130,9 @@ func (cfg Config) Validate() error {
if cfg.PerPage < 1 || cfg.PerPage > 100 {
return fmt.Errorf("per_page must be 1-100, got %d", cfg.PerPage)
}
if cfg.MaxCommits < 0 {
return fmt.Errorf("max_commits must be >= 0, got %d", cfg.MaxCommits)
}
if cfg.MaxDiffBytes < 0 {
return fmt.Errorf("max_diff_bytes must be >= 0, got %d", cfg.MaxDiffBytes)
}
Expand Down
8 changes: 8 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,11 @@ func TestValidateGitLabSearchScopeIncompatible(t *testing.T) {
t.Errorf("Validate: unexpected error for provider=gitlab, default_scope=repos: %v", err)
}
}

func TestValidateMaxCommits(t *testing.T) {
cfg := Default()
cfg.MaxCommits = -1
if err := cfg.Validate(); err == nil {
t.Fatal("Validate: want error for negative max commits")
}
}
11 changes: 10 additions & 1 deletion config/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ type Request struct {
IncludeDiffs *bool
// MaxDiffBytes overrides the default when non-nil.
MaxDiffBytes *int
// MaxCommits overrides the default when non-nil.
MaxCommits *int
// IncludePullRequests overrides the default when non-nil.
IncludePullRequests *bool
}
Expand Down Expand Up @@ -156,6 +158,13 @@ func (cfg Config) Resolve(req Request, now time.Time) (model.Query, error) {
if maxDiffBytes < 0 {
return model.Query{}, fmt.Errorf("max_diff_bytes must be >= 0, got %d", maxDiffBytes)
}
maxCommits := cfg.MaxCommits
if req.MaxCommits != nil {
maxCommits = *req.MaxCommits
}
if maxCommits < 0 {
return model.Query{}, fmt.Errorf("max_commits must be >= 0, got %d", maxCommits)
}

includePRs := cfg.IncludePullRequests
if req.IncludePullRequests != nil {
Expand All @@ -174,7 +183,7 @@ func (cfg Config) Resolve(req Request, now time.Time) (model.Query, error) {
IncludeFiles: includeFiles,
IncludeDiffs: includeDiffs,
MaxDiffBytes: maxDiffBytes,
MaxCommits: cfg.MaxCommits,
MaxCommits: maxCommits,
IncludePullRequests: includePRs,
}, nil
}
Expand Down
20 changes: 20 additions & 0 deletions config/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ func TestResolveDefaults(t *testing.T) {
if q.Scope != model.ScopeSearch {
t.Errorf("scope = %q, want default search", q.Scope)
}
if q.MaxCommits != DefaultMaxCommits {
t.Errorf("MaxCommits = %d, want default %d", q.MaxCommits, DefaultMaxCommits)
}
if !q.Until.Equal(now) {
t.Errorf("until = %v, want now %v", q.Until, now)
}
Expand Down Expand Up @@ -215,3 +218,20 @@ func TestResolveDiffsImplyFiles(t *testing.T) {
t.Errorf("MaxDiffBytes = %d, want 1234", q.MaxDiffBytes)
}
}

func TestResolveMaxCommitsOverride(t *testing.T) {
cfg := Default()
max := 0
q, err := cfg.Resolve(Request{Author: "x", MaxCommits: &max}, time.Now())
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if q.MaxCommits != 0 {
t.Errorf("MaxCommits = %d, want explicit unlimited 0", q.MaxCommits)
}

negative := -1
if _, err := cfg.Resolve(Request{Author: "x", MaxCommits: &negative}, time.Now()); err == nil {
t.Fatal("Resolve: want error for negative max commits")
}
}
42 changes: 42 additions & 0 deletions ghclient/collect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,48 @@ func TestCollectScopeOrg(t *testing.T) {
}
}

func TestCollectScopeOrgStopsRepoPaginationAtMaxCommits(t *testing.T) {
const firstPage = `[{"full_name":"skaphos/sting"}]`
const twoCommits = `[
{"sha":"one","commit":{"message":"first","author":{"date":"2026-05-21T11:00:00Z"}}},
{"sha":"two","commit":{"message":"second","author":{"date":"2026-05-21T12:00:00Z"}}}
]`
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/orgs/skaphos/repos"):
if r.URL.Query().Get("page") == "2" {
t.Errorf("org repo pagination should stop once MaxCommits is reached")
}
w.Header().Set("Link", `<https://api.github.test/orgs/skaphos/repos?page=2>; rel="next"`)
_, _ = w.Write([]byte(firstPage))
case strings.Contains(r.URL.Path, "/repos/skaphos/sting/commits"):
_, _ = w.Write([]byte(twoCommits))
default:
t.Errorf("unexpected path %q", r.URL.Path)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()

c := newTestClient(t, srv.URL, 50)
res, err := c.Collect(context.Background(), model.Query{
Author: "octocat",
Scope: model.ScopeOrg,
Org: "skaphos",
MaxCommits: 1,
})
if err != nil {
t.Fatalf("Collect: %v", err)
}
if res.Count != 1 {
t.Fatalf("Count = %d, want 1", res.Count)
}
if !res.Truncated {
t.Error("Truncated = false, want true")
}
}

// TestCollectScopeOrgSkipsBadRepo verifies that an org scan does not abort when
// one repo cannot be listed (here an empty repo returning 409): the bad repo is
// recorded in Result.Skipped and enumeration continues to the healthy repo.
Expand Down
96 changes: 56 additions & 40 deletions ghclient/ghclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ func (c *Client) searchByAuthor(ctx context.Context, q model.Query) ([]model.Com
opts := &github.SearchOptions{
Sort: "author-date",
Order: "desc",
ListOptions: github.ListOptions{PerPage: c.perPage},
ListOptions: github.ListOptions{PerPage: c.resultPageSize(q.MaxCommits)},
}
var out []model.Commit
for page := 1; ; page++ {
Expand Down Expand Up @@ -265,7 +265,11 @@ func (c *Client) listRepos(ctx context.Context, q model.Query) ([]model.Commit,
if !ok {
return nil, fmt.Errorf("invalid repo %q (want owner/repo)", target)
}
commits, err := c.collectRepo(ctx, owner, repo, q)
repoQuery := remainingQuery(q, len(out))
if repoQuery.MaxCommits == 0 && q.MaxCommits > 0 {
return out, nil
}
commits, err := c.collectRepo(ctx, owner, repo, repoQuery)
if err != nil {
return nil, err
}
Expand All @@ -287,31 +291,48 @@ func (c *Client) listOrg(ctx context.Context, q model.Query) ([]model.Commit, []
if q.Org == "" {
return nil, nil, fmt.Errorf("scope %q requires an org", model.ScopeOrg)
}
repos, err := c.orgRepos(ctx, q.Org)
if err != nil {
return nil, nil, err
opts := &github.RepositoryListByOrgOptions{
ListOptions: github.ListOptions{PerPage: c.perPage},
}
var (
out []model.Commit
skipped []model.SkippedRepo
)
for _, full := range repos {
owner, repo, ok := splitRepo(full)
if !ok {
continue
for page := 1; ; page++ {
if page > maxPages {
return nil, nil, fmt.Errorf("list org repos %s: exceeded max pages (%d)", q.Org, maxPages)
}
commits, err := c.collectRepo(ctx, owner, repo, q)
repos, resp, err := c.gh.Repositories.ListByOrg(ctx, q.Org, opts)
if err != nil {
if reason, skip := skipRepoReason(err); skip {
skipped = append(skipped, model.SkippedRepo{Repo: full, Reason: reason})
return nil, nil, apiError("list org repos "+q.Org, err)
}
for _, r := range repos {
full := r.GetFullName()
owner, repo, ok := splitRepo(full)
if !ok {
continue
}
return nil, nil, err
repoQuery := remainingQuery(q, len(out))
if repoQuery.MaxCommits == 0 && q.MaxCommits > 0 {
return out, skipped, nil
}
commits, err := c.collectRepo(ctx, owner, repo, repoQuery)
if err != nil {
if reason, skip := skipRepoReason(err); skip {
skipped = append(skipped, model.SkippedRepo{Repo: full, Reason: reason})
continue
}
return nil, nil, err
}
out = append(out, commits...)
if q.MaxCommits > 0 && len(out) >= q.MaxCommits {
return out, skipped, nil
}
}
out = append(out, commits...)
if q.MaxCommits > 0 && len(out) >= q.MaxCommits {
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return out, skipped, nil
}
Expand Down Expand Up @@ -358,7 +379,7 @@ func (c *Client) listRepoCommits(ctx context.Context, owner, repo string, q mode
Author: q.Author,
Since: q.Since,
Until: q.Until,
ListOptions: github.ListOptions{PerPage: c.perPage},
ListOptions: github.ListOptions{PerPage: c.resultPageSize(q.MaxCommits)},
}
full := owner + "/" + repo
var out []model.Commit
Expand Down Expand Up @@ -431,7 +452,7 @@ func (c *Client) pullRequestCommits(ctx context.Context, owner, repo string, q m
func (c *Client) prBranchCommits(ctx context.Context, owner, repo string, number int, q model.Query, seen map[string]bool) ([]model.Commit, error) {
full := owner + "/" + repo
source := fmt.Sprintf("pull/%d", number)
opts := &github.ListOptions{PerPage: c.perPage}
opts := &github.ListOptions{PerPage: c.resultPageSize(q.MaxCommits)}
var out []model.Commit
for page := 1; ; page++ {
if page > maxPages {
Expand Down Expand Up @@ -504,32 +525,27 @@ func inWindow(t, since, until time.Time) bool {
return true
}

func (c *Client) orgRepos(ctx context.Context, org string) ([]string, error) {
opts := &github.RepositoryListByOrgOptions{
ListOptions: github.ListOptions{PerPage: c.perPage},
}
var out []string
for page := 1; ; page++ {
if page > maxPages {
return nil, fmt.Errorf("list org repos %s: exceeded max pages (%d)", org, maxPages)
}
repos, resp, err := c.gh.Repositories.ListByOrg(ctx, org, opts)
if err != nil {
return nil, apiError("list org repos "+org, err)
}
for _, r := range repos {
out = append(out, r.GetFullName())
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
func needsDetail(q model.Query) bool {
return q.IncludeStats || q.IncludeFiles || q.IncludeDiffs
}

func (c *Client) resultPageSize(maxCommits int) int {
if maxCommits > 0 && maxCommits < c.perPage {
return maxCommits
}
return out, nil
return c.perPage
}

func needsDetail(q model.Query) bool {
return q.IncludeStats || q.IncludeFiles || q.IncludeDiffs
func remainingQuery(q model.Query, have int) model.Query {
if q.MaxCommits <= 0 {
return q
}
remaining := q.MaxCommits - have
if remaining < 0 {
remaining = 0
}
q.MaxCommits = remaining
return q
}

// enrichDetails fills each commit's stats/files/diffs concurrently, bounded by
Expand Down
Loading
Loading