gitlab_new_service.ts 16.1 KB
Newer Older
1
import * as https from 'https';
2 3 4 5
import { GraphQLClient, gql } from 'graphql-request';
import crossFetch from 'cross-fetch';
import { URL } from 'url';
import * as createHttpProxyAgent from 'https-proxy-agent';
6
import * as assert from 'assert';
7 8
import { tokenService } from '../services/token_service';
import { FetchError } from '../errors/fetch_error';
9
import { getUserAgentHeader } from '../utils/get_user_agent_header';
10
import { ensureAbsoluteAvatarUrl } from '../utils/ensure_absolute_avatar_url';
11
import { getHttpAgentOptions } from '../utils/get_http_agent_options';
12
import { GitLabProject } from './gitlab_project';
13
import { getRestIdFromGraphQLId } from '../utils/get_rest_id_from_graphql_id';
14
import { UserFriendlyError } from '../errors/user_friendly_error';
15
import { getMrPermissionsQuery, MrPermissionsQueryOptions } from './graphql/mr_permission';
16
import { GqlBasePosition, GqlGenericNote, GqlNote, noteDetailsFragment } from './graphql/shared';
17
import { GetProjectsOptions, GqlProjectsResult, queryGetProjects } from './graphql/get_projects';
18 19 20 21
import {
  getIssueDiscussionsQuery,
  getMrDiscussionsQuery,
  GetDiscussionsQueryOptions,
22 23
  GqlDiscussion,
  GetDiscussionsQueryResult,
T
Tomas Vik 已提交
24
  GqlTextDiffDiscussion,
25
} from './graphql/get_discussions';
T
Tomas Vik 已提交
26 27 28 29 30 31 32
import {
  GetSnippetsQueryOptions,
  GetSnippetsQueryResult,
  GqlBlob,
  GqlSnippet,
  queryGetSnippets,
} from './graphql/get_snippets';
33 34 35 36 37
import {
  GetProjectQueryOptions,
  GetProjectQueryResult,
  queryGetProject,
} from './graphql/get_project';
T
Tomas Vik 已提交
38
import { createDiffNoteMutation, GqlDiffPositionInput } from './graphql/create_diff_comment';
39
import { removeLeadingSlash } from '../utils/remove_leading_slash';
40
import { log, logError } from '../log';
T
Tomas Vik 已提交
41
import { isMr } from '../utils/is_mr';
42

43 44 45 46 47 48 49
interface CreateNoteResult {
  createNote: {
    errors: unknown[];
    note: GqlNote | null;
  };
}

50 51 52 53 54 55 56 57 58
interface RestLabelEvent {
  label: unknown;
  body: string;
  // eslint-disable-next-line camelcase
  created_at: string;
}

type Note = GqlDiscussion | RestLabelEvent;

59 60 61 62 63
interface GetDiscussionsOptions {
  issuable: RestIssuable;
  endCursor?: string;
}

64 65 66 67
interface RestNote {
  body: string;
}

68 69 70 71
function isLabelEvent(note: Note): note is RestLabelEvent {
  return (note as RestLabelEvent).label !== undefined;
}

72
// TODO: extract the mutation into a separate file like src/gitlab/graphql/get_project.ts
73 74 75 76 77 78 79 80
const discussionSetResolved = gql`
  mutation DiscussionToggleResolve($replyId: DiscussionID!, $resolved: Boolean!) {
    discussionToggleResolve(input: { id: $replyId, resolve: $resolved }) {
      errors
    }
  }
`;

81
// TODO: extract the mutation into a separate file like src/gitlab/graphql/get_project.ts
82
const createNoteMutation = gql`
83
  ${noteDetailsFragment}
84 85 86
  mutation CreateNote($issuableId: NoteableID!, $body: String!, $replyId: DiscussionID) {
    createNote(input: { noteableId: $issuableId, body: $body, discussionId: $replyId }) {
      errors
87 88 89
      note {
        ...noteDetails
      }
90 91 92 93
    }
  }
`;

94
// TODO: extract the mutation into a separate file like src/gitlab/graphql/get_project.ts
95 96 97 98 99 100 101 102
const deleteNoteMutation = gql`
  mutation DeleteNote($noteId: NoteID!) {
    destroyNote(input: { id: $noteId }) {
      errors
    }
  }
`;

103
// TODO: extract the mutation into a separate file like src/gitlab/graphql/get_project.ts
104 105 106 107 108 109 110 111
const updateNoteBodyMutation = gql`
  mutation UpdateNoteBody($noteId: NoteID!, $body: String) {
    updateNote(input: { id: $noteId, body: $body }) {
      errors
    }
  }
`;

112 113 114
const getProjectPath = (issuable: RestIssuable) => issuable.references.full.split(/[#!]/)[0];
const getIssuableGqlId = (issuable: RestIssuable) =>
  `gid://gitlab/${isMr(issuable) ? 'MergeRequest' : 'Issue'}/${issuable.id}`;
T
Tomas Vik 已提交
115
const getMrGqlId = (id: number) => `gid://gitlab/MergeRequest/${id}`;
116

117 118 119
export class GitLabNewService {
  client: GraphQLClient;

120
  constructor(readonly instanceUrl: string, readonly pipelineInstanceUrl?: string) {
121 122 123 124
    const ensureEndsWithSlash = (url: string) => url.replace(/\/?$/, '/');

    const endpoint = new URL('./api/graphql', ensureEndsWithSlash(this.instanceUrl)).href; // supports GitLab instances that are on a custom path, e.g. "https://example.com/gitlab"

125 126 127
    this.client = new GraphQLClient(endpoint, this.fetchOptions);
  }

128 129 130 131 132 133 134 135 136 137 138
  private get httpAgent() {
    const agentOptions = getHttpAgentOptions();
    if (agentOptions.proxy) {
      return createHttpProxyAgent(agentOptions.proxy);
    }
    if (this.instanceUrl.startsWith('https://')) {
      return new https.Agent(agentOptions);
    }
    return undefined;
  }

139 140 141 142 143
  private get fetchOptions() {
    const token = tokenService.getToken(this.instanceUrl);
    return {
      headers: {
        Authorization: `Bearer ${token}`,
144
        ...getUserAgentHeader(),
145
      },
146
      agent: this.httpAgent,
147 148 149
    };
  }

150 151 152 153 154 155 156 157 158 159
  async getVersion(): Promise<string | undefined> {
    try {
      const result = await crossFetch(`${this.instanceUrl}/api/v4/version`, this.fetchOptions);
      return (await result.json())?.version;
    } catch (e) {
      logError(e);
      return undefined;
    }
  }

160
  async getProject(projectPath: string): Promise<GitLabProject | undefined> {
161 162
    const options: GetProjectQueryOptions = { projectPath };
    const result = await this.client.request<GetProjectQueryResult>(queryGetProject, options);
163 164 165
    return result.project && new GitLabProject(result.project);
  }

166 167
  async getProjects(options: GetProjectsOptions): Promise<GitLabProject[]> {
    const results = await this.client.request<GqlProjectsResult>(queryGetProjects, options);
168 169 170
    return results.projects?.nodes?.map(project => new GitLabProject(project)) || [];
  }

171
  async getSnippets(projectPath: string): Promise<GqlSnippet[]> {
T
Tomas Vik 已提交
172 173 174 175
    const options: GetSnippetsQueryOptions = {
      projectPath,
    };
    const result = await this.client.request<GetSnippetsQueryResult>(queryGetSnippets, options);
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192

    const { project } = result;
    // this can mean three things: project doesn't exist, user doesn't have access, or user credentials are wrong
    // https://gitlab.com/gitlab-org/gitlab/-/issues/270055
    if (!project) {
      throw new Error(
        `Project ${projectPath} was not found. You might not have permissions to see it.`,
      );
    }
    const snippets = project.snippets.nodes;
    // each snippet has to contain projectId so we can make REST API call for the content
    return snippets.map(sn => ({
      ...sn,
      projectId: project.id,
    }));
  }

193
  // TODO change this method to use GraphQL once the lowest supported GitLab version is 14.1.0
194
  async getSnippetContent(snippet: GqlSnippet, blob: GqlBlob): Promise<string> {
195 196 197 198 199 200
    const getBranch = (rawPath: string) => {
      // raw path example: "/gitlab-org/gitlab-vscode-extension/-/snippets/111/raw/master/okr.md"
      const result = rawPath.match(/\/-\/snippets\/\d+\/raw\/([^/]+)\//);
      assert(result, `The rawPath is malformed ${rawPath}`);
      return result[1];
    };
201 202
    const projectId = getRestIdFromGraphQLId(snippet.projectId);
    const snippetId = getRestIdFromGraphQLId(snippet.id);
203 204
    const branch = getBranch(blob.rawPath);
    const url = `${this.instanceUrl}/api/v4/projects/${projectId}/snippets/${snippetId}/files/${branch}/${blob.path}/raw`;
205 206 207 208 209 210
    const result = await crossFetch(url, this.fetchOptions);
    if (!result.ok) {
      throw new FetchError(`Fetching snippet from ${url} failed`, result);
    }
    return result.text();
  }
T
Tomas Vik 已提交
211 212

  // This method has to use REST API till https://gitlab.com/gitlab-org/gitlab/-/issues/280803 gets done
213
  async getMrDiff(mr: RestMr): Promise<RestMrVersion> {
T
Tomas Vik 已提交
214 215 216 217 218 219 220 221 222 223 224 225
    const versionsUrl = `${this.instanceUrl}/api/v4/projects/${mr.project_id}/merge_requests/${mr.iid}/versions`;
    const versionsResult = await crossFetch(versionsUrl, this.fetchOptions);
    if (!versionsResult.ok) {
      throw new FetchError(`Fetching versions from ${versionsUrl} failed`, versionsResult);
    }
    const versions = await versionsResult.json();
    const lastVersion = versions[0];
    const lastVersionUrl = `${this.instanceUrl}/api/v4/projects/${mr.project_id}/merge_requests/${mr.iid}/versions/${lastVersion.id}`;
    const diffResult = await crossFetch(lastVersionUrl, this.fetchOptions);
    if (!diffResult.ok) {
      throw new FetchError(`Fetching MR diff from ${lastVersionUrl} failed`, diffResult);
    }
226 227 228 229
    return diffResult.json();
  }

  async getFileContent(path: string, ref: string, projectId: number): Promise<string> {
230
    const encodedPath = encodeURIComponent(removeLeadingSlash(path));
231 232 233 234 235 236
    const fileUrl = `${this.instanceUrl}/api/v4/projects/${projectId}/repository/files/${encodedPath}/raw?ref=${ref}`;
    const fileResult = await crossFetch(fileUrl, this.fetchOptions);
    if (!fileResult.ok) {
      throw new FetchError(`Fetching file from ${fileUrl} failed`, fileResult);
    }
    return fileResult.text();
T
Tomas Vik 已提交
237
  }
238 239 240 241 242 243

  /*
    The GraphQL endpoint sends us the note.htmlBody with links that start with `/`.
    This works well for the the GitLab webapp, but in VS Code we need to add the full host.
  */
  private addHostToUrl(discussion: GqlDiscussion): GqlDiscussion {
244 245 246
    const prependHost: <T extends GqlBasePosition | null>(
      note: GqlGenericNote<T>,
    ) => GqlGenericNote<T> = note => ({
247 248
      ...note,
      bodyHtml: note.bodyHtml.replace(/href="\//, `href="${this.instanceUrl}/`),
249 250
      author: {
        ...note.author,
251 252
        avatarUrl:
          note.author.avatarUrl && ensureAbsoluteAvatarUrl(this.instanceUrl, note.author.avatarUrl),
253
      },
254 255 256 257 258 259 260
    });
    return {
      ...discussion,
      notes: {
        ...discussion.notes,
        nodes: discussion.notes.nodes.map(prependHost),
      },
261
    } as GqlDiscussion;
262 263
  }

264
  async getDiscussions({ issuable, endCursor }: GetDiscussionsOptions): Promise<GqlDiscussion[]> {
265
    const projectPath = getProjectPath(issuable);
266 267
    const query = isMr(issuable) ? getMrDiscussionsQuery : getIssueDiscussionsQuery;
    const options: GetDiscussionsQueryOptions = {
268 269 270
      projectPath,
      iid: String(issuable.iid),
      endCursor,
271
    };
272
    const result = await this.client.request<GetDiscussionsQueryResult>(query, options);
273 274 275
    assert(result.project, `Project ${projectPath} was not found.`);
    const discussions =
      result.project.issue?.discussions || result.project.mergeRequest?.discussions;
276
    assert(discussions, `Discussions for issuable ${issuable.references.full} were not found.`);
277 278
    if (discussions.pageInfo?.hasNextPage) {
      assert(discussions.pageInfo.endCursor);
279 280 281 282
      const remainingPages = await this.getDiscussions({
        issuable,
        endCursor: discussions.pageInfo.endCursor,
      });
283 284 285 286
      return [...discussions.nodes, ...remainingPages];
    }
    return discussions.nodes.map(n => this.addHostToUrl(n));
  }
287

288 289
  async canUserCommentOnMr(mr: RestMr): Promise<boolean> {
    const projectPath = getProjectPath(mr);
290
    const queryOptions: MrPermissionsQueryOptions = {
291
      projectPath,
292
      iid: String(mr.iid),
293 294
    };
    const result = await this.client.request(getMrPermissionsQuery, queryOptions);
295
    assert(result?.project?.mergeRequest, `MR ${mr.references.full} was not found.`);
296 297 298
    return Boolean(result.project.mergeRequest.userPermissions?.createNote);
  }

299 300 301 302 303 304 305 306 307 308 309 310 311 312 313
  async setResolved(replyId: string, resolved: boolean): Promise<void> {
    try {
      return await this.client.request<void>(discussionSetResolved, {
        replyId,
        resolved,
      });
    } catch (e) {
      throw new UserFriendlyError(
        `Couldn't ${resolved ? 'resolve' : 'unresolve'} the discussion when calling the API.
        For more information, review the extension logs.`,
        e,
      );
    }
  }

314
  private async getLabelEvents(issuable: RestIssuable): Promise<RestLabelEvent[]> {
315
    const type = isMr(issuable) ? 'merge_requests' : 'issues';
316 317 318 319 320 321 322 323 324 325
    const labelEventsUrl = `${this.instanceUrl}/api/v4/projects/${issuable.project_id}/${type}/${issuable.iid}/resource_label_events?sort=asc&per_page=100`;
    const result = await crossFetch(labelEventsUrl, this.fetchOptions);
    if (!result.ok) {
      throw new FetchError(`Fetching file from ${labelEventsUrl} failed`, result);
    }
    return result.json();
  }

  async getDiscussionsAndLabelEvents(issuable: RestIssuable): Promise<Note[]> {
    const [discussions, labelEvents] = await Promise.all([
326
      this.getDiscussions({ issuable }),
327 328 329 330 331 332 333 334 335 336 337 338
      this.getLabelEvents(issuable),
    ]);

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

    return combinedEvents;
  }
339

340
  async createNote(issuable: RestIssuable, body: string, replyId?: string): Promise<GqlNote> {
T
Tomas Vik 已提交
341 342 343 344 345 346 347 348 349 350 351 352
    try {
      const result = await this.client.request<CreateNoteResult>(createNoteMutation, {
        issuableId: getIssuableGqlId(issuable),
        body,
        replyId,
      });
      if (result.createNote.errors.length > 0) {
        throw new Error(result.createNote.errors.join(','));
      }
      assert(result.createNote.note);
      return result.createNote.note;
    } catch (error) {
353 354
      throw new UserFriendlyError(
        `Couldn't create the comment when calling the API.
T
Tomas Vik 已提交
355 356
      For more information, review the extension logs.`,
        error,
357 358
      );
    }
359
  }
360 361 362 363 364 365 366 367 368 369 370 371 372 373

  async deleteNote(noteId: string): Promise<void> {
    try {
      await this.client.request<void>(deleteNoteMutation, {
        noteId,
      });
    } catch (e) {
      throw new UserFriendlyError(
        `Couldn't delete the comment when calling the API.
        For more information, review the extension logs.`,
        e,
      );
    }
  }
374

375 376 377 378
  /**
   * This method is used only as a replacement of optimistic locking when updating a note.
   * We request the latest note to validate that it hasn't changed since we last saw it.
   */
379
  private async getMrNote(mr: RestMr, noteId: number): Promise<RestNote> {
380 381 382 383 384 385 386 387 388 389 390 391
    const noteUrl = `${this.instanceUrl}/api/v4/projects/${mr.project_id}/merge_requests/${mr.iid}/notes/${noteId}`;
    const result = await crossFetch(noteUrl, this.fetchOptions);
    if (!result.ok) {
      throw new FetchError(`Fetching the latest note from ${noteUrl} failed`, result);
    }
    return result.json();
  }

  async updateNoteBody(
    noteGqlId: string,
    body: string,
    originalBody: string,
392
    mr: RestMr,
393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
  ): Promise<void> {
    const latestNote = await this.getMrNote(mr, getRestIdFromGraphQLId(noteGqlId));
    // This check is the best workaround we can do in the lack of optimistic locking
    // Issue to make this check in the GitLab instance: https://gitlab.com/gitlab-org/gitlab/-/issues/323808
    if (latestNote.body !== originalBody) {
      throw new UserFriendlyError(
        `This comment changed after you last viewed it, and can't be edited.
        Your new comment is NOT lost. To retrieve it, edit the comment again and copy your comment text,
        then update the original comment by opening the sidebar and running the
        "GitLab: Refresh sidebar" command.`,
        new Error(
          `You last saw:\n"${originalBody}"\nbut the latest version is:\n"${latestNote.body}"`,
        ),
      );
    }
408 409
    try {
      await this.client.request<void>(updateNoteBodyMutation, {
410
        noteId: noteGqlId,
411 412 413 414 415 416 417 418 419 420 421
        body,
      });
    } catch (e) {
      throw new UserFriendlyError(
        `Couldn't update the comment when calling the API.
        Your draft hasn't been lost. To see it, edit the comment.
        For more information, review the extension logs.`,
        e,
      );
    }
  }
T
Tomas Vik 已提交
422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439

  async createDiffNote(
    mrId: number,
    body: string,
    position: GqlDiffPositionInput,
  ): Promise<GqlTextDiffDiscussion> {
    try {
      const result = await this.client.request(createDiffNoteMutation, {
        issuableId: getMrGqlId(mrId),
        body,
        position,
      });
      assert(
        result?.createDiffNote?.note?.discussion,
        `Response doesn't contain a note with discussion: ${JSON.stringify(result)}`,
      );
      return result.createDiffNote.note.discussion;
    } catch (e) {
440
      log(body);
T
Tomas Vik 已提交
441 442 443
      throw new UserFriendlyError(
        `Extension failed to create the comment.
         Open extension logs to see your comment text and find more details about the error.`,
444
        new Error(`MR(${mrId}), ${JSON.stringify(position)}, ${e}`),
T
Tomas Vik 已提交
445 446 447
      );
    }
  }
448
}