Skip to content

Commit 9165970

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
improve bot detection, warn on collab access 403s
1 parent 00c6c6e commit 9165970

File tree

3 files changed

+96
-21
lines changed

3 files changed

+96
-21
lines changed

pkg/prx/github.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,22 @@ func (c *githubClient) doRequest(ctx context.Context, path string) ([]byte, *git
125125
}
126126
}
127127

128-
slog.ErrorContext(ctx, "GitHub API error",
129-
"status", resp.Status,
130-
"status_code", resp.StatusCode,
131-
"url", apiURL,
132-
"body", string(body),
133-
"headers", errorHeaders)
128+
// Log collaborator 403 errors as warnings since they're expected for repos without push access
129+
if resp.StatusCode == http.StatusForbidden && strings.Contains(apiURL, "/collaborators") {
130+
slog.WarnContext(ctx, "GitHub API access denied",
131+
"status", resp.Status,
132+
"status_code", resp.StatusCode,
133+
"url", apiURL,
134+
"body", string(body),
135+
"headers", errorHeaders)
136+
} else {
137+
slog.ErrorContext(ctx, "GitHub API error",
138+
"status", resp.Status,
139+
"status_code", resp.StatusCode,
140+
"url", apiURL,
141+
"body", string(body),
142+
"headers", errorHeaders)
143+
}
134144
return nil, nil, &GitHubAPIError{
135145
StatusCode: resp.StatusCode,
136146
Status: resp.Status,

pkg/prx/graphql_complete.go

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -919,18 +919,30 @@ type graphQLActor struct {
919919
ID string `json:"id,omitempty"`
920920
}
921921

922-
// isBotFromGraphQL determines if an actor is a bot.
923-
func isBotFromGraphQL(actor graphQLActor) bool {
922+
// isBot determines if an actor is a bot.
923+
func isBot(actor graphQLActor) bool {
924924
if actor.Login == "" {
925925
return false
926926
}
927-
// Check for bot patterns in login
927+
// Check for bot patterns in login (case-insensitive for better detection)
928928
login := actor.Login
929+
lowerLogin := strings.ToLower(login)
930+
931+
// Check for common bot suffixes
929932
if strings.HasSuffix(login, "[bot]") ||
930-
strings.HasSuffix(login, "-bot") ||
931-
strings.HasSuffix(login, "-robot") {
933+
strings.HasSuffix(lowerLogin, "-bot") ||
934+
strings.HasSuffix(lowerLogin, "_bot") ||
935+
strings.HasSuffix(lowerLogin, "-robot") ||
936+
strings.HasPrefix(lowerLogin, "bot-") {
937+
return true
938+
}
939+
940+
// Check for GitHub bot account patterns
941+
// Many bots end with "bot" without separator (e.g., "dependabot", "renovatebot")
942+
if strings.HasSuffix(lowerLogin, "bot") && len(login) > 3 {
932943
return true
933944
}
945+
934946
// In GraphQL, bots have different IDs than users
935947
// Bot IDs typically start with "BOT_" or have specific patterns
936948
// This is a heuristic that may need adjustment
@@ -1013,9 +1025,10 @@ func (c *Client) convertGraphQLToPullRequest(ctx context.Context, data *graphQLP
10131025
pr.MergeableState = strings.ToLower(data.MergeStateStatus)
10141026
}
10151027

1016-
// Author write access
1028+
// Author write access and bot detection
10171029
if data.Author.Login != "" {
10181030
pr.AuthorWriteAccess = c.writeAccessFromAssociation(ctx, owner, repo, data.Author.Login, data.AuthorAssociation)
1031+
pr.AuthorBot = isBot(data.Author)
10191032
}
10201033

10211034
// Assignees (initialize to empty slice if none)
@@ -1089,7 +1102,7 @@ func (c *Client) convertGraphQLToEventsComplete(ctx context.Context, data *graph
10891102
Timestamp: data.CreatedAt,
10901103
Actor: data.Author.Login,
10911104
Body: truncate(data.Body),
1092-
Bot: isBotFromGraphQL(data.Author),
1105+
Bot: isBot(data.Author),
10931106
WriteAccess: c.writeAccessFromAssociation(ctx, owner, repo, data.Author.Login, data.AuthorAssociation),
10941107
})
10951108

@@ -1102,7 +1115,7 @@ func (c *Client) convertGraphQLToEventsComplete(ctx context.Context, data *graph
11021115
}
11031116
if node.Commit.Author.User != nil {
11041117
event.Actor = node.Commit.Author.User.Login
1105-
event.Bot = isBotFromGraphQL(*node.Commit.Author.User)
1118+
event.Bot = isBot(*node.Commit.Author.User)
11061119
} else {
11071120
event.Actor = node.Commit.Author.Name
11081121
}
@@ -1126,7 +1139,7 @@ func (c *Client) convertGraphQLToEventsComplete(ctx context.Context, data *graph
11261139
Body: truncate(review.Body),
11271140
Outcome: strings.ToLower(review.State),
11281141
Question: containsQuestion(review.Body),
1129-
Bot: isBotFromGraphQL(review.Author),
1142+
Bot: isBot(review.Author),
11301143
WriteAccess: c.writeAccessFromAssociation(ctx, owner, repo, review.Author.Login, review.AuthorAssociation),
11311144
}
11321145
events = append(events, event)
@@ -1141,7 +1154,7 @@ func (c *Client) convertGraphQLToEventsComplete(ctx context.Context, data *graph
11411154
Actor: comment.Author.Login,
11421155
Body: truncate(comment.Body),
11431156
Question: containsQuestion(comment.Body),
1144-
Bot: isBotFromGraphQL(comment.Author),
1157+
Bot: isBot(comment.Author),
11451158
WriteAccess: c.writeAccessFromAssociation(ctx, owner, repo, comment.Author.Login, comment.AuthorAssociation),
11461159
}
11471160
events = append(events, event)
@@ -1156,7 +1169,7 @@ func (c *Client) convertGraphQLToEventsComplete(ctx context.Context, data *graph
11561169
Actor: comment.Author.Login,
11571170
Body: truncate(comment.Body),
11581171
Question: containsQuestion(comment.Body),
1159-
Bot: isBotFromGraphQL(comment.Author),
1172+
Bot: isBot(comment.Author),
11601173
WriteAccess: c.writeAccessFromAssociation(ctx, owner, repo, comment.Author.Login, comment.AuthorAssociation),
11611174
}
11621175
events = append(events, event)
@@ -1217,7 +1230,7 @@ func (c *Client) convertGraphQLToEventsComplete(ctx context.Context, data *graph
12171230
}
12181231
if node.Creator != nil {
12191232
event.Actor = node.Creator.Login
1220-
event.Bot = isBotFromGraphQL(*node.Creator)
1233+
event.Bot = isBot(*node.Creator)
12211234
}
12221235
events = append(events, event)
12231236
default:
@@ -1243,6 +1256,7 @@ func (c *Client) convertGraphQLToEventsComplete(ctx context.Context, data *graph
12431256
if data.MergedBy != nil {
12441257
event.Actor = data.MergedBy.Login
12451258
event.Kind = "pr_merged"
1259+
event.Bot = isBot(*data.MergedBy)
12461260
}
12471261
events = append(events, event)
12481262
}
@@ -1279,6 +1293,21 @@ func (*Client) parseGraphQLTimelineEvent(_ /* ctx */ context.Context, item map[s
12791293
return "unknown"
12801294
}
12811295

1296+
// Helper to check if actor is a bot
1297+
isActorBot := func() bool {
1298+
if actor, ok := item["actor"].(map[string]any); ok {
1299+
var actorObj graphQLActor
1300+
if login, ok := actor["login"].(string); ok {
1301+
actorObj.Login = login
1302+
}
1303+
if id, ok := actor["id"].(string); ok {
1304+
actorObj.ID = id
1305+
}
1306+
return isBot(actorObj)
1307+
}
1308+
return false
1309+
}
1310+
12821311
createdAt := getTime("createdAt")
12831312
if createdAt == nil {
12841313
return nil
@@ -1287,6 +1316,7 @@ func (*Client) parseGraphQLTimelineEvent(_ /* ctx */ context.Context, item map[s
12871316
event := &Event{
12881317
Timestamp: *createdAt,
12891318
Actor: getActor(),
1319+
Bot: isActorBot(),
12901320
}
12911321

12921322
switch typename {

pkg/prx/graphql_parity_test.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ func TestGraphQLBotDetection(t *testing.T) {
159159
expected bool
160160
}{
161161
{
162-
name: "bot suffix",
162+
name: "bot suffix with brackets",
163163
actor: graphQLActor{Login: "dependabot[bot]"},
164164
expected: true,
165165
},
@@ -168,16 +168,51 @@ func TestGraphQLBotDetection(t *testing.T) {
168168
actor: graphQLActor{Login: "renovate-bot"},
169169
expected: true,
170170
},
171+
{
172+
name: "minikube-bot",
173+
actor: graphQLActor{Login: "minikube-bot"},
174+
expected: true,
175+
},
171176
{
172177
name: "robot suffix",
173178
actor: graphQLActor{Login: "k8s-ci-robot"},
174179
expected: true,
175180
},
181+
{
182+
name: "bot underscore suffix",
183+
actor: graphQLActor{Login: "some_bot"},
184+
expected: true,
185+
},
186+
{
187+
name: "bot prefix",
188+
actor: graphQLActor{Login: "bot-service"},
189+
expected: true,
190+
},
191+
{
192+
name: "bot name without separator",
193+
actor: graphQLActor{Login: "dependabot"},
194+
expected: true,
195+
},
196+
{
197+
name: "uppercase BOT suffix",
198+
actor: graphQLActor{Login: "CI-BOT"},
199+
expected: true,
200+
},
176201
{
177202
name: "regular user",
178203
actor: graphQLActor{Login: "octocat"},
179204
expected: false,
180205
},
206+
{
207+
name: "user with bot in middle",
208+
actor: graphQLActor{Login: "robotnik"},
209+
expected: false,
210+
},
211+
{
212+
name: "short name ending in bot",
213+
actor: graphQLActor{Login: "bot"},
214+
expected: false,
215+
},
181216
{
182217
name: "bot ID prefix",
183218
actor: graphQLActor{Login: "actions", ID: "BOT_123"},
@@ -187,9 +222,9 @@ func TestGraphQLBotDetection(t *testing.T) {
187222

188223
for _, tt := range tests {
189224
t.Run(tt.name, func(t *testing.T) {
190-
result := isBotFromGraphQL(tt.actor)
225+
result := isBot(tt.actor)
191226
if result != tt.expected {
192-
t.Errorf("isBotFromGraphQL(%v) = %v, want %v",
227+
t.Errorf("isBot(%v) = %v, want %v",
193228
tt.actor, result, tt.expected)
194229
}
195230
})

0 commit comments

Comments
 (0)