Skip to content

Commit 8caeb49

Browse files
committed
feat: Implement quick review command
Adds a new command `appmap.quickReview` that allows users to select a Git reference (branch, tag, or commit) as a base and initiate an AI-driven code review of the changes relative to that base via Navie. Integrates the command into the Navie tool window and the menu.
1 parent d41a1b3 commit 8caeb49

File tree

9 files changed

+314
-34
lines changed

9 files changed

+314
-34
lines changed

appland-navie/dist/main.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -366373,15 +366373,11 @@ ${t5}`));
366373366373
}
366374366374
},
366375366375
mounted() {
366376-
if (initialData.codeSelection) {
366377-
this.$root.$on("on-thread-subscription", () => {
366376+
this.$root.$once("on-thread-subscription", () => {
366377+
if (initialData.codeSelection)
366378366378
this.$refs.ui.includeCodeSelection(initialData.codeSelection);
366379-
});
366380-
}
366381-
if (initialData.suggestion) {
366382-
this.$refs.ui.$refs.vchat.addUserMessage(initialData.suggestion.label);
366383-
this.$refs.ui.sendMessage(initialData.suggestion.prompt);
366384-
}
366379+
if (initialData.suggestion) this.$refs.ui.sendMessage(initialData.suggestion.prompt);
366380+
});
366385366381
}
366386366382
});
366387366383
handleAppMapMessages(app, vsCodeBridge_default, messages2);

appland-navie/dist/main.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

appland-navie/webview.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,11 @@ export function mountWebview() {
5151
},
5252
},
5353
mounted() {
54-
if (initialData.codeSelection) {
55-
this.$root.$on("on-thread-subscription", () => {
54+
this.$root.$once('on-thread-subscription', () => {
55+
if (initialData.codeSelection)
5656
this.$refs.ui.includeCodeSelection(initialData.codeSelection);
57-
});
58-
}
59-
if (initialData.suggestion) {
60-
this.$refs.ui.$refs.vchat.addUserMessage(initialData.suggestion.label);
61-
this.$refs.ui.sendMessage(initialData.suggestion.prompt);
62-
}
57+
if (initialData.suggestion) this.$refs.ui.sendMessage(initialData.suggestion.prompt);
58+
});
6359
},
6460
});
6561

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ allprojects {
8888
if (platformVersion >= 243) {
8989
bundledPlugin("com.intellij.modules.json")
9090
}
91+
bundledPlugin("Git4Idea")
9192
}
9293

9394
// added because org.jetbrains.intellij.platform resolves to an older version bundled with the SDK
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
package appland.actions;
2+
3+
import appland.webviews.navie.NavieEditorProvider;
4+
import appland.webviews.navie.NaviePromptSuggestion;
5+
import com.intellij.icons.AllIcons;
6+
import com.intellij.ide.util.PropertiesComponent;
7+
import com.intellij.openapi.actionSystem.ActionUpdateThread;
8+
import com.intellij.openapi.actionSystem.AnAction;
9+
import com.intellij.openapi.actionSystem.AnActionEvent;
10+
import com.intellij.openapi.progress.ProgressIndicator;
11+
import com.intellij.openapi.progress.Task;
12+
import com.intellij.openapi.project.DumbAware;
13+
import com.intellij.openapi.project.Project;
14+
import com.intellij.openapi.ui.popup.JBPopupFactory;
15+
import com.intellij.openapi.vcs.VcsException;
16+
import com.intellij.openapi.vfs.VirtualFile;
17+
import git4idea.commands.Git;
18+
import git4idea.commands.GitCommand;
19+
import git4idea.commands.GitLineHandler;
20+
import git4idea.repo.GitRepository;
21+
import git4idea.repo.GitRepositoryManager;
22+
import org.jetbrains.annotations.NotNull;
23+
import org.jetbrains.annotations.Nullable;
24+
25+
import javax.swing.*;
26+
import java.awt.*;
27+
import java.util.ArrayList;
28+
import java.util.Arrays;
29+
import java.util.Comparator;
30+
import java.util.List;
31+
import java.util.concurrent.atomic.AtomicBoolean;
32+
import java.util.regex.Pattern;
33+
34+
public class QuickReviewAction extends AnAction implements DumbAware {
35+
public static final String ACTION_ID = "appmap.quickReview";
36+
private static final String LAST_PICKED_REF_KEY = "appmap.quickReview.lastPickedRef";
37+
private static final List<String> COMMON_MAIN_BRANCHES = Arrays.asList(
38+
"main", "master", "develop", "release", "staging", "testing", "qa", "prod"
39+
);
40+
41+
@Override
42+
public @NotNull ActionUpdateThread getActionUpdateThread() {
43+
return ActionUpdateThread.BGT;
44+
}
45+
46+
@Override
47+
public void actionPerformed(@NotNull AnActionEvent e) {
48+
var project = e.getProject();
49+
if (project == null) {
50+
return;
51+
}
52+
53+
var repositoryManager = GitRepositoryManager.getInstance(project);
54+
var repositories = repositoryManager.getRepositories();
55+
if (repositories.isEmpty()) {
56+
return;
57+
}
58+
59+
// For now, we'll just use the first repository
60+
var repository = repositories.get(0);
61+
62+
new Task.Backgroundable(project, "Fetching Git Refs", true) {
63+
private List<GitRef> refs;
64+
private boolean dirty = false;
65+
66+
@Override
67+
public void run(@NotNull ProgressIndicator indicator) {
68+
try {
69+
refs = getItems(project, repository);
70+
dirty = isDirty();
71+
} catch (VcsException ex) {
72+
throw new RuntimeException("Failed to fetch Git refs", ex);
73+
}
74+
}
75+
76+
private boolean isDirty() {
77+
var handler = new GitLineHandler(project, repository.getRoot(), GitCommand.STATUS);
78+
handler.setSilent(true);
79+
handler.addParameters("--porcelain");
80+
try {
81+
var result = Git.getInstance().runCommand(handler);
82+
result.throwOnError();
83+
return !result.getOutput().isEmpty();
84+
} catch (VcsException e) {
85+
return false; // If we can't check, assume not dirty
86+
}
87+
}
88+
89+
@Override
90+
public void onSuccess() {
91+
if (refs == null || refs.isEmpty()) {
92+
return;
93+
}
94+
95+
var head = repository.getInfo().getCurrentRevision();
96+
var lastPickedRef = PropertiesComponent.getInstance().getValue(LAST_PICKED_REF_KEY);
97+
AtomicBoolean seenLastPicked = new AtomicBoolean(false);
98+
99+
refs = refs.stream()
100+
/* only show HEAD if the repository is dirty */
101+
.filter(gitRef -> dirty || !gitRef.commit.equals(head))
102+
.peek(gitRef -> {
103+
if (gitRef.commit.equals(head)) gitRef.description = "review uncommitted changes";
104+
if (gitRef.label.equals(lastPickedRef)) {
105+
gitRef.description = "last picked ⋅ " + gitRef.description;
106+
seenLastPicked.set(true);
107+
}
108+
})
109+
.sorted(Comparator.comparing((GitRef gitRef) ->
110+
// if we have seen the last picked ref, sort it to the start
111+
// otherwise show common main branches first
112+
seenLastPicked.get() ? !gitRef.label.equals(lastPickedRef)
113+
: !COMMON_MAIN_BRANCHES.contains(gitRef.label)))
114+
.toList();
115+
116+
var popup = JBPopupFactory.getInstance()
117+
.createPopupChooserBuilder(refs)
118+
.setRenderer(new GitRefCellRenderer())
119+
.setTitle("Select base for review")
120+
.setMovable(false)
121+
.setResizable(false)
122+
.setRequestFocus(true)
123+
.setItemChosenCallback((selectedValue) -> {
124+
if (selectedValue != null) {
125+
PropertiesComponent.getInstance().setValue(LAST_PICKED_REF_KEY, selectedValue.label);
126+
NavieEditorProvider.openEditorWithPrompt(project, new NaviePromptSuggestion(
127+
"Quick Review",
128+
String.format("@review /base=%s", selectedValue.label)));
129+
}
130+
}).createPopup();
131+
popup.showCenteredInCurrentWindow(project);
132+
}
133+
}.queue();
134+
}
135+
136+
private ArrayList<GitRef> getRefs(Project project, GitRepository repository) throws VcsException {
137+
var refs = new ArrayList<GitRef>();
138+
var handler = new GitCommandLineHandler(project, repository.getRoot(), "for-each-ref");
139+
handler.addParameters(
140+
"--format=%(objectname);%(if)%(HEAD)%(then)HEAD%(else)%(refname:short)%(end);%(refname:rstrip=-2);%(objectname:short) ⋅ %(creatordate:human)",
141+
"--merged", "HEAD", "--sort=-creatordate");
142+
var result = Git.getInstance().runCommand(handler);
143+
result.throwOnError();
144+
145+
refs.addAll(result.getOutput().stream()
146+
.map(GitRef::ofLine)
147+
.toList());
148+
return refs;
149+
}
150+
151+
private List<GitRef> getItems(Project project, GitRepository repository) throws VcsException {
152+
var refs = getRefs(project, repository);
153+
var head = repository.getInfo().getCurrentRevision();
154+
var nextRefIdx = refs.stream()
155+
.filter(gitRef -> !gitRef.commit.equals(head))
156+
.findFirst()
157+
.map(refs::indexOf)
158+
.orElse(-1);
159+
if (nextRefIdx > -1) {
160+
// add commits up to the next ref
161+
var nextCommit = refs.get(nextRefIdx).commit;
162+
var handler = new GitLineHandler(project, repository.getRoot(), GitCommand.LOG);
163+
handler.addParameters("--format=%H;%h;commit;%ch ⋅ %s", nextCommit + "...HEAD");
164+
var result = Git.getInstance().runCommand(handler);
165+
result.throwOnError();
166+
var commits = result.getOutput().stream()
167+
.map(GitRef::ofLine)
168+
.filter(gitRef -> !gitRef.commit.equals(head)) // Exclude HEAD commit
169+
.toList();
170+
if (!commits.isEmpty()) {
171+
// Insert commits before the next ref
172+
refs.addAll(nextRefIdx, commits);
173+
}
174+
}
175+
return refs;
176+
}
177+
178+
private static class GitRef {
179+
private static final Pattern PATTERN = Pattern.compile("^(.*?);(.*?);(.*?);(.*)$");
180+
String commit;
181+
String label;
182+
String type;
183+
String description;
184+
185+
GitRef(String commit, String label, String type, String description) {
186+
this.commit = commit;
187+
this.label = label;
188+
this.type = type;
189+
this.description = description;
190+
}
191+
192+
public static GitRef ofLine(String line) {
193+
var matcher = PATTERN.matcher(line);
194+
if (matcher.matches()) {
195+
return new GitRef(matcher.group(1), matcher.group(2), matcher.group(3), matcher.group(4));
196+
}
197+
throw new IllegalArgumentException("Invalid ref line: " + line);
198+
}
199+
200+
@Override
201+
public String toString() {
202+
return label + " (" + type + ") - " + commit + " - " + description;
203+
}
204+
205+
public Icon getIcon() {
206+
switch (label) {
207+
case "main":
208+
case "master":
209+
return AllIcons.Actions.Checked;
210+
case "develop":
211+
return AllIcons.Actions.Edit;
212+
case "release":
213+
return AllIcons.Actions.Download;
214+
case "staging":
215+
return AllIcons.Actions.Upload;
216+
case "testing":
217+
return AllIcons.Actions.Refresh;
218+
case "qa":
219+
return AllIcons.Actions.Execute;
220+
case "prod":
221+
return AllIcons.Actions.Restart;
222+
case "HEAD":
223+
return AllIcons.Actions.Pause;
224+
}
225+
switch (type) {
226+
case "refs/heads":
227+
return AllIcons.Vcs.Branch;
228+
case "refs/remotes":
229+
return AllIcons.Nodes.PpWeb;
230+
case "refs/tags":
231+
return AllIcons.Nodes.Bookmark;
232+
default:
233+
return AllIcons.Vcs.CommitNode;
234+
}
235+
}
236+
}
237+
238+
private static class GitRefCellRenderer extends DefaultListCellRenderer {
239+
@Override
240+
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
241+
var component = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
242+
if (value instanceof GitRef) {
243+
var ref = (GitRef) value;
244+
component.setText("<html><b>" + ref.label + "</b> " +
245+
"<small>" + ref.description + "</small></html>");
246+
component.setIcon(ref.getIcon());
247+
}
248+
return component;
249+
}
250+
}
251+
252+
// HACK: platform version 241 does not define GitCommand.FOR_EACH_REF
253+
// and the final GitCommand constructors are private, so we need to replace
254+
// the command in GitLineHandler with a custom one.
255+
class GitCommandLineHandler extends GitLineHandler {
256+
public GitCommandLineHandler(@Nullable Project project, @NotNull VirtualFile directory, @NotNull String command) {
257+
super(project, directory, GitCommand.LOG);
258+
var paramsList = this.myCommandLine.getParametersList();
259+
var index = paramsList.getParameters().indexOf(GitCommand.LOG.name());
260+
if (index != -1) {
261+
paramsList.set(index, command);
262+
} else {
263+
paramsList.addAt(0, command);
264+
}
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)