Skip to content

Commit c6dbb7b

Browse files
committed
Refactor GitHub key handling and improve Copilot assignment
Extracted GitHub App private key normalization to a utility for better reliability and CI compatibility. Enhanced Copilot assignment to use the GraphQL API and improved error handling. Refactored task creation flow to increment usage only after successful issue creation, updated dependencies, and fixed import paths.
1 parent 3bfe039 commit c6dbb7b

7 files changed

Lines changed: 409 additions & 75 deletions

File tree

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"test:paymaster": "jest workers/paymaster",
3434
"test:notifier": "jest workers/notifier",
3535
"test:js": "jest workers/javascript",
36+
"test:task-manager": "jest workers/task-manager",
3637
"test:clear": "jest --clearCache",
3738
"run-default": "yarn worker hawk-worker-default",
3839
"run-sentry": "yarn worker hawk-worker-sentry",
@@ -47,7 +48,8 @@
4748
"run-release": "yarn worker hawk-worker-release",
4849
"run-email": "yarn worker hawk-worker-email",
4950
"run-telegram": "yarn worker hawk-worker-telegram",
50-
"run-limiter": "yarn worker hawk-worker-limiter"
51+
"run-limiter": "yarn worker hawk-worker-limiter",
52+
"run-task-manager": "yarn worker hawk-worker-task-manager"
5153
},
5254
"dependencies": {
5355
"@babel/parser": "^7.26.9",
@@ -61,7 +63,7 @@
6163
"amqplib": "^0.8.0",
6264
"codex-accounting-sdk": "codex-team/codex-accounting-sdk",
6365
"debug": "^4.1.1",
64-
"dotenv": "^8.2.0",
66+
"dotenv": "^17.2.3",
6567
"migrate-mongo": "^7.2.1",
6668
"mockdate": "^3.0.2",
6769
"mongodb": "^3.5.7",

workers/task-manager/src/GithubService.ts

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import jwt from 'jsonwebtoken';
22
import { Octokit } from '@octokit/rest';
33
import type { Endpoints } from '@octokit/types';
4+
import { normalizeGitHubPrivateKey } from './utils/githubPrivateKey';
45

56
/**
67
* Type for GitHub Issue creation parameters
@@ -68,20 +69,7 @@ export class GitHubService {
6869
*/
6970
private getPrivateKey(): string {
7071
if (process.env.GITHUB_PRIVATE_KEY) {
71-
/**
72-
* Get private key from environment variable
73-
* Check if the string contains literal \n (backslash followed by n) instead of actual newlines
74-
*/
75-
let privateKey = process.env.GITHUB_PRIVATE_KEY;
76-
77-
if (privateKey.includes('\\n') && !privateKey.includes('\n')) {
78-
/**
79-
* Replace literal \n with actual newlines
80-
*/
81-
privateKey = privateKey.replace(/\\n/g, '\n');
82-
}
83-
84-
return privateKey;
72+
return normalizeGitHubPrivateKey(process.env.GITHUB_PRIVATE_KEY);
8573
}
8674

8775
throw new Error('GITHUB_PRIVATE_KEY must be set');
@@ -191,7 +179,7 @@ export class GitHubService {
191179
}
192180

193181
/**
194-
* Assign GitHub Copilot to an issue
182+
* Assign GitHub Copilot to an issue using GraphQL API
195183
*
196184
* @param {string} repoFullName - Repository full name (owner/repo)
197185
* @param {number} issueNumber - Issue number
@@ -222,13 +210,79 @@ export class GitHubService {
222210

223211
try {
224212
/**
225-
* Assign GitHub Copilot (github-copilot[bot]) as assignee
213+
* Step 1: Get repository ID and find Copilot bot ID
214+
* According to GitHub docs: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr
226215
*/
227-
await octokit.rest.issues.addAssignees({
216+
const repoInfoQuery = `
217+
query($owner: String!, $name: String!) {
218+
repository(owner: $owner, name: $name) {
219+
id
220+
issue(number: ${issueNumber}) {
221+
id
222+
}
223+
suggestedActors(capabilities: [CAN_BE_ASSIGNED], first: 100) {
224+
nodes {
225+
login
226+
__typename
227+
... on Bot {
228+
id
229+
}
230+
}
231+
}
232+
}
233+
}
234+
`;
235+
236+
const repoInfo: any = await octokit.graphql(repoInfoQuery, {
228237
owner,
229-
repo,
230-
issue_number: issueNumber,
231-
assignees: ['github-copilot[bot]'],
238+
name: repo,
239+
});
240+
241+
const repositoryId = repoInfo?.repository?.id;
242+
const issueId = repoInfo?.repository?.issue?.id;
243+
244+
if (!repositoryId || !issueId) {
245+
throw new Error(`Failed to get repository or issue ID for ${repoFullName}#${issueNumber}`);
246+
}
247+
248+
/**
249+
* Find Copilot bot in suggested actors
250+
*/
251+
const copilotBot = repoInfo.repository.suggestedActors.nodes.find(
252+
(node: any) => node.login === 'copilot-swe-agent'
253+
);
254+
255+
if (!copilotBot || !copilotBot.id) {
256+
throw new Error('Copilot coding agent (copilot-swe-agent) is not available for this repository');
257+
}
258+
259+
/**
260+
* Step 2: Assign issue to Copilot using GraphQL mutation
261+
*/
262+
const assignMutation = `
263+
mutation($assignableId: ID!, $actorIds: [ID!]!) {
264+
addAssigneesToAssignable(input: {
265+
assignableId: $assignableId
266+
assigneeIds: $actorIds
267+
}) {
268+
assignable {
269+
... on Issue {
270+
id
271+
number
272+
assignees(first: 10) {
273+
nodes {
274+
login
275+
}
276+
}
277+
}
278+
}
279+
}
280+
}
281+
`;
282+
283+
await octokit.graphql(assignMutation, {
284+
assignableId: issueId,
285+
actorIds: [copilotBot.id],
232286
});
233287

234288
return true;

workers/task-manager/src/index.ts

Lines changed: 87 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,18 @@ export default class TaskManagerWorker extends Worker {
5050
public async start(): Promise<void> {
5151
await this.accountsDb.connect();
5252
await this.eventsDb.connect();
53-
await super.start();
53+
54+
// await super.start();
55+
this.handle({type: 'auto-task-creation'})
5456
}
5557

5658
/**
5759
* Finish everything
5860
*/
5961
public async finish(): Promise<void> {
60-
await super.finish();
6162
await this.accountsDb.close();
6263
await this.eventsDb.close();
64+
await super.finish();
6365
}
6466

6567
/**
@@ -208,60 +210,105 @@ export default class TaskManagerWorker extends Worker {
208210
const eventsToProcess = events.slice(0, remainingBudget);
209211

210212
for (const event of eventsToProcess) {
211-
/**
212-
* Atomically increment usage.autoTasksCreated
213-
*/
214-
const incrementSuccess = await this.incrementAutoTasksCreated(projectId, dayStartUtc);
213+
await this.processEventForAutoTaskCreation({
214+
project,
215+
projectId,
216+
taskManager,
217+
event,
218+
dayStartUtc,
219+
});
220+
}
221+
}
215222

216-
if (!incrementSuccess) {
217-
this.logger.warn(`Failed to increment usage for project ${projectId}, budget may be exhausted`);
223+
/**
224+
* Process a single event for auto task creation
225+
*
226+
* @param params - method params
227+
* @param params.project - project
228+
* @param params.projectId - project id
229+
* @param params.taskManager - task manager config
230+
* @param params.event - grouped event
231+
* @param params.dayStartUtc - day start UTC used for usage increment
232+
*/
233+
private async processEventForAutoTaskCreation(params: {
234+
project: ProjectDBScheme;
235+
projectId: string;
236+
taskManager: ProjectTaskManagerConfig;
237+
event: GroupedEventDBScheme;
238+
dayStartUtc: Date;
239+
}): Promise<void> {
240+
const { project, projectId, taskManager, event, dayStartUtc } = params;
218241

219-
break;
220-
}
242+
/**
243+
* Format Issue data from event
244+
*/
245+
const issueData = formatIssueFromEvent(event, project);
221246

222-
/**
223-
* Format Issue data from event
224-
*/
225-
const issueData = formatIssueFromEvent(event, project);
247+
/**
248+
* Create GitHub Issue
249+
*/
250+
let githubIssue: { number: number; html_url: string } | null = null;
226251

227-
/**
228-
* Create GitHub Issue
229-
*/
230-
const githubIssue = await this.githubService.createIssue(
252+
try {
253+
githubIssue = await this.githubService.createIssue(
231254
taskManager.config.repoFullName,
232255
taskManager.config.installationId,
233256
issueData
234257
);
258+
} catch (error) {
259+
this.logger.error(`Failed to create GitHub issue for event ${event.groupHash} (project ${projectId}):`, error);
235260

236261
/**
237-
* Assign Copilot if enabled
262+
* Do not increment usage and do not save taskManagerItem if issue creation failed
238263
*/
239-
if (taskManager.assignAgent) {
240-
try {
241-
await this.githubService.assignCopilot(
242-
taskManager.config.repoFullName,
243-
githubIssue.number,
244-
taskManager.config.installationId
245-
);
246-
} catch (error) {
247-
/**
248-
* Log error but don't fail the task creation
249-
*/
250-
this.logger.warn(`Failed to assign Copilot to issue #${githubIssue.number}:`, error);
251-
}
252-
}
264+
return;
265+
}
266+
267+
/**
268+
* Atomically increment usage.autoTasksCreated (only after successful issue creation)
269+
*/
270+
const incrementSuccess = await this.incrementAutoTasksCreated(projectId, dayStartUtc);
271+
272+
if (!incrementSuccess) {
273+
this.logger.warn(
274+
`Issue #${githubIssue.number} was created but usage increment failed for project ${projectId} (budget may be exhausted)`
275+
);
253276

254277
/**
255-
* Save taskManagerItem to event
278+
* We still link the created issue to the event to avoid duplicates.
256279
*/
257-
await this.saveTaskManagerItem(projectId, event, githubIssue.number, taskManager, githubIssue.html_url);
280+
}
258281

259-
this.logger.info(`Created task for event ${event.groupHash} in project ${projectId}`, {
260-
issueNumber: githubIssue.number,
261-
issueUrl: githubIssue.html_url,
262-
assignAgent: taskManager.assignAgent,
263-
});
282+
this.logger.verbose(`Project ${projectId} has Copilot assigning ${taskManager.assignAgent ? 'enabled' : 'disabled'}`)
283+
284+
/**
285+
* Assign Copilot if enabled
286+
*/
287+
if (taskManager.assignAgent) {
288+
try {
289+
await this.githubService.assignCopilot(
290+
taskManager.config.repoFullName,
291+
githubIssue.number,
292+
taskManager.config.installationId
293+
);
294+
} catch (error) {
295+
/**
296+
* Log error but don't fail the task creation
297+
*/
298+
this.logger.warn(`Failed to assign Copilot to issue #${githubIssue.number}:`, error);
299+
}
264300
}
301+
302+
/**
303+
* Save taskManagerItem to event
304+
*/
305+
await this.saveTaskManagerItem(projectId, event, githubIssue.number, taskManager, githubIssue.html_url);
306+
307+
this.logger.info(`Created task for event ${event.groupHash} in project ${projectId}`, {
308+
issueNumber: githubIssue.number,
309+
issueUrl: githubIssue.html_url,
310+
assignAgent: taskManager.assignAgent,
311+
});
265312
}
266313

267314
/**
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Normalize and validate GitHub App private key.
3+
*
4+
* @param rawPrivateKey - raw value from env (GITHUB_PRIVATE_KEY)
5+
* @returns PEM-encoded private key string
6+
*/
7+
export function normalizeGitHubPrivateKey(rawPrivateKey: string): string {
8+
/**
9+
* Trim and remove surrounding quotes (dotenv can keep them)
10+
*/
11+
let privateKey = rawPrivateKey.trim();
12+
13+
if (
14+
(privateKey.startsWith('"') && privateKey.endsWith('"'))
15+
|| (privateKey.startsWith('\'') && privateKey.endsWith('\''))
16+
) {
17+
privateKey = privateKey.slice(1, -1);
18+
}
19+
20+
/**
21+
* Support passing base64-encoded private key (common in CI).
22+
* If it doesn't look like a PEM block but looks like base64, decode it.
23+
*/
24+
if (!privateKey.includes('BEGIN') && /^[A-Za-z0-9+/=\s]+$/.test(privateKey) && privateKey.length > 200) {
25+
try {
26+
privateKey = Buffer.from(privateKey, 'base64').toString('utf8');
27+
} catch {
28+
/**
29+
* Keep original value, we'll validate below.
30+
*/
31+
}
32+
}
33+
34+
/**
35+
* Replace literal "\n" sequences with actual newlines.
36+
*/
37+
if (privateKey.includes('\\n') && !privateKey.includes('\n')) {
38+
privateKey = privateKey.replace(/\\n/g, '\n');
39+
}
40+
41+
/**
42+
* Normalize Windows line endings if any.
43+
*/
44+
privateKey = privateKey.replace(/\r\n/g, '\n');
45+
46+
/**
47+
* Basic validation: must be a PEM private key.
48+
*/
49+
if (!privateKey.includes('-----BEGIN') || !privateKey.includes('PRIVATE KEY-----')) {
50+
throw new Error(
51+
'GITHUB_PRIVATE_KEY must be a valid PEM-encoded private key (-----BEGIN ... PRIVATE KEY----- ... -----END ... PRIVATE KEY-----)'
52+
);
53+
}
54+
55+
return privateKey;
56+
}
57+

workers/task-manager/src/utils/issue.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { GroupedEventDBScheme, ProjectDBScheme } from '@hawk.so/types';
2-
import { decodeUnsafeFields } from '../../../lib/utils/unsafeFields';
2+
import { decodeUnsafeFields } from '../../../../lib/utils/unsafeFields';
33
import type { IssueData } from '../GithubService';
44

55
/**

workers/task-manager/tests/issue.test.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,6 @@ import { ObjectId } from 'mongodb';
22
import type { GroupedEventDBScheme, ProjectDBScheme } from '@hawk.so/types';
33
import { formatIssueFromEvent } from '../src/utils/issue';
44

5-
/**
6-
* Mock decodeUnsafeFields to avoid actual decoding in tests
7-
*/
8-
jest.mock('../../../lib/utils/unsafeFields', () => ({
9-
decodeUnsafeFields: jest.fn((event) => {
10-
/**
11-
* In tests, we assume fields are already decoded
12-
*/
13-
return event;
14-
}),
15-
}));
16-
175
describe('formatIssueFromEvent', () => {
186
const mockProject: ProjectDBScheme = {
197
_id: new ObjectId('507f1f77bcf86cd799439011'),

0 commit comments

Comments
 (0)