Skip to content

Commit a449e53

Browse files
committed
Experimental support for workflow job debugging
1 parent e13745f commit a449e53

File tree

10 files changed

+1284
-3
lines changed

10 files changed

+1284
-3
lines changed

package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"activationEvents": [
2626
"onView:workflows",
2727
"onView:settings",
28+
"onView:github-actions.workflow-debug",
2829
"workspaceContains:**/.github/workflows/**",
2930
"workspaceContains:**/action.yml",
3031
"workspaceContains:**/action.yaml"
@@ -63,6 +64,21 @@
6364
]
6465
}
6566
],
67+
"breakpoints": [
68+
{
69+
"language": "github-actions-workflow"
70+
}
71+
],
72+
"debuggers": [
73+
{
74+
"type": "github-actions",
75+
"label": "GitHub Actions",
76+
"languages": [
77+
"github-actions-workflow"
78+
],
79+
"initialConfigurations": []
80+
}
81+
],
6682
"configuration": {
6783
"title": "GitHub Actions",
6884
"properties": {
@@ -155,6 +171,20 @@
155171
"light": "resources/icons/light/logs.svg"
156172
}
157173
},
174+
{
175+
"command": "github-actions.workflow.job.attachDebugger",
176+
"category": "GitHub Actions",
177+
"title": "Attach debugger to job",
178+
"when": "viewItem =~ /job/ && viewItem =~ /running/",
179+
"icon": "$(debug)"
180+
},
181+
{
182+
"command": "github-actions.workflow.job.rerunDebug",
183+
"category": "GitHub Actions",
184+
"title": "Re-run and debug job",
185+
"when": "viewItem =~ /job/ && viewItem =~ /failed/",
186+
"icon": "$(debug)"
187+
},
158188
{
159189
"command": "github-actions.step.logs",
160190
"category": "GitHub Actions",
@@ -267,6 +297,13 @@
267297
}
268298
],
269299
"views": {
300+
"debug": [
301+
{
302+
"id": "github-actions.workflow-debug",
303+
"name": "Actions Remote File System",
304+
"when": "github-actions.debugging"
305+
}
306+
],
270307
"github-actions": [
271308
{
272309
"id": "github-actions.current-branch",
@@ -373,6 +410,16 @@
373410
"when": "viewItem =~ /run\\s/",
374411
"group": "inline"
375412
},
413+
{
414+
"command": "github-actions.workflow.job.attachDebugger",
415+
"group": "inline@0",
416+
"when": "viewItem =~ /job/ && viewItem =~ /running/"
417+
},
418+
{
419+
"command": "github-actions.workflow.job.rerunDebug",
420+
"group": "inline@1",
421+
"when": "viewItem =~ /job/ && viewItem =~ /failed/"
422+
},
376423
{
377424
"command": "github-actions.workflow.logs",
378425
"group": "inline",
@@ -458,6 +505,14 @@
458505
"command": "github-actions.workflow.logs",
459506
"when": "false"
460507
},
508+
{
509+
"command": "github-actions.workflow.job.attachDebugger",
510+
"when": "false"
511+
},
512+
{
513+
"command": "github-actions.workflow.job.rerunDebug",
514+
"when": "false"
515+
},
461516
{
462517
"command": "github-actions.step.logs",
463518
"when": "false"
@@ -566,6 +621,7 @@
566621
"@actions/languageserver": "^0.3.46",
567622
"@actions/workflow-parser": "^0.3.46",
568623
"@octokit/rest": "^21.1.1",
624+
"@vscode/debugprotocol": "^1.68.0",
569625
"@vscode/vsce": "^2.19.0",
570626
"buffer": "^6.0.3",
571627
"crypto-browserify": "^3.12.0",
@@ -589,4 +645,4 @@
589645
"elliptic": "6.6.1"
590646
}
591647
}
592-
}
648+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as vscode from "vscode";
2+
import {WorkflowJobNode} from "../treeViews/shared/workflowJobNode";
3+
import {getGitHubContext} from "../git/repository";
4+
5+
export type AttachWorkflowJobDebuggerArgs = Pick<WorkflowJobNode, "gitHubRepoContext" | "job">;
6+
7+
export function registerAttachWorkflowJobDebugger(context: vscode.ExtensionContext) {
8+
context.subscriptions.push(
9+
vscode.commands.registerCommand(
10+
"github-actions.workflow.job.attachDebugger",
11+
async (args: AttachWorkflowJobDebuggerArgs) => {
12+
const job = args.job.job;
13+
const repoContext = args.gitHubRepoContext;
14+
const workflowName = job.workflow_name || undefined;
15+
const jobName = job.name;
16+
const title = workflowName ? `Workflow "${workflowName}" job "${jobName}"` : `Job "${jobName}"`;
17+
18+
// Get current GitHub user
19+
const gitHubContext = await getGitHubContext();
20+
const username = gitHubContext?.username || "unknown";
21+
22+
const debugConfig: vscode.DebugConfiguration = {
23+
name: `GitHub Actions: ${title}`,
24+
type: "github-actions",
25+
request: "attach",
26+
workflowName,
27+
jobName,
28+
// Identity fields for DAP proxy audit logging
29+
githubActor: username,
30+
githubRepository: `${repoContext.owner}/${repoContext.name}`,
31+
githubRunID: String(job.run_id),
32+
githubJobID: String(job.id)
33+
};
34+
35+
const folder = vscode.workspace.workspaceFolders?.[0];
36+
const started = await vscode.debug.startDebugging(folder, debugConfig);
37+
if (!started) {
38+
await vscode.window.showErrorMessage("Failed to start GitHub Actions debug session.");
39+
}
40+
}
41+
)
42+
);
43+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import * as vscode from "vscode";
2+
3+
import {WorkflowJob as WorkflowJobModel} from "../model";
4+
import {WorkflowJob} from "../store/WorkflowJob";
5+
import {WorkflowJobCommandArgs, WorkflowJobNode} from "../treeViews/shared/workflowJobNode";
6+
7+
export function registerReRunWorkflowJobWithDebug(context: vscode.ExtensionContext) {
8+
context.subscriptions.push(
9+
vscode.commands.registerCommand("github-actions.workflow.job.rerunDebug", async (args: WorkflowJobCommandArgs) => {
10+
const gitHubRepoContext = args.gitHubRepoContext;
11+
const job = args.job;
12+
const jobId = job.job.id;
13+
const runId = job.job.run_id;
14+
const jobName = job.job.name;
15+
16+
if (!jobId) {
17+
await vscode.window.showErrorMessage("Unable to re-run workflow job: missing job id.");
18+
return;
19+
}
20+
21+
if (!runId) {
22+
await vscode.window.showErrorMessage("Unable to re-run workflow job: missing run id.");
23+
return;
24+
}
25+
26+
try {
27+
await gitHubRepoContext.client.request("POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun", {
28+
owner: gitHubRepoContext.owner,
29+
repo: gitHubRepoContext.name,
30+
job_id: jobId,
31+
enable_debug_logging: true
32+
});
33+
} catch (e) {
34+
await vscode.window.showErrorMessage(
35+
`Could not re-run workflow job with debug logging: '${(e as Error).message}'`
36+
);
37+
return;
38+
}
39+
40+
WorkflowJobNode.setStatusOverride(runId, jobName, "pending", null);
41+
await refreshWorkflowViews();
42+
43+
const updatedJob = await pollJobRunning(gitHubRepoContext, runId, jobName, 15, 1000);
44+
if (!updatedJob) {
45+
await vscode.window.showWarningMessage("Job did not start running within 15 seconds.");
46+
return;
47+
}
48+
49+
await vscode.commands.executeCommand("github-actions.workflow.job.attachDebugger", {
50+
gitHubRepoContext,
51+
job: new WorkflowJob(gitHubRepoContext, updatedJob)
52+
});
53+
})
54+
);
55+
}
56+
57+
async function pollJobRunning(
58+
gitHubRepoContext: WorkflowJobCommandArgs["gitHubRepoContext"],
59+
runId: number,
60+
jobName: string,
61+
attempts: number,
62+
delayMs: number
63+
): Promise<WorkflowJobModel | undefined> {
64+
const rerunStart = Date.now();
65+
for (let attempt = 0; attempt < attempts; attempt++) {
66+
const job = await getJobByName(gitHubRepoContext, runId, jobName, rerunStart);
67+
if (job?.status === "in_progress") {
68+
await clearStatusOverride(runId, jobName);
69+
return job;
70+
}
71+
72+
await delay(delayMs);
73+
}
74+
75+
await clearStatusOverride(runId, jobName);
76+
return undefined;
77+
}
78+
79+
async function getJobByName(
80+
gitHubRepoContext: WorkflowJobCommandArgs["gitHubRepoContext"],
81+
runId: number,
82+
jobName: string,
83+
rerunStart: number
84+
): Promise<WorkflowJobModel | undefined> {
85+
try {
86+
const response = await gitHubRepoContext.client.actions.listJobsForWorkflowRun({
87+
owner: gitHubRepoContext.owner,
88+
repo: gitHubRepoContext.name,
89+
run_id: runId,
90+
per_page: 100
91+
});
92+
93+
const jobs = response.data.jobs ?? [];
94+
const matching = jobs.filter(job => job.name === jobName);
95+
if (matching.length === 0) {
96+
return undefined;
97+
}
98+
99+
const sorted = matching.sort((left, right) => {
100+
const leftStart = left.started_at ? Date.parse(left.started_at) : 0;
101+
const rightStart = right.started_at ? Date.parse(right.started_at) : 0;
102+
return rightStart - leftStart;
103+
});
104+
105+
const newest = sorted[0];
106+
if (newest.started_at) {
107+
const startedAt = Date.parse(newest.started_at);
108+
if (!Number.isNaN(startedAt) && startedAt < rerunStart - 5000) {
109+
return undefined;
110+
}
111+
}
112+
113+
return newest;
114+
} catch {
115+
return undefined;
116+
}
117+
}
118+
119+
async function refreshWorkflowViews(): Promise<void> {
120+
await Promise.all([
121+
vscode.commands.executeCommand("github-actions.explorer.refresh"),
122+
vscode.commands.executeCommand("github-actions.explorer.current-branch.refresh")
123+
]);
124+
}
125+
126+
async function clearStatusOverride(runId: number, jobName: string): Promise<void> {
127+
WorkflowJobNode.clearStatusOverride(runId, jobName);
128+
await refreshWorkflowViews();
129+
}
130+
131+
function delay(ms: number): Promise<void> {
132+
return new Promise(resolve => setTimeout(resolve, ms));
133+
}

0 commit comments

Comments
 (0)