chromium/tools/crbug/crbug.js

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

'use strict';

const process = require('child_process');
const https = require('https');

function log(msg) {
  // console.log(msg);
}

class CrBugUser {
  constructor(json) {
    this.name_ = json.displayName;
    this.id_ = json.name;
    this.email_ = json.email;
  }

  get name() {
    return this.name_;
  }
  get id() {
    return this.id_;
  }
  get email() {
    return this.email_;
  }
};

class CrBugIssue {
  constructor(json) {
    this.number_ = json.name;
    this.reporter_id_ = json.reporter;
    this.owner_id_ = json.owner ? json.owner.user : undefined;
    this.last_update_ = json.modifyTime;
    this.close_ = json.closeTime ? new Date(json.closeTime) : undefined;

    this.url_ = undefined;
    const parts = this.number_.split('/');
    if (parts[0] === 'projects' && parts[2] === 'issues') {
      const project = parts[1];
      const num = parts[3];
      this.url_ =
          `https://bugs.chromium.org/p/${project}/issues/detail?id=${num}`;
    }
  }

  get number() {
    return this.number_;
  }
  get owner_id() {
    return this.owner_id_;
  }
  get reporter_id() {
    return this.reporter_id_;
  }
  get url() {
    return this.url_;
  }
};

class CrBugComment {
  constructor(json) {
    this.user_id_ = json.commenter;
    this.timestamp_ = new Date(json.createTime);
    this.timestamp_.setSeconds(0);
    this.content_ = json.content;
    this.fields_ = json.amendments ?
        json.amendments.map(m => m.fieldName.toLowerCase()) :
        undefined;

    this.json_ = JSON.stringify(json);
  }

  get user_id() {
    return this.user_id_;
  }
  get timestamp() {
    return this.timestamp_;
  }
  get content() {
    return this.content_;
  }
  get updatedFields() {
    return this.fields_;
  }

  isActivity() {
    if (this.content)
      return true;

    const fields = this.updatedFields;

    // If bug A gets merged into bug B, then ignore the update for bug A. There
    // will also be an update for bug B, and that will be counted instead.
    if (fields && fields.indexOf('mergedinto') >= 0) {
      return false;
    }

    // If bug A is marked as blocked on bug B, then that triggers updates for
    // both bugs. So only count 'blockedon', and ignore 'blocking'.
    const allowedFields = [
      'blockedon', 'cc', 'components', 'label', 'owner', 'priority', 'status',
      'summary'
    ];
    if (fields && fields.some(f => allowedFields.indexOf(f) >= 0)) {
      return true;
    }
    return false;
  }
};

class CrBug {
  constructor(project) {
    this.token_ = this.getAuthToken_();
    this.project_ = project;
  }

  getAuthToken_() {
    const scope = 'https://www.googleapis.com/auth/userinfo.email';
    const args = [
      'luci-auth', 'token', '-use-id-token', '-audience',
      'https://monorail-prod.appspot.com', '-scopes', scope, '-json-output', '-'
    ];
    const stdout = process.execSync(args.join(' ')).toString().trim();
    const json = JSON.parse(stdout);
    return json.token;
  }

  async fetchFromServer_(path, message) {
    const hostname = 'api-dot-monorail-prod.appspot.com';
    return new Promise((resolve, reject) => {
      const postData = JSON.stringify(message);
      const options = {
        hostname: hostname,
        method: 'POST',
        path: path,
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
          'Authorization': `Bearer ${this.token_}`,
        }
      };

      let data = '';
      const req = https.request(options, (res) => {
        log(`STATUS: ${res.statusCode}`);
        log(`HEADERS: ${JSON.stringify(res.headers)}`);

        res.setEncoding('utf8');
        res.on('data', (chunk) => {
          log(`BODY: ${chunk}`);
          data += chunk;
        });
        res.on('end', () => {
          if (data.startsWith(')]}\'')) {
            resolve(JSON.parse(data.substr(4)));
          } else {
            resolve(data);
          }
        });
      });

      req.on('error', (e) => {
        console.error(`problem with request: ${e.message}`);
        reject(e.message);
      });

      // Write data to request body
      log(`Writing ${postData}`);
      req.write(postData);
      req.end();
    });
  }

  /**
   * Calls SearchIssues with the given parameters.
   *
   * @param {string} query The query to use to search.
   * @param {Number} pageSize The maximum issues to return.
   * @param {string} pageToken The page token from the previous call.
   *
   * @return {JSON}
   */
  async searchIssuesPagination_(query, pageSize, pageToken) {
    const message = {
      'projects': [this.project_],
      'query': query,
      'pageToken': pageToken,
    };
    if (pageSize) {
      message['pageSize'] = pageSize;
    }
    const url = '/prpc/monorail.v3.Issues/SearchIssues';
    return this.fetchFromServer_(url, message);
  }

  /**
   * Searches Monorail for issues using the given query.
   * TODO(crbug.com/monorail/7143): SearchIssues only accepts one project.
   *
   * @param {string} query The query to use to search.
   *
   * @return {Array<CrBugIssue>}
   */
  async search(query) {
    const pageSize = 100;
    let pageToken;
    let issues = [];
    do {
      const resp =
          await this.searchIssuesPagination_(query, pageSize, pageToken);
      if (resp.issues) {
        issues = issues.concat(resp.issues.map(i => new CrBugIssue(i)));
      }
      pageToken = resp.nextPageToken;
    } while (pageToken);
    return issues;
  }

  /**
   * Calls ListComments with the given parameters.
   *
   * @param {string} issueName Resource name of the issue.
   * @param {string} filter The approval filter query.
   * @param {Number} pageSize The maximum number of comments to return.
   * @param {string} pageToken The page token from the previous request.
   *
   * @return {JSON}
   */
  async listCommentsPagination_(issueName, pageToken, pageSize) {
    const message = {
      'parent': issueName,
      'pageToken': pageToken,
      'filter': '',
    };
    if (pageSize) {
      message['pageSize'] = pageSize;
    }
    const url = '/prpc/monorail.v3.Issues/ListComments';
    return this.fetchFromServer_(url, message);
  }

  /**
   * Returns all comments and previous/current descriptions of an issue.
   *
   * @param {CrBugIssue} issue The CrBugIssue instance.
   *
   * @return {Array<CrBugComment>}
   */
  async getComments(issue) {
    let pageToken;
    let comments = [];
    do {
      const resp = await this.listCommentsPagination_(issue.number, pageToken);
      if (resp.comments) {
        comments = comments.concat(resp.comments.map(c => new CrBugComment(c)));
      }
      pageToken = resp.nextPageToken;
    } while (pageToken);
    return comments;
  }

  /**
   * Returns the user associated with 'username'.
   *
   * @param {string} username The username (e.g. [email protected]).
   *
   * @return {CrBugUser}
   */
  async getUser(username) {
    const url = '/prpc/monorail.v3.Users/GetUser';
    const message = {
      name: `users/${username}`,
    };
    return new CrBugUser(await this.fetchFromServer_(url, message));
  }
};

module.exports = {
  CrBug,
};