From e1cfb917b4f3e531c4c6f25a4ff3c78c36ae39eb Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 14 Jan 2026 13:49:58 +0100 Subject: [PATCH 1/6] feat: poll for linked PR after assigning Copilot to issue Enhances the assign_copilot_to_issue tool to automatically poll for the PR created by the Copilot coding agent after assignment. Changes: - Add findLinkedCopilotPR() to query issue timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent - Add polling loop (9 attempts, 1s delay) matching remote server latency - Return structured JSON with PR details when found, or helpful note otherwise - Add PollConfig for configurable polling (used in tests to disable) - Add GraphQLFeaturesTransport for feature flag header support The returned response now includes: - issue_number, issue_url, owner, repo - pull_request object (if found during polling) - Note with instructions to use get_copilot_job_status if PR not yet created --- pkg/github/issues.go | 141 +++++++++++++++++++++++++++++++- pkg/github/issues_test.go | 17 +++- pkg/github/transport.go | 43 ++++++++++ pkg/github/transport_test.go | 151 +++++++++++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 pkg/github/transport.go create mode 100644 pkg/github/transport_test.go diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 63174c9e9..b0b158255 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1609,6 +1609,97 @@ func (d *mvpDescription) String() string { return sb.String() } +// linkedPullRequest represents a PR linked to an issue by Copilot. +type linkedPullRequest struct { + Number int + URL string + Title string + State string +} + +// pollConfigKey is a context key for polling configuration. +type pollConfigKey struct{} + +// PollConfig configures the PR polling behavior. +type PollConfig struct { + MaxAttempts int + Delay time.Duration +} + +// ContextWithPollConfig returns a context with polling configuration. +// Use this in tests to reduce or disable polling. +func ContextWithPollConfig(ctx context.Context, config PollConfig) context.Context { + return context.WithValue(ctx, pollConfigKey{}, config) +} + +// getPollConfig returns the polling configuration from context, or defaults. +func getPollConfig(ctx context.Context) PollConfig { + if config, ok := ctx.Value(pollConfigKey{}).(PollConfig); ok { + return config + } + // Default: 9 attempts with 1s delay = 8s max wait + // Based on observed latency in remote server: p50 ~5s, p90 ~7s + return PollConfig{MaxAttempts: 9, Delay: 1 * time.Second} +} + +// findLinkedCopilotPR searches for a PR created by the copilot-swe-agent bot that references the given issue. +// It queries the issue's timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent. +func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int) (*linkedPullRequest, error) { + // Query timeline items looking for CrossReferencedEvent from PRs by copilot-swe-agent + var query struct { + Repository struct { + Issue struct { + TimelineItems struct { + Nodes []struct { + TypeName string `graphql:"__typename"` + CrossReferencedEvent struct { + Source struct { + PullRequest struct { + Number int + URL string + Title string + State string + Author struct { + Login string + } + } `graphql:"... on PullRequest"` + } + } `graphql:"... on CrossReferencedEvent"` + } + } `graphql:"timelineItems(first: 20, itemTypes: [CROSS_REFERENCED_EVENT])"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "number": githubv4.Int(issueNumber), //nolint:gosec // Issue numbers are always small positive integers + } + + if err := client.Query(ctx, &query, variables); err != nil { + return nil, err + } + + // Look for a PR from copilot-swe-agent + for _, node := range query.Repository.Issue.TimelineItems.Nodes { + if node.TypeName != "CrossReferencedEvent" { + continue + } + pr := node.CrossReferencedEvent.Source.PullRequest + if pr.Number > 0 && pr.Author.Login == "copilot-swe-agent" { + return &linkedPullRequest{ + Number: pr.Number, + URL: pr.URL, + Title: pr.Title, + State: pr.State, + }, nil + } + } + + return nil, nil +} + func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.ServerTool { description := mvpDescription{ summary: "Assign Copilot to a specific issue in a GitHub repository.", @@ -1804,7 +1895,55 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) } - return utils.NewToolResultText("successfully assigned copilot to issue"), nil, nil + // Poll for a linked PR created by Copilot + pollConfig := getPollConfig(ctx) + + var linkedPR *linkedPullRequest + for attempt := range pollConfig.MaxAttempts { + if attempt > 0 { + time.Sleep(pollConfig.Delay) + } + + pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber)) + if err != nil { + // Log but don't fail - polling errors are non-fatal + continue + } + if pr != nil { + linkedPR = pr + break + } + } + + // Build the result + result := map[string]any{ + "message": "successfully assigned copilot to issue", + "issue_number": updateIssueMutation.UpdateIssue.Issue.Number, + "issue_url": updateIssueMutation.UpdateIssue.Issue.URL, + "owner": params.Owner, + "repo": params.Repo, + } + + // Add PR info if found during polling + if linkedPR != nil { + result["pull_request"] = map[string]any{ + "number": linkedPR.Number, + "url": linkedPR.URL, + "title": linkedPR.Title, + "state": linkedPR.State, + } + result["message"] = "successfully assigned copilot to issue - pull request created" + } else { + result["message"] = "successfully assigned copilot to issue - pull request pending" + result["note"] = "The pull request may still be in progress. Use get_copilot_job_status with the pull request number once created, or check the issue timeline for updates." + } + + r, err := json.Marshal(result) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil + } + + return utils.NewToolResultText(string(r)), result, nil }) } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 21e78874a..cb3950d5a 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2654,8 +2654,12 @@ func TestAssignCopilotToIssue(t *testing.T) { // Create call request request := createMCPRequest(tc.requestArgs) + // Disable polling in tests to avoid timeouts + ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0}) + ctx = ContextWithDeps(ctx, deps) + // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) + result, err := handler(ctx, &request) require.NoError(t, err) textContent := getTextResult(t, result) @@ -2667,7 +2671,16 @@ func TestAssignCopilotToIssue(t *testing.T) { } require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) - require.Equal(t, textContent.Text, "successfully assigned copilot to issue") + + // Verify the JSON response contains expected fields + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err, "response should be valid JSON") + assert.Equal(t, float64(123), response["issue_number"]) + assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"]) + assert.Equal(t, "owner", response["owner"]) + assert.Equal(t, "repo", response["repo"]) + assert.Contains(t, response["message"], "successfully assigned copilot to issue") }) } } diff --git a/pkg/github/transport.go b/pkg/github/transport.go new file mode 100644 index 000000000..79f100f58 --- /dev/null +++ b/pkg/github/transport.go @@ -0,0 +1,43 @@ +package github + +import ( + "net/http" + "strings" +) + +// GraphQLFeaturesTransport is an http.RoundTripper that adds GraphQL-Features +// header to requests based on context values. This is required for using +// non-GA GraphQL API features like the agent assignment API. +// +// Usage: +// +// httpClient := &http.Client{ +// Transport: &github.GraphQLFeaturesTransport{ +// Transport: http.DefaultTransport, +// }, +// } +// gqlClient := githubv4.NewClient(httpClient) +// +// Then use withGraphQLFeatures(ctx, "feature_name") when calling GraphQL operations. +type GraphQLFeaturesTransport struct { + // Transport is the underlying HTTP transport. If nil, http.DefaultTransport is used. + Transport http.RoundTripper +} + +// RoundTrip implements http.RoundTripper. +func (t *GraphQLFeaturesTransport) RoundTrip(req *http.Request) (*http.Response, error) { + transport := t.Transport + if transport == nil { + transport = http.DefaultTransport + } + + // Clone the request to avoid mutating the original + req = req.Clone(req.Context()) + + // Check for GraphQL-Features in context and add header if present + if features := GetGraphQLFeatures(req.Context()); len(features) > 0 { + req.Header.Set("GraphQL-Features", strings.Join(features, ", ")) + } + + return transport.RoundTrip(req) +} diff --git a/pkg/github/transport_test.go b/pkg/github/transport_test.go new file mode 100644 index 000000000..c98108255 --- /dev/null +++ b/pkg/github/transport_test.go @@ -0,0 +1,151 @@ +package github + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGraphQLFeaturesTransport(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + features []string + expectedHeader string + hasHeader bool + }{ + { + name: "no features in context", + features: nil, + expectedHeader: "", + hasHeader: false, + }, + { + name: "single feature in context", + features: []string{"issues_copilot_assignment_api_support"}, + expectedHeader: "issues_copilot_assignment_api_support", + hasHeader: true, + }, + { + name: "multiple features in context", + features: []string{"feature1", "feature2", "feature3"}, + expectedHeader: "feature1, feature2, feature3", + hasHeader: true, + }, + { + name: "empty features slice", + features: []string{}, + expectedHeader: "", + hasHeader: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var capturedHeader string + var headerExists bool + + // Create a test server that captures the request header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Get("GraphQL-Features") + headerExists = r.Header.Get("GraphQL-Features") != "" + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport + transport := &GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + } + + // Create a request + ctx := context.Background() + if tc.features != nil { + ctx = withGraphQLFeatures(ctx, tc.features...) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the header + assert.Equal(t, tc.hasHeader, headerExists) + if tc.hasHeader { + assert.Equal(t, tc.expectedHeader, capturedHeader) + } + }) + } +} + +func TestGraphQLFeaturesTransport_NilTransport(t *testing.T) { + t.Parallel() + + var capturedHeader string + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Get("GraphQL-Features") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport with nil Transport (should use DefaultTransport) + transport := &GraphQLFeaturesTransport{ + Transport: nil, + } + + // Create a request with features + ctx := withGraphQLFeatures(context.Background(), "test_feature") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the header was added + assert.Equal(t, "test_feature", capturedHeader) +} + +func TestGraphQLFeaturesTransport_DoesNotMutateOriginalRequest(t *testing.T) { + t.Parallel() + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport + transport := &GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + } + + // Create a request with features + ctx := withGraphQLFeatures(context.Background(), "test_feature") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + // Store the original header value + originalHeader := req.Header.Get("GraphQL-Features") + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the original request was not mutated + assert.Equal(t, originalHeader, req.Header.Get("GraphQL-Features")) +} From b23784f18ff3d52aecc4ec9b1e753ec66de91571 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 14 Jan 2026 14:03:42 +0100 Subject: [PATCH 2/6] fix: filter PRs by timestamp to avoid returning stale results When polling for a linked PR after assigning Copilot to an issue, we now capture the assignment time before the mutation and filter to only return PRs created after that time. This prevents the tool from incorrectly returning old PRs from previous Copilot assignments. --- pkg/github/issues.go | 48 ++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index b0b158255..4f412966e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1611,10 +1611,11 @@ func (d *mvpDescription) String() string { // linkedPullRequest represents a PR linked to an issue by Copilot. type linkedPullRequest struct { - Number int - URL string - Title string - State string + Number int + URL string + Title string + State string + CreatedAt time.Time } // pollConfigKey is a context key for polling configuration. @@ -1644,7 +1645,8 @@ func getPollConfig(ctx context.Context) PollConfig { // findLinkedCopilotPR searches for a PR created by the copilot-swe-agent bot that references the given issue. // It queries the issue's timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent. -func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int) (*linkedPullRequest, error) { +// The createdAfter parameter filters to only return PRs created after the specified time. +func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, createdAfter time.Time) (*linkedPullRequest, error) { // Query timeline items looking for CrossReferencedEvent from PRs by copilot-swe-agent var query struct { Repository struct { @@ -1655,11 +1657,12 @@ func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, re CrossReferencedEvent struct { Source struct { PullRequest struct { - Number int - URL string - Title string - State string - Author struct { + Number int + URL string + Title string + State string + CreatedAt githubv4.DateTime + Author struct { Login string } } `graphql:"... on PullRequest"` @@ -1681,19 +1684,23 @@ func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, re return nil, err } - // Look for a PR from copilot-swe-agent + // Look for a PR from copilot-swe-agent created after the assignment time for _, node := range query.Repository.Issue.TimelineItems.Nodes { if node.TypeName != "CrossReferencedEvent" { continue } pr := node.CrossReferencedEvent.Source.PullRequest if pr.Number > 0 && pr.Author.Login == "copilot-swe-agent" { - return &linkedPullRequest{ - Number: pr.Number, - URL: pr.URL, - Title: pr.Title, - State: pr.State, - }, nil + // Only return PRs created after the assignment time + if pr.CreatedAt.Time.After(createdAfter) { + return &linkedPullRequest{ + Number: pr.Number, + URL: pr.URL, + Title: pr.Title, + State: pr.State, + CreatedAt: pr.CreatedAt.Time, + }, nil + } } } @@ -1882,6 +1889,9 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server // The header will be read by the HTTP transport if it's configured to do so ctxWithFeatures := withGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") + // Capture the time before assignment to filter out older PRs during polling + assignmentTime := time.Now().UTC() + if err := client.Mutate( ctxWithFeatures, &updateIssueMutation, @@ -1895,7 +1905,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) } - // Poll for a linked PR created by Copilot + // Poll for a linked PR created by Copilot after the assignment pollConfig := getPollConfig(ctx) var linkedPR *linkedPullRequest @@ -1904,7 +1914,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server time.Sleep(pollConfig.Delay) } - pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber)) + pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) if err != nil { // Log but don't fail - polling errors are non-fatal continue From cfdc6a13568c1a921018fea048a3076435502c5b Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 14 Jan 2026 14:08:41 +0100 Subject: [PATCH 3/6] fix: remove tool name reference from pending note message --- pkg/github/issues.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 4f412966e..a8bd546d7 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1945,7 +1945,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server result["message"] = "successfully assigned copilot to issue - pull request created" } else { result["message"] = "successfully assigned copilot to issue - pull request pending" - result["note"] = "The pull request may still be in progress. Use get_copilot_job_status with the pull request number once created, or check the issue timeline for updates." + result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates." } r, err := json.Marshal(result) From 6cfa8ce485303dce4b6a06f8caf380a4b1bb0641 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 14 Jan 2026 14:12:59 +0100 Subject: [PATCH 4/6] fix: address review feedback - Document GraphQLFeaturesTransport is for library consumers - Convert githubv4.Int/String to native Go types in result map - Remove misleading log comment since tool handlers lack logger access --- pkg/github/issues.go | 6 +++--- pkg/github/transport.go | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index a8bd546d7..f3035b20a 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1916,7 +1916,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) if err != nil { - // Log but don't fail - polling errors are non-fatal + // Polling errors are non-fatal, continue to next attempt continue } if pr != nil { @@ -1928,8 +1928,8 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server // Build the result result := map[string]any{ "message": "successfully assigned copilot to issue", - "issue_number": updateIssueMutation.UpdateIssue.Issue.Number, - "issue_url": updateIssueMutation.UpdateIssue.Issue.URL, + "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number), + "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL), "owner": params.Owner, "repo": params.Repo, } diff --git a/pkg/github/transport.go b/pkg/github/transport.go index 79f100f58..afe9eaeba 100644 --- a/pkg/github/transport.go +++ b/pkg/github/transport.go @@ -9,6 +9,10 @@ import ( // header to requests based on context values. This is required for using // non-GA GraphQL API features like the agent assignment API. // +// This transport is exported for use by library consumers who need to build +// their own HTTP clients with GraphQL feature flag support. The MCP server +// itself uses an inline implementation in its transport stack. +// // Usage: // // httpClient := &http.Client{ From 40849b120525743d98fdb8beac7da9740d5bd17d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 14 Jan 2026 14:24:36 +0100 Subject: [PATCH 5/6] refactor: use GraphQLFeaturesTransport internally Replace inline GraphQL-Features header logic in bearerAuthTransport with the exported GraphQLFeaturesTransport. This removes code duplication and ensures the transport is actually used, not just exported. --- internal/ghmcp/server.go | 12 ++++-------- pkg/github/transport.go | 6 +++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 250f6b4cc..80b7b798d 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -96,8 +96,10 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, // We use NewEnterpriseClient unconditionally since we already parsed the API host gqlHTTPClient := &http.Client{ Transport: &bearerAuthTransport{ - transport: http.DefaultTransport, - token: cfg.Token, + transport: &github.GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + }, + token: cfg.Token, }, } gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) @@ -622,12 +624,6 @@ type bearerAuthTransport struct { func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.Header.Set("Authorization", "Bearer "+t.token) - - // Check for GraphQL-Features in context and add header if present - if features := github.GetGraphQLFeatures(req.Context()); len(features) > 0 { - req.Header.Set("GraphQL-Features", strings.Join(features, ", ")) - } - return t.transport.RoundTrip(req) } diff --git a/pkg/github/transport.go b/pkg/github/transport.go index afe9eaeba..0a4372b23 100644 --- a/pkg/github/transport.go +++ b/pkg/github/transport.go @@ -9,9 +9,9 @@ import ( // header to requests based on context values. This is required for using // non-GA GraphQL API features like the agent assignment API. // -// This transport is exported for use by library consumers who need to build -// their own HTTP clients with GraphQL feature flag support. The MCP server -// itself uses an inline implementation in its transport stack. +// This transport is used internally by the MCP server and is also exported +// for library consumers who need to build their own HTTP clients with +// GraphQL feature flag support. // // Usage: // From 3bd664538c214c6adbab662c0ed36c3ee59afc40 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 16 Jan 2026 10:52:10 +0100 Subject: [PATCH 6/6] feat: add progress notifications during PR polling in assign_copilot_to_issue Add MCP progress notifications during the PR polling loop to provide real-time status updates while waiting for Copilot to create a PR. Changes: - Use the request parameter to access the ServerSession for notifications - Send an initial progress notification when polling starts - Send progress updates on each polling attempt with attempt count - Only send notifications when progressToken is provided by the client This aligns with the behavior in create_pull_request_with_copilot tool and improves the user experience during the waiting period. --- pkg/github/issues.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index ce3f138df..62e1a0bac 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1757,7 +1757,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server }, }, []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string `mapstructure:"owner"` Repo string `mapstructure:"repo"` @@ -1919,12 +1919,35 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server // Poll for a linked PR created by Copilot after the assignment pollConfig := getPollConfig(ctx) + // Get progress token from request for sending progress notifications + progressToken := request.Params.GetProgressToken() + + // Send initial progress notification that assignment succeeded and polling is starting + if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: 0, + Total: float64(pollConfig.MaxAttempts), + Message: "Copilot assigned to issue, waiting for PR creation...", + }) + } + var linkedPR *linkedPullRequest for attempt := range pollConfig.MaxAttempts { if attempt > 0 { time.Sleep(pollConfig.Delay) } + // Send progress notification if progress token is available + if progressToken != nil && request.Session != nil { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: float64(attempt + 1), + Total: float64(pollConfig.MaxAttempts), + Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts), + }) + } + pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) if err != nil { // Polling errors are non-fatal, continue to next attempt