gitlab_service.ts 17.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11
import * as vscode from 'vscode';
import * as request from 'request-promise';
import * as fs from 'fs';
import { tokenService } from './services/token_service';
import { UserFriendlyError } from './errors/user_friendly_error';
import { ApiError } from './errors/api_error';
import { getCurrentWorkspaceFolder } from './services/workspace_service';
import { createGitService } from './git_service_factory';
import { GitRemote } from './git/git_remote_parser';
import { handleError, logError } from './log';
import { getUserAgentHeader } from './utils/get_user_agent_header';
T
Tomas Vik 已提交
12 13
import { CustomQueryType } from './gitlab/custom_query_type';
import { CustomQuery } from './gitlab/custom_query';
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39

interface GitLabProject {
  id: number;
  name: string;
  namespace: {
    id: number;
    kind: string;
  };
  // eslint-disable-next-line camelcase
  path_with_namespace: string;
}

interface GitLabPipeline {
  id: number;
}

interface GitLabJob {
  name: string;
  // eslint-disable-next-line camelcase
  created_at: string;
}

const projectCache: Record<string, GitLabProject> = {};
let versionCache: string | null = null;

async function fetch(path: string, method = 'GET', data?: Record<string, unknown>) {
40 41 42
  const { ignoreCertificateErrors, ca, cert, certKey } = vscode.workspace.getConfiguration(
    'gitlab',
  );
T
Tomas Vik 已提交
43
  const instanceUrl = await createGitService(
44 45 46
    // fetching of instanceUrl is the only GitService method that doesn't need workspaceFolder
    // TODO: remove this default value once we implement https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/issues/260
    (await getCurrentWorkspaceFolder()) || '',
T
Tomas Vik 已提交
47
  ).fetchCurrentInstanceUrl();
48
  const { proxy } = vscode.workspace.getConfiguration('http');
49
  const apiRoot = `${instanceUrl}/api/v4`;
50
  const glToken = tokenService.getToken(instanceUrl);
51
  const tokens = tokenService.getInstanceUrls().join(', ');
52

F
Fatih Acet 已提交
53
  if (!glToken) {
54 55 56 57 58 59 60 61 62 63
    let err = `
      GitLab Workflow: Cannot make request.
      GitLab URL for this workspace is set to ${instanceUrl}
      and there is no matching token for this URL.
    `;

    if (tokens.length) {
      err = `${err} You have configured tokens for ${tokens}.`;
    }

T
Tomas Vik 已提交
64
    vscode.window.showInformationMessage(err);
T
Tomas Vik 已提交
65
    throw new Error(err);
F
Fatih Acet 已提交
66 67
  }

68
  const config: request.RequestPromiseOptions = {
F
Fatih Acet 已提交
69
    method,
70
    headers: {
F
Fatih Acet 已提交
71
      'PRIVATE-TOKEN': glToken,
72
      ...getUserAgentHeader(),
F
Fatih Acet 已提交
73
    },
74
    rejectUnauthorized: !ignoreCertificateErrors,
75 76
  };

77
  if (proxy) {
78 79 80
    config.proxy = proxy;
  }

P
Pierre Carru 已提交
81 82 83 84
  if (ca) {
    try {
      config.ca = fs.readFileSync(ca);
    } catch (e) {
85
      handleError(new UserFriendlyError(`Cannot read CA '${ca}'`, e));
P
Pierre Carru 已提交
86 87 88
    }
  }

89 90 91 92
  if (cert) {
    try {
      config.cert = fs.readFileSync(cert);
    } catch (e) {
93
      handleError(new UserFriendlyError(`Cannot read CA '${cert}'`, e));
94 95 96 97 98 99 100
    }
  }

  if (certKey) {
    try {
      config.key = fs.readFileSync(certKey);
    } catch (e) {
101
      handleError(new UserFriendlyError(`Cannot read CA '${certKey}'`, e));
102 103 104
    }
  }

F
Fatih Acet 已提交
105 106 107 108
  if (data) {
    config.formData = data;
  }

109 110 111 112 113 114 115
  config.transform = (body, response) => {
    try {
      return {
        response: JSON.parse(body),
        headers: response.headers,
      };
    } catch (e) {
116
      handleError(
T
Tomas Vik 已提交
117 118
        new UserFriendlyError('Failed to parse GitLab API response', e, `Response body: ${body}`),
      );
119 120 121
      return { error: e };
    }
  };
122

123
  return await request(`${apiRoot}${path}`, config);
124 125
}

126
async function fetchProjectData(remote: GitRemote | null) {
F
Fatih Acet 已提交
127
  if (remote) {
128 129 130 131 132 133 134
    if (!(`${remote.namespace}_${remote.project}` in projectCache)) {
      const { namespace, project } = remote;
      const { response } = await fetch(`/projects/${namespace.replace(/\//g, '%2F')}%2F${project}`);
      const projectData = response;
      projectCache[`${remote.namespace}_${remote.project}`] = projectData;
    }
    return projectCache[`${remote.namespace}_${remote.project}`] || null;
F
Fatih Acet 已提交
135 136 137 138 139
  }

  return null;
}

140
export async function fetchCurrentProject(workspaceFolder: string): Promise<GitLabProject | null> {
141
  try {
T
Tomas Vik 已提交
142
    const remote = await createGitService(workspaceFolder).fetchGitRemote();
143 144 145

    return await fetchProjectData(remote);
  } catch (e) {
146 147 148 149
    throw new ApiError(e, 'get current project');
  }
}

150
export async function fetchCurrentProjectSwallowError(workspaceFolder: string) {
151
  try {
T
Tomas Vik 已提交
152
    return await fetchCurrentProject(workspaceFolder);
153
  } catch (error) {
154
    logError(error);
155 156 157 158
    return null;
  }
}

159
export async function fetchCurrentPipelineProject(workspaceFolder: string) {
160
  try {
T
Tomas Vik 已提交
161
    const remote = await createGitService(workspaceFolder).fetchGitRemotePipeline();
162 163 164

    return await fetchProjectData(remote);
  } catch (e) {
165
    logError(e);
166 167 168 169
    return null;
  }
}

170
export async function fetchCurrentUser() {
171
  try {
172 173
    const { response: user } = await fetch('/user');
    return user;
174
  } catch (e) {
175
    throw new ApiError(e, 'get current user');
176
  }
177
}
F
Fatih Acet 已提交
178

179
async function fetchFirstUserByUsername(userName: string) {
180 181 182 183
  try {
    const { response: users } = await fetch(`/users?username=${userName}`);
    return users[0];
  } catch (e) {
184
    handleError(new UserFriendlyError('Error when fetching GitLab user.', e));
185 186
    return undefined;
  }
187 188
}

189
export async function fetchVersion() {
190
  try {
191 192 193 194
    if (!versionCache) {
      const { response } = await fetch('/version');
      versionCache = response.version;
    }
195
  } catch (e) {
196
    logError(e);
197
  }
198

199
  return versionCache;
200 201
}

202 203 204 205 206 207 208 209
export async function getAllGitlabProjects() {
  if (!vscode.workspace.workspaceFolders) {
    return [];
  }
  const projectsWithUri = vscode.workspace.workspaceFolders.map(async workspaceFolder => ({
    label: (await fetchCurrentProject(workspaceFolder.uri.fsPath))?.name,
    uri: workspaceFolder.uri.fsPath,
  }));
210

211
  const fetchedProjectsWithUri = await Promise.all(projectsWithUri);
212

213
  return fetchedProjectsWithUri.filter(p => p.label);
214 215
}

216
export async function fetchLastPipelineForCurrentBranch(workspaceFolder: string) {
217 218 219 220
  const project = await fetchCurrentPipelineProject(workspaceFolder);
  let pipeline = null;

  if (project) {
T
Tomas Vik 已提交
221
    const branchName = await createGitService(workspaceFolder).fetchTrackingBranchName();
222 223 224 225 226
    const pipelinesRootPath = `/projects/${project.id}/pipelines`;
    const { response } = await fetch(`${pipelinesRootPath}?ref=${branchName}`);
    const pipelines = response;

    if (pipelines.length) {
227 228
      const fetchResult = await fetch(`${pipelinesRootPath}/${pipelines[0].id}`);
      pipeline = fetchResult.response;
229 230 231 232 233 234
    }
  }

  return pipeline;
}

235 236
type QueryValue = string | boolean | string[] | number | undefined;

T
Tomas Vik 已提交
237
export async function fetchIssuables(params: CustomQuery, workspaceFolder: string) {
238
  const { type, scope, state, author, assignee, wip } = params;
239
  let { searchIn, pipelineId } = params;
F
Fatih Acet 已提交
240 241
  const config = {
    type: type || 'merge_requests',
242
    scope: scope || 'all',
F
Fatih Acet 已提交
243
    state: state || 'opened',
244
  };
245
  let issuable = null;
246

247 248 249
  const version = await fetchVersion();
  if (!version) {
    return [];
250
  }
F
Fatih Acet 已提交
251

252
  const project = await fetchCurrentProjectSwallowError(workspaceFolder);
F
Fatih Acet 已提交
253
  if (project) {
254 255 256 257 258 259 260 261 262 263
    if (config.type === 'vulnerabilities' && config.scope !== 'dismissed') {
      config.scope = 'all';
    } else if (
      (config.type === 'issues' || config.type === 'merge_requests') &&
      config.scope !== 'assigned_to_me' &&
      config.scope !== 'created_by_me'
    ) {
      config.scope = 'all';
    }

264
    // Normalize scope parameter for version < 11 instances.
265
    const [major] = version.split('.');
266 267 268 269
    if (parseInt(major, 10) < 11) {
      config.scope = config.scope.replace(/_/g, '-');
    }

270
    let path = '';
F
Fatih Acet 已提交
271

272 273 274 275 276 277 278
    if (config.type === 'epics') {
      if (project.namespace.kind === 'group') {
        path = `/groups/${project.namespace.id}/${config.type}?include_ancestor_groups=true&state=${config.state}`;
      } else {
        return [];
      }
    } else {
T
Tomas Vik 已提交
279 280 281
      const searchKind =
        config.type === CustomQueryType.VULNERABILITY ? 'vulnerability_findings' : config.type;
      path = `/projects/${project.id}/${searchKind}?scope=${config.scope}&state=${config.state}`;
282 283 284 285 286 287
    }
    if (config.type === 'issues') {
      if (author) {
        path = `${path}&author_username=${author}`;
      }
    } else if (author) {
288 289 290
      const authorUser = await fetchFirstUserByUsername(author);
      if (authorUser) {
        path = `${path}&author_id=${authorUser.id}`;
291 292 293 294 295 296 297 298 299
      } else {
        path = `${path}&author_id=-1`;
      }
    }
    if (assignee === 'Any' || assignee === 'None') {
      path = `${path}&assignee_id=${assignee}`;
    } else if (assignee && config.type === 'issues') {
      path = `${path}&assignee_username=${assignee}`;
    } else if (assignee) {
300 301 302
      const assigneeUser = await fetchFirstUserByUsername(assignee);
      if (assigneeUser) {
        path = `${path}&assignee_id=${assigneeUser.id}`;
303 304 305 306 307 308 309 310 311 312 313 314 315
      } else {
        path = `${path}&assignee_id=-1`;
      }
    }
    if (searchIn) {
      if (searchIn === 'all') {
        searchIn = 'title,description';
      }
      path = `${path}&in=${searchIn}`;
    }
    if (config.type === 'merge_requests' && wip) {
      path = `${path}&wip=${wip}`;
    }
316
    let issueQueryParams: Record<string, QueryValue> = {};
317
    if (config.type === 'issues') {
318 319 320 321 322 323 324 325 326
      issueQueryParams = {
        confidential: params.confidential,
        'not[labels]': params.excludeLabels,
        'not[milestone]': params.excludeMilestone,
        'not[author_username]': params.excludeAuthor,
        'not[assignee_username]': params.excludeAssignee,
        'not[search]': params.excludeSearch,
        'not[in]': params.excludeSearchIn,
      };
327 328 329
    }
    if (pipelineId) {
      if (pipelineId === 'branch') {
330 331 332 333
        const workspace = await getCurrentWorkspaceFolder();
        if (workspace) {
          pipelineId = await fetchLastPipelineForCurrentBranch(workspace);
        }
334 335 336
      }
      path = `${path}&pipeline_id=${pipelineId}`;
    }
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
    const queryParams: Record<string, QueryValue> = {
      labels: params.labels,
      milestone: params.milestone,
      search: params.search,
      created_before: params.createdBefore,
      created_after: params.createdAfter,
      updated_before: params.updatedBefore,
      updated_after: params.updatedAfter,
      order_by: params.orderBy,
      sort: params.sort,
      per_page: params.maxResults,
      report_type: params.reportTypes,
      severity: params.severityLevels,
      confidence: params.confidenceLevels,
      ...issueQueryParams,
    };
    const usedQueryParamNames = Object.keys(queryParams).filter(k => queryParams[k]);
    const urlQuery = usedQueryParamNames.reduce(
      (acc, name) => `${acc}&${name}=${queryParams[name]}`,
      '',
    );
    path = `${path}${urlQuery}`;
359 360
    const { response } = await fetch(path);
    issuable = response;
F
Fatih Acet 已提交
361
  }
362
  return issuable;
F
Fatih Acet 已提交
363 364
}

365 366 367 368
export async function fetchLastJobsForCurrentBranch(
  pipeline: GitLabPipeline,
  workspaceFolder: string,
) {
369
  const project = await fetchCurrentPipelineProject(workspaceFolder);
370
  if (project) {
371
    const { response } = await fetch(`/projects/${project.id}/pipelines/${pipeline.id}/jobs`);
372
    let jobs: GitLabJob[] = response;
373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390

    // Gitlab return multiple jobs if you retry the pipeline we filter to keep only the last
    const alreadyProcessedJob = new Set();
    jobs = jobs.sort((one, two) => (one.created_at > two.created_at ? -1 : 1));
    jobs = jobs.filter(job => {
      if (alreadyProcessedJob.has(job.name)) {
        return false;
      }
      alreadyProcessedJob.add(job.name);
      return true;
    });

    return jobs;
  }

  return null;
}

391
export async function fetchOpenMergeRequestForCurrentBranch(workspaceFolder: string) {
392
  const project = await fetchCurrentProjectSwallowError(workspaceFolder);
T
Tomas Vik 已提交
393
  const branchName = await createGitService(workspaceFolder).fetchTrackingBranchName();
F
Fatih Acet 已提交
394

395
  const path = `/projects/${project?.id}/merge_requests?state=opened&source_branch=${branchName}`;
396 397
  const { response } = await fetch(path);
  const mrs = response;
F
Fatih Acet 已提交
398

399 400
  if (mrs.length > 0) {
    return mrs[0];
F
Fatih Acet 已提交
401 402
  }

403
  return null;
F
Fatih Acet 已提交
404 405
}

F
Fatih Acet 已提交
406 407 408 409 410
/**
 * Cancels or retries last pipeline or creates a new pipeline for current branch.
 *
 * @param {string} action create|retry|cancel
 */
411
export async function handlePipelineAction(action: string, workspaceFolder: string) {
412
  const pipeline = await fetchLastPipelineForCurrentBranch(workspaceFolder);
413
  const project = await fetchCurrentProjectSwallowError(workspaceFolder);
F
Fatih Acet 已提交
414 415 416 417 418

  if (pipeline && project) {
    let endpoint = `/projects/${project.id}/pipelines/${pipeline.id}/${action}`;

    if (action === 'create') {
T
Tomas Vik 已提交
419
      const branchName = await createGitService(workspaceFolder).fetchTrackingBranchName();
F
Fatih Acet 已提交
420 421 422 423
      endpoint = `/projects/${project.id}/pipeline?ref=${branchName}`;
    }

    try {
424
      const { response } = await fetch(endpoint, 'POST');
425
      return response;
F
Fatih Acet 已提交
426
    } catch (e) {
427
      throw new UserFriendlyError(`Failed to ${action} pipeline.`, e);
F
Fatih Acet 已提交
428 429 430
    }
  } else {
    vscode.window.showErrorMessage('GitLab Workflow: No project or pipeline found.');
431
    return undefined;
F
Fatih Acet 已提交
432 433 434
  }
}

435
export async function fetchMRIssues(mrId: number, workspaceFolder: string) {
436
  const project = await fetchCurrentProjectSwallowError(workspaceFolder);
F
Fatih Acet 已提交
437 438 439 440
  let issues = [];

  if (project) {
    try {
441 442 443 444
      const { response } = await fetch(
        `/projects/${project.id}/merge_requests/${mrId}/closes_issues`,
      );
      issues = response;
F
Fatih Acet 已提交
445
    } catch (e) {
446
      logError(e);
F
Fatih Acet 已提交
447 448 449 450
    }
  }

  return issues;
F
Fatih Acet 已提交
451
}
F
Fatih Acet 已提交
452

453 454
// TODO specify the correct interface when we convert `create_snippet.js`
export async function createSnippet(data: { id: string }) {
F
Fatih Acet 已提交
455
  let snippet;
F
Fatih Acet 已提交
456
  let path = '/snippets';
457

F
Fatih Acet 已提交
458 459
  if (data.id) {
    path = `/projects/${data.id}/snippets`;
460
  }
F
Fatih Acet 已提交
461 462

  try {
463 464
    const { response } = await fetch(path, 'POST', data);
    snippet = response;
F
Fatih Acet 已提交
465
  } catch (e) {
466
    handleError(new UserFriendlyError('Failed to create your snippet.', e));
F
Fatih Acet 已提交
467 468 469
  }

  return snippet;
F
Fatih Acet 已提交
470
}
F
Fatih Acet 已提交
471

472
export async function validateCIConfig(content: string) {
473
  let validCIConfig = null;
F
Fatih Acet 已提交
474 475

  try {
476 477
    const { response } = await fetch('/ci/lint', 'POST', { content });
    validCIConfig = response;
F
Fatih Acet 已提交
478
  } catch (e) {
479
    handleError(new UserFriendlyError('Failed to validate CI configuration.', e));
F
Fatih Acet 已提交
480 481
  }

482 483 484
  return validCIConfig;
}

485 486 487 488 489 490 491 492 493
interface LabelEvent {
  label: unknown;
  body: string;
  // eslint-disable-next-line camelcase
  created_at: string;
}

export async function fetchLabelEvents(issuable: RestIssuable): Promise<LabelEvent[]> {
  let labelEvents: LabelEvent[] = [];
494 495 496 497 498 499 500 501

  try {
    const type = issuable.sha ? 'merge_requests' : 'issues';
    const { response } = await fetch(
      `/projects/${issuable.project_id}/${type}/${issuable.iid}/resource_label_events?sort=asc&per_page=100`,
    );
    labelEvents = response;
  } catch (e) {
502
    handleError(new UserFriendlyError('Failed to fetch label events for this issuable.', e));
503 504 505
  }

  labelEvents.forEach(el => {
506 507
    // Temporarily disable eslint to be able to start enforcing stricter rules
    // eslint-disable-next-line no-param-reassign
508 509 510
    el.body = '';
  });
  return labelEvents;
F
Fatih Acet 已提交
511 512
}

513 514 515 516 517 518 519 520 521
interface Discussion {
  notes: {
    // eslint-disable-next-line camelcase
    created_at: string;
  }[];
}

export async function fetchDiscussions(issuable: RestIssuable, page = 1): Promise<Discussion[]> {
  let discussions: Discussion[] = [];
F
Fatih Acet 已提交
522 523

  try {
F
Fatih Acet 已提交
524
    const type = issuable.sha ? 'merge_requests' : 'issues';
525 526
    const { response, headers } = await fetch(
      `/projects/${issuable.project_id}/${type}/${issuable.iid}/discussions?sort=asc&per_page=5&page=${page}`,
F
Fatih Acet 已提交
527
    );
528 529 530
    discussions = response;
    if (page === 1 && headers['x-next-page'] !== '') {
      const pages = [];
531 532
      // Temporarily disable eslint to be able to start enforcing stricter rules
      // eslint-disable-next-line no-plusplus
533 534 535
      for (let i = 2; i <= headers['x-total-pages']; i++) {
        pages.push(fetchDiscussions(issuable, i));
      }
T
Tomas Vik 已提交
536
      const results = await Promise.all(pages);
537 538 539 540
      results.forEach(result => {
        discussions = discussions.concat(result);
      });
    }
F
Fatih Acet 已提交
541
  } catch (e) {
542
    handleError(new UserFriendlyError('Failed to fetch discussions for this issuable.', e));
F
Fatih Acet 已提交
543 544 545 546 547
  }

  return discussions;
}

548
export async function renderMarkdown(markdown: string, workspaceFolder: string) {
F
Fatih Acet 已提交
549
  let rendered = { html: markdown };
550 551 552 553
  const version = await fetchVersion();
  if (!version) {
    return markdown;
  }
F
Fatih Acet 已提交
554
  const [major] = version.split('.');
F
Fatih Acet 已提交
555 556 557 558 559 560

  if (parseInt(major, 10) < 11) {
    return markdown;
  }

  try {
T
Tomas Vik 已提交
561
    const project = await fetchCurrentProject(workspaceFolder);
562
    const { response } = await fetch('/markdown', 'POST', {
F
Fatih Acet 已提交
563
      text: markdown,
564 565
      // eslint-disable-next-line camelcase
      project: project?.path_with_namespace,
F
Fatih Acet 已提交
566 567
      gfm: 'true', // Needs to be a string for the API
    });
568
    rendered = response;
F
Fatih Acet 已提交
569
  } catch (e) {
570
    logError(e);
F
Fatih Acet 已提交
571 572 573 574
    return markdown;
  }

  return rendered.html;
F
Fatih Acet 已提交
575
}
F
Fatih Acet 已提交
576

577
export async function saveNote(params: {
T
Tomas Vik 已提交
578
  issuable: RestIssuable;
579 580 581
  note: string;
  noteType: { path: string };
}) {
F
Fatih Acet 已提交
582
  try {
583 584 585
    const projectId = params.issuable.project_id;
    const { iid } = params.issuable;
    const { path } = params.noteType;
586
    const { response } = await fetch(`/projects/${projectId}/${path}/${iid}/notes`, 'POST', {
587
      body: params.note,
F
Fatih Acet 已提交
588
    });
589
    return response;
F
Fatih Acet 已提交
590
  } catch (e) {
591
    logError(e);
592 593
  }

594
  return { success: false };
595
}
596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617

type note = Discussion | LabelEvent;

function isLabelEvent(object: any): object is LabelEvent {
  return Boolean(object.label);
}

export async function fetchDiscussionsAndLabelEvents(issuable: RestIssuable): Promise<note[]> {
  const [discussions, labelEvents] = await Promise.all([
    fetchDiscussions(issuable),
    fetchLabelEvents(issuable),
  ]);

  const combinedEvents: note[] = [...discussions, ...labelEvents];
  combinedEvents.sort((a: note, b: note) => {
    const aCreatedAt = isLabelEvent(a) ? a.created_at : a.notes[0].created_at;
    const bCreatedAt = isLabelEvent(b) ? b.created_at : b.notes[0].created_at;
    return aCreatedAt < bCreatedAt ? -1 : 1;
  });

  return combinedEvents;
}