Skip to content

reviewable.io review completion condition

Jeremy Nimmer edited this page Mar 19, 2022 · 11 revisions

This is the most recent reviewable.io "review completion condition script" for Drake, which repository admins can edit at https://reviewable.io/repositories#RobotLocomotion%2Fdrake. This page is for backup purposes. Please try to keep it sync'd with the service.

// Any time you edit this file, paste the final version into the drake-ci wiki for backup:
// https://github.com/RobotLocomotion/drake-ci/wiki/reviewable.io-review-completion-condition

// Our review status will report the bad_reasons when non-empty, or good_reasons otherwise.
// A non-empty bad_reasons will prevent merging.
var good_reasons = [];
var bad_reasons = [];

// Check for a project-wide moratorium.
var isMoratorium = false;
if (isMoratorium) {
  bad_reasons.push('moratorium on merges is in effect');
}

// Keep this list sync'd with https://drake.mit.edu/developers.html
var platformReviewers = {'ericcousineau-tri' : true,
                         'ggould-tri' : true,
                         'jwnimmer-tri' : true, 
                         'rpoyner-tri' : true,
                         'sammy-tri' : true,
                         'sherm1' : true, 
                         'xuchenhan-tri' : true,
                         'russtedrake' : true,
                         };

// Tabulate the emoji approvals.
var approvals = {};
_.each(review.sentiments, function(sentiment) {
  var key = sentiment.username.toLowerCase();
  var emojis = _.indexBy(sentiment.emojis);
  if (emojis.lgtm_cancel) {
    delete approvals[key];
  } else if (emojis.lgtm_strong || emojis.lgtm || emojis.LGTM) {
    approvals[key] = sentiment.username;
  }
});

// Tabulate the files under review.
// Set the File Matrix group for 'dev' files
// Set the File Matrix group for 'pydrake' files
var platformRequiredFiles = []
var platformExemptFiles = []
var completion_files = review.files;
for (var i in review.files) {
  var file = review.files[i];
  var revisions = file["revisions"];
  var currentRevision = revisions[revisions.length - 1];
  var reverted = currentRevision["reverted"];
  var path = file["path"];
  if (reverted) {
    // They do not require any code reviews.
    completion_files[i].group = "Reverted";
  } else if (path.includes("/dev/")) {
    platformExemptFiles.push(path);
    if (!("group" in file)) {
      completion_files[i].group = "Dev Folders";
    }
  } else {
    platformRequiredFiles.push(path);
  }
  if (path.includes("bindings/pydrake")) {
    if (!("group" in file)) {
      completion_files[i].group = "pydrake";
    }
  }
}

// Helpers
function plural(noun, len) {
  if (len > 1) return noun + 's';
  return noun;
}

// Check that all discussions are resolved.
var unresolved = review.summary.numUnresolvedDiscussions;
if (unresolved === 0) {
  good_reasons.push('all discussions resolved');
} else {
  bad_reasons.push(unresolved + ' unresolved ' + plural('discussion', unresolved))
}

// Check that every assignee has approved.
var platformReviewerAssigned = false;
var assignees = review.pullRequest.assignees;
var good_assignees = [];
var bad_assignees = [];
for (var i in assignees) {
  var username = assignees[i].username;
  var key = username.toLowerCase();
  var displayName;
  if (platformReviewers[key]) {
    displayName = username + "(platform)";
    platformReviewerAssigned = true;
  } else {
    displayName = username;
  }
  if (approvals[key]) {
    good_assignees.push(displayName);
  } else {
    bad_assignees.push(displayName);
  }
}
if (good_assignees.length > 0) {
  good_reasons.push('LGTM from ' + plural('assignee', good_assignees.length) + ' ' + good_assignees);
}
if (bad_assignees.length > 0) {
  bad_reasons.push('LGTM missing from ' + plural('assignee', bad_assignees.length) + ' ' + bad_assignees);
}

// Check for platform reviewer approval (when required).
if (platformRequiredFiles.length == 0) {
  good_reasons.push("exempt from platform review")
} else {
  if (!platformReviewerAssigned) {
    bad_reasons.push("needs platform reviewer assigned")
  }
}

// Check for minimum reviewer count.
var singleReviewerOk = review.labels.indexOf('status: single reviewer ok') >= 0;
if (!singleReviewerOk && (platformRequiredFiles.length > 0)) {
  if (assignees.length < 2) {
    bad_reasons.push("needs at least two assigned reviewers")
  }
} else if (assignees.length < 1) {
  bad_reasons.push("needs at least one assigned reviewer")
}

// Check for DNM label.
if (review.labels.indexOf('status: do not merge') >= 0) {
  bad_reasons.push('labeled "do not merge"');
}

// Check for commit curation.
var multipleCommits = review.pullRequest.numCommits > 1;
var properlyCurated = review.labels.indexOf('status: commits are properly curated') >= 0;
var squashingNow = review.labels.indexOf('status: squashing now') >= 0;
if (multipleCommits && !(properlyCurated || squashingNow)) {
  bad_reasons.push('commits need curation (https://drake.mit.edu/reviewable.html#curated-commits)');
}
var mergeStyle = (multipleCommits && properlyCurated) ? 'merge' : 'squash';

// Check that merges are always onto the tip.
var baseSha = review.revisions.slice(-1)[0].baseCommitSha;
var headSha = review.pullRequest.target.headCommitSha;
if (mergeStyle == 'merge') {
  if (baseSha == headSha) {
    good_reasons.push('base commit is latest master');
  } else {
    bad_reasons.push('when planning a "properly curated" merge commit the PR must always be rebased onto latest master');
  }
}

// Check for release notes.
var has_release_notes_label = false;
for (var i in review.labels) {
  var label = review.labels[i];
  if (label.indexOf('release notes:') >= 0) {
     has_release_notes_label = true;
  }
}
if (!has_release_notes_label) {
  bad_reasons.push('missing label for release notes');
}

// List bad reasons (only) if there are any, otherwise list the good ones (only).
var description;
if (bad_reasons.length > 0) {
  description = bad_reasons.join(', ');
} else {
  description = good_reasons.join(', ');  
}

// Make commit messages nice.
// TODO(jwnimmer-tri) Return this below.
var defaultSquashCommitMessage = review.pullRequest.title + ' (#' + review.pullRequest.number + ')\n\n' + review.pullRequest.body.trim()

return {
  completed: (bad_reasons.length === 0),
  description: description,
  mergeStyle: mergeStyle,
  disableGitHubApprovals: true,
  syncRequestedReviewers: false,
  files: completion_files,
  debug: {approvals: approvals, baseSha: baseSha, headSha: headSha,
          defaultSquashCommitMessage: defaultSquashCommitMessage,
          platformExemptFiles: platformExemptFiles,
          platformRequiredFiles: platformRequiredFiles}
};