Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/api/v3/apps-script-client/IssueService.js b/api/v3/apps-script-client/IssueService.js
new file mode 100644
index 0000000..d1c6c9d
--- /dev/null
+++ b/api/v3/apps-script-client/IssueService.js
@@ -0,0 +1,908 @@
+// Copyright 2020 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.
+
+/* eslint-disable no-unused-vars */
+
+const COMMENT_TYPE_DESCRIPTION = 'DESCRIPTION';
+
+/**
+ * Fetches the issue from Monorail.
+ * @param {string} issueName The resource name of the issue.
+ * @return {Issue}
+ */
+function getIssue(issueName) {
+ const message = {'name': issueName};
+ const url = URL + 'monorail.v3.Issues/GetIssue';
+ return run_(url, message);
+}
+
+/**
+ * Fetches all the given issues from Monorail.
+ * @param {Array<string>} issueNames The resource names of the issues.
+ * @return {Array<Issue>}
+ */
+function batchGetIssues(issueNames) {
+ const message = {'names': issueNames};
+ const url = URL + 'monorail.v3.Issues/BatchGetIssues';
+ return run_(url, message);
+}
+
+/**
+ * Fetches all the ApprovalValues that belong to the given issue.
+ * @param {string} issueName The resource name of the issue.
+ * @return {Array<ApprovalValue>}
+ */
+function listApprovalValues(issueName) {
+ const message = {'parent': issueName};
+ const url = URL + 'monorail.v3.Issues/ListApprovalValues';
+ return run_(url, message);
+}
+
+/**
+ * Calls SearchIssues with the given parameters.
+ * @param {Array<string>} projectNames resource names of the projects to search.
+ * @param {string} query The query to use to search.
+ * @param {string} orderBy The issue fields to order issues by.
+ * @param {Number} pageSize The maximum issues to return.
+ * @param {string} pageToken The page token from the previous call.
+ * @return {Array<SearchIssuesResponse>}
+ */
+function searchIssuesPagination_(
+ projectNames, query, orderBy, pageSize, pageToken) {
+ const message = {
+ 'projects': projectNames,
+ 'query': query,
+ 'orderBy': orderBy,
+ 'pageToken': pageToken};
+ if (pageSize) {
+ message['pageSize'] = pageSize;
+ }
+ const url = URL + 'monorail.v3.Issues/SearchIssues';
+ return run_(url, message);
+}
+
+// TODO(crbug.com/monorail/7143): SearchIssues only accepts one project.
+/**
+ * Searches Monorail for issues using the given query.
+ * NOTE: We currently only accept `projectNames` with one and only one project.
+ * @param {Array<string>} projects Resource names of the projects to search
+ * within.
+ * @param {string=} query The query to use to search.
+ * @param {string=} orderBy The issue fields to order issues by,
+ * e.g. 'EstDays,Opened,-stars'
+ * @return {Array<Issue>}
+ */
+function searchIssues(projects, query, orderBy) {
+ const pageSize = 100;
+ let pageToken;
+
+ issues = [];
+
+ do {
+ const resp = searchIssuesPagination_(
+ projects, query, orderBy, pageSize, pageToken);
+ issues = issues.concat(resp.issues);
+ 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 {ListCommentsResponse}
+ */
+function listCommentsPagination_(issueName, filter, pageSize, pageToken) {
+ const message = {
+ 'parent': issueName,
+ 'pageToken': pageToken,
+ 'filter': filter,
+ };
+ if (pageSize) {
+ message['pageSize'] = pageSize;
+ }
+ const url = URL + 'monorail.v3.Issues/ListComments';
+ return run_(url, message);
+}
+
+/**
+ * Returns all comments and previous/current descriptions of an issue.
+ * @param {string} issueName Resource name of the Issue.
+ * @param {string=} filter The filter query filtering out comments.
+ * We only accept `approval = "<approvalDef resource name>""`.
+ * e.g. 'approval = "projects/chromium/approvalDefs/34"'
+ * @return {Array<Comment>}
+ */
+function listComments(issueName, filter) {
+ let pageToken;
+
+ let comments = [];
+ do {
+ const resp = listCommentsPagination_(issueName, filter, '', pageToken);
+ comments = comments.concat(resp.comments);
+ pageToken = resp.nextPageToken;
+ }
+ while (pageToken);
+
+ return comments;
+}
+
+/**
+ * Gets the current description of an issue.
+ * @param {string} issueName Resource name of the Issue.
+ * @param {string=} filter The filter query filtering out comments.
+ * We only accept `approval = "<approvalDef resource name>""`.
+ * e.g. 'approval = "projects/chromium/approvalDefs/34"'
+ * @return {Comment}
+ */
+function getCurrentDescription(issueName, filter) {
+ const allComments = listComments(issueName, filter);
+ for (let i = (allComments.length - 1); i > -1; i--) {
+ if (allComments[i].type === COMMENT_TYPE_DESCRIPTION) {
+ return allComments[i];
+ }
+ }
+}
+
+/**
+ * Gets the first (non-description) comment of an issue.
+ * @param {string} issueName Resource name of the Issue.
+ * @param {string=} filter The filter query filtering out comments.
+ * We only accept `approval = "<approvalDef resource name>""`.
+ * e.g. 'approval = "projects/chromium/approvalDefs/34"'
+ * @return {Comment}
+ */
+function getFirstComment(issueName, filter) {
+ const allComments = listComments(issueName, filter);
+ for (let i = 0; i < allComments.length; i++) {
+ if (allComments[i].type !== COMMENT_TYPE_DESCRIPTION) {
+ return allComments[i];
+ }
+ }
+ return null;
+}
+
+/**
+ * Gets the last (non-description) comment of an issue.
+ * @param {string} issueName The resource name of the issue.
+ * @param {string=} filter The filter query filtering out comments.
+ * We only accept `approval = "<approvalDef resource name>""`.
+ * e.g. 'approval = "projects/chromium/approvalDefs/34"'
+ * @return {Issue}
+ */
+function getLastComment(issueName, filter) {
+ const allComments = listComments(issueName, filter);
+ for (let i = (allComments.length - 1); i > -1; i--) {
+ if (allComments[i].type != COMMENT_TYPE_DESCRIPTION) {
+ return allComments[i];
+ }
+ }
+ return null;
+}
+
+/**
+ * Checks if the given label exists in the issue.
+ * @param {Issue} issue The issue to search within for the label.
+ * @param {string} label The label to search for.
+ * @return {boolean}
+ */
+function hasLabel(issue, label) {
+ if (issue.labels) {
+ const testLabel = label.toLowerCase();
+ return issue.labels.some(({label}) => testLabel === label.toLowerCase());
+ }
+ return false;
+}
+
+/**
+ * Checks if the issue has any labels matching the given regex.
+ * @param {Issue} issue The issue to search within for matching labels.
+ * @param {string} regex The regex pattern to use to search for labels.
+ * @return {boolean}
+ */
+function hasLabelMatching(issue, regex) {
+ if (issue.labels) {
+ const re = new RegExp(regex, 'i');
+ return issue.labels.some(({label}) => re.test(label));
+ }
+ return false;
+}
+
+/**
+ * Returns all labels in the issue that match the given regex.
+ * @param {Issue} issue The issue to search within for matching labels.
+ * @param {string} regex The regex pattern to use to search for labels.
+ * @return {Array<string>}
+ */
+function getLabelsMatching(issue, regex) {
+ const labels = [];
+ if (issue.labels) {
+ const re = new RegExp(regex, 'i');
+ for (let i = 0; i < issue.labels.length; i++) {
+ if (re.test(issue.labels[i].label)) {
+ labels.push(issue.labels[i].label);
+ }
+ }
+ }
+ return labels;
+}
+
+/**
+ * Get the comment where the given label was added, if any.
+ * @param {string} issueName The resource name of the issue.
+ * @param {string} label The label that was remove.
+ * @return {Comment}
+ */
+function getLabelSetComment(issueName, label) {
+ const comments = listComments(issueName);
+ for (let i = 0; i < comments.length; i++) {
+ const comment = comments[i];
+ if (comment.amendments) {
+ for (let j = 0; j < comment.amendments.length; j++) {
+ const amendment = comment.amendments[j];
+ if (amendment['fieldName'] === 'Labels' &&
+ amendment['newOrDeltaValue'].toLowerCase() === (
+ label.toLocaleLowerCase())) {
+ return comment;
+ }
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * Get the comment where the given label was removed, if any.
+ * @param {string} issueName The resource name of the issue.
+ * @param {string} label The label that was remove.
+ * @return {Comment}
+ */
+function getLabelRemoveComment(issueName, label) {
+ const comments = listComments(issueName);
+ for (let i = 0; i < comments.length; i++) {
+ const comment = comments[i];
+ if (comment.amendments) {
+ for (let j = 0; j < comment.amendments.length; j++) {
+ const amendment = comment.amendments[j];
+ if (amendment['fieldName'] === 'Labels' &&
+ amendment[
+ 'newOrDeltaValue'].toLowerCase() === (
+ '-' + label.toLocaleLowerCase())) {
+ return comment;
+ }
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * Updates the issue to have the given label added.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue The issue to update.
+ * @param {string} label The label to add.
+ */
+function addLabel(issue, label) {
+ if (hasLabel(issue, label)) return;
+ maybeCreateDelta_(issue);
+ // Add the label to the issue's delta.labelsAdd.
+ issue.delta.labelsAdd.push(label);
+ // Add the label to the issue.
+ issue.labels.push({label: label});
+ // 'labels' added to updateMask in saveChanges().
+}
+
+/**
+ * Updates the issue to have the given label removed from the issue.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue The issue to update.
+ * @param {string} label The label to remove.
+ */
+function removeLabel(issue, label) {
+ if (!hasLabel(issue, label)) return;
+ maybeCreateDelta_(issue);
+ // Add the label to the issue's delta.labelsRemove.
+ issue.delta.labelsRemove.push(label);
+ // Remove label from issue.
+ for (let i = 0; i < issue.labels.length; i++) {
+ if (issue.labels[i].label.toLowerCase() === label.toLowerCase()) {
+ issue.labels.splice(i, 1);
+ break;
+ }
+ }
+}
+
+/**
+ * Sets the owner of the given issue.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {string} ownerName The resource name of the new owner,
+ * e.g. 'users/chicken@email.com'
+*/
+function setOwner(issue, ownerName) {
+ maybeCreateDelta_(issue);
+ issue.owner = {'user': ownerName};
+ if (issue.delta.updateMask.indexOf('owner.user') === -1) {
+ issue.delta.updateMask.push('owner.user');
+ }
+}
+
+/**
+ * Sets the summary of the given issue.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {string} summary The new summary of the issue.
+*/
+function setSummary(issue, summary) {
+ maybeCreateDelta_(issue);
+ issue.summary = summary;
+ if (issue.delta.updateMask.indexOf('summary') === -1) {
+ issue.delta.updateMask.push('summary');
+ }
+}
+
+/**
+ *Sets the status of the given issue.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {string} status The new status of the issue e.g. 'Available'.
+*/
+function setStatus(issue, status) {
+ maybeCreateDelta_(issue);
+ issue.status.status = status;
+ if (issue.delta.updateMask.indexOf('status.status') === -1) {
+ issue.delta.updateMask.push('status.status');
+ }
+}
+
+/**
+ * Sets the merged into issue for the given issue.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {IssueRef} mergedIntoRef IssueRef of the issue to merge into.
+ */
+function setMergedInto(issue, mergedIntoRef) {
+ maybeCreateDelta_(issue);
+ issue.mergedIntoIssueRef = mergedIntoRef;
+ if (issue.delta.updateMask.indexOf('mergedIntoIssueRef') === -1) {
+ issue.delta.updateMask.push('mergedIntoIssueRef');
+ }
+}
+
+/**
+ * Checks if target is found in source.
+ * @param {IssueRef} target The IssueRef to look for.
+ * @param {Array<IssueRef>} source the IssueRefs to look in.
+ * @return {number} index of target in source, -1 if not found.
+ */
+function issueRefExists_(target, source) {
+ for (let i = 0; i < source.length; i++) {
+ if ((source[i].issue === target.issue || (!source[i].issue && !target.issue)
+ ) && (source[i].extIdentifier === target.extIdentifier || (
+ !source[i].extIdentifier && !target.extIdentifier))) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Makes blocking issue ref changes.
+ * blockingIssuesAdd are added before blockingIssuesRemove are removed.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {Array<IssueRef>} blockingIssuesAdd issues to add as blocking issues.
+ * @param {Array<IssueRef>} blockingIssuesRemove issues to remove from blocking
+ * issues.
+ */
+function addBlockingIssueChanges(
+ issue, blockingIssuesAdd, blockingIssuesRemove) {
+ maybeCreateDelta_(issue);
+ blockingIssuesAdd.forEach((addRef) => {
+ const iInIssue = issueRefExists_(addRef, issue.blockingIssueRefs);
+ if (iInIssue === -1) { // addRef not found in issue
+ issue.blockingIssueRefs.push(addRef);
+ issue.delta.blockingAdd.push(addRef);
+ const iInDeltaRemove = issueRefExists_(
+ addRef, issue.delta.blockingRemove);
+ if (iInDeltaRemove != -1) {
+ // Remove addRef from blckingRemove that may have been added earlier.
+ issue.delta.blockingRemove.splice(iInDeltaRemove, 1);
+ }
+ // issue.delta.updateMask is updated in saveChanges()
+ }
+ });
+ // Add blockingIssuesAdd to issue and issue.delta.blockingAdd if not in
+ // issue.blockingIssues
+ blockingIssuesRemove.forEach((removeRef) => {
+ const iInIssue = issueRefExists_(removeRef, issue.blockingIssueRefs);
+ if (iInIssue > -1) {
+ issue.blockingIssueRefs.splice(iInIssue, 1);
+ issue.delta.blockingRemove.push(removeRef);
+ const iInDeltaAdd = issueRefExists_(removeRef, issue.delta.blockingAdd);
+ if (iInDeltaAdd != -1) {
+ issue.delta.blockingAdd.splice(iInDeltaAdd, 1);
+ }
+ }
+ });
+}
+
+/**
+ * Makes blocked-on issue ref changes.
+ * blockedOnIssuesAdd are added before blockedOnIssuesRemove are removed.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {Array<IssueRef>} blockedOnIssuesAdd issues to add as blockedon
+ * issues.
+ * @param {Array<IssueRef>} blockedOnIssuesRemove issues to remove from
+ * blockedon issues.
+ */
+function addBlockedOnIssueChanges(
+ issue, blockedOnIssuesAdd, blockedOnIssuesRemove) {
+ maybeCreateDelta_(issue);
+ blockedOnIssuesAdd.forEach((addRef) => {
+ const iInIssue = issueRefExists_(addRef, issue.blockedOnIssueRefs);
+ if (iInIssue === -1) { // addRef not found in issue
+ issue.blockedOnIssueRefs.push(addRef);
+ issue.delta.blockedOnAdd.push(addRef);
+ const iInDeltaRemove = issueRefExists_(
+ addRef, issue.delta.blockedOnRemove);
+ if (iInDeltaRemove != -1) {
+ // Remove addRef from blckingRemove that may have been added earlier.
+ issue.delta.blockedOnRemove.splice(iInDeltaRemove, 1);
+ }
+ // issue.delta.updateMask is updated in saveChanges()
+ }
+ });
+ // Add blockedOnIssuesAdd to issue and issue.delta.blockedOnAdd if not in
+ // issue.blockedOnIssues.
+ blockedOnIssuesRemove.forEach((removeRef) => {
+ const iInIssue = issueRefExists_(removeRef, issue.blockedOnIssueRefs);
+ if (iInIssue > -1) {
+ issue.blockedOnIssueRefs.splice(iInIssue, 1);
+ issue.delta.blockedOnRemove.push(removeRef);
+ const iInDeltaAdd = issueRefExists_(removeRef, issue.delta.blockedOnAdd);
+ if (iInDeltaAdd != -1) {
+ issue.delta.blockedOnAdd.splice(iInDeltaAdd, 1);
+ }
+ }
+ });
+}
+
+
+/**
+ * Looks for a component name in an Array of ComponentValues.
+ * @param {string} compName Resource name of the Component to look for.
+ * @param {Array<ComponentValue>} compArray List of ComponentValues.
+ * @return {number} Index of compName in compArray, -1 if not found.
+ */
+function componentExists_(compName, compArray) {
+ for (let i = 0; i < compArray.length; i++) {
+ if (compArray[i].component === compName) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Adds the component changes to the issue.
+ * componentNamesAdd are added before componentNamesremove are removed.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {Array<string>} componentNamesAdd Array of component resource names.
+ * @param {Array<string>} componentNamesRemove Array or component resource
+ * names.
+
+*/
+function addComponentChanges(issue, componentNamesAdd, componentNamesRemove) {
+ maybeCreateDelta_(issue);
+ componentNamesAdd.forEach((compName) => {
+ const iInIssue = componentExists_(compName, issue.components);
+ if (iInIssue === -1) { // compName is not in issue.
+ issue.components.push({'component': compName});
+ issue.delta.componentsAdd.push(compName);
+ const iInDeltaRemove = issue.delta.componentsRemove.indexOf(compName);
+ if (iInDeltaRemove != -1) {
+ // Remove compName from issue.delta.componentsRemove that may have been
+ // added before.
+ issue.delta.componentsRemove.splice(iInDeltaRemove, 1);
+ }
+ // issue.delta.updateMask is updated in saveChanges()
+ }
+ });
+
+ componentNamesRemove.forEach((compName) => {
+ const iInIssue = componentExists_(compName, issue.components);
+ if (iInIssue != -1) { // compName was found in issue.
+ issue.components.splice(iInIssue, 1);
+ issue.delta.componentsRemove.push(compName);
+ const iInDeltaAdd = issue.delta.componentsAdd.indexOf(compName);
+ if (iInDeltaAdd != -1) {
+ // Remove compName from issue.delta.componentsAdd that may have been
+ // added before.
+ issue.delta.componentsAdd.splice(iInDeltaAdd, 1);
+ }
+ }
+ });
+}
+
+/**
+ * Checks if the fieldVal is found in fieldValsArray
+ * @param {FieldValue} fieldVal the field to look for.
+ * @param {Array<FieldValue>} fieldValsArray the Array to look within.
+ * @return {number} the index of fieldVal in fieldValsArray, or -1 if not found.
+ */
+function fieldValueExists_(fieldVal, fieldValsArray) {
+ for (let i = 0; i < fieldValsArray.length; i++) {
+ const currFv = fieldValsArray[i];
+ if (currFv.field === fieldVal.field && currFv.value === fieldVal.value && (
+ currFv.phase === fieldVal.phase || (
+ !currFv.phase && !fieldVal.phase))) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Adds the FieldValue changes to the issue.
+ * fieldValuesAdd are added before fieldValuesRemove are removed.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {Array<FieldValue>} fieldValuesAdd Array of FieldValues to add.
+ * @param {Array<FieldValue>} fieldValuesRemove Array of FieldValues to remove.
+*/
+function addFieldValueChanges(issue, fieldValuesAdd, fieldValuesRemove) {
+ maybeCreateDelta_(issue);
+ fieldValuesAdd.forEach((fvAdd) => {
+ const iInIssue = fieldValueExists_(fvAdd, issue.fieldValues);
+ if (iInIssue === -1) { // fvAdd is not already in issue, so we can add it.
+ issue.fieldValues.push(fvAdd);
+ issue.delta.fieldValuesAdd.push(fvAdd);
+ const iInDeltaRemove = fieldValueExists_(
+ fvAdd, issue.delta.fieldValuesRemove);
+ if (iInDeltaRemove != -1) {
+ // fvAdd was added to fieldValuesRemove in a previous call.
+ issue.delta.fieldValuesRemove.splice(iInDeltaRemove, 1);
+ }
+ // issue.delta.updateMask is updated in saveChanges()
+ }
+ });
+ // issue.delta.updateMask is updated in saveChanges()
+ fieldValuesRemove.forEach((fvRemove) => {
+ const iInIssue = fieldValueExists_(fvRemove, issue.fieldValues);
+ if (iInIssue != -1) { // fvRemove is in issue, so we can remove it.
+ issue.fieldValues.splice(iInIssue, 1);
+ issue.delta.fieldValuesRemove.push(fvRemove);
+ const iInDeltaAdd = fieldValueExists_(
+ fvRemove, issue.delta.fieldValuesAdd);
+ if (iInDeltaAdd != -1) {
+ // fvRemove was added to fieldValuesAdd in a previous call.
+ issue.delta.fieldValuesAdd.splice(iInDeltaAdd, 1);
+ }
+ }
+ });
+}
+
+/**
+ * Checks for the existence of userName in userValues
+ * @param {string} userName A user resource name to look for.
+ * @param {Array<UserValue>} userValues UserValues to search through.
+ * @return {number} Index of userName's UserValue in userValues or -1 if not
+ * found.
+ */
+function userValueExists_(userName, userValues) {
+ for (let i = 0; i< userValues.length; i++) {
+ if (userValues[i].user === userName) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Adds the CC changes to the issue.
+ * ccNamesAdd are added before ccNamesRemove are removed.
+ * This method does not call Monorail's API to save this change.
+ * Call saveChanges() to send all updates to Monorail.
+ * @param {Issue} issue Issue to change.
+ * @param {Array<string>} ccNamesAdd Array if user resource names.
+ * @param {Array<string>} ccNamesRemove Array if user resource names.
+*/
+function addCcChanges(issue, ccNamesAdd, ccNamesRemove) {
+ maybeCreateDelta_(issue);
+ ccNamesAdd.forEach((ccName) => {
+ const iInIssue = userValueExists_(ccName, issue.ccUsers);
+ if (iInIssue === -1) { // User is not in issue, so we can add them.
+ issue.ccUsers.push({'user': ccName});
+ issue.delta.ccsAdd.push(ccName);
+ const iInDeltaRemove = issue.delta.ccsRemove.indexOf(ccName);
+ if (iInDeltaRemove != -1) {
+ // ccName was added to ccsRemove in a previous call.
+ issue.delta.ccsRemove.splice(iInDeltaRemove, 1);
+ }
+ }
+ });
+ ccNamesRemove.forEach((ccName) => {
+ const iInIssue = userValueExists_(ccName, issue.ccUsers);
+ if (iInIssue != -1) { // User is in issue, so we can remove it.
+ issue.ccUsers.splice(iInIssue, 1);
+ issue.delta.ccsRemove.push(ccName);
+ const iInDeltaAdd = issue.delta.ccsAdd.indexOf(ccName);
+ if (iInDeltaAdd != -1) {
+ // ccName was added to delta.ccsAdd in a previous all.
+ issue.delta.ccsAdd.splice(iInDeltaAdd, 1);
+ }
+ }
+ });
+}
+
+/**
+ * Set the pending comment of the issue.
+ * @param {Issue} issue Issue whose comment we want to set.
+ * @param {string} comment Comment that we want for the issue.
+ */
+function setComment(issue, comment) {
+ maybeCreateDelta_(issue);
+ issue.delta.comment = comment;
+}
+
+/**
+ * Get the pending comment for the issue.
+ * @param {Issue} issue Issue whose comment we want.
+ * @return {string}
+ */
+function getPendingComment(issue) {
+ if (issue.delta) {
+ return issue.delta.comment;
+ }
+ return '';
+}
+
+/**
+ * Adds to the existing pending comment
+ * @param {Issue} issue Issue to update.
+ * @param {string} comment The comment string to add to the existing one.
+ */
+function appendComment(issue, comment) {
+ maybeCreateDelta_(issue);
+ issue.delta.comment = issue.delta.comment.concat(comment);
+}
+
+/**
+ * Sets up an issue for pending changes.
+ * @param {Issue} issue The issue that needs to be updated.
+ */
+function maybeCreateDelta_(issue) {
+ if (!issue.delta) {
+ issue.delta = newIssueDelta_();
+ if (!issue.components) {
+ issue.components = [];
+ };
+ if (!issue.blockingIssueRefs) {
+ issue.blockingIssueRefs = [];
+ }
+ if (!issue.blockedOnIssueRefs) {
+ issue.blockedOnIssueRefs = [];
+ }
+ if (!issue.ccUsers) {
+ issue.ccUsers = [];
+ }
+ if (!issue.labels) {
+ issue.labels = [];
+ }
+ if (!issue.fieldValues) {
+ issue.fieldValues = [];
+ }
+ }
+}
+
+/**
+ * Creates an IssueDelta
+ * @return {IssueDelta_}
+ */
+function newIssueDelta_() {
+ return new IssueDelta_();
+}
+
+/** Used to track pending changes to an issue.*/
+function IssueDelta_() {
+ /** Array<string> */ this.updateMask = [];
+
+ // User resource names.
+ /** Array<string> */ this.ccsRemove = [];
+ /** Array<string> */ this.ccsAdd = [];
+
+ /** Array<IssueRef> */ this.blockedOnRemove = [];
+ /** Array<IssueRef> */ this.blockedOnAdd = [];
+ /** Array<IssueRef> */ this.blockingRemove = [];
+ /** Array<IssueRef> */ this.blockingAdd = [];
+
+ // Component resource names.
+ /** Array<string> */ this.componentsRemove = [];
+ /** Array<string> */ this.componentsAdd = [];
+
+ // Label values, e.g. 'Security-Notify'.
+ /** Array<string> */ this.labelsRemove = [];
+ /** Array<string> */ this.labelsAdd = [];
+
+ /** Array<FieldValue> */ this.fieldValuesRemove = [];
+ /** Array<FieldValue> */ this.fieldValuesAdd = [];
+
+ this.comment = '';
+}
+
+/**
+ * Calls Monorail's API to update the issue.
+ * @param {Issue} issue The issue to update where issue['delta'] is expected
+ * to exist.
+ * @param {boolean} sendEmail True if the update should trigger email
+ * notifications.
+ * @return {Issue}
+ */
+function saveChanges(issue, sendEmail) {
+ if (!issue.delta) {
+ throw new Error('No pending changes for issue.');
+ }
+
+ const modifyDelta = {
+ 'ccsRemove': issue.delta.ccsRemove,
+ 'blockedOnIssuesRemove': issue.delta.blockedOnRemove,
+ 'blockingIssuesRemove': issue.delta.blockingRemove,
+ 'componentsRemove': issue.delta.componentsRemove,
+ 'labelsRemove': issue.delta.labelsRemove,
+ 'fieldValsRemove': issue.delta.fieldValuesRemove,
+ 'issue': {
+ 'name': issue.name,
+ 'fieldValues': issue.delta.fieldValuesAdd,
+ 'blockedOnIssueRefs': issue.delta.blockedOnAdd,
+ 'blockingIssueRefs': issue.delta.blockingAdd,
+ 'mergedIntoIssueRef': issue.mergedIntoIssueRef,
+ 'summary': issue.summary,
+ 'status': issue.status,
+ 'owner': issue.owner,
+ 'labels': [],
+ 'ccUsers': [],
+ 'components': [],
+ },
+ };
+
+ if (issue.delta.fieldValuesAdd.length > 0) {
+ issue.delta.updateMask.push('fieldValues');
+ }
+
+ if (issue.delta.blockedOnAdd.length > 0) {
+ issue.delta.updateMask.push('blockedOnIssueRefs');
+ }
+
+ if (issue.delta.blockingAdd.length > 0) {
+ issue.delta.updateMask.push('blockingIssueRefs');
+ }
+
+ if (issue.delta.ccsAdd.length > 0) {
+ issue.delta.updateMask.push('ccUsers');
+ }
+ issue.delta.ccsAdd.forEach((userResourceName) => {
+ modifyDelta.issue['ccUsers'].push({'user': userResourceName});
+ });
+
+ if (issue.delta.labelsAdd.length > 0) {
+ issue.delta.updateMask.push('labels');
+ }
+ issue.delta.labelsAdd.forEach((label) => {
+ modifyDelta.issue['labels'].push({'label': label});
+ });
+
+ if (issue.delta.componentsAdd.length > 0) {
+ issue.delta.updateMask.push('components');
+ }
+ issue.delta.componentsAdd.forEach((compResourceName) => {
+ modifyDelta.issue['components'].push({'component': compResourceName});
+ });
+
+ modifyDelta['updateMask'] = issue.delta.updateMask.join();
+
+ const message = {
+ 'deltas': [modifyDelta],
+ 'notifyType': sendEmail ? 'EMAIL' : 'NO_NOTIFICATION',
+ 'commentContent': issue.delta.comment,
+ };
+
+ const url = URL + 'monorail.v3.Issues/ModifyIssues';
+ response = run_(url, message);
+ if (!response.issues) {
+ Logger.log('All changes Noop');
+ return null;
+ }
+ issue = response.issues[0];
+ return issue;
+}
+
+/**
+ * Creates an Issue.
+ * @param {string} projectName: Resource name of the parent project.
+ * @param {string} summary: Summary of the issue.
+ * @param {string} description: Description of the issue.
+ * @param {string} status: Status of the issue, e.g. "Untriaged".
+ * @param {boolean} sendEmail: True if this should trigger email notifications.
+ * @param {string=} ownerName: Resource name of the issue owner.
+ * @param {Array<string>=} ccNames: Resource names of the users to cc.
+ * @param {Array<string>=} labels: Labels to add to the issue,
+ * e.g. "Restict-View-Google".
+ * @param {Array<string>=} componentNames: Resource names of components to add.
+ * @param {Array<FieldValue>=} fieldValues: FieldValues to add to the issue.
+ * @param {Array<IssueRef>=} blockedOnRefs: IssueRefs for blocked on issues.
+ * @param {Array<IssueRef>=} blockingRefs: IssueRefs for blocking issues.
+ * @return {Issue}
+ */
+function makeIssue(
+ projectName, summary, description, status, sendEmail, ownerName, ccNames,
+ labels, componentNames, fieldValues, blockedOnRefs, blockingRefs) {
+ const issue = {
+ 'summary': summary,
+ 'status': {'status': status},
+ 'ccUsers': [],
+ 'components': [],
+ 'labels': [],
+ };
+
+ if (ownerName) {
+ issue['owner'] = {'user': ownerName};
+ }
+
+ if (ccNames) {
+ ccNames.forEach((ccName) => {
+ issue['ccUsers'].push({'user': ccName});
+ });
+ };
+
+ if (labels) {
+ labels.forEach((label) => {
+ issue['labels'].push({'label': label});
+ });
+ };
+
+ if (componentNames) {
+ componentNames.forEach((componentName) => {
+ issue['components'].push({'component': componentName});
+ });
+ };
+
+ if (fieldValues) {
+ issue['fieldValues'] = fieldValues;
+ };
+
+ if (blockedOnRefs) {
+ issue['blockedOnIssueRefs'] = blockedOnRefs;
+ };
+
+ if (blockingRefs) {
+ issue['blockingIssueRefs'] = blockingRefs;
+ };
+
+ const message = {
+ 'parent': projectName,
+ 'issue': issue,
+ 'description': description,
+ 'notifyType': sendEmail ? 'EMAIL': 'NO_NOTIFICATION',
+ };
+ const url = URL + 'monorail.v3.Issues/MakeIssue';
+ return run_(url, message);
+}
diff --git a/api/v3/apps-script-client/ProjectService.js b/api/v3/apps-script-client/ProjectService.js
new file mode 100644
index 0000000..1487a53
--- /dev/null
+++ b/api/v3/apps-script-client/ProjectService.js
@@ -0,0 +1,52 @@
+// Copyright 2020 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.
+
+/* eslint-disable no-unused-vars */
+
+/**
+ * Creates a ComponentDef.
+ * @param {string} projectName The resource name of the parent project.
+ * @param {string} value The name of the component
+ * e.g. "Triage" or "Triage>Security".
+ * @param {string=} docstring Short description of the ComponentDef.
+ * @param {Array<string>=} admins Array of User resource names to set as admins.
+ * @param {Array<string>=} ccs Array of User resources names to set as auto-ccs.
+ * @param {Array<string>=} labels Array of labels.
+ * @return {ComponentDef}
+ */
+function createComponentDef(
+ projectName, value, docstring, admins, ccs, labels) {
+ const componentDef = {
+ 'value': value,
+ 'docstring': docstring,
+ };
+ if (admins) {
+ componentDef['admins'] = admins;
+ }
+ if (ccs) {
+ componentDef['ccs'] = ccs;
+ }
+ if (labels) {
+ componentDef['labels'] = labels;
+ }
+ const message = {
+ 'parent': projectName,
+ 'componentDef': componentDef,
+ };
+ const url = URL + 'monorail.v3.Projects/CreateComponentDef';
+ return run_(url, message);
+}
+
+/**
+ * Deletes a ComponentDef.
+ * @param {string} componentName Resource name of the ComponentDef to delete.
+ * @return {EmptyProto}
+ */
+function deleteComponentDef(componentName) {
+ const message = {
+ 'name': componentName,
+ };
+ const url = URL + 'monorail.v3.Projects/DeleteComponentDef';
+ return run_(url, message);
+}
diff --git a/api/v3/apps-script-client/README.md b/api/v3/apps-script-client/README.md
new file mode 100644
index 0000000..a15a5f9
--- /dev/null
+++ b/api/v3/apps-script-client/README.md
@@ -0,0 +1,8 @@
+## This directory contains code that make up our v3 Apps Script client library.
+
+client.js is purposely omitted.
+
+To make updates to the library:
+1) Update the code here and send in a CL for review.
+2) Merge the Cl and copy-paste the changes into Apps Script at go/monorail-v3-apps-script.
+3) Create a new static version in Apps Script and update the labeled version 'latest' to point to the new static version.
diff --git a/api/v3/apps-script-client/UserService.js b/api/v3/apps-script-client/UserService.js
new file mode 100644
index 0000000..6402db5
--- /dev/null
+++ b/api/v3/apps-script-client/UserService.js
@@ -0,0 +1,27 @@
+// Copyright 2020 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.
+
+/* eslint-disable no-unused-vars */
+
+/**
+ * Fetches the user from Monorai.
+ * @param {string} userName The resource name of the user.
+ * @return {User}
+ */
+function getUser(userName) {
+ const message = {'name': userName};
+ const url = URL + 'monorail.v3.Users/GetUser';
+ return run_(url, message);
+}
+
+/**
+ * Fetches the users from Monorail.
+ * @param {Array<string>} userNames The resource names of the users.
+ * @return {Array<User>}
+ */
+function batchGetUsers(userNames) {
+ const message = {'names': userNames};
+ const url = URL + 'monorail.v3.Users/BatchGetUsers';
+ return run_(url, message);
+}
diff --git a/api/v3/apps-script-client/helpers.js b/api/v3/apps-script-client/helpers.js
new file mode 100644
index 0000000..05ee920
--- /dev/null
+++ b/api/v3/apps-script-client/helpers.js
@@ -0,0 +1,82 @@
+// Copyright 2020 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.
+
+/* eslint-disable no-unused-vars */
+
+/**
+ * Returns the user's resource name.
+ * @param {string|number} user The user's email or user_id
+ * @return {string}
+ */
+function computeUserName(user) {
+ return `users/${user}`;
+}
+
+/**
+ * Returns the users' resource names.
+ * @param {Array<string|number>} users Array of user emails/user_ids.
+ * @return {Array<string>}
+ */
+function computeUserNames(users) {
+ const userNames = [];
+ users.forEach((user) => {
+ userNames.push(computeUserName(user));
+ });
+ return userNames;
+}
+
+
+/**
+ * Returns the issue's resource name.
+ * @param {string} project The name of the project the issue belongs to,
+ * e.g. 'chromium'.
+ * @param {number} id The issue's id.
+ * @return {string}
+ */
+function computeIssueName(project, id) {
+ return `projects/${project}/issues/${id}`;
+}
+
+/**
+ * Returns the project's resource name.
+ * @param {string} project The display name of the project, e.g. 'chromium'.
+ * @return {string}
+ */
+function computeProjectName(project) {
+ return `projects/${project}`;
+}
+
+/**
+ * Returns the projects' resource names in the same order.
+ * @param {Array<string>} projects The display names of the projects,
+ * e.g. 'chromium'.
+ * @return {Array<string>}
+ */
+function computeProjectNames(projects) {
+ const projectNames = [];
+ projects.forEach((project) => {
+ projectNames.push(computeProjectName(project));
+ });
+ return projectNames;
+}
+
+/**
+ * Returns the FieldDef's resource name.
+ * @param {string} project The display name of the project, e.g. 'chromium'.
+ * @param {number} fieldId ID of the FieldDef.
+ * @return {string}
+ */
+function computeFieldDefName(project, fieldId) {
+ return `projects/${project}/fieldDefs/${fieldId}`;
+}
+
+/**
+ * Returns the ComponentDef's resource name.
+ * @param {string} project The display name of the project, e.g. 'chromium'.
+ * @param {number|string} componentIdOrPath ID or value of the ComponentDef.
+ * @return {string}
+*/
+function computeComponentDefName(project, componentIdOrPath) {
+ return `projects/${project}/componentDefs/${componentIdOrPath}`;
+}
diff --git a/api/v3/apps-script-client/types.js b/api/v3/apps-script-client/types.js
new file mode 100644
index 0000000..cafba21
--- /dev/null
+++ b/api/v3/apps-script-client/types.js
@@ -0,0 +1,75 @@
+// Copyright 2020 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.
+
+/* eslint-disable max-len */
+
+/**
+ * The label of an issue.
+ * @typedef {Object} LabelValue
+ * @property {string} label - the string label. e.g. 'Target-99'.
+ * @property {string} derivation - How the label was derived. One of 'EXPLICIT', 'RULE'.
+ */
+
+/**
+ * A user involved in an issue.
+ * @typedef {Object} UserValue
+ * @property {string} user - The User resource name.
+ * @property {string} derivation - How the user was derived. One of 'EXPLICIT', 'RULE'.
+ */
+
+/**
+ * A component involved in an issue.
+ * @typedef {Object} ComponentValue
+ * @property {string} component - The ComponentDef resource name.
+ * @property {string} derivation - How the component was derived. One of 'EXPLICIT', 'RULE'.
+ */
+
+/**
+ * A field involved in an issue.
+ * @typedef {Object} FieldValue
+ * @property {string} field - The FieldDef resource name.
+ * @property {string} value - The value associated with the field.
+ * @property {string} derivation - How the value was derived. One of 'EXPLICIT', 'RULE'.
+ * @property {string} phase - The phase of an issue that this value belongs to, if any.
+ */
+
+/**
+ * The status of an issue.
+ * @typedef {Object} StatusValue
+ * @property {string} status - The status. e.g. 'Available'.
+ * @property {string} derivation - How the status was derived. One of 'EXPLICIT', 'RULE'.
+ */
+
+/**
+ * A reference to monorail or external issue.
+ * @typedef {Object} IssueRef
+ * @property {string} [issue] - The resource name of the issue.
+ * @property {string} [extIdentifier] - The identifier of an external issue e.g 'b/123'.
+ */
+
+/**
+ * An Issue.
+ * @typedef {Object} Issue
+ * @property {string} name - The resource name of the issue.
+ * @property {string} summary - The issue summary.
+ * @property {string} state - The current state of the issue. One of 'ACTIVE', 'DELETED', 'SPAM'.
+ * @property {string} reporter - The User resource name of the issue reporter.
+ * @property {UserValue} owner - The issue's owner.
+ * @property {StatusValue} status - The issue status.
+ * @property {IssueRef} mergedIntoIssueRef - The issue this issue is merged into.
+ * @property {Array<IssueRef>} blockedOnIssueRefs - TODO
+ * @property {Array<IssueRef>} blockingIssueRefs - TODO
+ * @property {Array<LabelValue>} labels - The labels of the issue.
+ * @property {Array<FieldValue>} fieldValues - TODO
+ * @property {Array<UserValue>} ccUsers - The users cc'd to this issue.
+ * @property {Array<ComponentValue>} components - The Components added to the issue.
+ * @property {Number} attachmentCount - The number of attachments this issue holds.
+ * @property {Number} starCount - The number of stars this issue has.
+ * @property {Array<FieldValue>} fieldValues - The field values of the issue.
+ * @property {Array<string>} phases - The names of all Phases in this issue.
+ * @property {Object} delta - Holds the pending changes that will be applied with SaveChanges().
+ */
+// TODO(crbug.com/monorail/6456): createTime, closeTime, modifyTime, componentModifyTime, statusModifyTime, ownerModifyTime
+
+// TODO(crbug.com/monorail/6456): Add other classes.