Skip to content

Commit 148c313

Browse files
authored
Add check run logs (#8506)
* Add check run logs * Copilot PR feedback
1 parent 737cc75 commit 148c313

14 files changed

Lines changed: 211 additions & 14 deletions

File tree

Lines changed: 1 addition & 0 deletions
Loading

src/common/uri.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,8 @@ export enum Schemes {
799799
Git = 'git', // File content from the git extension
800800
PRQuery = 'prquery', // PR query tree item
801801
GitHubCommit = 'githubcommit', // file content from GitHub for a commit
802-
CommitsNode = 'commitsnode' // Commits tree node, for decorations
802+
CommitsNode = 'commitsnode', // Commits tree node, for decorations
803+
CheckRunLog = 'checkrunlog' // Check run log content
803804
}
804805

805806
export function resolvePath(from: vscode.Uri, to: string) {

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { NotificationsManager } from './notifications/notificationsManager';
4141
import { NotificationsProvider } from './notifications/notificationsProvider';
4242
import { ThemeWatcher } from './themeWatcher';
4343
import { resumePendingCheckout, UriHandler } from './uriHandler';
44+
import { CheckRunLogContentProvider } from './view/checkRunLogContentProvider';
4445
import { CommentDecorationProvider } from './view/commentDecorationProvider';
4546
import { CommitsDecorationProvider } from './view/commitsDecorationProvider';
4647
import { CompareChanges } from './view/compareChangesTreeDataProvider';
@@ -483,6 +484,7 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll
483484
context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage }));
484485
const githubFilesystemProvider = new GitHubCommitFileSystemProvider(reposManager, apiImpl, credentialStore);
485486
context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.GitHubCommit, githubFilesystemProvider, { isReadonly: new vscode.MarkdownString(vscode.l10n.t('GitHub commits cannot be edited')) }));
487+
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(Schemes.CheckRunLog, new CheckRunLogContentProvider(reposManager)));
486488

487489
await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager, createPrHelper, copilotRemoteAgentManager, themeWatcher, prsTreeModel);
488490
return apiImpl;

src/github/githubRepository.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,54 @@ export class GitHubRepository extends Disposable {
969969
return jobs.data.jobs;
970970
}
971971

972+
async getCheckRunLogs(checkRunDatabaseId: number): Promise<string> {
973+
Logger.debug(`Fetch check run logs - enter`, this.id);
974+
const { octokit, remote } = await this.ensure();
975+
976+
// Try GitHub Actions logs first (works for Actions workflow runs)
977+
try {
978+
const result = await octokit.call(octokit.api.actions.downloadJobLogsForWorkflowRun, {
979+
owner: remote.owner,
980+
repo: remote.repositoryName,
981+
job_id: checkRunDatabaseId,
982+
});
983+
Logger.debug(`Fetch check run logs via Actions API - done`, this.id);
984+
return result.data as string;
985+
} catch {
986+
// Not a GitHub Actions job - fall through to Checks API
987+
}
988+
989+
// Fall back to Checks API output (works for any GitHub App, e.g. Azure Pipelines)
990+
try {
991+
const result = await octokit.call(octokit.api.checks.get, {
992+
owner: remote.owner,
993+
repo: remote.repositoryName,
994+
check_run_id: checkRunDatabaseId,
995+
});
996+
const output = result.data.output;
997+
const parts: string[] = [];
998+
if (output.title) {
999+
parts.push(output.title);
1000+
parts.push('');
1001+
}
1002+
if (output.summary) {
1003+
parts.push(output.summary);
1004+
parts.push('');
1005+
}
1006+
if (output.text) {
1007+
parts.push(output.text);
1008+
}
1009+
if (parts.length === 0) {
1010+
return 'No log output available for this check run.';
1011+
}
1012+
Logger.debug(`Fetch check run logs via Checks API - done`, this.id);
1013+
return parts.join('\n');
1014+
} catch (e) {
1015+
Logger.error(`Unable to fetch check run logs: ${e}`, this.id);
1016+
throw e;
1017+
}
1018+
}
1019+
9721020
async fork(): Promise<string | undefined> {
9731021
try {
9741022
Logger.debug(`Fork repository`, this.id);
@@ -1700,6 +1748,7 @@ export class GitHubRepository extends Disposable {
17001748
if (isCheckRun(context)) {
17011749
return {
17021750
id: context.id,
1751+
databaseId: context.databaseId,
17031752
url: context.checkSuite?.app?.url,
17041753
avatarUrl:
17051754
context.checkSuite?.app?.logoUrl &&
@@ -1715,10 +1764,12 @@ export class GitHubRepository extends Disposable {
17151764
event: context.checkSuite?.workflowRun?.event,
17161765
targetUrl: context.detailsUrl,
17171766
isRequired: context.isRequired,
1767+
isCheckRun: true,
17181768
};
17191769
} else {
17201770
return {
17211771
id: context.id,
1772+
databaseId: undefined,
17221773
url: context.targetUrl ?? undefined,
17231774
avatarUrl: context.avatarUrl
17241775
? getAvatarWithEnterpriseFallback(context.avatarUrl, undefined, this.remote.isEnterprise)
@@ -1730,6 +1781,7 @@ export class GitHubRepository extends Disposable {
17301781
event: undefined,
17311782
targetUrl: context.targetUrl,
17321783
isRequired: context.isRequired,
1784+
isCheckRun: false,
17331785
};
17341786
}
17351787
}));
@@ -1750,6 +1802,7 @@ export class GitHubRepository extends Disposable {
17501802
checks.state = CheckState.Pending;
17511803
checks.statuses.push({
17521804
id: '',
1805+
databaseId: undefined,
17531806
url: undefined,
17541807
avatarUrl: undefined,
17551808
state: CheckState.Pending,
@@ -1758,7 +1811,8 @@ export class GitHubRepository extends Disposable {
17581811
workflowName: undefined,
17591812
event: undefined,
17601813
targetUrl: prUrl,
1761-
isRequired: true
1814+
isRequired: true,
1815+
isCheckRun: false
17621816
});
17631817
}
17641818
}

src/github/graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,7 @@ export interface StatusContext {
994994
export interface CheckRun {
995995
__typename: string;
996996
id: string;
997+
databaseId: number | null;
997998
conclusion:
998999
| 'ACTION_REQUIRED'
9991000
| 'CANCELLED'

src/github/interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export enum CheckState {
346346

347347
export interface PullRequestCheckStatus {
348348
id: string;
349+
databaseId: number | null | undefined;
349350
url: string | undefined;
350351
avatarUrl: string | undefined;
351352
state: CheckState;
@@ -355,6 +356,7 @@ export interface PullRequestCheckStatus {
355356
workflowName: string | undefined;
356357
event: string | undefined;
357358
isRequired: boolean;
359+
isCheckRun: boolean;
358360
}
359361

360362
export interface PullRequestChecks {

src/github/pullRequestOverview.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ITeam,
1818
MergeMethod,
1919
MergeMethodsAvailability,
20+
PullRequestCheckStatus,
2021
PullRequestMergeability,
2122
ReviewEventEnum,
2223
ReviewState,
@@ -38,6 +39,7 @@ import { ITelemetry } from '../common/telemetry';
3839
import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent';
3940
import { asPromise, formatError } from '../common/utils';
4041
import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview';
42+
import { toCheckRunLogUri } from '../view/checkRunLogContentProvider';
4143

4244
export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestModel> {
4345
public static override ID: string = 'PullRequestOverviewPanel';
@@ -519,6 +521,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
519521
return this.openSessionLog(message);
520522
case 'pr.cancel-coding-agent':
521523
return this.cancelCodingAgent(message);
524+
case 'pr.view-check-logs':
525+
return this.viewCheckLogs(message);
522526
case 'pr.openCommitChanges':
523527
return this.openCommitChanges(message);
524528
case 'pr.delete-review':
@@ -641,6 +645,27 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
641645
}
642646
}
643647

648+
private async viewCheckLogs(message: IRequestMessage<{ status: PullRequestCheckStatus }>): Promise<void> {
649+
try {
650+
const { status } = message.args;
651+
if (!status.databaseId) {
652+
return this._replyMessage(message, { error: 'Logs are only available for GitHub Actions check runs.' });
653+
}
654+
const uri = toCheckRunLogUri({
655+
owner: this._item.remote.owner,
656+
repo: this._item.remote.repositoryName,
657+
checkRunDatabaseId: status.databaseId,
658+
checkName: status.context,
659+
});
660+
661+
await vscode.window.showTextDocument(uri, { preview: true, preserveFocus: false });
662+
return this._replyMessage(message, {});
663+
} catch (e) {
664+
Logger.error(`View check run logs failed: ${formatError(e)}`, PullRequestOverviewPanel.ID);
665+
return this._replyMessage(message, { error: formatError(e) });
666+
}
667+
}
668+
644669
private async cancelCodingAgent(message: IRequestMessage<TimelineEvent>): Promise<void> {
645670
try {
646671
let result = false;

src/github/queriesShared.gql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,7 @@ query GetChecks($owner: String!, $name: String!, $number: Int!) {
10881088
}
10891089
... on CheckRun {
10901090
id
1091+
databaseId
10911092
conclusion
10921093
title
10931094
detailsUrl
@@ -1171,6 +1172,7 @@ query GetChecksWithoutSuite($owner: String!, $name: String!, $number: Int!) {
11711172
}
11721173
... on CheckRun {
11731174
id
1175+
databaseId
11741176
conclusion
11751177
title
11761178
detailsUrl

src/lm/tools/activePullRequestTool.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import * as vscode from 'vscode';
88
import { FetchIssueResult } from './fetchIssueTool';
99
import { GitChangeType, InMemFileChange } from '../../common/file';
10+
import Logger from '../../common/logger';
1011
import { CommentEvent, EventType, ReviewEvent } from '../../common/timelineEvent';
12+
import { CheckState } from '../../github/interface';
1113
import { PullRequestModel } from '../../github/pullRequestModel';
1214
import { RepositoriesManager } from '../../github/repositoriesManager';
1315

@@ -50,6 +52,26 @@ export abstract class PullRequestTool implements vscode.LanguageModelTool<FetchI
5052
}
5153

5254
const status = await pullRequest.getStatusChecks();
55+
const statuses = status[0]?.statuses ?? [];
56+
57+
// Fetch logs for failed check runs in parallel
58+
const statusChecks = await Promise.all(statuses.map(async (s) => {
59+
const entry: Record<string, any> = {
60+
context: s.context,
61+
description: s.description,
62+
state: s.state,
63+
name: s.workflowName,
64+
targetUrl: s.targetUrl,
65+
};
66+
if (s.state === CheckState.Failure && s.isCheckRun && s.databaseId) {
67+
try {
68+
entry.logs = await pullRequest.githubRepository.getCheckRunLogs(s.databaseId);
69+
} catch (e) {
70+
Logger.error(`Failed to fetch check run logs for ${s.context}: ${e}`, 'PullRequestTool');
71+
}
72+
}
73+
return entry;
74+
}));
5375
const timeline = (pullRequest.timelineEvents && pullRequest.timelineEvents.length > 0) ? pullRequest.timelineEvents : await pullRequest.getTimelineEvents();
5476
const pullRequestInfo = {
5577
title: pullRequest.title,
@@ -72,15 +94,7 @@ export abstract class PullRequestTool implements vscode.LanguageModelTool<FetchI
7294
};
7395
}),
7496
state: pullRequest.state,
75-
statusChecks: status[0]?.statuses.map((status) => {
76-
return {
77-
context: status.context,
78-
description: status.description,
79-
state: status.state,
80-
name: status.workflowName,
81-
targetUrl: status.targetUrl
82-
};
83-
}),
97+
statusChecks,
8498
reviewRequirements: {
8599
approvalsNeeded: status[1]?.count ?? 0,
86100
currentApprovals: status[1]?.approvals.length ?? 0,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*---------------------------------------------------------------------------------------------
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 * as vscode from 'vscode';
7+
import Logger from '../common/logger';
8+
import { Schemes } from '../common/uri';
9+
import { formatError } from '../common/utils';
10+
import { RepositoriesManager } from '../github/repositoriesManager';
11+
12+
interface CheckRunLogParams {
13+
owner: string;
14+
repo: string;
15+
checkRunDatabaseId: number;
16+
checkName: string;
17+
}
18+
19+
export function toCheckRunLogUri(params: CheckRunLogParams): vscode.Uri {
20+
return vscode.Uri.from({
21+
scheme: Schemes.CheckRunLog,
22+
path: `/${params.owner}/${params.repo}/${params.checkName}.log`,
23+
query: JSON.stringify(params),
24+
});
25+
}
26+
27+
function fromCheckRunLogUri(uri: vscode.Uri): CheckRunLogParams | undefined {
28+
if (uri.scheme !== Schemes.CheckRunLog) {
29+
return undefined;
30+
}
31+
try {
32+
return JSON.parse(uri.query);
33+
} catch {
34+
return undefined;
35+
}
36+
}
37+
38+
export class CheckRunLogContentProvider implements vscode.TextDocumentContentProvider {
39+
constructor(private readonly _reposManager: RepositoriesManager) { }
40+
41+
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
42+
const params = fromCheckRunLogUri(uri);
43+
if (!params) {
44+
return '';
45+
}
46+
47+
for (const folderManager of this._reposManager.folderManagers) {
48+
const repo = folderManager.findRepo(r =>
49+
r.remote.owner === params.owner && r.remote.repositoryName === params.repo
50+
);
51+
if (repo) {
52+
try {
53+
return await repo.getCheckRunLogs(params.checkRunDatabaseId);
54+
} catch (e) {
55+
Logger.error(`Failed to fetch check run logs: ${formatError(e)}`, 'CheckRunLog');
56+
return `Failed to fetch check run logs. See logs for details.`;
57+
}
58+
}
59+
}
60+
61+
Logger.error(`No repository found for ${params.owner}/${params.repo}`, 'CheckRunLog');
62+
return `Unable to fetch logs: repository ${params.owner}/${params.repo} not found.`;
63+
}
64+
}

0 commit comments

Comments
 (0)