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:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user