| // 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; |
| } |
| } |