Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 71 additions & 3 deletions src/commands/triggerWorkflowRun.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
import {basename} from "path";
import * as vscode from "vscode";

import {getGitHead, getGitHubContextForWorkspaceUri, GitHubRepoContext} from "../git/repository";
import {getWorkflowUri, parseWorkflowFile} from "../workflow/workflow";

import {Workflow} from "../model";
import {RunStore} from "../store/store";

import {log} from "../log";

interface TriggerRunCommandOptions {
wf?: Workflow;
gitHubRepoContext: GitHubRepoContext;
}

export function registerTriggerWorkflowRun(context: vscode.ExtensionContext) {
export function registerTriggerWorkflowRun(context: vscode.ExtensionContext, store: RunStore) {
context.subscriptions.push(
vscode.commands.registerCommand(
"github-actions.explorer.triggerRun",
async (args: TriggerRunCommandOptions | vscode.Uri) => {
let workflowUri: vscode.Uri | null = null;
let workflowIdForApi: number | string | undefined;

if (args instanceof vscode.Uri) {
workflowUri = args;
workflowIdForApi = basename(workflowUri.fsPath);
} else if (args.wf) {
const wf: Workflow = args.wf;
workflowUri = getWorkflowUri(args.gitHubRepoContext, wf.path);
workflowIdForApi = wf.id;
}

if (!workflowUri) {
Expand All @@ -43,6 +51,28 @@ export function registerTriggerWorkflowRun(context: vscode.ExtensionContext) {
return;
}

const relativeWorkflowPath = vscode.workspace.asRelativePath(workflowUri, false);
if (!workflowIdForApi) {
workflowIdForApi = basename(workflowUri.fsPath);
Comment on lines +54 to +56
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflowIdForApi is set twice when args is a vscode.Uri - once at line 27 and again at line 56. The second assignment is redundant since the condition "!workflowIdForApi" will never be true when args is a Uri. Consider restructuring this logic to avoid the unnecessary check and assignment.

Copilot uses AI. Check for mistakes.
}

let latestRunId: number | undefined;
try {
log(`Fetching latest run for workflow: ${workflowIdForApi}`);
const result = await gitHubRepoContext.client.actions.listWorkflowRuns({
owner: gitHubRepoContext.owner,
repo: gitHubRepoContext.name,
workflow_id: workflowIdForApi,
per_page: 1
});
latestRunId = result.data.workflow_runs[0]?.id;
log(`Latest run ID before trigger: ${latestRunId}`);
} catch (e) {
log(`Error fetching latest run: ${(e as Error).message}`);
}

let dispatched = false;

let selectedEvent: string | undefined;
if (workflow.events.workflow_dispatch !== undefined && workflow.events.repository_dispatch !== undefined) {
selectedEvent = await vscode.window.showQuickPick(["repository_dispatch", "workflow_dispatch"], {
Expand Down Expand Up @@ -85,8 +115,6 @@ export function registerTriggerWorkflowRun(context: vscode.ExtensionContext) {
}

try {
const relativeWorkflowPath = vscode.workspace.asRelativePath(workflowUri, false);

await gitHubRepoContext.client.actions.createWorkflowDispatch({
owner: gitHubRepoContext.owner,
repo: gitHubRepoContext.name,
Expand All @@ -95,6 +123,7 @@ export function registerTriggerWorkflowRun(context: vscode.ExtensionContext) {
inputs
});

dispatched = true;
vscode.window.setStatusBarMessage(`GitHub Actions: Workflow event dispatched`, 2000);
} catch (error) {
return vscode.window.showErrorMessage(`Could not create workflow dispatch: ${(error as Error)?.message}`);
Expand Down Expand Up @@ -134,10 +163,49 @@ export function registerTriggerWorkflowRun(context: vscode.ExtensionContext) {
client_payload: {}
});

dispatched = true;
vscode.window.setStatusBarMessage(`GitHub Actions: Repository event '${event_type}' dispatched`, 2000);
}
}

if (dispatched) {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Window,
title: "Waiting for workflow run to start..."
},
async () => {
log("Starting loop to check for new workflow run...");
for (let i = 0; i < 20; i++) {
if (i > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
try {
log(`Checking for new run (attempt ${i + 1}/20)...`);
const result = await gitHubRepoContext.client.actions.listWorkflowRuns({
owner: gitHubRepoContext.owner,
repo: gitHubRepoContext.name,
workflow_id: workflowIdForApi as string | number,
per_page: 1
});
const newLatestRunId = result.data.workflow_runs[0]?.id;
log(`Latest run ID found: ${newLatestRunId} (Previous: ${latestRunId ?? "none"})`);

if (newLatestRunId && newLatestRunId !== latestRunId) {
log(`Found new workflow run: ${newLatestRunId}. Triggering refresh and polling.`);
await vscode.commands.executeCommand("github-actions.explorer.refresh");
// Poll for 15 minutes (225 * 4s)
store.pollRun(newLatestRunId, gitHubRepoContext, 4000, 225);
break;
}
} catch (e) {
log(`Error checking for new run: ${(e as Error).message}`);
}
}
}
);
}

return vscode.commands.executeCommand("github-actions.explorer.refresh");
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the loop completes (either by finding the new run or exhausting 20 attempts), the code always calls the refresh command at line 207. However, if a new run was found and already triggered a refresh at line 194, this results in a redundant refresh. Consider skipping the final refresh if the new run was already detected and refreshed.

Copilot uses AI. Check for mistakes.
}
)
Expand Down
9 changes: 8 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export async function activate(context: vscode.ExtensionContext) {

const store = new RunStore();

// Handle focus changes to pause/resume polling
context.subscriptions.push(
vscode.window.onDidChangeWindowState(e => {
store.setFocused(e.focused);
})
);

// Pinned workflows
await initPinnedWorkflows(store);

Expand All @@ -73,7 +80,7 @@ export async function activate(context: vscode.ExtensionContext) {
registerOpenWorkflowFile(context);
registerOpenWorkflowJobLogs(context);
registerOpenWorkflowStepLogs(context);
registerTriggerWorkflowRun(context);
registerTriggerWorkflowRun(context, store);
registerReRunWorkflowRun(context);
registerCancelWorkflowRun(context);

Expand Down
29 changes: 27 additions & 2 deletions src/store/store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {setInterval} from "timers";
import {EventEmitter} from "vscode";
import {GitHubRepoContext} from "../git/repository";
import {logDebug} from "../log";
import {log, logDebug} from "../log";
import * as model from "../model";
import {WorkflowRun} from "./workflowRun";

Expand All @@ -20,6 +20,18 @@ type Updater = {
export class RunStore extends EventEmitter<RunStoreEvent> {
private runs = new Map<number, WorkflowRun>();
private updaters = new Map<number, Updater>();
private _isFocused = true;
private _isViewVisible = true;

setFocused(focused: boolean) {
this._isFocused = focused;
logDebug(`[Store]: Focus state changed to ${String(focused)}`);
}

setViewVisible(visible: boolean) {
this._isViewVisible = visible;
logDebug(`[Store]: View visibility changed to ${String(visible)}`);
}

getRun(runId: number): WorkflowRun | undefined {
return this.runs.get(runId);
Expand All @@ -46,6 +58,7 @@ export class RunStore extends EventEmitter<RunStoreEvent> {
* Start polling for updates for the given run
*/
pollRun(runId: number, repoContext: GitHubRepoContext, intervalMs: number, attempts = 10) {
log(`Starting polling for run ${runId} every ${intervalMs}ms for ${attempts} attempts`);
const existingUpdater: Updater | undefined = this.updaters.get(runId);
if (existingUpdater && existingUpdater.handle) {
clearInterval(existingUpdater.handle);
Expand All @@ -65,7 +78,11 @@ export class RunStore extends EventEmitter<RunStoreEvent> {
}

private async fetchRun(updater: Updater) {
logDebug("Updating run: ", updater.runId);
if (!this._isFocused || !this._isViewVisible) {
return;
}

log(`Fetching run update: ${updater.runId}. Remaining attempts: ${updater.remainingAttempts}`);

updater.remainingAttempts--;
Comment on lines +81 to 87
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetchRun method skips the API call when the window is not focused or view is not visible, but it still decrements remainingAttempts (line 87). This means if the window is unfocused for a long time, the remaining attempts could be exhausted without making any API calls. When the user returns to the window, polling may have already stopped. Consider only decrementing remainingAttempts when an API call is actually made.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

if (updater.remainingAttempts === 0) {
Expand All @@ -83,6 +100,14 @@ export class RunStore extends EventEmitter<RunStoreEvent> {
});

const run = result.data;
log(`Polled run: ${run.id} Status: ${run.status || "null"} Conclusion: ${run.conclusion || "null"}`);
this.addRun(updater.repoContext, run);

if (run.status === "completed") {
if (updater.handle) {
clearInterval(updater.handle);
}
this.updaters.delete(updater.runId);
}
}
}
8 changes: 3 additions & 5 deletions src/store/workflowRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,9 @@ abstract class WorkflowRunBase {
}

updateRun(run: model.WorkflowRun) {
if (this._run.status !== "completed" || this._run.updated_at !== run.updated_at) {
// Refresh jobs if the run is not completed or it was updated (i.e. re-run)
// For in-progress runs, we can't rely on updated at to change when jobs change
this._jobs = undefined;
}
// Always clear jobs cache when updating run to ensure we get latest job status
// This is critical for polling to work correctly for in-progress runs
this._jobs = undefined;

this._run = run;
}
Expand Down
12 changes: 11 additions & 1 deletion src/treeViews/treeViews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ import {WorkflowsTreeProvider} from "./workflows";

export async function initTreeViews(context: vscode.ExtensionContext, store: RunStore): Promise<void> {
const workflowTreeProvider = new WorkflowsTreeProvider(store);
context.subscriptions.push(vscode.window.registerTreeDataProvider("github-actions.workflows", workflowTreeProvider));
const workflowTreeView = vscode.window.createTreeView("github-actions.workflows", {
treeDataProvider: workflowTreeProvider
});
context.subscriptions.push(workflowTreeView);

store.setViewVisible(workflowTreeView.visible);
context.subscriptions.push(
workflowTreeView.onDidChangeVisibility(e => {
store.setViewVisible(e.visible);
})
);

const settingsTreeProvider = new SettingsTreeProvider();
context.subscriptions.push(vscode.window.registerTreeDataProvider("github-actions.settings", settingsTreeProvider));
Expand Down
12 changes: 12 additions & 0 deletions src/treeViews/workflowRunTreeDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export abstract class WorkflowRunTreeDataProvider {
): WorkflowRunNode[] {
return runData.map(runData => {
const workflowRun = this.store.addRun(gitHubRepoContext, runData);

// Auto-poll active runs
if (
workflowRun.run.status === "in_progress" ||
workflowRun.run.status === "queued" ||
workflowRun.run.status === "waiting" ||
workflowRun.run.status === "requested"
) {
// Poll every 4 seconds for up to 15 minutes (225 attempts)
this.store.pollRun(workflowRun.run.id, gitHubRepoContext, 4000, 225);
Comment on lines +28 to +36
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pollRun method in workflowRunTreeDataProvider is called every time runNodes is invoked, which could be during tree refreshes. This means that active runs that are already being polled will have their polling restarted with full attempts (225), potentially creating multiple polling intervals for the same run. The store's pollRun does clear the existing interval before creating a new one, but this restart behavior may not be desired. Consider checking if polling is already active for a run before starting new polling.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +35 to +36
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic numbers 4000 and 225 are used directly without explanation. While there is a comment stating "Poll every 4 seconds for up to 15 minutes", having these values as named constants would improve maintainability and make it easier to adjust the polling strategy across the codebase. Consider defining constants like POLL_INTERVAL_MS and MAX_POLL_ATTEMPTS.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

}

const node = new WorkflowRunNode(
this.store,
gitHubRepoContext,
Expand Down
3 changes: 2 additions & 1 deletion src/treeViews/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import {WorkflowNode} from "./workflows/workflowNode";
import {getWorkflowNodes, WorkflowsRepoNode} from "./workflows/workflowsRepoNode";
import {WorkflowStepNode} from "./workflows/workflowStepNode";

type WorkflowsTreeNode =
export type WorkflowsTreeNode =
| AuthenticationNode
| NoGitHubRepositoryNode
| WorkflowsRepoNode
| WorkflowNode
| WorkflowRunNode
| PreviousAttemptsNode
Expand Down