Skip to content

Commit 5cddb5b

Browse files
committed
Refactor Copilot assignment to use GraphQL and OAuth
Updated the assignCopilot method to use the user-to-server OAuth token and GitHub GraphQL API for assigning the Copilot agent to issues. Improved error handling, added detailed logging, and ensured compatibility with the Copilot bot assignment process. Also added input validation for installationId in relevant methods.
1 parent c66c159 commit 5cddb5b

File tree

1 file changed

+202
-28
lines changed

1 file changed

+202
-28
lines changed

src/integrations/github/service.ts

Lines changed: 202 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ export class GitHubService {
280280
/**
281281
* Get installation access token
282282
*/
283+
if (!installationId) {
284+
throw new Error('installationId is required for getting repositories');
285+
}
286+
283287
const accessToken = await this.createInstallationToken(installationId);
284288

285289
/**
@@ -351,17 +355,17 @@ export class GitHubService {
351355
}
352356

353357
/**
354-
* Create a GitHub issue
358+
* Create a GitHub issue using GitHub App installation token
355359
*
356360
* @param {string} repoFullName - Repository full name (owner/repo)
357-
* @param {string} installationId - GitHub App installation ID
361+
* @param {string | null} installationId - GitHub App installation ID
358362
* @param {IssueData} issueData - Issue data (title, body, labels)
359363
* @returns {Promise<GitHubIssue>} Created issue
360364
* @throws {Error} If issue creation fails
361365
*/
362366
public async createIssue(
363367
repoFullName: string,
364-
installationId: string,
368+
installationId: string | null,
365369
issueData: IssueData
366370
): Promise<GitHubIssue> {
367371
const [owner, repo] = repoFullName.split('/');
@@ -371,15 +375,22 @@ export class GitHubService {
371375
}
372376

373377
/**
374-
* Get installation access token
378+
* Get installation access token (GitHub App token)
375379
*/
380+
if (!installationId) {
381+
throw new Error('installationId is required for creating GitHub issues');
382+
}
383+
376384
const accessToken = await this.createInstallationToken(installationId);
377385

378386
/**
379-
* Create Octokit instance with installation access token and configured timeout
387+
* Create Octokit instance with installation token and configured timeout
380388
*/
381389
const octokit = this.createOctokit(accessToken);
382390

391+
/**
392+
* Create issue via REST API using installation token
393+
*/
383394
try {
384395
const { data } = await octokit.rest.issues.create({
385396
owner,
@@ -401,44 +412,207 @@ export class GitHubService {
401412
}
402413

403414
/**
404-
* Assign GitHub Copilot to an issue
415+
* Assign Copilot agent to a GitHub issue using user-to-server OAuth token
405416
*
406-
* @param {string} owner - Repository owner
407-
* @param {string} repo - Repository name
417+
* @param {string} repoFullName - Repository full name (owner/repo)
408418
* @param {number} issueNumber - Issue number
409-
* @param {string} installationId - GitHub App installation ID
410-
* @returns {Promise<boolean>} True if assignment was successful
411-
* @throws {Error} If assignment fails
419+
* @param {string} delegatedUserToken - User-to-server OAuth token
420+
* @returns {Promise<void>}
421+
* @throws {Error} If Copilot assignment fails
412422
*/
413423
public async assignCopilot(
414-
owner: string,
415-
repo: string,
424+
repoFullName: string,
416425
issueNumber: number,
417-
installationId: string
418-
): Promise<boolean> {
419-
/**
420-
* Get installation access token
421-
*/
422-
const accessToken = await this.createInstallationToken(installationId);
426+
delegatedUserToken: string
427+
): Promise<void> {
428+
const [owner, repo] = repoFullName.split('/');
429+
430+
if (!owner || !repo) {
431+
throw new Error(`Invalid repository name format: ${repoFullName}. Expected format: owner/repo`);
432+
}
423433

424434
/**
425-
* Create Octokit instance with installation access token and configured timeout
435+
* Create Octokit instance with user-to-server OAuth token
426436
*/
427-
const octokit = this.createOctokit(accessToken);
437+
const octokit = this.createOctokit(delegatedUserToken);
428438

429439
try {
430440
/**
431-
* Assign GitHub Copilot coding agent (copilot-swe-agent[bot]) as assignee
432-
* According to GitHub docs: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr
441+
* Step 1: Get repository ID and find Copilot bot ID
433442
*/
434-
await octokit.rest.issues.addAssignees({
443+
const repoInfoQuery = `
444+
query($owner: String!, $name: String!) {
445+
repository(owner: $owner, name: $name) {
446+
id
447+
issue(number: ${issueNumber}) {
448+
id
449+
}
450+
suggestedActors(capabilities: [CAN_BE_ASSIGNED], first: 100) {
451+
nodes {
452+
login
453+
__typename
454+
... on Bot {
455+
id
456+
}
457+
... on User {
458+
id
459+
}
460+
}
461+
}
462+
}
463+
}
464+
`;
465+
466+
const repoInfo: any = await octokit.graphql(repoInfoQuery, {
435467
owner,
436-
repo,
437-
issue_number: issueNumber,
438-
assignees: ['copilot-swe-agent[bot]'],
468+
name: repo,
439469
});
440470

441-
return true;
471+
console.log('[GitHub API] Repository info query response:', JSON.stringify(repoInfo, null, 2));
472+
473+
const repositoryId = repoInfo?.repository?.id;
474+
const issueId = repoInfo?.repository?.issue?.id;
475+
476+
if (!repositoryId) {
477+
throw new Error(`Failed to get repository ID for ${repoFullName}`);
478+
}
479+
480+
if (!issueId) {
481+
throw new Error(`Failed to get issue ID for issue #${issueNumber}`);
482+
}
483+
484+
/**
485+
* Find Copilot bot in suggested actors
486+
*/
487+
let copilotBot = repoInfo.repository.suggestedActors.nodes.find(
488+
(node: any) => node.login === 'copilot-swe-agent'
489+
);
490+
491+
console.log('[GitHub API] Copilot bot found in suggestedActors:', copilotBot ? { login: copilotBot.login, id: copilotBot.id } : 'not found');
492+
493+
/**
494+
* If not found in suggestedActors, try to get it directly by login
495+
*/
496+
if (!copilotBot || !copilotBot.id) {
497+
console.log('[GitHub API] Trying to get Copilot bot directly by login...');
498+
499+
try {
500+
const copilotBotQuery = `
501+
query($login: String!) {
502+
user(login: $login) {
503+
id
504+
login
505+
__typename
506+
}
507+
}
508+
`;
509+
510+
const copilotUserInfo: any = await octokit.graphql(copilotBotQuery, {
511+
login: 'copilot-swe-agent',
512+
});
513+
514+
console.log('[GitHub API] Direct Copilot bot query response:', JSON.stringify(copilotUserInfo, null, 2));
515+
516+
if (copilotUserInfo?.user?.id) {
517+
copilotBot = {
518+
login: copilotUserInfo.user.login,
519+
id: copilotUserInfo.user.id,
520+
};
521+
}
522+
} catch (directQueryError) {
523+
console.log('[GitHub API] Failed to get Copilot bot directly:', directQueryError);
524+
}
525+
}
526+
527+
if (!copilotBot || !copilotBot.id) {
528+
throw new Error('Copilot coding agent (copilot-swe-agent) is not available for this repository');
529+
}
530+
531+
console.log('[GitHub API] Using Copilot bot:', { login: copilotBot.login, id: copilotBot.id });
532+
533+
/**
534+
* Step 2: Assign Copilot to issue via GraphQL
535+
* Note: Assignable is a union type (Issue | PullRequest), so we need to use fragments
536+
*/
537+
const assignCopilotMutation = `
538+
mutation($issueId: ID!, $assigneeIds: [ID!]!) {
539+
addAssigneesToAssignable(input: {
540+
assignableId: $issueId
541+
assigneeIds: $assigneeIds
542+
}) {
543+
assignable {
544+
... on Issue {
545+
id
546+
number
547+
assignees(first: 10) {
548+
nodes {
549+
login
550+
}
551+
}
552+
}
553+
... on PullRequest {
554+
id
555+
number
556+
assignees(first: 10) {
557+
nodes {
558+
login
559+
}
560+
}
561+
}
562+
}
563+
}
564+
}
565+
`;
566+
567+
const response: any = await octokit.graphql(assignCopilotMutation, {
568+
issueId,
569+
assigneeIds: [copilotBot.id],
570+
});
571+
572+
console.log('[GitHub API] Assign Copilot mutation response:', JSON.stringify(response, null, 2));
573+
574+
const assignable = response?.addAssigneesToAssignable?.assignable;
575+
576+
if (!assignable) {
577+
throw new Error('Failed to assign Copilot to issue');
578+
}
579+
580+
/**
581+
* Assignable is a union type (Issue | PullRequest), so we need to check which type it is
582+
* Both Issue and PullRequest have assignees field, so we can access it directly
583+
*
584+
* Note: The assignees list might not be immediately updated in the response,
585+
* so we check if the mutation succeeded (assignable is not null) rather than
586+
* verifying the assignees list directly
587+
*/
588+
const assignedLogins = assignable.assignees?.nodes?.map((n: any) => n.login) || [];
589+
590+
/**
591+
* Log assignees for debugging (but don't fail if Copilot is not in the list yet)
592+
* GitHub API might not immediately reflect the assignment in the response
593+
*/
594+
console.log(`[GitHub API] Issue assignees after mutation:`, assignedLogins);
595+
596+
/**
597+
* Get issue number from assignable (works for both Issue and PullRequest)
598+
*/
599+
const assignedNumber = assignable.number;
600+
601+
/**
602+
* If Copilot is in the list, log success. Otherwise, just log a warning
603+
* but don't throw an error, as the mutation might have succeeded even if
604+
* the response doesn't show the assignee yet
605+
*/
606+
if (assignedLogins.includes('copilot-swe-agent')) {
607+
console.log(`[GitHub API] Successfully assigned Copilot to issue #${assignedNumber}`);
608+
} else {
609+
/**
610+
* Mutation succeeded (assignable is not null), but assignees list might not be updated yet
611+
* This is a known behavior of GitHub API - the mutation succeeds but the response
612+
* might not immediately reflect the new assignee
613+
*/
614+
console.log(`[GitHub API] Copilot assignment mutation completed for issue #${assignedNumber}, but assignees list not yet updated in response`);
615+
}
442616
} catch (error) {
443617
throw new Error(`Failed to assign Copilot: ${error instanceof Error ? error.message : String(error)}`);
444618
}

0 commit comments

Comments
 (0)