feat: add cross-integration entity references (#1862)

* feat: add cross-integration entity references

* fix(web): anchor entity reference menu to composer

* fix: keep Kandev tasks under @ mentions

* fix: address entity reference review findings

* test(e2e): use option role for prompt menu

* test(e2e): update passthrough mention roles

* fix: preserve queued references and paginate Azure PRs
This commit is contained in:
José Almeida
2026-07-23 09:03:56 +01:00
committed by GitHub
parent 67507c9273
commit 7de788ceed
171 changed files with 15719 additions and 507 deletions
@@ -403,7 +403,7 @@ func isNewerCheck(a, b CheckRun) bool {
// non-empty pull_request.merged_at; promote those to the "merged" state so the
// UI renders the purple merged icon instead of the red closed one.
func convertSearchItemToPR(
number int, title, htmlURL, state, authorLogin, repositoryURL, mergedAt string,
id int64, nodeID string, number int, title, htmlURL, state, authorLogin, repositoryURL, mergedAt string,
draft bool, createdAt, updatedAt time.Time,
) *PR {
owner, repo := parseRepoURL(repositoryURL)
@@ -411,6 +411,8 @@ func convertSearchItemToPR(
state = prStateMerged
}
return &PR{
ID: id,
NodeID: nodeID,
Number: number,
Title: title,
HTMLURL: htmlURL,
@@ -482,6 +484,8 @@ func buildSearchQuery(typeQualifier, filter, customQuery string) string {
// issueSearchItem is an issue item from the GitHub search API.
type issueSearchItem struct {
ID int64 `json:"id"`
NodeID string `json:"node_id"`
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
@@ -517,6 +521,8 @@ func convertSearchItemToIssue(item issueSearchItem) *Issue {
assignees[i] = a.Login
}
issue := &Issue{
ID: item.ID,
NodeID: item.NodeID,
Number: item.Number,
Title: item.Title,
Body: item.Body,
@@ -321,6 +321,7 @@ func TestIsNewerCheck(t *testing.T) {
func TestConvertSearchItemToPR(t *testing.T) {
now := time.Now()
pr := convertSearchItemToPR(
101, "PR_kwDOA",
42, "Fix bug", "https://github.com/owner/repo/pull/42", "open",
"alice", "https://api.github.com/repos/myorg/myrepo", "", false,
now, now,
@@ -329,6 +330,9 @@ func TestConvertSearchItemToPR(t *testing.T) {
if pr.Number != 42 {
t.Errorf("expected number 42, got %d", pr.Number)
}
if pr.ID != 101 || pr.NodeID != "PR_kwDOA" {
t.Errorf("identity = (%d, %q), want (101, PR_kwDOA)", pr.ID, pr.NodeID)
}
if pr.Title != "Fix bug" {
t.Errorf("expected title 'Fix bug', got %s", pr.Title)
}
@@ -350,6 +354,7 @@ func TestConvertSearchItemToPRMerged(t *testing.T) {
now := time.Now()
mergedAt := "2025-01-02T03:04:05Z"
pr := convertSearchItemToPR(
102, "PR_kwDOB",
7, "Land feature", "https://github.com/owner/repo/pull/7", "closed",
"bob", "https://api.github.com/repos/myorg/myrepo", mergedAt, false,
now, now,
@@ -1130,6 +1130,8 @@ func (c *GHClient) parsePRList(data string, owner, repo string) ([]*PR, error) {
// ghSearchItem is a PR item from the GitHub search API.
type ghSearchItem struct {
ID int64 `json:"id"`
NodeID string `json:"node_id"`
Number int `json:"number"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
@@ -1155,6 +1157,7 @@ func (c *GHClient) parseSearchResults(data string) ([]*PR, error) {
prs := make([]*PR, len(items))
for i, item := range items {
prs[i] = convertSearchItemToPR(
item.ID, item.NodeID,
item.Number, item.Title, item.HTMLURL, item.State,
item.User.Login, item.RepositoryURL, item.PullRequest.MergedAt,
item.Draft, item.CreatedAt, item.UpdatedAt,
@@ -127,6 +127,24 @@ func TestParseTimePtrValue(t *testing.T) {
}
}
func TestGHSearchParsingPreservesImmutableIdentity(t *testing.T) {
prs, err := (&GHClient{}).parseSearchResults(`[{"id":202,"node_id":"PR_kwDOB","number":8,"title":"Fix auth","html_url":"https://github.com/acme/web/pull/8","state":"open","repository_url":"https://api.github.com/repos/acme/web","pull_request":{}}]`)
if err != nil {
t.Fatalf("parse search results: %v", err)
}
if len(prs) != 1 || prs[0].ID != 202 || prs[0].NodeID != "PR_kwDOB" {
t.Fatalf("PRs = %#v", prs)
}
issues := parseIssueSearchResults([]issueSearchItem{{
ID: 303, NodeID: "I_kwDOC", Number: 9, Title: "Broken login",
RepositoryURL: "https://api.github.com/repos/acme/web",
}})
if len(issues) != 1 || issues[0].ID != 303 || issues[0].NodeID != "I_kwDOC" {
t.Fatalf("issues = %#v", issues)
}
}
func TestGHClient_ListCheckRuns_PaginatesCheckRuns(t *testing.T) {
binDir := t.TempDir()
logPath := filepath.Join(t.TempDir(), "gh-args.log")
+4
View File
@@ -12,6 +12,8 @@ const TaskCIAutoFixMaxRounds = 10
// PR represents a GitHub Pull Request.
type PR struct {
ID int64 `json:"id"`
NodeID string `json:"node_id"`
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
@@ -549,6 +551,8 @@ type DailyCount struct {
// Issue represents a GitHub Issue (not a PR).
type Issue struct {
ID int64 `json:"id"`
NodeID string `json:"node_id"`
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
@@ -222,6 +222,7 @@ func (c *PATClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, c
prs := make([]*PR, len(result.Items))
for i, item := range result.Items {
prs[i] = convertSearchItemToPR(
item.ID, item.NodeID,
item.Number, item.Title, item.HTMLURL, item.State,
item.User.Login, item.RepositoryURL, item.PullRequest.MergedAt,
item.Draft, item.CreatedAt, item.UpdatedAt,
@@ -253,6 +254,7 @@ func (c *PATClient) SearchPRsPaged(ctx context.Context, filter, customQuery stri
prs := make([]*PR, len(result.Items))
for i, item := range result.Items {
prs[i] = convertSearchItemToPR(
item.ID, item.NodeID,
item.Number, item.Title, item.HTMLURL, item.State,
item.User.Login, item.RepositoryURL, item.PullRequest.MergedAt,
item.Draft, item.CreatedAt, item.UpdatedAt,
@@ -981,6 +983,8 @@ type patIssue struct {
}
type patSearchItem struct {
ID int64 `json:"id"`
NodeID string `json:"node_id"`
Number int `json:"number"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
@@ -108,6 +108,27 @@ func TestConvertPatPR_Merged(t *testing.T) {
}
}
func TestPATClientSearchPreservesImmutableIdentity(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/issues" {
t.Errorf("path = %q, want /search/issues", r.URL.Path)
http.Error(w, "unexpected path", http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"total_count":1,"items":[{"id":101,"node_id":"PR_kwDOA","number":7,"title":"Fix auth","html_url":"https://github.com/acme/web/pull/7","state":"open","repository_url":"https://api.github.com/repos/acme/web","pull_request":{}}]}`))
}))
t.Cleanup(srv.Close)
client := newPATClientPointingAt(t, srv.URL)
page, err := client.SearchPRsPaged(context.Background(), "", "auth", 1, 5)
if err != nil {
t.Fatalf("search PRs: %v", err)
}
if len(page.PRs) != 1 || page.PRs[0].ID != 101 || page.PRs[0].NodeID != "PR_kwDOA" {
t.Fatalf("PRs = %#v", page.PRs)
}
}
func TestPATClient_ListCheckRunsPaginatesCheckRuns(t *testing.T) {
var requestedPages []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -0,0 +1,99 @@
package github
import (
"context"
"errors"
"strings"
)
const (
defaultMentionSearchLimit = 5
maxMentionSearchLimit = 10
)
var (
ErrMentionWorkspaceRequired = errors.New("GitHub mention workspace is required")
ErrMentionQueryRequired = errors.New("GitHub mention query is required")
)
// SearchMentionIssuesForWorkspace translates plain composer text into a
// server-owned, title-only GitHub issue query within one workspace's scope.
func (s *Service) SearchMentionIssuesForWorkspace(
ctx context.Context,
workspaceID, query string,
limit int,
) ([]*Issue, error) {
workspaceID, query, limit, err := normalizeMentionSearch(workspaceID, query, limit)
if err != nil {
return nil, err
}
result, err := s.SearchUserIssuesPagedForWorkspace(
ctx,
workspaceID,
"",
buildMentionTitleQuery("type:issue", query),
1,
limit,
)
if err != nil {
return nil, err
}
if result == nil || result.Issues == nil {
return []*Issue{}, nil
}
return result.Issues, nil
}
// SearchMentionPullRequestsForWorkspace is the pull-request counterpart to
// SearchMentionIssuesForWorkspace.
func (s *Service) SearchMentionPullRequestsForWorkspace(
ctx context.Context,
workspaceID, query string,
limit int,
) ([]*PR, error) {
workspaceID, query, limit, err := normalizeMentionSearch(workspaceID, query, limit)
if err != nil {
return nil, err
}
result, err := s.SearchUserPRsPagedForWorkspace(
ctx,
workspaceID,
"",
buildMentionTitleQuery("type:pr", query),
1,
limit,
)
if err != nil {
return nil, err
}
if result == nil || result.PRs == nil {
return []*PR{}, nil
}
return result.PRs, nil
}
func normalizeMentionSearch(workspaceID, query string, limit int) (string, string, int, error) {
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return "", "", 0, ErrMentionWorkspaceRequired
}
query = strings.TrimSpace(query)
if query == "" {
return "", "", 0, ErrMentionQueryRequired
}
switch {
case limit <= 0:
limit = defaultMentionSearchLimit
case limit > maxMentionSearchLimit:
limit = maxMentionSearchLimit
}
return workspaceID, query, limit, nil
}
// buildMentionTitleQuery keeps the server-owned type qualifier outside the
// quoted user input so qualifier-like title text cannot change result types.
func buildMentionTitleQuery(typeQualifier, query string) string {
query = strings.ReplaceAll(query, `\`, `\\`)
query = strings.ReplaceAll(query, `"`, `\"`)
return typeQualifier + ` in:title "` + query + `"`
}
@@ -0,0 +1,87 @@
package github
import (
"context"
"testing"
)
type mentionSearchClient struct {
*MockClient
issueQuery string
prQuery string
issues []*Issue
prs []*PR
}
func (c *mentionSearchClient) ListIssuesPaged(
_ context.Context,
_, customQuery string,
page, perPage int,
) (*IssueSearchPage, error) {
c.issueQuery = customQuery
return &IssueSearchPage{Issues: c.issues, TotalCount: len(c.issues), Page: page, PerPage: perPage}, nil
}
func (c *mentionSearchClient) SearchPRsPaged(
_ context.Context,
_, customQuery string,
page, perPage int,
) (*PRSearchPage, error) {
c.prQuery = customQuery
return &PRSearchPage{PRs: c.prs, TotalCount: len(c.prs), Page: page, PerPage: perPage}, nil
}
func TestServiceSearchMentionsUsesServerBuiltTitleOnlyQuery(t *testing.T) {
client := &mentionSearchClient{
MockClient: NewMockClient(),
issues: []*Issue{{ID: 101, NodeID: "I_kwDOA", Number: 7, Title: "Fix auth", RepoOwner: "acme", RepoName: "web"}},
prs: []*PR{{ID: 202, NodeID: "PR_kwDOB", Number: 8, Title: "Fix auth flow", RepoOwner: "acme", RepoName: "web"}},
}
service := &Service{client: client, searchCache: newTTLCache()}
issues, err := service.SearchMentionIssuesForWorkspace(
context.Background(),
"workspace-1",
`fix type:pr is:issue repo:foreign/private "auth" \\ flow`,
3,
)
if err != nil {
t.Fatalf("search issue mentions: %v", err)
}
prs, err := service.SearchMentionPullRequestsForWorkspace(
context.Background(),
"workspace-1",
`fix type:pr is:issue repo:foreign/private "auth" \\ flow`,
4,
)
if err != nil {
t.Fatalf("search PR mentions: %v", err)
}
wantIssueQuery := `type:issue in:title "fix type:pr is:issue repo:foreign/private \"auth\" \\\\ flow"`
wantPRQuery := `type:pr in:title "fix type:pr is:issue repo:foreign/private \"auth\" \\\\ flow"`
if client.issueQuery != wantIssueQuery || client.prQuery != wantPRQuery {
t.Fatalf(
"queries = issue %q PR %q, want issue %q PR %q",
client.issueQuery,
client.prQuery,
wantIssueQuery,
wantPRQuery,
)
}
if len(issues) != 1 || issues[0].NodeID != "I_kwDOA" {
t.Fatalf("issues = %#v", issues)
}
if len(prs) != 1 || prs[0].NodeID != "PR_kwDOB" {
t.Fatalf("PRs = %#v", prs)
}
}
func TestServiceSearchMentionsRequiresExplicitWorkspaceAndQuery(t *testing.T) {
service := &Service{client: NewMockClient(), searchCache: newTTLCache()}
if _, err := service.SearchMentionIssuesForWorkspace(context.Background(), "", "auth", 5); err == nil {
t.Fatal("blank workspace was accepted")
}
if _, err := service.SearchMentionPullRequestsForWorkspace(context.Background(), "workspace-1", " ", 5); err == nil {
t.Fatal("blank query was accepted")
}
}