diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 93cc2e03..4ee012f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,12 +12,14 @@ permissions: jobs: build: - runs-on: ubuntu-latest + # Keep the runner compatible with the locked Playwright browser installer. + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 16.x + node-version: 24.x cache: "npm" registry-url: "https://npm.pkg.github.com" - run: npm ci diff --git a/src/commands/triggerWorkflowRun.ts b/src/commands/triggerWorkflowRun.ts index 3deba5b0..b5ed2938 100644 --- a/src/commands/triggerWorkflowRun.ts +++ b/src/commands/triggerWorkflowRun.ts @@ -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) { @@ -43,6 +51,28 @@ export function registerTriggerWorkflowRun(context: vscode.ExtensionContext) { return; } + const relativeWorkflowPath = vscode.workspace.asRelativePath(workflowUri, false); + if (!workflowIdForApi) { + workflowIdForApi = basename(workflowUri.fsPath); + } + + 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"], { @@ -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, @@ -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}`); @@ -134,10 +163,53 @@ export function registerTriggerWorkflowRun(context: vscode.ExtensionContext) { client_payload: {} }); + dispatched = true; vscode.window.setStatusBarMessage(`GitHub Actions: Repository event '${event_type}' dispatched`, 2000); } } + if (dispatched) { + const refreshed = 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); + return true; + } + } catch (e) { + log(`Error checking for new run: ${(e as Error).message}`); + } + } + return false; + } + ); + if (refreshed) { + return; + } + } + return vscode.commands.executeCommand("github-actions.explorer.refresh"); } ) diff --git a/src/extension.ts b/src/extension.ts index 210c9548..dbee6d20 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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); @@ -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); diff --git a/src/store/polling.test.ts b/src/store/polling.test.ts new file mode 100644 index 00000000..f4c0962b --- /dev/null +++ b/src/store/polling.test.ts @@ -0,0 +1,263 @@ +import {jest} from "@jest/globals"; +import {Octokit} from "@octokit/rest"; +import {setImmediate as nextTurn} from "node:timers"; +import type * as vscode from "vscode"; +import type {GitHubRepoContext} from "../git/repository"; +import type {WorkflowRun as RunData} from "../model"; +import type {WorkflowRunNode} from "../treeViews/shared/workflowRunNode"; + +class TestUri { + constructor(public fsPath: string) {} + + static file(path: string) { + return new TestUri(path); + } +} + +const executeCommand = jest.fn<(command: string) => Promise>(); +const registerCommand = + jest.fn<(command: string, callback: (uri: vscode.Uri) => Promise) => vscode.Disposable>(); +const showInputBox = jest.fn<() => Promise>(); +const log = jest.fn(); +let repoContext: GitHubRepoContext; + +// Jest 29.0's ESM resolver also needs the virtual module registered in its CommonJS map. +jest.mock("vscode", () => ({}), {virtual: true}); +jest.unstable_mockModule( + "vscode", + () => ({ + Uri: TestUri, + EventEmitter: class { + event = jest.fn<(listener: unknown) => void>(); + fire = jest.fn<(event: unknown) => void>(); + }, + commands: {executeCommand, registerCommand}, + workspace: { + getWorkspaceFolder: () => ({uri: TestUri.file("/repo")}), + asRelativePath: () => ".github/workflows/build.yml" + }, + window: { + showInputBox, + setStatusBarMessage: jest.fn(), + withProgress: async (_options: unknown, task: () => Promise) => task() + }, + ProgressLocation: {Window: 10} + }), + {virtual: true} +); +jest.unstable_mockModule("../log", () => ({log, logDebug: jest.fn()})); +jest.unstable_mockModule("timers", () => ({ + setInterval: (callback: () => void, intervalMs: number) => setInterval(callback, intervalMs) +})); +jest.unstable_mockModule("../git/repository", () => ({ + getGitHead: () => Promise.resolve("refs/heads/main"), + getGitHubContextForWorkspaceUri: () => Promise.resolve(repoContext) +})); +jest.unstable_mockModule("../workflow/workflow", () => ({ + parseWorkflowFile: () => Promise.resolve({events: {workflow_dispatch: {}}}), + getWorkflowUri: () => TestUri.file("/repo/.github/workflows/build.yml") +})); +jest.unstable_mockModule("./workflowRun", () => ({ + WorkflowRun: class { + constructor(_context: GitHubRepoContext, public run: RunData) {} + + updateRun(run: RunData) { + this.run = run; + } + } +})); +jest.unstable_mockModule("../treeViews/shared/workflowRunNode", () => ({ + WorkflowRunNode: class { + updateRun = jest.fn(); + } +})); + +const {RunStore} = await import("./store"); +const {WorkflowRunTreeDataProvider} = await import("../treeViews/workflowRunTreeDataProvider"); +const {registerTriggerWorkflowRun} = await import("../commands/triggerWorkflowRun"); +const {Uri} = await import("vscode"); + +class TestProvider extends WorkflowRunTreeDataProvider { + refresh(run: RunData) { + return this.runNodes(repoContext, [run]); + } + + protected _updateNode = jest.fn<(node: WorkflowRunNode) => void>(); +} + +const runData = {id: 42, status: "in_progress", updated_at: "2025-01-01T00:00:00Z"} as RunData; + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + repoContext = { + client: new Octokit(), + repositoryState: undefined, + workspaceUri: Uri.file("/repo"), + id: 1, + owner: "owner", + name: "repo", + organizationOwned: false, + defaultBranch: "main", + permissionLevel: "write" + }; + executeCommand.mockResolvedValue(undefined); + showInputBox.mockResolvedValue("main"); +}); + +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +function mockRunFetch(run: RunData = runData) { + return jest.spyOn(repoContext.client.actions, "getWorkflowRun").mockResolvedValue({ + data: run, + status: 200, + headers: {}, + url: "https://api.github.com/repos/owner/repo/actions/runs/42" + }); +} + +async function advanceTime(ms: number) { + await new Promise(resolve => nextTurn(resolve)); + for (let elapsed = 0; elapsed < ms; elapsed += 1000) { + jest.advanceTimersByTime(Math.min(1000, ms - elapsed)); + await new Promise(resolve => nextTurn(resolve)); + } +} + +describe("active run polling", () => { + it("preserves the timer deadline when tree nodes refresh", async () => { + const fetch = mockRunFetch(); + const provider = new TestProvider(new RunStore()); + provider.refresh(runData); + await advanceTime(3000); + provider.refresh(runData); + await advanceTime(1000); + expect(fetch).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); + }); + + it("does not replenish the 225-attempt budget on tree refresh", async () => { + const fetch = mockRunFetch(); + const provider = new TestProvider(new RunStore()); + provider.refresh(runData); + await advanceTime(4000); + provider.refresh(runData); + await advanceTime(4000 * 224); + expect(fetch).toHaveBeenCalledTimes(225); + expect(jest.getTimerCount()).toBe(0); + await advanceTime(4000); + expect(fetch).toHaveBeenCalledTimes(225); + }); + + it("preserves explicit restart behavior for rerun and cancel commands", async () => { + const fetch = mockRunFetch(); + const store = new RunStore(); + const provider = new TestProvider(store); + provider.refresh(runData); + await advanceTime(3000); + store.pollRun(runData.id, repoContext, 1000, 2); + provider.refresh(runData); + await advanceTime(2000); + expect(fetch).toHaveBeenCalledTimes(2); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each(["focus", "visibility"])("pauses without consuming attempts when %s is lost", async state => { + const fetch = mockRunFetch(); + const store = new RunStore(); + const setActive = (active: boolean) => { + if (state === "focus") store.setFocused(active); + else store.setViewVisible(active); + }; + store.pollRun(runData.id, repoContext, 1000, 2); + setActive(false); + await advanceTime(10000); + expect(fetch).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(1); + setActive(true); + await advanceTime(2000); + expect(fetch).toHaveBeenCalledTimes(2); + expect(jest.getTimerCount()).toBe(0); + }); + + it("stops polling when the run completes", async () => { + const fetch = mockRunFetch({...runData, status: "completed"}); + new TestProvider(new RunStore()).refresh(runData); + await advanceTime(8000); + expect(fetch).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + }); +}); + +describe("triggered workflow refresh", () => { + function setupTrigger() { + const listRuns = jest.spyOn(repoContext.client.actions, "listWorkflowRuns").mockResolvedValue({ + data: {total_count: 1, workflow_runs: [runData]}, + status: 200, + headers: {}, + url: "https://api.github.com/repos/owner/repo/actions/workflows/build.yml/runs" + }); + const dispatch = jest.spyOn(repoContext.client.actions, "createWorkflowDispatch").mockResolvedValue({ + data: undefined as never, + status: 204, + headers: {}, + url: "https://api.github.com/repos/owner/repo/actions/workflows/build.yml/dispatches" + }); + const store = new RunStore(); + const poll = jest.spyOn(store, "pollRun").mockImplementation(() => undefined); + registerCommand.mockReturnValue({dispose: () => undefined}); + const context: Partial = {subscriptions: []}; + registerTriggerWorkflowRun(context as vscode.ExtensionContext, store); + const callback = registerCommand.mock.calls[0][1]; + return {listRuns, dispatch, poll, invoke: () => callback(Uri.file("/repo/.github/workflows/build.yml"))}; + } + + it("refreshes once and polls immediately when a new run is detected", async () => { + const {listRuns, poll, invoke} = setupTrigger(); + listRuns.mockResolvedValueOnce({ + data: {total_count: 0, workflow_runs: []}, + status: 200, + headers: {}, + url: "https://api.github.com/repos/owner/repo/actions/workflows/build.yml/runs" + }); + await invoke(); + expect(listRuns).toHaveBeenCalledTimes(2); + expect(executeCommand).toHaveBeenCalledTimes(1); + expect(executeCommand).toHaveBeenCalledWith("github-actions.explorer.refresh"); + expect(poll).toHaveBeenCalledWith(runData.id, repoContext, 4000, 225); + expect(jest.getTimerCount()).toBe(0); + }); + + it("keeps the fallback refresh when discovery times out", async () => { + const {listRuns, poll, invoke} = setupTrigger(); + const pending = invoke(); + await advanceTime(19000); + await pending; + expect(listRuns).toHaveBeenCalledTimes(21); + expect(executeCommand).toHaveBeenCalledTimes(1); + expect(poll).not.toHaveBeenCalled(); + }); + + it("keeps the fallback refresh when discovery requests fail", async () => { + const {listRuns, poll, invoke} = setupTrigger(); + listRuns.mockRejectedValue(new Error("API unavailable")); + const pending = invoke(); + await advanceTime(19000); + await pending; + expect(log).toHaveBeenCalledWith("Error checking for new run: API unavailable"); + expect(executeCommand).toHaveBeenCalledTimes(1); + expect(poll).not.toHaveBeenCalled(); + }); + + it("does not poll when dispatch is cancelled", async () => { + const {dispatch, poll, invoke} = setupTrigger(); + showInputBox.mockResolvedValue(undefined); + await invoke(); + expect(dispatch).not.toHaveBeenCalled(); + expect(poll).not.toHaveBeenCalled(); + expect(executeCommand).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/store/store.ts b/src/store/store.ts index 44918f33..244b1877 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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"; @@ -20,6 +20,18 @@ type Updater = { export class RunStore extends EventEmitter { private runs = new Map(); private updaters = new Map(); + 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); @@ -43,10 +55,16 @@ export class RunStore extends EventEmitter { } /** - * Start polling for updates for the given run + * Start polling for updates for the given run. + * Set restartExisting to false during tree refreshes to preserve the current polling budget. */ - pollRun(runId: number, repoContext: GitHubRepoContext, intervalMs: number, attempts = 10) { + pollRun(runId: number, repoContext: GitHubRepoContext, intervalMs: number, attempts = 10, restartExisting = true) { const existingUpdater: Updater | undefined = this.updaters.get(runId); + if (existingUpdater && !restartExisting) { + return; + } + + log(`Starting polling for run ${runId} every ${intervalMs}ms for ${attempts} attempts`); if (existingUpdater && existingUpdater.handle) { clearInterval(existingUpdater.handle); } @@ -65,7 +83,11 @@ export class RunStore extends EventEmitter { } 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--; if (updater.remainingAttempts === 0) { @@ -83,6 +105,14 @@ export class RunStore extends EventEmitter { }); 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); + } } } diff --git a/src/store/workflowRun.ts b/src/store/workflowRun.ts index 1001bc2e..25eb5fe0 100644 --- a/src/store/workflowRun.ts +++ b/src/store/workflowRun.ts @@ -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; } diff --git a/src/treeViews/treeViews.ts b/src/treeViews/treeViews.ts index 6261b1b2..f75c569b 100644 --- a/src/treeViews/treeViews.ts +++ b/src/treeViews/treeViews.ts @@ -11,7 +11,17 @@ import {WorkflowsTreeProvider} from "./workflows"; export async function initTreeViews(context: vscode.ExtensionContext, store: RunStore): Promise { 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)); diff --git a/src/treeViews/workflowRunTreeDataProvider.ts b/src/treeViews/workflowRunTreeDataProvider.ts index 4ce67dd4..6606f76e 100644 --- a/src/treeViews/workflowRunTreeDataProvider.ts +++ b/src/treeViews/workflowRunTreeDataProvider.ts @@ -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, false); + } + const node = new WorkflowRunNode( this.store, gitHubRepoContext, diff --git a/src/treeViews/workflows.ts b/src/treeViews/workflows.ts index b91a55b6..51f4bcdc 100644 --- a/src/treeViews/workflows.ts +++ b/src/treeViews/workflows.ts @@ -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