ssube/isolex

View on GitHub
src/endpoint/GitlabEndpoint.ts

Summary

Maintainability
A
2 hrs
Test Coverage
A
98%
import { mustExist } from '@apextoaster/js-utils';
import { Request, Response } from 'express';
import { Inject } from 'noicejs';

import { Endpoint, Handler } from '.';
import { INJECT_METRICS } from '../BaseService';
import { Command, CommandOptions, CommandVerb } from '../entity/Command';
import { ContextChannel, Context } from '../entity/Context';
import { Message } from '../entity/Message';
import { applyTransforms } from '../transform';
import { createServiceCounter, incrementServiceCounter, StringCounter } from '../utils/Metrics';
import { TYPE_JSON } from '../utils/Mime';
import { TemplateScope } from '../utils/Template';
import { BaseEndpointOptions, STATUS_NOTFOUND, STATUS_SUCCESS } from './BaseEndpoint';
import { HookEndpoint, HookEndpointData } from './HookEndpoint';

/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, camelcase */

export interface GitlabBaseWebhook {
  object_kind: string;
}

export interface GitlabIssueWebhook extends GitlabBaseWebhook {
  object_kind: 'issue';
  user: any;
  project: any;
  repository: any;
  object_attributes: any;
  assignees: Array<any>;
  assignee: any;
  labels: Array<any>;
  changes: any;
}

export interface GitlabBuildWebhook extends GitlabBaseWebhook {
  object_kind: 'build';
  ref: string;
  tag: boolean;
  before_sha: string;
  sha: string;
  job_id: number;
  job_name: string;
  job_stage: string;
  job_status: string; // TODO: enum
  job_started_at: any;
  job_finished_at: any;
  job_duration: any;
  job_allow_failure: boolean;
  project_id: number;
  project_name: string;
  user: any;
  commit: any;
  repository: any;
}

export interface GitlabNoteWebhook extends GitlabBaseWebhook {
  object_kind: 'note';
  user: any;
  project: any;
  repository: any;
  object_attributes: any;
  commit: any;
}

export interface GitlabPipelineWebhook extends GitlabBaseWebhook {
  object_kind: 'pipeline';
  object_attributes: any;
  user: any;
  project: any;
  commit: any;
  builds: Array<any>;
}

export interface GitlabPushWebhook extends GitlabBaseWebhook {
  object_kind: 'push';
  before: string;
  after: string;
  ref: string;
  checkout_sha: string;
  user_id: number;
  user_name: string;
  user_username: string;
  user_email: string;
  user_avatar: string;
  project_id: number;
  project: any;
  repository: any;
  commits: Array<any>;
  total_commits_count: number;
}
/* eslint-enable @typescript-eslint/no-explicit-any */

export type GitlabWebhook = GitlabBuildWebhook | GitlabIssueWebhook | GitlabNoteWebhook | GitlabPipelineWebhook | GitlabPushWebhook;

export interface GitlabEndpointData extends HookEndpointData {
  defaultCommand: CommandOptions;
}

@Inject(INJECT_METRICS)
export class GitlabEndpoint extends HookEndpoint<GitlabEndpointData> implements Endpoint {
  protected readonly hookCounter: StringCounter;

  constructor(options: BaseEndpointOptions<GitlabEndpointData>) {
    super(options, 'isolex#/definitions/service-endpoint-gitlab');

    this.hookCounter = createServiceCounter(mustExist(options[INJECT_METRICS]), {
      help: 'webhook events received from gitlab',
      labelNames: ['eventType'],
      name: 'endpoint_gitlab_hook',
    });
  }

  public get paths(): Array<string> {
    return [
      ...super.paths,
      '/gitlab',
    ];
  }

  @Handler(CommandVerb.Create, '/webhook')
  public async postHook(req: Request, res: Response) {
    const hook: GitlabBaseWebhook = req.body;
    return this.hookKind(req, res, hook);
  }

  public async hookKind(req: Request, res: Response, data: GitlabBaseWebhook) {
    this.logger.debug({
      body: req.body,
      eventType: data.object_kind,
      req,
      res,
    }, 'gitlab endpoint got webhook');
    incrementServiceCounter(this, this.hookCounter, {
      eventType: data.object_kind,
    });

    switch (data.object_kind) {
      case 'build':
        return this.buildHook(req, res, data as GitlabBuildWebhook);
      case 'issue':
        return this.issueHook(req, res, data as GitlabIssueWebhook);
      case 'note':
        return this.noteHook(req, res, data as GitlabNoteWebhook);
      case 'pipeline':
        return this.pipelineHook(req, res, data as GitlabPipelineWebhook);
      case 'push':
        return this.pushHook(req, res, data as GitlabPushWebhook);
      default:
        this.logger.warn({
          kind: data.object_kind,
        }, 'unknown hook kind');
        res.sendStatus(STATUS_NOTFOUND);
    }
  }

  public async buildHook(req: Request, res: Response, data: GitlabBuildWebhook) {
    this.logger.debug(data, 'gitlab job hook');
    const txData = await this.transformData(req, res, data);
    const user = mustExist(this.hookUser);
    const cmdCtx = await this.createContext({
      channel: {
        id: data.project_name,
        thread: data.ref,
      },
      source: {
        kind: this.kind,
        name: this.name,
      },
      sourceUser: {
        name: user.name,
        uid: this.data.hookUser,
      },
      user,
    });
    const cmd = await this.createHookCommand(cmdCtx, txData, data.object_kind);
    await this.bot.executeCommand(cmd);
    res.sendStatus(STATUS_SUCCESS);
  }

  // tslint:disable-next-line:no-identical-functions
  public async issueHook(req: Request, res: Response, data: GitlabIssueWebhook) {
    this.logger.debug(data, 'gitlab issue hook');
    const txData = await this.transformData(req, res, data);
    const user = mustExist(this.hookUser);
    const cmdCtx = await this.createContext({
      channel: {
        id: data.project.id,
        thread: data.object_attributes.id,
      },
      source: {
        kind: this.kind,
        name: this.name,
      },
      sourceUser: {
        name: user.name,
        uid: this.data.hookUser,
      },
      user,
    });
    const cmd = await this.createHookCommand(cmdCtx, txData, data.object_kind);
    await this.bot.executeCommand(cmd);
    res.sendStatus(STATUS_SUCCESS);
  }

  // tslint:disable-next-line:no-identical-functions
  public async noteHook(req: Request, res: Response, data: GitlabNoteWebhook) {
    this.logger.debug(data, 'gitlab note hook');
    const txData = await this.transformData(req, res, data);
    const user = mustExist(this.hookUser);
    const cmdCtx = await this.createContext({
      channel: {
        id: data.project.id,
        thread: data.object_attributes.id,
      },
      source: this.getMetadata(),
      sourceUser: {
        name: user.name,
        uid: this.data.hookUser,
      },
      user,
    });
    const cmd = await this.createHookCommand(cmdCtx, txData, data.object_kind);
    await this.bot.executeCommand(cmd);
    res.sendStatus(STATUS_SUCCESS);
  }

  public async pipelineHook(req: Request, res: Response, data: GitlabPipelineWebhook) {
    this.logger.debug(data, 'gitlab pipeline hook');
    const txData = await this.transformData(req, res, data);
    const cmdCtx = await this.createContext({
      channel: {
        id: data.project.web_url,
        thread: data.object_attributes.ref,
      },
      source: this.getMetadata(),
      sourceUser: {
        name: data.user.name,
        uid: data.user.username,
      },
      user: mustExist(this.hookUser),
    });
    const cmd = await this.createHookCommand(cmdCtx, txData, data.object_kind);
    await this.bot.executeCommand(cmd);
    res.sendStatus(STATUS_SUCCESS);
  }

  public async pushHook(req: Request, res: Response, data: GitlabPushWebhook) {
    this.logger.debug(data, 'gitlab push hook');
    const txData = await this.transformData(req, res, data);
    const cmdCtx = await this.createContext({
      channel: {
        id: data.project.web_url,
        thread: data.ref,
      },
      source: this.getMetadata(),
      sourceUser: {
        name: data.user_name,
        uid: data.user_username,
      },
      user: mustExist(this.hookUser),
    });
    const cmd = await this.createHookCommand(cmdCtx, txData, data.object_kind);
    await this.bot.executeCommand(cmd);
    res.sendStatus(STATUS_SUCCESS);
  }

  protected async transformData(req: Request, res: Response, data: GitlabWebhook) {
    const msg = await this.createHookMessage(req, res, data);

    const txData = await applyTransforms(this.transforms, msg, TYPE_JSON, data);
    this.logger.debug({ data, txData }, 'applied transforms');

    /* eslint-disable-next-line @typescript-eslint/tslint/config */
    if (Array.isArray(txData) || typeof txData === 'string') {
      this.logger.warn({ data: txData }, 'transforms did not return object');
    }

    return txData;
  }

  protected getHookChannel(data: GitlabWebhook): ContextChannel {
    return {
      id: data.object_kind,
      thread: '',
    };
  }

  protected async createHookMessage(req: Request, res: Response, data: GitlabWebhook): Promise<Message> {
    const channel = this.getHookChannel(data);
    const context = await this.createHookContext(channel);
    const labels = new Map(this.labels);
    labels.set('hook', data.object_kind);

    // fake message for the transforms to check and filter
    return new Message({
      body: data.object_kind,
      context,
      labels,
      reactions: [],
      type: TYPE_JSON,
    });
  }

  protected async createHookCommand(context: Context, data: TemplateScope, kind: string): Promise<Command> {
    const labels = new Map(this.labels);
    labels.set('hook', kind);

    return new Command({
      context,
      data,
      labels,
      noun: this.data.defaultCommand.noun,
      verb: this.data.defaultCommand.verb,
    });
  }
}