Merge "Add 'has:hashtag' search operator"
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 580aef5..23b0024 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -32,6 +32,7 @@
import com.google.gerrit.server.patch.filediff.Edit;
import com.google.protobuf.Descriptors.FieldDescriptor;
import java.util.Optional;
+import org.eclipse.jgit.attributes.Attribute;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.lib.AbbreviatedObjectId;
import org.eclipse.jgit.lib.FileMode;
@@ -66,8 +67,17 @@
* parameters.
*/
static GitFileDiff create(DiffEntry diffEntry, FileHeader fileHeader) {
+ Attribute diffAttr = diffEntry.getDiffAttribute();
+ // Treat the file as binary if .gitattributes explicitly unsets diffing (e.g. "-diff"
+ // or the "binary" macro which JGit expands to "-diff -merge -text").
+ boolean isBinary = diffAttr != null && diffAttr.getState() == Attribute.State.UNSET;
+
ImmutableList<Edit> edits =
- fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
+ isBinary
+ ? ImmutableList.of()
+ : fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
+
+ PatchType patchType = isBinary ? PatchType.BINARY : FileHeaderUtil.getPatchType(fileHeader);
return builder()
.edits(edits)
@@ -77,7 +87,7 @@
.oldPath(FileHeaderUtil.getOldPath(fileHeader))
.newPath(FileHeaderUtil.getNewPath(fileHeader))
.changeType(FileHeaderUtil.getChangeType(fileHeader))
- .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
+ .patchType(Optional.of(patchType))
.oldMode(Optional.of(mapFileMode(diffEntry.getOldMode())))
.newMode(Optional.of(mapFileMode(diffEntry.getNewMode())))
.build();
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 7c8555e..1234262 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -20,6 +20,7 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.server.git.GitRepositoryManager;
@@ -423,6 +424,66 @@
}
}
+ @Test
+ public void gitattributesDiffOverride() throws Exception {
+ String jsonFile = "file_2.json";
+ // 1. First, let's create a commit where the file is modified but there is no .gitattributes.
+ // It should be diffed as UNIFIED.
+ ObjectId oldCommitId1 =
+ createCommit(repo, null, ImmutableList.of(new FileEntity(jsonFile, "{}")));
+ ObjectId newCommitId1 =
+ createCommit(
+ repo, oldCommitId1, ImmutableList.of(new FileEntity(jsonFile, "{\"foo\": \"bar\"}")));
+ FileDiffOutput diffOutput1 =
+ diffOperations.getModifiedFileAgainstParent(
+ testProjectName, newCommitId1, 0, jsonFile, null);
+ assertThat(diffOutput1.patchType()).hasValue(PatchType.UNIFIED);
+ assertThat(diffOutput1.edits()).isNotEmpty();
+
+ // 2. Now let's create a commit where .gitattributes specifies "-diff" for the file.
+ // It should be treated as BINARY.
+ ObjectId oldCommitId2 =
+ createCommit(
+ repo,
+ null,
+ ImmutableList.of(
+ new FileEntity(".gitattributes", jsonFile + " -diff"),
+ new FileEntity(jsonFile, "{}")));
+ ObjectId newCommitId2 =
+ createCommit(
+ repo,
+ oldCommitId2,
+ ImmutableList.of(
+ new FileEntity(".gitattributes", jsonFile + " -diff"),
+ new FileEntity(jsonFile, "{\"foo\": \"bar\"}")));
+ FileDiffOutput diffOutput2 =
+ diffOperations.getModifiedFileAgainstParent(
+ testProjectName, newCommitId2, 0, jsonFile, null);
+ assertThat(diffOutput2.patchType()).hasValue(PatchType.BINARY);
+ assertThat(diffOutput2.edits()).isEmpty();
+
+ // 3. Let's also test with "binary" macro attribute.
+ ObjectId oldCommitId3 =
+ createCommit(
+ repo,
+ null,
+ ImmutableList.of(
+ new FileEntity(".gitattributes", jsonFile + " binary"),
+ new FileEntity(jsonFile, "{}")));
+ ObjectId newCommitId3 =
+ createCommit(
+ repo,
+ oldCommitId3,
+ ImmutableList.of(
+ new FileEntity(".gitattributes", jsonFile + " binary"),
+ new FileEntity(jsonFile, "{\"foo\": \"bar\"}")));
+ FileDiffOutput diffOutput3 =
+ diffOperations.getModifiedFileAgainstParent(
+ testProjectName, newCommitId3, 0, jsonFile, null);
+ assertThat(diffOutput3.patchType()).hasValue(PatchType.BINARY);
+ assertThat(diffOutput3.edits()).isEmpty();
+ }
+
static class FileEntity {
String name;
String content;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 3020bff..5e8cdd7 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -633,6 +633,18 @@
>
`
: nothing}
+ ${this.shouldShowDisagreeButton()
+ ? html`
+ <gr-button
+ id="disagreeBtn"
+ link
+ class="action disagree"
+ ?disabled=${this.saving}
+ @click=${this.handleCommentDisagree}
+ >Disagree</gr-button
+ >
+ `
+ : nothing}
<gr-button
id="ackBtn"
link
@@ -967,6 +979,14 @@
);
}
+ protected handleCommentDisagree() {
+ this.createReplyComment(
+ 'Disagree.',
+ /* userWantsToEdit= */ false,
+ /* unresolved= */ false
+ );
+ }
+
private handleReplyToComment(e: ReplyToCommentEvent) {
const {content, userWantsToEdit, unresolved} = e.detail;
this.createReplyComment(content, userWantsToEdit, unresolved);
@@ -1034,6 +1054,17 @@
return this.isOwner && !hasUserSuggestion(comment);
}
+ protected shouldShowDisagreeButton(): boolean {
+ return !!(
+ this.thread &&
+ this.account &&
+ this.unresolved &&
+ this.thread.comments.length === 1 &&
+ this.isOwner &&
+ this.thread.comments[0]?.is_ai
+ );
+ }
+
private handleAppliedFix(fixSuggestion?: FixSuggestionInfo) {
const message = this.getLastComment()?.message;
assert(!!message, 'empty message');
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts
index 9731c4e8..bf06c20 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts
@@ -114,6 +114,15 @@
await visualDiffDarkTheme(element, 'gr-comment-thread-with-ai');
});
+ test('with AI comment author disagree', async () => {
+ element.isOwner = true;
+ element.thread = createThread({...c_ai, unresolved: true});
+ await element.updateComplete;
+
+ await visualDiff(element, 'gr-comment-thread-with-ai-disagree');
+ await visualDiffDarkTheme(element, 'gr-comment-thread-with-ai-disagree');
+ });
+
test('unresolved inline code review', async () => {
// Simulate inline context by setting custom properties
element.style.setProperty('--gr-comment-thread-width', '100%');
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 8b5e166..d40490f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -872,4 +872,52 @@
]);
});
});
+
+ suite('Disagree button', () => {
+ setup(async () => {
+ element.isOwner = true;
+ element.account = createAccountDetailWithId(13);
+ element.thread = createThread({...c1, is_ai: true, unresolved: true});
+ await element.updateComplete;
+ });
+
+ test('renders with unresolved AI comment when owner', async () => {
+ assert.isOk(query(element, '#disagreeBtn'));
+ });
+
+ test('does not show disagree button if comment is not AI', async () => {
+ element.thread = createThread({...c1, is_ai: false, unresolved: true});
+ await element.updateComplete;
+ assert.isNotOk(query(element, '#disagreeBtn'));
+ });
+
+ test('does not show disagree button if user is not change owner', async () => {
+ element.isOwner = false;
+ await element.updateComplete;
+ assert.isNotOk(query(element, '#disagreeBtn'));
+ });
+
+ test('does not show disagree button if thread has more than 1 comment', async () => {
+ element.thread = createThread(
+ {...c1, is_ai: true, unresolved: true},
+ createComment()
+ );
+ await element.updateComplete;
+ assert.isNotOk(query(element, '#disagreeBtn'));
+ });
+
+ test('handleCommentDisagree creates a "Disagree." reply', async () => {
+ const createReplyCommentSpy = sinon.spy(
+ element as unknown as {createReplyComment: () => void},
+ 'createReplyComment'
+ );
+ queryAndAssert<GrButton>(element, '#disagreeBtn').click();
+ assert.isTrue(createReplyCommentSpy.calledOnce);
+ assert.deepEqual(createReplyCommentSpy.firstCall.args, [
+ 'Disagree.',
+ false,
+ false,
+ ]);
+ });
+ });
});
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index c986efe..3fbc552 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -321,9 +321,18 @@
);
this.checksLatest$ = select(this.state$, state => state.pluginStateLatest);
this.checksSelected$ = select(
- combineLatest([this.state$, this.changeViewModel.checksPatchset$]),
- ([state, ps]) => {
- const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST;
+ combineLatest([
+ this.state$,
+ this.changeViewModel.checksPatchset$,
+ this.changeModel.latestPatchNum$,
+ ]),
+ ([state, ps, latestPs]) => {
+ // When no distinct patchset is selected SELECTED fetch is skipped
+ // (see initFetchingOfData), so fall back to the LATEST state here.
+ const checksPs =
+ ps && ps !== latestPs
+ ? ChecksPatchset.SELECTED
+ : ChecksPatchset.LATEST;
return this.getPluginState(state, checksPs);
}
);
@@ -865,29 +874,54 @@
this.reloadSubjects[pluginName],
pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
this.documentVisibilityChange$,
+ // Only the SELECTED subscription needs the latest patchset here, to
+ // detect when it coincides with the latest (see below). The LATEST
+ // subscription already has it as its second element, so feeding it in
+ // again would make combineLatest emit twice per change.
+ patchset === ChecksPatchset.SELECTED
+ ? this.changeModel.latestPatchNum$
+ : of(undefined),
])
.pipe(
takeWhile(_ => !!this.providers[pluginName]),
filter(_ => document.visibilityState !== 'hidden'),
throttleTime(500, undefined, {leading: true, trailing: true}),
- switchMap(([change, patchNum]): Observable<FetchResponse> => {
- if (!change || !patchNum) return of(this.empty());
- if (typeof patchNum !== 'number') return of(this.empty());
- assertIsDefined(change.revisions, 'change.revisions');
- const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
- // Sometimes patchNum is updated earlier than change, so change
- // revisions don't have patchNum yet
- if (!patchsetSha) return of(this.empty());
- const data: ChangeData = {
- changeNumber: change?._number,
- patchsetNumber: patchNum,
- patchsetSha,
- repo: change.project,
- commitMessage: getCurrentRevision(change)?.commit?.message,
- changeInfo: change as ChangeInfo,
- };
- return this.fetchResults(pluginName, data, patchset);
- }),
+ switchMap(
+ ([
+ change,
+ patchNum,
+ ,
+ ,
+ ,
+ latestPatchNum,
+ ]): Observable<FetchResponse> => {
+ if (!change || !patchNum) return of(this.empty());
+ if (typeof patchNum !== 'number') return of(this.empty());
+ // Skip the duplicate fetch when the selected patchset is the
+ // latest: the LATEST subscription already fetches it and
+ // checksSelected$ falls back to that state.
+ if (
+ patchset === ChecksPatchset.SELECTED &&
+ patchNum === latestPatchNum
+ ) {
+ return of(this.empty());
+ }
+ assertIsDefined(change.revisions, 'change.revisions');
+ const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
+ // Sometimes patchNum is updated earlier than change, so change
+ // revisions don't have patchNum yet
+ if (!patchsetSha) return of(this.empty());
+ const data: ChangeData = {
+ changeNumber: change?._number,
+ patchsetNumber: patchNum,
+ patchsetSha,
+ repo: change.project,
+ commitMessage: getCurrentRevision(change)?.commit?.message,
+ changeInfo: change as ChangeInfo,
+ };
+ return this.fetchResults(pluginName, data, patchset);
+ }
+ ),
catchError(e => {
// This should not happen and is really severe, because it means that
// the Observable has terminated and we won't recover from that. No
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 94b3d0f..368b7e6 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -17,6 +17,7 @@
import {
Action,
Category,
+ ChangeData,
CheckRun,
ChecksApiConfig,
ChecksProvider,
@@ -27,9 +28,11 @@
import {
createCheckResult,
createParsedChange,
+ createRevisions,
createRun,
+ getCurrentRevision,
} from '../../test/test-data-generators';
-import {waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {waitEventLoop, waitUntil, waitUntilCalled} from '../../test/test-utils';
import {ParsedChangeInfo} from '../../types/types';
import {
changeModelToken,
@@ -38,7 +41,11 @@
import {assert} from '@open-wc/testing';
import {testResolver} from '../../test/common-test-setup';
import {changeViewModelToken} from '../views/change';
-import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
+import {
+ NumericChangeId,
+ PatchSetNumber,
+ RevisionPatchSetNum,
+} from '../../api/rest-api';
import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
import {deepEqual} from '../../utils/deep-util';
@@ -79,6 +86,29 @@
};
}
+/**
+ * A provider that echoes back the patchset it was asked to fetch, so that tests
+ * can assert which patchset a tab is populated with.
+ */
+function createPatchsetTaggingProvider(): ChecksProvider {
+ return {
+ fetch: (data: ChangeData) =>
+ Promise.resolve({
+ responseCode: ResponseCode.OK,
+ runs: [createRun({patchset: data.patchsetNumber})],
+ }),
+ };
+}
+
+/** A change with two patchsets, so latest (2) and older (1) are distinct. */
+function createTwoPatchsetChange(): ParsedChangeInfo {
+ return updateRevisionsWithCommitShas({
+ ...createParsedChange(),
+ revisions: createRevisions(2),
+ current_revision: getCurrentRevision(1),
+ })!;
+}
+
suite('checks-model tests', () => {
let model: ChecksModel;
@@ -130,6 +160,130 @@
clock.restore();
});
+ test('no duplicate fetch when viewing latest patchset (no selection)', async () => {
+ const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+ let change: ParsedChangeInfo | undefined = undefined;
+ testResolver(changeModelToken).change$.subscribe(c => (change = c));
+ let latestRuns: CheckRun[] = [];
+ let selectedRuns: CheckRun[] = [];
+ model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+ model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+ const provider = createPatchsetTaggingProvider();
+ const fetchSpy = sinon.spy(provider, 'fetch');
+
+ model.register({
+ pluginName: PLUGIN_NAME,
+ provider,
+ config: CONFIG_POLLING_NONE,
+ });
+ await waitUntil(() => change === undefined);
+
+ // Viewing the latest patchset (2), no explicit checks patchset override.
+ testResolver(changeViewModelToken).updateState({
+ patchNum: 2 as RevisionPatchSetNum,
+ });
+ const testChange = createTwoPatchsetChange();
+ testResolver(changeModelToken).updateStateChange(testChange);
+ await waitUntil(() => deepEqual(change, testChange));
+
+ // Fire the throttled emission, then flush the fetch promise into state.
+ clock.tick(600);
+ await waitEventLoop();
+
+ // The SELECTED patchset equals LATEST, so only a single fetch is needed.
+ assert.equal(fetchSpy.callCount, 1);
+ // Both tabs show data for the latest patchset (2).
+ assert.isNotEmpty(latestRuns);
+ assert.isNotEmpty(selectedRuns);
+ assert.isTrue(latestRuns.every(r => r.patchset === 2));
+ assert.isTrue(selectedRuns.every(r => r.patchset === 2));
+
+ clock.restore();
+ });
+
+ test('no duplicate fetch when latest patchset is explicitly selected', async () => {
+ const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+ let change: ParsedChangeInfo | undefined = undefined;
+ testResolver(changeModelToken).change$.subscribe(c => (change = c));
+ let latestRuns: CheckRun[] = [];
+ let selectedRuns: CheckRun[] = [];
+ model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+ model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+ const provider = createPatchsetTaggingProvider();
+ const fetchSpy = sinon.spy(provider, 'fetch');
+
+ model.register({
+ pluginName: PLUGIN_NAME,
+ provider,
+ config: CONFIG_POLLING_NONE,
+ });
+ await waitUntil(() => change === undefined);
+
+ // Viewing patchset 1 but explicitly selecting the latest patchset (2) in the
+ // checks tab. checksPatchset differs from patchNum, so it is not reset.
+ testResolver(changeViewModelToken).updateState({
+ patchNum: 1 as RevisionPatchSetNum,
+ checksPatchset: 2 as PatchSetNumber,
+ });
+ const testChange = createTwoPatchsetChange();
+ testResolver(changeModelToken).updateStateChange(testChange);
+ await waitUntil(() => deepEqual(change, testChange));
+
+ // Fire the throttled emission, then flush the fetch promise into state.
+ clock.tick(600);
+ await waitEventLoop();
+
+ // Selected patchset (2) equals latest, so still only a single fetch.
+ assert.equal(fetchSpy.callCount, 1);
+ assert.isNotEmpty(latestRuns);
+ assert.isNotEmpty(selectedRuns);
+ assert.isTrue(latestRuns.every(r => r.patchset === 2));
+ assert.isTrue(selectedRuns.every(r => r.patchset === 2));
+
+ clock.restore();
+ });
+
+ test('fetches both patchsets when an older one is selected', async () => {
+ const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+ let change: ParsedChangeInfo | undefined = undefined;
+ testResolver(changeModelToken).change$.subscribe(c => (change = c));
+ let latestRuns: CheckRun[] = [];
+ let selectedRuns: CheckRun[] = [];
+ model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+ model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+ const provider = createPatchsetTaggingProvider();
+ const fetchSpy = sinon.spy(provider, 'fetch');
+
+ model.register({
+ pluginName: PLUGIN_NAME,
+ provider,
+ config: CONFIG_POLLING_NONE,
+ });
+ await waitUntil(() => change === undefined);
+
+ // Explicitly selecting the older patchset (1); latest is 2.
+ testResolver(changeViewModelToken).updateState({
+ checksPatchset: 1 as PatchSetNumber,
+ });
+ const testChange = createTwoPatchsetChange();
+ testResolver(changeModelToken).updateStateChange(testChange);
+ await waitUntil(() => deepEqual(change, testChange));
+
+ // Fire the throttled emission, then flush the fetch promise into state.
+ clock.tick(600);
+ await waitEventLoop();
+
+ // Distinct patchsets require two fetches: one for latest, one for selected.
+ assert.equal(fetchSpy.callCount, 2);
+ // The latest tab shows patchset 2, the selected tab shows the older 1.
+ assert.isNotEmpty(latestRuns);
+ assert.isNotEmpty(selectedRuns);
+ assert.isTrue(latestRuns.every(r => r.patchset === 2));
+ assert.isTrue(selectedRuns.every(r => r.patchset === 1));
+
+ clock.restore();
+ });
+
test('fetch throttle', async () => {
const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
let change: ParsedChangeInfo | undefined = undefined;
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree-dark.png
new file mode 100644
index 0000000..025d1be
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree.png
new file mode 100644
index 0000000..7e1a2af
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree.png
Binary files differ