Commit b06a1d9f authored by Lennard Sprong's avatar Lennard Sprong Committed by Tomas Vik (OOO back on 2026-08-31)
Browse files

feat: Add Download Artifacts context button

parent 2f651555
Loading
Loading
Loading
Loading
+13 −0
Original line number Diff line number Diff line
@@ -238,6 +238,11 @@
        "command": "gl.clearSelectedProject",
        "title": "Clear selected project",
        "category": "GitLab"
      },
      {
        "command": "gl.downloadArtifacts",
        "title": "Download artifacts",
        "category": "GitLab"
      }
    ],
    "menus": {
@@ -286,6 +291,10 @@
          "command": "gl.checkoutMrBranch",
          "when": "false"
        },
        {
          "command": "gl.downloadArtifacts",
          "when": "false"
        },
        {
          "command": "gl.openMrFile",
          "when": "false"
@@ -424,6 +433,10 @@
        {
          "command": "gl.clearSelectedProject",
          "when": "view =~ /issuesAndMrs/ && viewItem == selected-project"
        },
        {
          "command": "gl.downloadArtifacts",
          "when": "view =~ /currentBranchInfo/ && viewItem == with-artifacts"
        }
      ],
      "comments/comment/title": [
+1 −0
Original line number Diff line number Diff line
@@ -44,6 +44,7 @@ export const USER_COMMANDS = {
  SELECT_PROJECT: 'gl.selectProject',
  ASSIGN_PROJECT: 'gl.assignProject',
  CLEAR_SELECTED_PROJECT: 'gl.clearSelectedProject',
  DOWNLOAD_ARTIFACTS: 'gl.downloadArtifacts',
  AUTHENTICATE: 'gl.authenticate',
};

+75 −0
Original line number Diff line number Diff line
import * as vscode from 'vscode';
import { VS_COMMANDS } from '../command_names';
import { asMock } from '../test_utils/as_mock';
import { job, artifact } from '../test_utils/entities';
import { JobItemModel } from '../tree_view/items/job_item_model';
import { StageItemModel } from '../tree_view/items/stage_item_model';
import { downloadArtifacts } from './download_artifact';

describe('downloadArtifacts', () => {
  const traceJob: RestJob = { ...job, artifacts: [{ ...artifact, file_type: 'trace' }] };
  const archiveJob: RestJob = {
    ...job,
    name: 'test 3',
    artifacts: [{ ...artifact, file_type: 'archive' }],
  };
  const junitJob: RestJob = { ...job, name: 'test 2', artifacts: [artifact] };
  const multipleJob: RestJob = {
    ...job,
    name: 'test 1',
    artifacts: [{ ...artifact, file_type: 'cobertura' }, artifact],
  };

  afterEach(() => {
    jest.resetAllMocks();
  });

  it('rejects jobs without artifacts', async () => {
    await downloadArtifacts(new JobItemModel(traceJob));
    expect(vscode.window.showQuickPick).not.toBeCalled();
    expect(vscode.commands.executeCommand).not.toBeCalled();
    expect(vscode.window.showWarningMessage).toBeCalled();
  });

  it('displays the artifacts in a picker', async () => {
    await downloadArtifacts(new JobItemModel(multipleJob));
    expect(vscode.window.showQuickPick).toBeCalled();
    const items = asMock(vscode.window.showQuickPick).mock.lastCall[0];
    expect(items).toHaveLength(2);
    expect(items[0].label).toBe('$(file) cobertura');
    expect(items[1].label).toBe('$(file) junit');
  });

  it('displays the job name when multiple jobs are given', async () => {
    await downloadArtifacts(new StageItemModel('test', [archiveJob, multipleJob]));
    expect(vscode.window.showQuickPick).toBeCalled();
    const items = asMock(vscode.window.showQuickPick).mock.lastCall[0];
    expect(items).toHaveLength(3);
    expect(items[0].label).toBe('$(file-zip) test 3:archive');
    expect(items[1].label).toBe('$(file) test 1:cobertura');
    expect(items[2].label).toBe('$(file) test 1:junit');
  });

  it('allows cancelling the picker', async () => {
    asMock(vscode.window.showQuickPick).mockResolvedValue(undefined);
    await downloadArtifacts(new StageItemModel('test', [junitJob, multipleJob]));
    expect(vscode.window.showQuickPick).toBeCalled();
    expect(vscode.commands.executeCommand).not.toBeCalled();
  });

  it('downloads the selected artifact', async () => {
    asMock(vscode.window.showQuickPick).mockImplementation(options => options[1]);
    await downloadArtifacts(new StageItemModel('test', [junitJob, multipleJob]));
    expect(vscode.window.showQuickPick).toBeCalled();

    const uri = `${multipleJob.web_url}/artifacts/download?file_type=cobertura`;
    expect(vscode.commands.executeCommand).toBeCalledWith(VS_COMMANDS.OPEN, vscode.Uri.parse(uri));
  });

  it('downloads immediately when only an archive is available', async () => {
    await downloadArtifacts(new JobItemModel(archiveJob));
    expect(vscode.window.showQuickPick).not.toBeCalled();
    const uri = `${archiveJob.web_url}/artifacts/download?file_type=archive`;
    expect(vscode.commands.executeCommand).toBeCalledWith(VS_COMMANDS.OPEN, vscode.Uri.parse(uri));
  });
});
+47 −0
Original line number Diff line number Diff line
import * as vscode from 'vscode';
import { VS_COMMANDS } from '../command_names';
import { isArtifactDownloadable, JobProvider } from '../tree_view/items/job_provider';

const first = <T>(a: T[]): T | undefined => a[0];

interface ArtifactPickItem extends vscode.QuickPickItem {
  job: RestJob;
  artifact: RestArtifact;
}

export async function downloadArtifacts(item: JobProvider): Promise<void> {
  let result: ArtifactPickItem | undefined;

  const jobs = item.getJobs();
  const singleJob = jobs.length === 1 ? first(jobs) : null;
  const artifacts = jobs.flatMap(
    j => j.artifacts?.filter(isArtifactDownloadable).map(a => ({ artifact: a, job: j })) ?? [],
  );

  if (!artifacts.length) {
    await vscode.window.showWarningMessage('This job does not have downloadable artifacts.');
    return;
  }

  const items: ArtifactPickItem[] = artifacts.map(a => ({
    label: `$(${a.artifact.file_type === 'archive' ? 'file-zip' : 'file'}) ${
      singleJob ? '' : `${a.job.name}:`
    }${a.artifact.file_type}`,
    description: a.artifact.filename,
    job: a.job,
    artifact: a.artifact,
  }));

  if (singleJob && items.length === 1 && items[0].artifact.file_type === 'archive') {
    result = first(items);
  } else {
    result = await vscode.window.showQuickPick(items, {
      title: 'Download artifacts',
    });
  }

  if (!result) return;

  const uri = `${result.job.web_url}/artifacts/download?file_type=${result.artifact.file_type}`;
  await vscode.commands.executeCommand(VS_COMMANDS.OPEN, vscode.Uri.parse(uri));
}
+2 −0
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ import { changeTypeDecorationProvider } from './review/change_type_decoration_pr
import { checkoutMrBranch } from './commands/checkout_mr_branch';
import { cloneWiki } from './commands/clone_wiki';
import { createSnippetPatch } from './commands/create_snippet_patch';
import { downloadArtifacts } from './commands/download_artifact';
import { applySnippetPatch } from './commands/apply_snippet_patch';
import { openMrFile } from './commands/open_mr_file';
import { GitLabRemoteFileSystem } from './remotefs/gitlab_remote_file_system';
@@ -135,6 +136,7 @@ const registerCommands = (
    [USER_COMMANDS.SELECT_PROJECT]: selectProjectCommand,
    [USER_COMMANDS.ASSIGN_PROJECT]: assignProject,
    [USER_COMMANDS.CLEAR_SELECTED_PROJECT]: clearSelectedProjects,
    [USER_COMMANDS.DOWNLOAD_ARTIFACTS]: downloadArtifacts,
    [PROGRAMMATIC_COMMANDS.NO_IMAGE_REVIEW]: () =>
      vscode.window.showInformationMessage("GitLab MR review doesn't support images yet."),
  };
Loading