// Copyright 2022 The Chromium Authors. All rights reserved. // 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} */ 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} */ 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. linus@chromium.org). * * @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, };