src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts

342 LOC · 293 covered · 49 uncovered · 44 ranges · 996 concepts · 15 introducers · 518 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- agentHostOctoKitService.ts ×8
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { LRUCache } from '../../../../base/common/map.js';
7 > import { createDecorator } from '../../../instantiation/common/instantiation.js';
8 > import { ILogService } from '../../../log/common/log.js';
9 > import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js';
10 >
11 > export type FetchFunction = typeof globalThis.fetch;
12 >
13 > /**
14 > * Successful result of {@link IAgentHostOctoKitService.createPullRequest}.
15 > *
16 > * Mirrors the `CreatedPullRequest` type returned by `OctoKitService` in
17 > * `extensions/copilot/src/platform/github/common/githubService.ts` so the
18 > * shapes line up if/when the two are ported together.
19 > */
20 > export interface CreatedPullRequest {
21 > readonly url: string;
22 > readonly number: number;
23 > readonly nodeId?: string;
24 > }
25 >
26 > /**
27 > * Merge strategy used when enabling auto-merge on a pull request.
28 > * Mirrors the GitHub GraphQL `PullRequestMergeMethod` enum.
29 > */
30 > export type AutoMergeMethod = 'MERGE' | 'SQUASH' | 'REBASE';
31 >
32 > interface GitHubPullRequestResponseItem {
33 > readonly number?: unknown;
34 > readonly html_url?: unknown;
35 > readonly node_id?: unknown;
36 > }
37 >
38 > export interface IGitHubApiResponse<T> {
39 > readonly data: T | undefined;
40 > readonly statusCode: number;
41 > readonly etag?: string;
42 > }
43 >
44 > /**
45 > * Minimal GitHub REST client living in the agent-host process.
46 > *
47 > * The agent host runs headless and has no access to the workbench
48 > * `IOctoKitService` / Octokit / VS Code auth providers. This service is a
49 > * deliberately small re-implementation of the bits we need, modelled on
50 > * `OctoKitService` from the Copilot extension so the API surface is
51 > * familiar. Only operations the agent host actually needs are exposed —
52 > * extend this interface as new changeset operations are added.
53 > *
54 > * The caller is responsible for supplying a GitHub OAuth token with the
55 > * scopes required by the operation (e.g. `repo` for {@link createPullRequest}).
56 > * Tokens are typically obtained from the agent host's
57 > * `authenticate(resource, token)` token store, which the workbench pushes
58 > * on session create via the same channel used for `ICopilotApiService`.
59 > */
60 > export interface IAgentHostOctoKitService {
61 > readonly _serviceBrand: undefined;
62 >
63 > /**
64 > * Creates a pull request on github.com.
65 > *
66 > * Mirrors `OctoKitService.createPullRequest` from the Copilot extension.
67 > * Throws on non-2xx responses or malformed payloads.
68 > */
69 > createPullRequest(
70 > owner: string,
71 > repo: string,
72 > title: string,
73 > body: string,
74 > head: string,
75 > base: string,
76 > draft: boolean,
77 > token: string,
78 > signal: AbortSignal,
79 > ): Promise<CreatedPullRequest>;
80 >
81 > /** Finds the most recently updated pull request for `owner:branch`, if any. */
82 > findPullRequestByHeadBranch(owner: string, repo: string, branch: string, token: string, signal: AbortSignal): Promise<CreatedPullRequest | undefined>;
83 >
84 > /**
85 > * Enables auto-merge on a pull request so GitHub merges it automatically
86 > * once all required reviews and status checks pass.
87 > *
88 > * Issues the GraphQL `enablePullRequestAutoMerge` mutation. `pullRequestId`
89 > * is the pull request's GraphQL global node id (see
90 > * {@link CreatedPullRequest.nodeId}). Throws on GraphQL or transport errors,
91 > * including when the repository does not allow the requested merge method or
92 > * auto-merge is not enabled for the repository.
93 > */
94 > enablePullRequestAutoMerge(pullRequestId: string, mergeMethod: AutoMergeMethod, token: string, signal: AbortSignal): Promise<void>;
95 > }
96 >
97 > export const IAgentHostOctoKitService = createDecorator<IAgentHostOctoKitService>('agentHostOctoKitService');
98 >
99 > const GITHUB_API_VERSION = '2022-11-28';
100 > const MAX_ERROR_RESPONSE_BODY_LENGTH = 500;
101 >
102 > const ENABLE_AUTO_MERGE_MUTATION = `mutation EnableAutoMerge($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!) {
103 > enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: $mergeMethod }) {
104 > pullRequest { id }
105 > }
106 > }`;
107 >
108 > export class AgentHostOctoKitService implements IAgentHostOctoKitService {
109 >
110 > declare readonly _serviceBrand: undefined;
111 >
112 > private readonly _fetch: FetchFunction;
113 >
114 > /**
115 > * A cache of ETags for pull request search results.
116 > */
117 > private readonly pullRequestSearchEtags = new LRUCache<string, string>(100);
118 >
119 > constructor(
120 > fetchFn: FetchFunction | undefined, agentHostOctoKitService.ts ×1
121 > @ILogService private readonly _logService: ILogService,
122 > @IAgentHostGitHubEndpointService private readonly _endpoint: IAgentHostGitHubEndpointService,
123 > ) {
124 > this._fetch = fetchFn ?? globalThis.fetch;
125 > }
127 > async createPullRequest(
128 > owner: string, agentHostOctoKitService.ts ×4
129 > repo: string,
130 > title: string,
131 > body: string,
132 > head: string,
133 > base: string,
134 > draft: boolean,
135 > token: string,
136 > signal: AbortSignal,
137 > ): Promise<CreatedPullRequest> {
138 > const response = await this._makeGHAPIRequest<GitHubPullRequestResponseItem>(
139 > `repos/${owner}/${repo}/pulls`,
140 > 'POST',
141 > token,
142 > signal,
143 > { title, body, head, base, draft },
144 > );
146 > const number = response.data?.number;
147 > const html_url = response.data?.html_url; agentHostOctoKitService.ts ×4
148 > if (typeof html_url !== 'string' || typeof number !== 'number') {
149 > throw new Error(`Failed to create pull request for ${owner}/${repo}`); agentHostOctoKitService.ts ×1
150 > }
152 > const node_id = response.data?.node_id;
153 > return { url: html_url, number, nodeId: typeof node_id === 'string' ? node_id : undefined }; agentHostOctoKitService.ts ×4
154 > }
156 > async findPullRequestByHeadBranch(owner: string, repo: string, branch: string, token: string, signal: AbortSignal): Promise<CreatedPullRequest | undefined> {
157 > const routeSlug = `repos/${owner}/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=all&sort=updated&direction=desc&per_page=1`; agentHostOctoKitService.ts ×5
158 >
159 > const etag = this.pullRequestSearchEtags.get(routeSlug);
160 > const response = await this._makeGHAPIRequest<GitHubPullRequestResponseItem[]>(routeSlug, 'GET', token, signal, undefined, etag);
161 >
162 > if (response.etag) {
163 this.pullRequestSearchEtags.set(routeSlug, response.etag);
164 }
166 > if (
167 > response.statusCode === 304 ||
168 > !Array.isArray(response.data) ||
169 > response.data.length === 0
170 > ) {
171 return undefined;
172 }
174 > const first = response.data[0];
175 > const html_url = first?.html_url;
176 > const number = first?.number;
177 > const node_id = first?.node_id;
178 > return typeof html_url === 'string' && typeof number === 'number'
179 > ? {
180 > number,
181 > url: html_url,
182 > nodeId: typeof node_id === 'string'
183 > ? node_id
184 : undefined
186 : undefined;
189 > async enablePullRequestAutoMerge(pullRequestId: string, mergeMethod: AutoMergeMethod, token: string, signal: AbortSignal): Promise<void> {
190 > await this._makeGraphQLRequest(ENABLE_AUTO_MERGE_MUTATION, { pullRequestId, mergeMethod }, token, signal); agentHostOctoKitService.ts ×6
191 > }
193 > private async _makeGHAPIRequest<T>(
194 > routeSlug: string, agentHostOctoKitService.ts ×7
195 > method: 'GET' | 'POST',
196 > token: string,
197 > signal: AbortSignal,
198 > body?: Record<string, unknown>,
199 > etag?: string
200 > ): Promise<IGitHubApiResponse<T>> {
201 > const url = `${this._endpoint.getApiBaseUri()}/${routeSlug}`;
202 > const headers: Record<string, string> = {
203 > 'Accept': 'application/vnd.github+json',
204 > 'Authorization': `Bearer ${token}`,
205 > 'X-GitHub-Api-Version': GITHUB_API_VERSION,
206 > };
207 > if (etag) {
208 headers['If-None-Match'] = etag;
209 }
210 > if (body) { agentHostOctoKitService.ts ×7
211 > headers['Content-Type'] = 'application/json'; agentHostOctoKitService.ts ×4
212 > }
214 > let response: Response;
215 > try {
216 > response = await this._fetch(url, {
217 > method,
218 > headers,
219 > body: body ? JSON.stringify(body) : undefined,
220 > signal,
221 > });
222 > } catch (err) {
223 if (signal.aborted) {
224 throw err;
225 }
226 this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Network error`, err);
227 throw err;
228 }
230 > // Inspect rate limit header
231 > const rateLimitHeader = response.headers.get('x-ratelimit-remaining');
232 > if (rateLimitHeader) {
233 const rateLimitRemaining = parseRateLimitHeader(rateLimitHeader);
234 if (rateLimitRemaining !== undefined && rateLimitRemaining < 100) {
235 this._logService.warn(`[AgentHostOctoKitService] ${method} ${url} - GitHub API rate limit low: ${rateLimitRemaining} remaining`);
236 }
237 }
239 > const statusCode = response.status ?? 0;
240 > const responseETag = response.headers.get('etag') ?? undefined;
241 >
242 > if (
243 > statusCode === 204 /* No Content */ ||
244 > statusCode === 304 /* Not Modified */
245 > ) {
246 return { data: undefined, statusCode, etag: responseETag };
247 }
249 > if (!response.ok) {
250 > const errorText = await response.text().catch(() => undefined); agentHostOctoKitService.ts ×4
251 > const errorDetail = this._formatErrorResponseBody(errorText);
252 > this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Status: ${response.status}${errorDetail ? ` - ${errorDetail}` : ''}`);
253 > throw new Error(`GitHub API request failed: ${method} ${routeSlug} - ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`);
254 > }
256 > try {
257 > const data = await response.json();
258 > return { data, statusCode, etag: responseETag };
259 > } catch (err) {
260 this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Failed to parse JSON`, err);
261 throw err;
262 }
265 > private async _makeGraphQLRequest(
266 > query: string, agentHostOctoKitService.ts ×6
267 > variables: Record<string, unknown>,
268 > token: string,
269 > signal: AbortSignal,
270 > ): Promise<unknown> {
271 > const url = this._endpoint.getGraphQlUri();
272 > const headers: Record<string, string> = {
273 > 'Accept': 'application/json',
274 > 'Authorization': `Bearer ${token}`,
275 > 'Content-Type': 'application/json',
276 > 'X-GitHub-Api-Version': GITHUB_API_VERSION,
277 > };
278 >
279 > let response: Response;
280 > try {
281 > response = await this._fetch(url, {
282 > method: 'POST',
283 > headers,
284 > body: JSON.stringify({ query, variables }),
285 > signal,
286 > });
287 > } catch (err) {
288 if (signal.aborted) {
289 throw err;
290 }
291 this._logService.error(`[AgentHostOctoKit] POST ${url} - Network error`, err);
292 throw err;
293 }
295 > if (!response.ok) {
296 const errorText = await response.text().catch(() => undefined);
297 const errorDetail = this._formatErrorResponseBody(errorText);
298 this._logService.error(`[AgentHostOctoKit] POST ${url} - Status: ${response.status}${errorDetail ? ` - ${errorDetail}` : ''}`);
299 throw new Error(`GitHub GraphQL request failed: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`);
300 }
302 > let json: { data?: unknown; errors?: ReadonlyArray<{ message?: unknown }> };
303 > try {
304 > json = await response.json();
305 > } catch (err) {
306 this._logService.error(`[AgentHostOctoKit] POST ${url} - Failed to parse JSON`, err);
307 throw err;
308 }
310 > // GraphQL reports failures with a 200 status code and an `errors` array.
311 > if (Array.isArray(json.errors) && json.errors.length > 0) {
312 > const message = json.errors.map(error => { agentHostOctoKitService.ts ×2
313 > return typeof error?.message === 'string'
314 > ? error.message
315 : JSON.stringify(error);
316 > }).join('; '); agentHostOctoKitService.ts ×2
317 > this._logService.error(`[AgentHostOctoKit] POST ${url} - GraphQL error: ${message}`);
318 > throw new Error(`GitHub GraphQL request failed: ${message}`);
319 > }
321 > return json.data;
324 > private _formatErrorResponseBody(errorText: string | undefined): string | undefined {
325 > const normalized = errorText?.replace(/\s+/g, ' ').trim(); agentHostOctoKitService.ts ×4
326 > if (!normalized) {
327 return undefined;
328 }
329 > return normalized.length > MAX_ERROR_RESPONSE_BODY_LENGTH agentHostOctoKitService.ts ×4
330 > ? `${normalized.substring(0, MAX_ERROR_RESPONSE_BODY_LENGTH)}...` agentHostOctoKitService.ts ×1
331 > : normalized; agentHostOctoKitService.ts ×1
334 >
335 function parseRateLimitHeader(value: string | string[] | undefined): number | undefined {
336 if (value === undefined) {
337 return undefined;
338 }
339 const str = Array.isArray(value) ? value[0] : value;
340 const parsed = parseInt(str, 10);
341 return isNaN(parsed) ? undefined : parsed;
342 }