Skip to content

Commit 17a073b

Browse files
committed
Integrate GitHub issue creation and Copilot assignment
Added GitHubService for authenticating as a GitHub App and creating issues via the GitHub API. Implemented formatting of issue data from events, including stacktrace and source code snippets. Updated TaskManagerWorker to use real GitHub issue creation and Copilot assignment, replacing previous mocked logic. Added environment variables for GitHub App configuration and updated documentation. Included tests for issue formatting.
1 parent b5e7e42 commit 17a073b

7 files changed

Lines changed: 732 additions & 62 deletions

File tree

workers/task-manager/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ MAX_AUTO_TASKS_PER_DAY=10
55
# Number of tasks handling simultaneously
66
# Default: 1
77
SIMULTANEOUS_TASKS=1
8+
9+
# GitHub App configuration
10+
GITHUB_APP_ID=your_github_app_id
11+
GITHUB_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----

workers/task-manager/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ The worker implements daily rate limiting:
1818

1919
## Environment Variables
2020

21-
- `REGISTRY_URL` - RabbitMQ registry connection URL
2221
- `MAX_AUTO_TASKS_PER_DAY` - Maximum auto tasks per day (default: 10)
22+
- `GITHUB_APP_ID` - GitHub App ID
23+
- `GITHUB_PRIVATE_KEY` - GitHub App private key (PEM format)
2324

2425
## Usage
2526

workers/task-manager/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,13 @@
33
"version": "1.0.0",
44
"main": "src/index.ts",
55
"license": "MIT",
6-
"workerType": "hawk-worker-task-manager"
6+
"workerType": "hawk-worker-task-manager",
7+
"dependencies": {
8+
"@octokit/rest": "^22.0.1",
9+
"@octokit/types": "^16.0.0",
10+
"jsonwebtoken": "^9.0.3"
11+
},
12+
"devDependencies": {
13+
"@types/jsonwebtoken": "^8.3.5"
14+
}
715
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import jwt from 'jsonwebtoken';
2+
import { Octokit } from '@octokit/rest';
3+
import type { Endpoints } from '@octokit/types';
4+
5+
/**
6+
* Type for GitHub Issue creation parameters
7+
*/
8+
export type IssueData = Pick<
9+
Endpoints['POST /repos/{owner}/{repo}/issues']['parameters'],
10+
'title' | 'body' | 'labels'
11+
>;
12+
13+
/**
14+
* Type for GitHub Issue response data
15+
*/
16+
export type GitHubIssue = Pick<
17+
Endpoints['POST /repos/{owner}/{repo}/issues']['response']['data'],
18+
'number' | 'html_url' | 'title' | 'state'
19+
>;
20+
21+
/**
22+
* Service for interacting with GitHub API from workers
23+
* Simplified version of api/src/integrations/github/service.ts
24+
* Only includes methods needed for creating issues
25+
*/
26+
export class GitHubService {
27+
/**
28+
* GitHub App ID from environment variables
29+
*/
30+
private readonly appId: string;
31+
32+
/**
33+
* Default timeout for GitHub API requests (in milliseconds)
34+
*/
35+
private static readonly DEFAULT_TIMEOUT = 10000;
36+
37+
/**
38+
* Creates an instance of GitHubService
39+
*/
40+
constructor() {
41+
if (!process.env.GITHUB_APP_ID) {
42+
throw new Error('GITHUB_APP_ID environment variable is not set');
43+
}
44+
45+
this.appId = process.env.GITHUB_APP_ID;
46+
}
47+
48+
/**
49+
* Create Octokit instance with configured timeout
50+
*
51+
* @param auth - Authentication token (JWT or installation access token)
52+
* @returns Configured Octokit instance
53+
*/
54+
private createOctokit(auth: string): Octokit {
55+
return new Octokit({
56+
auth,
57+
request: {
58+
timeout: GitHubService.DEFAULT_TIMEOUT,
59+
},
60+
});
61+
}
62+
63+
/**
64+
* Get private key from environment variables
65+
*
66+
* @returns {string} Private key in PEM format with real newlines
67+
* @throws {Error} If GITHUB_PRIVATE_KEY is not set
68+
*/
69+
private getPrivateKey(): string {
70+
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;
85+
}
86+
87+
throw new Error('GITHUB_PRIVATE_KEY must be set');
88+
}
89+
90+
/**
91+
* Create JWT token for GitHub App authentication
92+
*
93+
* @returns {string} JWT token
94+
*/
95+
private createJWT(): string {
96+
const privateKey = this.getPrivateKey();
97+
const now = Math.floor(Date.now() / 1000);
98+
99+
/**
100+
* JWT payload for GitHub App
101+
* - iat: issued at time (current time)
102+
* - exp: expiration time (10 minutes from now, GitHub allows up to 10 minutes)
103+
* - iss: issuer (GitHub App ID)
104+
*/
105+
const payload = {
106+
iat: now - 60, // Allow 1 minute clock skew
107+
exp: now + 600, // 10 minutes expiration
108+
iss: this.appId,
109+
};
110+
111+
return jwt.sign(payload, privateKey, { algorithm: 'RS256' });
112+
}
113+
114+
/**
115+
* Get installation access token from GitHub API
116+
*
117+
* @param {string} installationId - GitHub App installation ID
118+
* @returns {Promise<string>} Installation access token (valid for 1 hour)
119+
* @throws {Error} If token creation fails
120+
*/
121+
private async createInstallationToken(installationId: string): Promise<string> {
122+
const token = this.createJWT();
123+
124+
/**
125+
* Create Octokit instance with JWT authentication and configured timeout
126+
*/
127+
const octokit = this.createOctokit(token);
128+
129+
try {
130+
/**
131+
* Request installation access token
132+
*/
133+
const { data } = await octokit.rest.apps.createInstallationAccessToken({
134+
installation_id: parseInt(installationId, 10),
135+
});
136+
137+
return data.token;
138+
} catch (error) {
139+
throw new Error(`Failed to create installation token: ${error instanceof Error ? error.message : String(error)}`);
140+
}
141+
}
142+
143+
/**
144+
* Create a GitHub issue
145+
*
146+
* @param {string} repoFullName - Repository full name (owner/repo)
147+
* @param {string} installationId - GitHub App installation ID
148+
* @param {IssueData} issueData - Issue data (title, body, labels)
149+
* @returns {Promise<GitHubIssue>} Created issue
150+
* @throws {Error} If issue creation fails
151+
*/
152+
public async createIssue(
153+
repoFullName: string,
154+
installationId: string,
155+
issueData: IssueData
156+
): Promise<GitHubIssue> {
157+
const [owner, repo] = repoFullName.split('/');
158+
159+
if (!owner || !repo) {
160+
throw new Error(`Invalid repository name format: ${repoFullName}. Expected format: owner/repo`);
161+
}
162+
163+
/**
164+
* Get installation access token
165+
*/
166+
const accessToken = await this.createInstallationToken(installationId);
167+
168+
/**
169+
* Create Octokit instance with installation access token and configured timeout
170+
*/
171+
const octokit = this.createOctokit(accessToken);
172+
173+
try {
174+
const { data } = await octokit.rest.issues.create({
175+
owner,
176+
repo,
177+
title: issueData.title,
178+
body: issueData.body,
179+
labels: issueData.labels,
180+
});
181+
182+
return {
183+
number: data.number,
184+
html_url: data.html_url,
185+
title: data.title,
186+
state: data.state,
187+
};
188+
} catch (error) {
189+
throw new Error(`Failed to create issue: ${error instanceof Error ? error.message : String(error)}`);
190+
}
191+
}
192+
193+
/**
194+
* Assign GitHub Copilot to an issue
195+
*
196+
* @param {string} repoFullName - Repository full name (owner/repo)
197+
* @param {number} issueNumber - Issue number
198+
* @param {string} installationId - GitHub App installation ID
199+
* @returns {Promise<boolean>} True if assignment was successful
200+
* @throws {Error} If assignment fails
201+
*/
202+
public async assignCopilot(
203+
repoFullName: string,
204+
issueNumber: number,
205+
installationId: string
206+
): Promise<boolean> {
207+
const [owner, repo] = repoFullName.split('/');
208+
209+
if (!owner || !repo) {
210+
throw new Error(`Invalid repository name format: ${repoFullName}. Expected format: owner/repo`);
211+
}
212+
213+
/**
214+
* Get installation access token
215+
*/
216+
const accessToken = await this.createInstallationToken(installationId);
217+
218+
/**
219+
* Create Octokit instance with installation access token and configured timeout
220+
*/
221+
const octokit = this.createOctokit(accessToken);
222+
223+
try {
224+
/**
225+
* Assign GitHub Copilot (github-copilot[bot]) as assignee
226+
*/
227+
await octokit.rest.issues.addAssignees({
228+
owner,
229+
repo,
230+
issue_number: issueNumber,
231+
assignees: ['github-copilot[bot]'],
232+
});
233+
234+
return true;
235+
} catch (error) {
236+
throw new Error(`Failed to assign Copilot: ${error instanceof Error ? error.message : String(error)}`);
237+
}
238+
}
239+
}

0 commit comments

Comments
 (0)