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: 9 additions & 0 deletions provider/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"time"

"github.com/gkanitz/coderepute/provider"
"github.com/gkanitz/coderepute/provider/recognition"
)

const defaultBaseURL = "https://api.github.com"
Expand Down Expand Up @@ -208,6 +209,7 @@ type githubFilesResponse struct {
type apiUser struct {
Login string `json:"login"`
ID int64 `json:"id"`
Type string `json:"type,omitempty"`
}

type apiPull struct {
Expand Down Expand Up @@ -259,6 +261,7 @@ func (a *Adapter) FetchActivity(ctx context.Context, opts provider.FetchOptions)
}
}
as.AccessManifest = a.counting.Manifest(provider.GitHubNeverRequested(), "All requests are to the GitHub REST API and GraphQL API. No repository contents, file contents, diffs, branch names, colleague profiles, or commit data are ever requested.")
as.AccessManifest.AIRecognitionVersion = recognition.Version()
return as, nil
}

Expand All @@ -268,6 +271,7 @@ type pendingReview struct {
prNumber int64
submittedAt time.Time
state string
authorClass string // classification of the reviewed PR's author
}

// fetchRepoActivity collects one repo's activity into the set. Everything
Expand Down Expand Up @@ -318,6 +322,9 @@ func (a *Adapter) fetchRepoActivity(ctx context.Context, repo string, subjectID
continue
}
// Someone else's PR: only the subject's in-window reviews matter.
// Classify the PR author and record only the class string --
// the colleague's identity never leaves the adapter.
authorClass := recognition.Classify(p.User.Login, p.User.Type)
for _, rv := range reviews {
if rv.User.ID != subjectID || !inWindow(rv.SubmittedAt, window) {
continue
Expand All @@ -326,6 +333,7 @@ func (a *Adapter) fetchRepoActivity(ctx context.Context, repo string, subjectID
prNumber: p.Number,
submittedAt: rv.SubmittedAt,
state: rv.State,
authorClass: authorClass,
})
}
}
Expand All @@ -343,6 +351,7 @@ func (a *Adapter) fetchRepoActivity(ctx context.Context, repo string, subjectID
SubmittedAt: rv.submittedAt,
State: rv.state,
CommentCount: commentCounts[rv.prNumber],
AuthorClass: rv.authorClass,
})
}
return nil
Expand Down
128 changes: 128 additions & 0 deletions provider/github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -577,3 +577,131 @@ func TestFetchActivityWidenedReviewAllTimeWindow(t *testing.T) {
t.Errorf("got %d PRs, want 1", len(as.PullRequests))
}
}

// TestFetchActivityAuthorClassification verifies that the author of a reviewed
// PR is classified against the recognition ruleset and the class string is
// recorded on the Review, without leaking the colleague's identity.
func TestFetchActivityAuthorClassification(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/octocat":
w.Header().Set("X-OAuth-Scopes", "repo")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"login":"octocat","id":583231}`))
case "/repos/acme/widgets/pulls":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"number":1,"user":{"login":"copilot[bot]","id":999001,"type":"Bot"},"created_at":"2026-02-15T08:00:00Z","updated_at":"2026-02-20T08:00:00Z"},
{"number":2,"user":{"login":"human-colleague","id":888001},"created_at":"2026-02-15T08:00:00Z","updated_at":"2026-02-20T08:00:00Z"}
]`))
case "/repos/acme/widgets/pulls/1/reviews":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"user":{"login":"octocat","id":583231},"state":"APPROVED","submitted_at":"2026-02-20T09:00:00Z"}
]`))
case "/repos/acme/widgets/pulls/2/reviews":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"user":{"login":"octocat","id":583231},"state":"COMMENTED","submitted_at":"2026-02-20T10:00:00Z"}
]`))
case "/repos/acme/widgets/pulls/comments":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[]`))
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)

adapter := github.New("test-token", github.WithBaseURL(srv.URL))
as, err := adapter.FetchActivity(context.Background(), provider.FetchOptions{
Repos: []string{"acme/widgets"},
Subject: "octocat",
Window: provider.Window{
Since: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
Until: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
},
})
if err != nil {
t.Fatalf("FetchActivity: %v", err)
}

// Expect two reviews: one on a copilot-authored PR, one on a human-authored PR.
if len(as.ReviewsGiven) != 2 {
t.Fatalf("got %d reviews given, want 2", len(as.ReviewsGiven))
}

// Review on copilot-authored PR should have AuthorClass = "copilot".
var copilotReview, humanReview *provider.Review
for i, rv := range as.ReviewsGiven {
if rv.AuthorClass == "copilot" {
copilotReview = &as.ReviewsGiven[i]
} else if rv.AuthorClass == "" {
humanReview = &as.ReviewsGiven[i]
}
}

if copilotReview == nil {
t.Errorf("no review with AuthorClass 'copilot' found; all reviews: %+v", as.ReviewsGiven)
}
if humanReview == nil {
t.Errorf("no review with AuthorClass '' (human) found; all reviews: %+v", as.ReviewsGiven)
}

// Verify that no colleague identity (login, ID) appears in ReviewsGiven.
dump := fmt.Sprintf("%+v", as.ReviewsGiven)
for _, forbidden := range []string{"copilot[bot]", "999001", "888001"} {
if strings.Contains(dump, forbidden) {
t.Errorf("Review carries prohibited colleague identity %q", forbidden)
}
}
}

// TestFetchActivityBotAuthorClassification verifies that a PR authored by an
// unknown bot (matched only by type:"Bot" or *[bot] login) gets AuthorClass "bot".
func TestFetchActivityBotAuthorClassification(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/octocat":
w.Header().Set("X-OAuth-Scopes", "repo")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"login":"octocat","id":583231}`))
case "/repos/acme/widgets/pulls":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"number":1,"user":{"login":"some-unknown-tool[bot]","id":999002,"type":"Bot"},"created_at":"2026-02-15T08:00:00Z","updated_at":"2026-02-20T08:00:00Z"}
]`))
case "/repos/acme/widgets/pulls/1/reviews":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"user":{"login":"octocat","id":583231},"state":"APPROVED","submitted_at":"2026-02-20T09:00:00Z"}
]`))
case "/repos/acme/widgets/pulls/comments":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[]`))
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)

adapter := github.New("test-token", github.WithBaseURL(srv.URL))
as, err := adapter.FetchActivity(context.Background(), provider.FetchOptions{
Repos: []string{"acme/widgets"},
Subject: "octocat",
Window: provider.Window{
Since: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
Until: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
},
})
if err != nil {
t.Fatalf("FetchActivity: %v", err)
}

if len(as.ReviewsGiven) != 1 {
t.Fatalf("got %d reviews given, want 1", len(as.ReviewsGiven))
}
if as.ReviewsGiven[0].AuthorClass != "bot" {
t.Errorf("AuthorClass = %q, want %q (unknown bot should get 'bot')", as.ReviewsGiven[0].AuthorClass, "bot")
}
}
4 changes: 4 additions & 0 deletions provider/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type Manifest struct {
Endpoints []EndpointCount `json:"endpoints"`
NeverRequested []string `json:"never_requested"`
Notes string `json:"notes"`
// AIRecognitionVersion is the version of the embedded recognition ruleset
// (airuleset.json) used during this fetch, or zero if no classification
// was performed.
AIRecognitionVersion int `json:"ai_recognition_version,omitempty"`
}

// RouteEntry maps one URL pattern to its route class. The Pattern is a
Expand Down
6 changes: 6 additions & 0 deletions provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ type Review struct {
// populated from diff-shape data when available. Zero means "unknown /
// no diff data" and triggers the fallback deep-review threshold.
PRLines int
// AuthorClass is the classification of the reviewed PR's author: a
// canonical agent id ("copilot", "devin") when the author matches the
// curated recognition ruleset, "bot" when matched only by structural
// bot-type indicators, or "" for a human author. Populated inside the
// adapter; the colleague's identity never leaves the adapter.
AuthorClass string
}

// ReviewComment is a single review comment written or received by the subject.
Expand Down
45 changes: 45 additions & 0 deletions provider/recognition/airuleset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"version": 1,
"entries": [
{
"login": "copilot[bot]",
"agent": "copilot"
},
{
"login": "devin[bot]",
"agent": "devin"
},
{
"login": "codeium[bot]",
"agent": "codeium"
},
{
"login": "tabnine[bot]",
"agent": "tabnine"
},
{
"login": "amazon-q-developer[bot]",
"agent": "amazon-q"
},
{
"login": "coderabbit[bot]",
"agent": "coderabbit"
},
{
"login": "codesee[bot]",
"agent": "codesee"
},
{
"login": "github-actions[bot]",
"agent": "github-actions"
},
{
"login": "dependabot[bot]",
"agent": "dependabot"
},
{
"login": "github-merge-queue[bot]",
"agent": "github-merge-queue"
}
]
}
90 changes: 90 additions & 0 deletions provider/recognition/recognition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Package recognition provides an embedded, versioned ruleset for classifying
// PR/MR authors as human, bot, or a recognized AI agent. The ruleset follows
// the same go:embed pattern as metrics/bands/bands.json.
package recognition

import (
"embed"
"encoding/json"
"fmt"
"strings"
"sync"
)

//go:embed airuleset.json
var rulesetFS embed.FS

// Entry maps a known agent login to its canonical agent id.
type Entry struct {
Login string `json:"login"`
Agent string `json:"agent"`
}

// Ruleset is the top-level structure of the embedded airuleset.json.
type Ruleset struct {
Version int `json:"version"`
Entries []Entry `json:"entries"`
}

var (
once sync.Once
loginMap map[string]string // login -> canonical agent id
version int
)

// load reads and parses the embedded airuleset.json once.
func load() {
raw, err := rulesetFS.ReadFile("airuleset.json")
if err != nil {
panic(fmt.Sprintf("recognition: embedded airuleset.json: %v", err))
}
var rs Ruleset
if err := json.Unmarshal(raw, &rs); err != nil {
panic(fmt.Sprintf("recognition: parse embedded airuleset.json: %v", err))
}
version = rs.Version
loginMap = make(map[string]string, len(rs.Entries))
for _, e := range rs.Entries {
loginMap[strings.ToLower(e.Login)] = e.Agent
}
}

// agentIDForLogin returns the canonical agent id for a known agent login, or
// false if the login is not in the curated ruleset.
func agentIDForLogin(login string) (string, bool) {
once.Do(load)
aid, ok := loginMap[strings.ToLower(login)]
return aid, ok
}

// Classify returns the canonical agent id when the given login+type matches a
// curated ruleset entry, "bot" when the match is structural (GitHub type:"Bot"
// or *[bot] login pattern), or "" for a human author. The login comparison is
// case-insensitive.
func Classify(login, userType string) string {
if login == "" {
return ""
}
// Layer 1: curated agent ruleset.
if aid, ok := agentIDForLogin(login); ok {
return aid
}
// Layer 2: structural bot-type.
// GitHub API returns type:"Bot" for bot accounts.
if userType == "Bot" {
return "bot"
}
// GitHub bot logins follow the *[bot] pattern (e.g. "copilot[bot]").
// This also catches ruleset entries that didn't match above, though
// that's redundant — ruleset entries use [bot] logins.
if strings.HasSuffix(login, "[bot]") {
return "bot"
}
return ""
}

// Version returns the embedded airuleset.json version number.
func Version() int {
once.Do(load)
return version
}
Loading
Loading