This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 4bc679efd fix(github_graphql): resolve Bot actors so bot-authored PRs 
keep their author (#9086)
4bc679efd is described below

commit 4bc679efdd332f9a3d1a1e8afb7476d6a4fa9f06
Author: Nikita Bolotov <[email protected]>
AuthorDate: Tue Sep 1 06:53:29 2026 +0300

    fix(github_graphql): resolve Bot actors so bot-authored PRs keep their 
author (#9086)
    
    `author`, `mergedBy` and review authors are typed `Actor` in the GitHub
    schema, which `Bot` implements alongside `User`. The inline query spread
    only `... on User`, so every pull request, issue and review opened by a
    GitHub App, Dependabot or Renovate came back with an empty selection set
    and was stored with no author at all.
    
    `... on Bot` cannot simply be added to the existing query: GitHub rejects
    the whole request with "Fragment on Bot can't be spread inside User" when
    it is spread on a `User`-typed field such as assignees or a commit author.
    Actor fields therefore get their own GraphqlInlineActorQuery, and both
    types resolve through an Account() accessor.
    
    Bot logins are normalized to the `[bot]` suffix the REST collector stores,
    so an account keeps one identity across collectors, and the accounts
    lookup skips bot logins because `user(login:)` never resolves them.
    
    Fixes #9085
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../github_graphql/tasks/account_collector.go      |   9 +-
 .../tasks/account_graphql_pre_extractor.go         |  73 ++++++++++++-
 .../tasks/account_graphql_pre_extractor_test.go    | 121 +++++++++++++++++++++
 .../github_graphql/tasks/issue_collector.go        |   2 +-
 .../github_graphql/tasks/issue_extractor.go        |  10 +-
 .../plugins/github_graphql/tasks/pr_collector.go   |   6 +-
 .../plugins/github_graphql/tasks/pr_extractor.go   |  28 ++---
 7 files changed, 221 insertions(+), 28 deletions(-)

diff --git a/backend/plugins/github_graphql/tasks/account_collector.go 
b/backend/plugins/github_graphql/tasks/account_collector.go
index a75013d0d..37ecb9716 100644
--- a/backend/plugins/github_graphql/tasks/account_collector.go
+++ b/backend/plugins/github_graphql/tasks/account_collector.go
@@ -75,10 +75,17 @@ func CollectAccount(taskCtx plugin.SubTaskContext) 
errors.Error {
        db := taskCtx.GetDal()
        data := taskCtx.GetData().(*githubTasks.GithubTaskData)
 
+       // `user(login:)` resolves Users only, a bot login always comes back as
+       // "Could not resolve to a User", so bots are left out of this lookup. 
They are
+       // still converted to accounts: ConvertAccounts reads 
_tool_github_repo_accounts
+       // and only enriches it from _tool_github_accounts when a row is there.
        cursor, err := db.Cursor(
                dal.Select("login"),
                dal.From(models.GithubRepoAccount{}.TableName()),
-               dal.Where("repo_github_id = ? and connection_id=?", 
data.Options.GithubId, data.Options.ConnectionId),
+               dal.Where(
+                       "repo_github_id = ? and connection_id = ? and login not 
like ?",
+                       data.Options.GithubId, data.Options.ConnectionId, 
"%"+botLoginSuffix,
+               ),
        )
        if err != nil {
                return err
diff --git 
a/backend/plugins/github_graphql/tasks/account_graphql_pre_extractor.go 
b/backend/plugins/github_graphql/tasks/account_graphql_pre_extractor.go
index e4b47e475..54bc44a70 100644
--- a/backend/plugins/github_graphql/tasks/account_graphql_pre_extractor.go
+++ b/backend/plugins/github_graphql/tasks/account_graphql_pre_extractor.go
@@ -18,9 +18,16 @@ limitations under the License.
 package tasks
 
 import (
+       "strings"
+
        "github.com/apache/incubator-devlake/plugins/github/models"
 )
 
+// botLoginSuffix is how the REST API reports a bot login: `dependabot[bot]`.
+// GraphQL's `Bot.login` omits it, so it is appended back to give a bot the 
same
+// identity no matter which collector produced it.
+const botLoginSuffix = `[bot]`
+
 type GithubAccountEdge struct {
        Login     string
        Id        int `graphql:"databaseId"`
@@ -30,18 +37,76 @@ type GithubAccountEdge struct {
        AvatarUrl string
        HtmlUrl   string `graphql:"url"`
 }
+
+// GithubBotEdge holds what a `Bot` actor exposes. The GitHub schema gives 
`Bot`
+// a narrower field set than `User`: no name, company or email.
+type GithubBotEdge struct {
+       Login     string
+       Id        int `graphql:"databaseId"`
+       AvatarUrl string
+       HtmlUrl   string `graphql:"url"`
+}
+
+// GraphqlInlineAccountQuery is for fields the schema types as `User`, such as
+// assignees and commit authors.
 type GraphqlInlineAccountQuery struct {
        GithubAccountEdge `graphql:"... on User"`
 }
 
-func extractGraphqlPreAccount(result *[]interface{}, res 
*GraphqlInlineAccountQuery, repoId int, connId uint64) {
-       if res == nil || res.Id == 0 {
+// Account returns the account this query resolved to, or a zero value.
+func (q *GraphqlInlineAccountQuery) Account() GithubAccountEdge {
+       if q == nil {
+               return GithubAccountEdge{}
+       }
+       return q.GithubAccountEdge
+}
+
+// GraphqlInlineActorQuery is for fields the schema types as `Actor`, such as a
+// pull request author or the user who merged it. `Actor` is implemented by
+// `User` and `Bot` among others, so spreading only `... on User` leaves every
+// GitHub App, Dependabot or Renovate author empty.
+//
+// The two inline queries cannot be merged into one: spreading `... on Bot` on 
a
+// `User`-typed field makes GitHub reject the whole query with
+// "Fragment on Bot can't be spread inside User".
+type GraphqlInlineActorQuery struct {
+       GithubAccountEdge `graphql:"... on User"`
+       Bot               GithubBotEdge `graphql:"... on Bot"`
+}
+
+// Account returns the actor as an account, whichever type it resolved to.
+func (q *GraphqlInlineActorQuery) Account() GithubAccountEdge {
+       if q == nil {
+               return GithubAccountEdge{}
+       }
+       if q.GithubAccountEdge.Id != 0 {
+               return q.GithubAccountEdge
+       }
+       if q.Bot.Id == 0 {
+               return GithubAccountEdge{}
+       }
+       login := q.Bot.Login
+       if !strings.HasSuffix(login, botLoginSuffix) {
+               login += botLoginSuffix
+       }
+       // Name, Company and Email stay empty: that is what the REST collector 
stores
+       // for a bot as well.
+       return GithubAccountEdge{
+               Login:     login,
+               Id:        q.Bot.Id,
+               AvatarUrl: q.Bot.AvatarUrl,
+               HtmlUrl:   q.Bot.HtmlUrl,
+       }
+}
+
+func extractGraphqlPreAccount(result *[]interface{}, account 
GithubAccountEdge, repoId int, connId uint64) {
+       if account.Id == 0 {
                return
        }
        *result = append(*result, &models.GithubRepoAccount{
                ConnectionId: connId,
                RepoGithubId: repoId,
-               Login:        res.Login,
-               AccountId:    res.Id,
+               Login:        account.Login,
+               AccountId:    account.Id,
        })
 }
diff --git 
a/backend/plugins/github_graphql/tasks/account_graphql_pre_extractor_test.go 
b/backend/plugins/github_graphql/tasks/account_graphql_pre_extractor_test.go
new file mode 100644
index 000000000..4979e5523
--- /dev/null
+++ b/backend/plugins/github_graphql/tasks/account_graphql_pre_extractor_test.go
@@ -0,0 +1,121 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+       "encoding/json"
+       "strings"
+       "testing"
+
+       "github.com/merico-ai/graphql"
+       "github.com/stretchr/testify/assert"
+)
+
+func TestInlineAccountQueryAccount(t *testing.T) {
+       var nilQuery *GraphqlInlineAccountQuery
+       assert.Equal(t, 0, nilQuery.Account().Id)
+
+       user := &GraphqlInlineAccountQuery{
+               GithubAccountEdge: GithubAccountEdge{Login: `octocat`, Id: 
583231, Name: `The Octocat`},
+       }
+       assert.Equal(t, `octocat`, user.Account().Login)
+       assert.Equal(t, 583231, user.Account().Id)
+}
+
+func TestInlineActorQueryAccount(t *testing.T) {
+       var nilQuery *GraphqlInlineActorQuery
+       assert.Equal(t, GithubAccountEdge{}, nilQuery.Account())
+
+       // an actor that resolved to neither User nor Bot, e.g. a deleted 
account
+       empty := &GraphqlInlineActorQuery{}
+       assert.Equal(t, GithubAccountEdge{}, empty.Account())
+
+       user := &GraphqlInlineActorQuery{
+               GithubAccountEdge: GithubAccountEdge{Login: `octocat`, Id: 
583231, Email: `[email protected]`},
+       }
+       assert.Equal(t, `octocat`, user.Account().Login)
+       assert.Equal(t, `[email protected]`, user.Account().Email)
+
+       // Bot.login has no `[bot]` suffix, the REST collector stores one
+       bot := &GraphqlInlineActorQuery{
+               Bot: GithubBotEdge{Login: `dependabot`, Id: 49699333, HtmlUrl: 
`https://github.com/apps/dependabot`},
+       }
+       assert.Equal(t, `dependabot[bot]`, bot.Account().Login)
+       assert.Equal(t, 49699333, bot.Account().Id)
+       assert.Equal(t, `https://github.com/apps/dependabot`, 
bot.Account().HtmlUrl)
+       assert.Empty(t, bot.Account().Name)
+
+       // a login that already carries the suffix is left alone
+       suffixed := &GraphqlInlineActorQuery{
+               Bot: GithubBotEdge{Login: `renovate[bot]`, Id: 29139614},
+       }
+       assert.Equal(t, `renovate[bot]`, suffixed.Account().Login)
+}
+
+// the raw layer holds the marshalled query result, so a bot author has to 
survive
+// the round trip the extractors read it back through
+func TestInlineActorQueryUnmarshalBotAuthor(t *testing.T) {
+       raw := 
`{"Login":"","Id":0,"Name":"","Company":"","Email":"","AvatarUrl":"","HtmlUrl":"",
+               
"Bot":{"Login":"dependabot","Id":49699333,"AvatarUrl":"","HtmlUrl":""}}`
+       actor := &GraphqlInlineActorQuery{}
+       assert.Nil(t, json.Unmarshal([]byte(raw), actor))
+       assert.Equal(t, `dependabot[bot]`, actor.Account().Login)
+       assert.Equal(t, 49699333, actor.Account().Id)
+
+       // rows collected before the Bot fragment existed still extract as 
before
+       legacy := `{"Login":"octocat","Id":583231,"Name":"The 
Octocat","Company":"","Email":"","AvatarUrl":"","HtmlUrl":""}`
+       actor = &GraphqlInlineActorQuery{}
+       assert.Nil(t, json.Unmarshal([]byte(legacy), actor))
+       assert.Equal(t, `octocat`, actor.Account().Login)
+}
+
+func TestExtractGraphqlPreAccountSkipsUnresolvedActors(t *testing.T) {
+       var results []interface{}
+       var missing *GraphqlInlineActorQuery
+       extractGraphqlPreAccount(&results, missing.Account(), 1, 1)
+       assert.Empty(t, results)
+
+       bot := &GraphqlInlineActorQuery{Bot: GithubBotEdge{Login: `dependabot`, 
Id: 49699333}}
+       extractGraphqlPreAccount(&results, bot.Account(), 1, 1)
+       assert.Len(t, results, 1)
+}
+
+// `... on Bot` may only be spread where the schema says `Actor`; on a `User` 
field
+// GitHub rejects the whole query with "Fragment on Bot can't be spread inside 
User"
+func TestBotFragmentOnlySpreadOnActorFields(t *testing.T) {
+       prQuery, _ := graphql.ConstructQuery(&GraphqlQueryPrWrapper{}, 
map[string]interface{}{})
+       issueQuery, _ := graphql.ConstructQuery(&GraphqlQueryIssueWrapper{}, 
map[string]interface{}{})
+
+       assert.Contains(t, prQuery, `author{... on User{`)
+       assert.Contains(t, prQuery, `... on Bot{`)
+       assert.Contains(t, issueQuery, `... on Bot{`)
+
+       // assignees and commit authors are typed `User`, they must stay 
User-only
+       for _, userField := range []string{`assignees(first: 1){nodes{`, 
`assignees(first: 100){nodes{`} {
+               if idx := strings.Index(prQuery+issueQuery, userField); idx >= 
0 {
+                       tail := (prQuery + issueQuery)[idx : idx+120]
+                       assert.NotContains(t, tail, `... on Bot`)
+               }
+       }
+       commitAuthor := `commits(first: 100){`
+       if idx := strings.Index(prQuery, commitAuthor); idx >= 0 {
+               tail := prQuery[idx : idx+400]
+               assert.Contains(t, tail, `user{... on User{`)
+               assert.NotContains(t, tail, `user{... on 
User{login,databaseId,name,company,email,avatarUrl,url},... on Bot`)
+       }
+}
diff --git a/backend/plugins/github_graphql/tasks/issue_collector.go 
b/backend/plugins/github_graphql/tasks/issue_collector.go
index 36c33584c..9069a1c72 100644
--- a/backend/plugins/github_graphql/tasks/issue_collector.go
+++ b/backend/plugins/github_graphql/tasks/issue_collector.go
@@ -68,7 +68,7 @@ type GraphqlQueryIssue struct {
        StateReason  string
        Title        string
        Body         string
-       Author       *GraphqlInlineAccountQuery
+       Author       *GraphqlInlineActorQuery
        Url          string
        ClosedAt     *time.Time
        CreatedAt    time.Time
diff --git a/backend/plugins/github_graphql/tasks/issue_extractor.go 
b/backend/plugins/github_graphql/tasks/issue_extractor.go
index e27679c83..a825c6a89 100644
--- a/backend/plugins/github_graphql/tasks/issue_extractor.go
+++ b/backend/plugins/github_graphql/tasks/issue_extractor.go
@@ -83,9 +83,9 @@ func ExtractIssues(taskCtx plugin.SubTaskContext) 
errors.Error {
                        results = append(results, githubLabels...)
                        results = append(results, githubIssue)
                        if len(issue.AssigneeList.Assignees) > 0 {
-                               extractGraphqlPreAccount(&results, 
&issue.AssigneeList.Assignees[0], data.Options.GithubId, 
data.Options.ConnectionId)
+                               extractGraphqlPreAccount(&results, 
issue.AssigneeList.Assignees[0].Account(), data.Options.GithubId, 
data.Options.ConnectionId)
                        }
-                       extractGraphqlPreAccount(&results, issue.Author, 
data.Options.GithubId, data.Options.ConnectionId)
+                       extractGraphqlPreAccount(&results, 
issue.Author.Account(), data.Options.GithubId, data.Options.ConnectionId)
                        for _, assignee := range issue.AssigneeList.Assignees {
                                issueAssignee := &models.GithubIssueAssignee{
                                        ConnectionId: githubIssue.ConnectionId,
@@ -147,9 +147,9 @@ func convertGithubIssue(milestoneMap map[int]int, issue 
*GraphqlQueryIssue, conn
                githubIssue.AssigneeId = issue.AssigneeList.Assignees[0].Id
                githubIssue.AssigneeName = issue.AssigneeList.Assignees[0].Login
        }
-       if issue.Author != nil {
-               githubIssue.AuthorId = issue.Author.Id
-               githubIssue.AuthorName = issue.Author.Login
+       if author := issue.Author.Account(); author.Id != 0 {
+               githubIssue.AuthorId = author.Id
+               githubIssue.AuthorName = author.Login
        }
        if issue.ClosedAt != nil {
                temp := uint(issue.ClosedAt.Sub(issue.CreatedAt).Minutes())
diff --git a/backend/plugins/github_graphql/tasks/pr_collector.go 
b/backend/plugins/github_graphql/tasks/pr_collector.go
index 669839385..c05cac2ef 100644
--- a/backend/plugins/github_graphql/tasks/pr_collector.go
+++ b/backend/plugins/github_graphql/tasks/pr_collector.go
@@ -72,7 +72,7 @@ type GraphqlQueryPr struct {
                        Name string
                }
        } `graphql:"labels(first: 100)"`
-       Author    *GraphqlInlineAccountQuery
+       Author    *GraphqlInlineActorQuery
        Assignees struct {
                // FIXME now domain layer just support one assignee
                Assignees []GraphqlInlineAccountQuery `graphql:"nodes"`
@@ -99,7 +99,7 @@ type GraphqlQueryPr struct {
        } `graphql:"reviews(first: 100)"`
        Additions      int
        Deletions      int
-       MergedBy       *GraphqlInlineAccountQuery
+       MergedBy       *GraphqlInlineActorQuery
        ReviewRequests struct {
                Nodes []ReviewRequestNode `graphql:"nodes"`
        } `graphql:"reviewRequests(first: 10)"`
@@ -128,7 +128,7 @@ type Team struct {
 
 type GraphqlQueryReview struct {
        Body       string
-       Author     *GraphqlInlineAccountQuery
+       Author     *GraphqlInlineActorQuery
        State      string `json:"state"`
        DatabaseId int    `json:"databaseId"`
        Commit     struct {
diff --git a/backend/plugins/github_graphql/tasks/pr_extractor.go 
b/backend/plugins/github_graphql/tasks/pr_extractor.go
index 59bc72cdb..8d6fd863b 100644
--- a/backend/plugins/github_graphql/tasks/pr_extractor.go
+++ b/backend/plugins/github_graphql/tasks/pr_extractor.go
@@ -78,7 +78,7 @@ func ExtractPrs(taskCtx plugin.SubTaskContext) errors.Error {
                        if err != nil {
                                return nil, err
                        }
-                       extractGraphqlPreAccount(&results, rawL.Author, 
data.Options.GithubId, data.Options.ConnectionId)
+                       extractGraphqlPreAccount(&results, 
rawL.Author.Account(), data.Options.GithubId, data.Options.ConnectionId)
                        for _, label := range rawL.Labels.Nodes {
                                results = append(results, &models.GithubPrLabel{
                                        ConnectionId: data.Options.ConnectionId,
@@ -110,10 +110,10 @@ func ExtractPrs(taskCtx plugin.SubTaskContext) 
errors.Error {
                                                PullRequestId: 
githubPr.GithubId,
                                        }
 
-                                       if apiPullRequestReview.Author != nil {
-                                               githubPrReview.AuthorUserId = 
apiPullRequestReview.Author.Id
-                                               githubPrReview.AuthorUsername = 
apiPullRequestReview.Author.Login
-                                               
extractGraphqlPreAccount(&results, apiPullRequestReview.Author, 
data.Options.GithubId, data.Options.ConnectionId)
+                                       if author := 
apiPullRequestReview.Author.Account(); author.Id != 0 {
+                                               githubPrReview.AuthorUserId = 
author.Id
+                                               githubPrReview.AuthorUsername = 
author.Login
+                                               
extractGraphqlPreAccount(&results, author, data.Options.GithubId, 
data.Options.ConnectionId)
                                        }
 
                                        results = append(results, 
githubPrReview)
@@ -145,7 +145,7 @@ func ExtractPrs(taskCtx plugin.SubTaskContext) errors.Error 
{
                                        CommitAuthoredDate: 
githubCommit.AuthoredDate,
                                }
                                results = append(results, 
githubPullRequestCommit)
-                               extractGraphqlPreAccount(&results, 
apiPullRequestCommit.Commit.Author.User, data.Options.GithubId, 
data.Options.ConnectionId)
+                               extractGraphqlPreAccount(&results, 
apiPullRequestCommit.Commit.Author.User.Account(), data.Options.GithubId, 
data.Options.ConnectionId)
                        }
                        return results, nil
                },
@@ -180,16 +180,16 @@ func convertGithubPullRequest(pull *GraphqlQueryPr, 
connId uint64, repoId int) (
                Deletions:       pull.Deletions,
                IsDraft:         pull.IsDraft,
        }
-       if pull.MergedBy != nil {
-               githubPull.MergedByName = pull.MergedBy.Login
-               githubPull.MergedById = pull.MergedBy.Id
+       if mergedBy := pull.MergedBy.Account(); mergedBy.Id != 0 {
+               githubPull.MergedByName = mergedBy.Login
+               githubPull.MergedById = mergedBy.Id
        }
        if pull.MergeCommit != nil {
                githubPull.MergeCommitSha = pull.MergeCommit.Oid
        }
-       if pull.Author != nil {
-               githubPull.AuthorName = pull.Author.Login
-               githubPull.AuthorId = pull.Author.Id
+       if author := pull.Author.Account(); author.Id != 0 {
+               githubPull.AuthorName = author.Login
+               githubPull.AuthorId = author.Id
        }
        return githubPull, nil
 }
@@ -206,8 +206,8 @@ func convertPullRequestCommit(prCommit GraphqlQueryCommit) 
(*models.GithubCommit
                CommittedDate:  prCommit.Commit.Committer.Date,
                Url:            prCommit.Url,
        }
-       if prCommit.Commit.Author.User != nil {
-               githubCommit.AuthorId = prCommit.Commit.Author.User.Id
+       if user := prCommit.Commit.Author.User.Account(); user.Id != 0 {
+               githubCommit.AuthorId = user.Id
        }
        return githubCommit, nil
 }

Reply via email to