blob: 89c9c78ca6af82cc63a79838b54bb6a7d5e2b7fc [file] [log] [blame]
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.googlesource.gerrit.plugins.copyright;
import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.AUTHOR_OWNER;
import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.LICENSE;
import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FIRST_PARTY;
import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.THIRD_PARTY;
import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FORBIDDEN;
import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.UNKNOWN;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
import com.google.gerrit.extensions.api.changes.ReviewResult;
import com.google.gerrit.extensions.client.Comment;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.events.RevisionCreatedListener;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.metrics.Counter0;
import com.google.gerrit.metrics.Counter1;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.Description.Units;
import com.google.gerrit.metrics.Field;
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.metrics.Timer1;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.PluginUser;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.git.validators.CommitValidationMessage;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.restapi.change.ListChangeComments;
import com.google.gerrit.server.restapi.change.PostReview;
import com.google.inject.Singleton;
import com.google.inject.Provider;
import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.Match;
import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType;
import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
/** Utility to report revision findings on the review thread. */
@Singleton
public class CopyrightReviewApi {
static final ImmutableList<Match> ALWAYS_REVIEW = ImmutableList.of();
private final Metrics metrics;
private final Provider<PluginUser> pluginUserProvider;
private final Provider<CurrentUser> userProvider;
private final IdentifiedUser.GenericFactory identifiedUserFactory;
private final ChangeNotes.Factory changeNotesFactory;
private final ChangeResource.Factory changeResourceFactory;
private final PatchSetUtil psUtil;
private final PostReview postReview;
private final ListChangeComments listChangeComments;
@Singleton
static class Metrics {
final Counter0 reviewCount;
final Counter0 commentCount;
final Timer0 reviewTimer;
final Counter1 reviewCountByProject;
final Counter1 commentCountByProject;
final Timer1 reviewTimerByProject;
@Inject
Metrics(MetricMaker metricMaker) {
Field<String> project = Field.ofString("project", "project name");
reviewCount =
metricMaker.newCounter(
"plugin/copyright/review_count",
new Description("Total number of posted reviews").setRate().setUnit("reviews"));
commentCount =
metricMaker.newCounter(
"plugin/copyright/comment_count",
new Description("Total number of posted review comments")
.setRate()
.setUnit("comments"));
reviewTimer =
metricMaker.newTimer(
"plugin/copyright/review_latency",
new Description("Time spent posting reviews to revisions")
.setCumulative()
.setUnit(Units.MICROSECONDS));
reviewCountByProject =
metricMaker.newCounter(
"plugin/copyright/review_count_by_project",
new Description("Total number of posted reviews").setRate().setUnit("reviews"),
project);
commentCountByProject =
metricMaker.newCounter(
"plugin/copyright/comment_count_by_project",
new Description("Total number of posted review comments")
.setRate()
.setUnit("comments"),
project);
reviewTimerByProject =
metricMaker.newTimer(
"plugin/copyright/review_latency_by_project",
new Description("Time spent posting reviews to revisions")
.setCumulative()
.setUnit(Units.MICROSECONDS),
project);
}
}
@Inject
CopyrightReviewApi(
Metrics metrics,
Provider<PluginUser> pluginUserProvider,
Provider<CurrentUser> userProvider,
IdentifiedUser.GenericFactory identifiedUserFactory,
ChangeNotes.Factory changeNotesFactory,
ChangeResource.Factory changeResourceFactory,
PatchSetUtil psUtil,
PostReview postReview,
ListChangeComments listChangeComments) {
this.metrics = metrics;
this.pluginUserProvider = pluginUserProvider;
this.userProvider = userProvider;
this.identifiedUserFactory = identifiedUserFactory;
this.changeNotesFactory = changeNotesFactory;
this.changeResourceFactory = changeResourceFactory;
this.psUtil = psUtil;
this.postReview = postReview;
this.listChangeComments = listChangeComments;
}
/**
* Reports validation findings for a proposed new plugin configuration to the review thread for
* the newly pushed revision.
*
* @param pluginName as installed in gerrit
* @param project identifies the All-Projects config for recording metrics
* @param path identifies the file to attach messages to (@code gerrit.config or project.config}
* @param oldConfig the prior state of the plugin configuration
* @param newConfig the new state of the plugin configuration
* @param event describes the pushed revision with the new configuration
* @throws RestApiException if an error occurs updating the review thread
*/
ReviewResult reportConfigMessages(
String pluginName,
String project,
String path,
CopyrightConfig.ScannerConfig oldConfig,
CopyrightConfig.ScannerConfig newConfig,
RevisionCreatedListener.Event event) throws RestApiException {
Preconditions.checkNotNull(newConfig);
Preconditions.checkNotNull(newConfig.messages);
Preconditions.checkArgument(!newConfig.messages.isEmpty());
long startNanos = System.nanoTime();
metrics.reviewCount.increment();
metrics.reviewCountByProject.increment(project);
try {
int fromAccountId =
oldConfig != null && oldConfig.fromAccountId > 0
? oldConfig.fromAccountId
: newConfig.fromAccountId;
ChangeResource change = getChange(event, fromAccountId);
StringBuilder message = new StringBuilder();
message.append(pluginName);
message.append(" plugin issues parsing new configuration");
ReviewInput ri = new ReviewInput()
.message(message.toString());
Map<String, List<CommentInfo>> priorComments = getComments(change);
if (priorComments == null) {
priorComments = ImmutableMap.of();
}
int numComments = 0;
ImmutableMap.Builder<String, List<CommentInput>> comments = ImmutableMap.builder();
for (CommitValidationMessage m : newConfig.messages) {
message.setLength(0);
message.append(m.getType().toString());
message.append(" ");
message.append(m.getMessage());
CommentInput ci = new CommentInput();
ci.line = 0;
ci.unresolved = true;
ci.message = message.toString();
if (containsComment(priorComments.get(path), ci)) {
continue;
}
comments.put(path, ImmutableList.of(ci));
numComments++;
}
if (numComments > 0) {
ri.comments = comments.build();
}
metrics.commentCount.incrementBy((long) numComments);
metrics.commentCountByProject.incrementBy(project, (long) numComments);
return review(change, ri);
} finally {
long elapsedMicros = (System.nanoTime() - startNanos) / 1000;
metrics.reviewTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
metrics.reviewTimerByProject.record(project, elapsedMicros, TimeUnit.MICROSECONDS);
}
}
/**
* Reports {@link CopyrightValidator} findings from a scanned revision on its review thread.
*
* @param project identifies the project where change pushed for recording metrics
* @param scannerConfig the state of the plugin configuration and scanner
* @param event describes the pushed revision scanned by the plugin
* @param findings maps each scanned file to the copyright matches found by the scanner
* @throws RestApiException if an error occurs updating the review thread
*/
ReviewResult reportScanFindings(
String project,
CopyrightConfig.ScannerConfig scannerConfig,
RevisionCreatedListener.Event event,
Map<String, ImmutableList<Match>> findings) throws RestApiException {
long startNanos = System.nanoTime();
metrics.reviewCount.increment();
metrics.reviewCountByProject.increment(project);
try {
boolean tpAllowed = scannerConfig.isThirdPartyAllowed(project);
boolean reviewRequired = false;
for (Map.Entry<String, ImmutableList<Match>> entry : findings.entrySet()) {
if (entry.getValue() == ALWAYS_REVIEW) {
reviewRequired = true;
break;
}
PartyType pt = partyType(entry.getValue());
if (pt.compareTo(THIRD_PARTY) > 0) {
reviewRequired = true;
break;
}
if (pt == THIRD_PARTY && !tpAllowed) {
reviewRequired = true;
break;
}
}
ChangeResource change = getChange(event, scannerConfig.fromAccountId);
ReviewInput ri = new ReviewInput()
.message("Copyright scan")
.label(scannerConfig.reviewLabel, reviewRequired ? -1 : +2);
if (reviewRequired) {
ri = addReviewers(ri, scannerConfig.ccs, ReviewerState.CC);
ri = addReviewers(ri, scannerConfig.reviewers, ReviewerState.REVIEWER);
}
ImmutableMap.Builder<String, List<CommentInput>> comments = ImmutableMap.builder();
if (reviewRequired) {
ri = ri.message("This change requires copyright review.");
} else {
ri = ri.message("This change appears to comply with copyright requirements.");
}
Map<String, List<CommentInfo>> priorComments = getComments(change);
if (priorComments == null) {
priorComments = ImmutableMap.of();
}
int numCommentsAdded = 0;
for (Map.Entry<String, ImmutableList<Match>> entry : findings.entrySet()) {
ImmutableList<CommentInput> newComments = null;
if (entry.getValue() == ALWAYS_REVIEW) {
CommentInput ci = new CommentInput();
ci.line = 0;
ci.unresolved = true;
ci.message = entry.getKey() + " always requires copyright review";
if (containsComment(priorComments.get(entry.getKey()), ci)) {
continue;
}
newComments = ImmutableList.of(ci);
} else {
PartyType pt = partyType(entry.getValue());
newComments = reviewComments(project, pt, tpAllowed, entry.getValue());
List<CommentInfo> prior = priorComments.get(entry.getKey());
newComments = ImmutableList.copyOf(
newComments.stream().filter(ci -> !containsComment(prior, ci))
.toArray(i -> new CommentInput[i]));
if (newComments.isEmpty()) {
continue;
}
}
numCommentsAdded += newComments.size();
comments.put(entry.getKey(), newComments);
}
if (numCommentsAdded > 0) {
ri.comments = comments.build();
}
metrics.commentCount.incrementBy((long) numCommentsAdded);
metrics.commentCountByProject.incrementBy(project, (long) numCommentsAdded);
return review(change, ri);
} finally {
long elapsedMicros = (System.nanoTime() - startNanos) / 1000;
metrics.reviewTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
metrics.reviewTimerByProject.record(project, elapsedMicros, TimeUnit.MICROSECONDS);
}
}
/**
* Returns the {@link com.google.gerrit.server.CurrentUser} seeming to send the review comments.
*
* Impersonates {@code fromAccountId} if configured by {@code fromAccountId =} in plugin
* configuration -- falling back to the identity of the user pushing the revision.
*/
CurrentUser getSendingUser(int fromAccountId) {
PluginUser pluginUser = pluginUserProvider.get();
return fromAccountId <= 0 ? userProvider.get() :
identifiedUserFactory.runAs(null, new Account.Id(fromAccountId), pluginUser);
}
/**
* Constructs a {@link com.google.gerrit.server.change.ChangeResource} from the notes log for
* the change onto which a new revision was pushed.
*
* @param event describes the newly pushed revision
* @param fromAccountId identifies the configured user to impersonate when sending review comments
* @throws RestApiException if an error occurs looking up the notes log for the change
*/
private ChangeResource getChange(RevisionCreatedListener.Event event, int fromAccountId)
throws RestApiException {
try {
CurrentUser fromUser = getSendingUser(fromAccountId);
ChangeNotes notes =
changeNotesFactory.createChecked(new Change.Id(event.getChange()._number));
return changeResourceFactory.create(notes, fromUser);
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
throw e instanceof RestApiException
? (RestApiException) e : new RestApiException("Cannot load change", e);
}
}
/**
* Returns a modified {@link com.google.gerrit.extensions.api.changes.ReviewInput} after adding
* {@code reviewers} as {@code type} CC or REVIEWER.
*/
@VisibleForTesting
ReviewInput addReviewers(ReviewInput ri, Iterable<String> reviewers, ReviewerState type) {
for (String reviewer : reviewers) {
ri = ri.reviewer(reviewer, type, true);
}
return ri;
}
/**
* Retrieves all of the prior review comments already attached to {@code change}.
*
* @throws RestApiException if an error occurs retrieving the comments
*/
private Map<String, List<CommentInfo>> getComments(ChangeResource change)
throws RestApiException {
try {
return listChangeComments.apply(change);
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
throw e instanceof RestApiException
? (RestApiException) e : new RestApiException("Cannot list comments", e);
}
}
/**
* Adds the code review described by {@code ri} to the review thread of {@code change}.
*
* @throws RestApiException if an error occurs updating the review thread
*/
private ReviewResult review(ChangeResource change, ReviewInput ri) throws RestApiException {
try {
PatchSet ps = psUtil.current(change.getNotes());
if (ps == null) {
throw new ResourceNotFoundException(IdString.fromDecoded("current"));
}
RevisionResource revision = RevisionResource.createNonCacheable(change, ps);
return postReview.apply(revision, ri).value();
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
throw e instanceof RestApiException
? (RestApiException) e : new RestApiException("Cannot post review", e);
}
}
/** Returns true if {@code priorComments} already includes a comment identical to {@code ci}. */
@VisibleForTesting
boolean containsComment(Iterable<? extends Comment> priorComments, CommentInput ci) {
if (priorComments == null) {
return false;
}
for (Comment prior : priorComments) {
if (Objects.equals(prior.line, ci.line)
&& Objects.equals(prior.range, ci.range)
&& Objects.equals(prior.message, ci.message)) {
return true;
}
}
return false;
}
/**
* Puts the pieces together from a scanner finding to construct a coherent human-reable message.
*
* @param project describes the project or repository where the revision was pushed
* @param overallPt identifies the calculated
* {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for all of
* the findings in the file. e.g. 1p license + 3p owner == 1p, no license + 3p owner == 3p
* @param pt identifies the {@code PartyType} of the current finding
* @param mt identifies the
* {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType} of the
* current finding. i.e. AUTHOR_OWNER or LICENSE
* @param tpAllowed is true if {@code project} allows third-party code
* @param text of the message for the finding
*/
private String buildMessage(
String project,
PartyType overallPt,
PartyType pt,
MatchType mt,
boolean tpAllowed,
StringBuilder text) {
StringBuilder message = new StringBuilder();
switch (pt) {
case FIRST_PARTY:
message.append("First-party ");
break;
case THIRD_PARTY:
message.append("Third-party ");
break;
case FORBIDDEN:
message.append("Disapproved ");
break;
default:
message.append("Unrecognized ");
break;
}
message.append(mt == AUTHOR_OWNER ? "author or owner " : "license ");
if (pt == THIRD_PARTY && overallPt != FIRST_PARTY) {
if (!tpAllowed) {
message.append("dis");
}
message.append("allowed in repository ");
message.append(project);
}
message.append(":\n\n");
message.append(text);
return message.toString();
}
/**
* Converts the scanner findings in {@code matches} into human-readable review comments.
*
* @param project the project or repository to which the revision was pushed
* @param pt the calculated overall
* {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the
* findings e.g. 1p license + 3p owner = 1p, no license + 3p owner = 3p
* @param tpAllowed is true if {@code project} allows third-party code
* @param matches describes the location and types of matches found in a file
* @return a list of {@link com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput} to
* add to the {@link com.google.gerrit.extensions.api.changes.ReviewInput} to add to the
* review thread for the current patch set.
*/
@VisibleForTesting
ImmutableList<CommentInput> reviewComments(
String project, PartyType pt, boolean tpAllowed, Iterable<Match> matches) {
ImmutableList.Builder<CommentInput> builder = ImmutableList.builder();
CommentInput ci = null;
PartyType previousPt = UNKNOWN;
MatchType previousMt = LICENSE;
StringBuilder text = new StringBuilder();
for (Match m : matches) {
if (ci == null
|| previousPt != m.partyType
|| m.endLine > ci.range.startLine + 60
|| m.startLine > ci.range.endLine + 40) {
if (ci != null) {
ci.message = buildMessage(project, pt, previousPt, previousMt, tpAllowed, text);
builder.add(ci);
}
ci = new CommentInput();
boolean allowed = m.partyType == FIRST_PARTY
|| (m.partyType == THIRD_PARTY && tpAllowed)
|| (m.partyType == THIRD_PARTY && m.matchType == AUTHOR_OWNER && pt == FIRST_PARTY);
ci.unresolved = !allowed;
ci.range = new Comment.Range();
ci.line = m.endLine;
ci.range.startLine = m.startLine;
ci.range.endLine = m.endLine;
ci.range.startCharacter = 0;
ci.range.endCharacter = 0;
previousPt = m.partyType;
previousMt = m.matchType;
text.setLength(0);
text.append(m.text);
continue;
}
text.append("...");
text.append(m.text);
// an author or owner inside a license declaration is a normal part of a license declaration
if (m.matchType == LICENSE) {
previousMt = LICENSE;
}
if (m.startLine < ci.range.startLine) {
ci.range.startLine = m.startLine;
}
if (m.endLine > ci.range.endLine) {
ci.range.endLine = m.endLine;
ci.line = m.endLine;
}
}
if (ci != null) {
ci.message = buildMessage(project, pt, previousPt, previousMt, tpAllowed, text);
builder.add(ci);
}
return builder.build();
}
/**
* Calculates and returns the overall
* {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the
* copyright scanner findings in {@code matches}.
*/
@VisibleForTesting
PartyType partyType(Iterable<Match> matches) {
PartyType pt = PartyType.FIRST_PARTY;
boolean hasThirdPartyOwner = false;
boolean hasFirstPartyLicense = false;
for (Match match : matches) {
if (match.partyType == PartyType.THIRD_PARTY && match.matchType == MatchType.AUTHOR_OWNER) {
hasThirdPartyOwner = true;
} else if (match.partyType == PartyType.FIRST_PARTY && match.matchType == MatchType.LICENSE) {
hasFirstPartyLicense = true;
} else if (match.partyType.compareTo(pt) > 0) {
pt = match.partyType;
}
}
if (pt == PartyType.FIRST_PARTY && hasThirdPartyOwner && !hasFirstPartyLicense) {
return PartyType.THIRD_PARTY;
}
return pt;
}
}