mirror of
https://github.com/jlengrand/error-prone-support.git
synced 2026-03-10 08:11:25 +00:00
Merge
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
package tech.picnic.errorprone.openai;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.file.Path;
|
||||
@@ -30,10 +29,10 @@ public final class AiCoder {
|
||||
|
||||
// XXX: Drop the `IOException` and properly handle it.
|
||||
@Command(
|
||||
name = "analyze-build-output",
|
||||
name = "process-build-output",
|
||||
mixinStandardHelpOptions = true,
|
||||
description = "Attempts to resolve issues extracted from build output.")
|
||||
void analyzeBuildOutput(
|
||||
void processBuildOutput(
|
||||
@Option(
|
||||
names = {"-a", "--auto-fix"},
|
||||
description = "Submit all issues to OpenAI and accept the results.")
|
||||
@@ -56,7 +55,7 @@ public final class AiCoder {
|
||||
+ buildOutputSource.buildOutputFile);
|
||||
}
|
||||
|
||||
InteractiveShell.run(openAi, buildOutputSource.buildOutputFile.orElseThrow());
|
||||
InteractiveBuildOutputProcessor.run(openAi, buildOutputSource.buildOutputFile.orElseThrow());
|
||||
}
|
||||
|
||||
public static void main(String... args) {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package tech.picnic.errorprone.openai;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkArgument;
|
||||
import static com.google.common.collect.ImmutableList.toImmutableList;
|
||||
import static com.google.common.collect.ImmutableMap.toImmutableMap;
|
||||
import static com.google.common.collect.ImmutableSet.toImmutableSet;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.Comparator.comparingInt;
|
||||
import static java.util.stream.Collectors.joining;
|
||||
import static org.fusesource.jansi.Ansi.ansi;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Multimaps;
|
||||
import com.google.common.collect.Streams;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.IntStream;
|
||||
import javax.annotation.concurrent.NotThreadSafe;
|
||||
import org.fusesource.jansi.Ansi;
|
||||
import org.jline.console.SystemRegistry;
|
||||
import org.jline.console.impl.SystemRegistryImpl;
|
||||
import org.jline.keymap.KeyMap;
|
||||
import org.jline.reader.EndOfFileException;
|
||||
import org.jline.reader.LineReader;
|
||||
import org.jline.reader.LineReaderBuilder;
|
||||
import org.jline.reader.MaskingCallback;
|
||||
import org.jline.reader.Parser;
|
||||
import org.jline.reader.Reference;
|
||||
import org.jline.reader.UserInterruptException;
|
||||
import org.jline.reader.impl.DefaultParser;
|
||||
import org.jline.terminal.Terminal;
|
||||
import org.jline.terminal.TerminalBuilder;
|
||||
import org.jline.widget.TailTipWidgets;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import picocli.CommandLine;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.HelpCommand;
|
||||
import picocli.CommandLine.Parameters;
|
||||
import picocli.shell.jline3.PicocliCommands;
|
||||
import picocli.shell.jline3.PicocliCommands.PicocliCommandsFactory;
|
||||
import tech.picnic.errorprone.openai.IssueExtractor.Issue;
|
||||
|
||||
// XXX: Review whether to enable a *subset* of JLine's built-ins. See
|
||||
// https://github.com/remkop/picocli/tree/main/picocli-shell-jline3#jline-316-and-picocli-44-example.
|
||||
// XXX: Consider utilizing `less` for paging. See
|
||||
// https://github.com/jline/jline3/wiki/Nano-and-Less-Customization.
|
||||
// XXX: Should we even support those `files.isEmpty()` cases?
|
||||
// XXX: Allow issues to be dropped?
|
||||
// XXX: Mark files modified/track modification count?
|
||||
// XXX: List full diff over multiple rounds?
|
||||
// XXX: Allow submission of a custom instruction.
|
||||
@Command(name = "")
|
||||
@NotThreadSafe
|
||||
final class InteractiveBuildOutputProcessor {
|
||||
private final OpenAi openAi;
|
||||
private final PrintWriter out;
|
||||
private final ImmutableList<FileIssues> files;
|
||||
private int currentIndex = 0;
|
||||
|
||||
InteractiveBuildOutputProcessor(
|
||||
OpenAi openAi, PrintWriter output, ImmutableSet<Issue<Path>> issues) {
|
||||
this.openAi = openAi;
|
||||
this.out = output;
|
||||
this.files =
|
||||
Multimaps.asMap(Multimaps.index(issues, Issue::file)).values().stream()
|
||||
.map(fileIssues -> new FileIssues(ImmutableList.copyOf(fileIssues)))
|
||||
.collect(toImmutableList());
|
||||
}
|
||||
|
||||
// XXX: Drop the `IOException` and properly handle it.
|
||||
public static void run(OpenAi openAi, Path buildOutputFile) throws IOException {
|
||||
// XXX: Allow the path to be specified.
|
||||
IssueExtractor<Path> issueExtractor =
|
||||
new PathResolvingIssueExtractor(
|
||||
new PathFinder(FileSystems.getDefault(), Path.of("")),
|
||||
new SelectFirstIssueExtractor<>(
|
||||
ImmutableSet.of(
|
||||
new MavenCheckstyleIssueExtractor(), new PlexusCompilerIssueExtractor())));
|
||||
|
||||
// XXX: Force a file or command to be passed. Can be stdin in non-interactive mode.
|
||||
ImmutableSet<Issue<Path>> issues =
|
||||
LogLineExtractor.mavenErrorAndWarningExtractor()
|
||||
.extract(Files.newInputStream(buildOutputFile))
|
||||
.stream()
|
||||
.flatMap(issueExtractor::extract)
|
||||
.collect(toImmutableSet());
|
||||
|
||||
try (Terminal terminal = TerminalBuilder.terminal()) {
|
||||
InteractiveBuildOutputProcessor processor =
|
||||
new InteractiveBuildOutputProcessor(openAi, terminal.writer(), issues);
|
||||
|
||||
PicocliCommandsFactory factory = new PicocliCommandsFactory();
|
||||
factory.setTerminal(terminal);
|
||||
|
||||
CommandLine commandLine = new CommandLine(processor, factory);
|
||||
PicocliCommands commands = new PicocliCommands(commandLine);
|
||||
|
||||
Parser parser = new DefaultParser();
|
||||
|
||||
SystemRegistry systemRegistry =
|
||||
new SystemRegistryImpl(parser, terminal, () -> Path.of("").toAbsolutePath(), null);
|
||||
systemRegistry.setCommandRegistries(commands);
|
||||
systemRegistry.register("help", commands);
|
||||
|
||||
LineReader reader =
|
||||
LineReaderBuilder.builder()
|
||||
.terminal(terminal)
|
||||
.completer(systemRegistry.completer())
|
||||
.parser(parser)
|
||||
.build();
|
||||
new TailTipWidgets(
|
||||
reader, systemRegistry::commandDescription, 5, TailTipWidgets.TipType.COMPLETER)
|
||||
.enable();
|
||||
reader.getKeyMaps().get("main").bind(new Reference("tailtip-toggle"), KeyMap.ctrl('t'));
|
||||
|
||||
processor.issues();
|
||||
while (true) {
|
||||
try {
|
||||
systemRegistry.cleanUp();
|
||||
systemRegistry.execute(
|
||||
reader.readLine(processor.prompt(), null, (MaskingCallback) null, null));
|
||||
} catch (UserInterruptException e) {
|
||||
/* User pressed Ctrl+C. */
|
||||
} catch (EndOfFileException e) {
|
||||
/* User pressed Ctrl+D. */
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
systemRegistry.trace(e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Failed to create terminal", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Command(aliases = "i", subcommands = HelpCommand.class, description = "List issues.")
|
||||
void issues() {
|
||||
if (files.isEmpty()) {
|
||||
out.println(ansi().fgRed().a("No issues.").reset());
|
||||
return;
|
||||
}
|
||||
|
||||
renderIssueDetails(files.get(currentIndex));
|
||||
}
|
||||
|
||||
// XXX: Review `throws` clause.
|
||||
// XXX: Allow to submit a custom instruction.
|
||||
@Command(aliases = "s", subcommands = HelpCommand.class, description = "Submit issues to OpenAI.")
|
||||
void submit(
|
||||
@Parameters(description = "The subset of issues to submit (default: all)") @Nullable
|
||||
Set<Integer> issueNumbers)
|
||||
throws IOException {
|
||||
if (files.isEmpty()) {
|
||||
out.println(ansi().fgRed().a("No issues.").reset());
|
||||
return;
|
||||
}
|
||||
|
||||
FileIssues fileIssues = files.get(currentIndex);
|
||||
if (issueNumbers != null) {
|
||||
for (int i : issueNumbers) {
|
||||
if (i <= 0 || i > fileIssues.issues().size()) {
|
||||
out.println(ansi().fgRed().a("Invalid issue number: " + i).reset());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImmutableList<Issue<Path>> allIssues = fileIssues.issues();
|
||||
ImmutableList<Issue<Path>> selectedIssues =
|
||||
issueNumbers == null
|
||||
? allIssues
|
||||
: issueNumbers.stream().map(n -> allIssues.get(n - 1)).collect(toImmutableList());
|
||||
|
||||
// XXX: Use `ansi()` and a separate thread to show a spinner.
|
||||
out.println("Submitting issue(s) OpenAI...");
|
||||
|
||||
String originalCode = Files.readString(fileIssues.file());
|
||||
String instruction =
|
||||
Streams.mapWithIndex(
|
||||
selectedIssues.stream(),
|
||||
(description, index) -> String.format("%s. %s", index + 1, description))
|
||||
.collect(joining("\n", "Resolve the following issues:\n", "\n"));
|
||||
String result = openAi.requestEdit(originalCode, instruction);
|
||||
|
||||
// XXX: Here, store the result.
|
||||
|
||||
Diffs.printUnifiedDiff(originalCode, result, fileIssues.relativeFile(), out);
|
||||
}
|
||||
|
||||
@Command(
|
||||
aliases = "a",
|
||||
subcommands = HelpCommand.class,
|
||||
description = "Apply the changes suggested by OpenAI")
|
||||
void apply() {
|
||||
// XXX: Implement.
|
||||
// Verify that there's a proposal.
|
||||
|
||||
// XXX: Apply the result only if it applies cleanly.
|
||||
}
|
||||
|
||||
@Command(aliases = "n", subcommands = HelpCommand.class, description = "Move to the next issue.")
|
||||
void next() {
|
||||
if (currentIndex < files.size() - 1) {
|
||||
currentIndex++;
|
||||
issues();
|
||||
} else {
|
||||
out.println("No next issue.");
|
||||
}
|
||||
}
|
||||
|
||||
@Command(
|
||||
aliases = "p",
|
||||
subcommands = HelpCommand.class,
|
||||
description = "Move to the previous issue.")
|
||||
void previous() {
|
||||
if (currentIndex > 0) {
|
||||
currentIndex--;
|
||||
issues();
|
||||
} else {
|
||||
out.println("No previous issue.");
|
||||
}
|
||||
}
|
||||
|
||||
String prompt() {
|
||||
if (files.isEmpty()) {
|
||||
return ansi().fgRed().a("No issues").reset().a('>').toString();
|
||||
}
|
||||
|
||||
return ansi()
|
||||
.fgCyan()
|
||||
.a(files.get(currentIndex).relativeFile())
|
||||
.reset()
|
||||
.a(" (")
|
||||
.bold()
|
||||
.a(currentIndex + 1)
|
||||
.a('/')
|
||||
.a(files.size())
|
||||
.boldOff()
|
||||
.a(")>")
|
||||
.toString();
|
||||
}
|
||||
|
||||
private void renderIssueDetails(FileIssues fileIssues) {
|
||||
out.println(ansi().a("Issues for ").fgCyan().a(fileIssues.relativeFile()).reset().a(':'));
|
||||
renderIssueContext(fileIssues);
|
||||
renderIssues(fileIssues);
|
||||
// XXX: Here, also list the currently suggested patch, if already generated.
|
||||
// (...and not yet submitted?)
|
||||
}
|
||||
|
||||
private void renderIssueContext(FileIssues fileIssues) {
|
||||
ImmutableMap<Integer, ImmutableSet<Integer>> issueLines =
|
||||
fileIssues.issues().stream()
|
||||
.filter(issue -> issue.line().isPresent())
|
||||
.collect(
|
||||
toImmutableMap(
|
||||
issue -> issue.line().getAsInt(),
|
||||
issue ->
|
||||
issue.column().isPresent()
|
||||
? ImmutableSet.of(issue.column().getAsInt())
|
||||
: ImmutableSet.of(),
|
||||
(a, b) -> ImmutableSet.<Integer>builder().addAll(a).addAll(b).build()));
|
||||
|
||||
// XXX: Make context configurable.
|
||||
// XXX: This would be nicer with a `RangeSet`, but then we'd hit
|
||||
// https://github.com/google/guava/issues/3033.
|
||||
ImmutableSet<Integer> ranges =
|
||||
issueLines.keySet().stream()
|
||||
.flatMap(line -> IntStream.range(line - 3, line + 4).boxed())
|
||||
.collect(toImmutableSet());
|
||||
|
||||
boolean printedCode = false;
|
||||
try {
|
||||
List<String> lines = Files.readAllLines(fileIssues.file(), UTF_8);
|
||||
for (int i = 1; i <= lines.size(); i++) {
|
||||
int salience =
|
||||
(ranges.contains(i - 1) ? 1 : 0)
|
||||
+ (ranges.contains(i) ? 1 : 0)
|
||||
+ (ranges.contains(i + 1) ? 1 : 0);
|
||||
if (salience > 1) {
|
||||
String line = lines.get(i - 1);
|
||||
out.print(ansi().fgYellow().a(String.format(Locale.ROOT, "%4d: ", i)).reset());
|
||||
out.println(
|
||||
issueLines.containsKey(i) ? highlightIssueLine(line, issueLines.get(i)) : line);
|
||||
printedCode = true;
|
||||
} else if (salience > 0 && printedCode) {
|
||||
out.println(ansi().fgBlue().a(".....").reset());
|
||||
printedCode = false;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
// XXX: Review.
|
||||
throw new UncheckedIOException("Failed to read file", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Ansi highlightIssueLine(String line, ImmutableSet<Integer> positions) {
|
||||
Ansi ansi = ansi().fgRed();
|
||||
for (int i = 0; i < line.length(); i++) {
|
||||
if (positions.contains(i + 1)) {
|
||||
ansi.bold().a(line.charAt(i)).boldOff();
|
||||
} else {
|
||||
ansi.a(line.charAt(i));
|
||||
}
|
||||
}
|
||||
return ansi.reset();
|
||||
}
|
||||
|
||||
private void renderIssues(FileIssues fileIssues) {
|
||||
ImmutableList<Issue<Path>> issues = fileIssues.issues();
|
||||
for (int i = 0; i < issues.size(); i++) {
|
||||
out.println(
|
||||
ansi()
|
||||
.fgBlue()
|
||||
.format(String.format(Locale.ROOT, "%4d. ", i + 1))
|
||||
.reset()
|
||||
.a(issues.get(i).description()));
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: Expose an `ImmutableSet` instead?
|
||||
record FileIssues(Path file, ImmutableList<Issue<Path>> issues) {
|
||||
FileIssues(ImmutableList<Issue<Path>> issues) {
|
||||
this(
|
||||
issues.stream()
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("No issues provided"))
|
||||
.file(),
|
||||
issues);
|
||||
}
|
||||
|
||||
FileIssues(Path file, ImmutableList<Issue<Path>> issues) {
|
||||
this.file = file;
|
||||
this.issues =
|
||||
ImmutableList.sortedCopyOf(
|
||||
comparingInt((Issue<Path> issue) -> issue.line().orElse(-1))
|
||||
.thenComparingInt(issue -> issue.column().orElse(-1)),
|
||||
issues);
|
||||
|
||||
checkArgument(!issues.isEmpty(), "No issues provided");
|
||||
checkArgument(
|
||||
issues.stream().allMatch(issue -> issue.file().equals(file)),
|
||||
"Issues must all reference the same file");
|
||||
}
|
||||
|
||||
Path relativeFile() {
|
||||
return file.getFileSystem().getPath("").toAbsolutePath().relativize(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
package tech.picnic.errorprone.openai;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkArgument;
|
||||
import static com.google.common.collect.ImmutableList.toImmutableList;
|
||||
import static com.google.common.collect.ImmutableMap.toImmutableMap;
|
||||
import static com.google.common.collect.ImmutableSet.toImmutableSet;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.Comparator.comparingInt;
|
||||
import static java.util.stream.Collectors.joining;
|
||||
import static org.fusesource.jansi.Ansi.ansi;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Multimaps;
|
||||
import com.google.common.collect.Streams;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.IntStream;
|
||||
import javax.annotation.concurrent.NotThreadSafe;
|
||||
import org.fusesource.jansi.Ansi;
|
||||
import org.jline.console.SystemRegistry;
|
||||
import org.jline.console.impl.SystemRegistryImpl;
|
||||
import org.jline.keymap.KeyMap;
|
||||
import org.jline.reader.EndOfFileException;
|
||||
import org.jline.reader.LineReader;
|
||||
import org.jline.reader.LineReaderBuilder;
|
||||
import org.jline.reader.MaskingCallback;
|
||||
import org.jline.reader.Parser;
|
||||
import org.jline.reader.Reference;
|
||||
import org.jline.reader.UserInterruptException;
|
||||
import org.jline.reader.impl.DefaultParser;
|
||||
import org.jline.terminal.Terminal;
|
||||
import org.jline.terminal.TerminalBuilder;
|
||||
import org.jline.widget.TailTipWidgets;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import picocli.CommandLine;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.HelpCommand;
|
||||
import picocli.CommandLine.Parameters;
|
||||
import picocli.shell.jline3.PicocliCommands;
|
||||
import picocli.shell.jline3.PicocliCommands.PicocliCommandsFactory;
|
||||
import tech.picnic.errorprone.openai.IssueExtractor.Issue;
|
||||
|
||||
// XXX: Review whether to enable a *subset* of JLine's built-ins. See
|
||||
// https://github.com/remkop/picocli/tree/main/picocli-shell-jline3#jline-316-and-picocli-44-example.
|
||||
// XXX: Consider utilizing `less` for paging. See
|
||||
// https://github.com/jline/jline3/wiki/Nano-and-Less-Customization.
|
||||
public final class InteractiveShell {
|
||||
// XXX: Drop the `IOException` and properly handle it.
|
||||
public static void run(OpenAi openAi, Path buildOutputFile) throws IOException {
|
||||
// XXX: Allow the path to be specified.
|
||||
IssueExtractor<Path> issueExtractor =
|
||||
new PathResolvingIssueExtractor(
|
||||
new PathFinder(FileSystems.getDefault(), Path.of("")),
|
||||
new SelectFirstIssueExtractor<>(
|
||||
ImmutableSet.of(
|
||||
new MavenCheckstyleIssueExtractor(), new PlexusCompilerIssueExtractor())));
|
||||
|
||||
// XXX: Force a file or command to be passed. Can be stdin in non-interactive mode.
|
||||
ImmutableSet<Issue<Path>> issues =
|
||||
LogLineExtractor.mavenErrorAndWarningExtractor()
|
||||
.extract(Files.newInputStream(buildOutputFile))
|
||||
.stream()
|
||||
.flatMap(issueExtractor::extract)
|
||||
.collect(toImmutableSet());
|
||||
|
||||
try (Terminal terminal = TerminalBuilder.terminal()) {
|
||||
IssueResolutionController issueResolutionController =
|
||||
new IssueResolutionController(openAi, terminal.writer(), issues);
|
||||
|
||||
PicocliCommandsFactory factory = new PicocliCommandsFactory();
|
||||
factory.setTerminal(terminal);
|
||||
|
||||
CommandLine commandLine = new CommandLine(issueResolutionController, factory);
|
||||
PicocliCommands commands = new PicocliCommands(commandLine);
|
||||
|
||||
Parser parser = new DefaultParser();
|
||||
|
||||
SystemRegistry systemRegistry =
|
||||
new SystemRegistryImpl(parser, terminal, () -> Path.of("").toAbsolutePath(), null);
|
||||
systemRegistry.setCommandRegistries(commands);
|
||||
systemRegistry.register("help", commands);
|
||||
|
||||
LineReader reader =
|
||||
LineReaderBuilder.builder()
|
||||
.terminal(terminal)
|
||||
.completer(systemRegistry.completer())
|
||||
.parser(parser)
|
||||
.build();
|
||||
new TailTipWidgets(
|
||||
reader, systemRegistry::commandDescription, 5, TailTipWidgets.TipType.COMPLETER)
|
||||
.enable();
|
||||
reader.getKeyMaps().get("main").bind(new Reference("tailtip-toggle"), KeyMap.ctrl('t'));
|
||||
|
||||
issueResolutionController.issues();
|
||||
while (true) {
|
||||
try {
|
||||
systemRegistry.cleanUp();
|
||||
systemRegistry.execute(
|
||||
reader.readLine(
|
||||
issueResolutionController.prompt(), null, (MaskingCallback) null, null));
|
||||
} catch (UserInterruptException e) {
|
||||
/* User pressed Ctrl+C. */
|
||||
} catch (EndOfFileException e) {
|
||||
/* User pressed Ctrl+D. */
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
systemRegistry.trace(e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Failed to create terminal", e);
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: Should we even support those `files.isEmpty()` cases?
|
||||
// XXX: Allow issues to be dropped?
|
||||
// XXX: Mark files modified/track modification count?
|
||||
// XXX: List full diff over multiple rounds?
|
||||
// XXX: Allow submission of a custom instruction.
|
||||
// XXX: Merge with top-level class.
|
||||
@Command(name = "")
|
||||
@NotThreadSafe
|
||||
static final class IssueResolutionController {
|
||||
private final OpenAi openAi;
|
||||
private final PrintWriter out;
|
||||
private final ImmutableList<FileIssues> files;
|
||||
private int currentIndex = 0;
|
||||
|
||||
IssueResolutionController(OpenAi openAi, PrintWriter output, ImmutableSet<Issue<Path>> issues) {
|
||||
this.openAi = openAi;
|
||||
this.out = output;
|
||||
this.files =
|
||||
Multimaps.asMap(Multimaps.index(issues, Issue::file)).values().stream()
|
||||
.map(fileIssues -> new FileIssues(ImmutableList.copyOf(fileIssues)))
|
||||
.collect(toImmutableList());
|
||||
}
|
||||
|
||||
@Command(aliases = "i", subcommands = HelpCommand.class, description = "List issues.")
|
||||
void issues() {
|
||||
if (files.isEmpty()) {
|
||||
out.println(ansi().fgRed().a("No issues.").reset());
|
||||
return;
|
||||
}
|
||||
|
||||
renderIssueDetails(files.get(currentIndex));
|
||||
}
|
||||
|
||||
// XXX: Review `throws` clause.
|
||||
// XXX: Allow to submit a custom instruction.
|
||||
@Command(
|
||||
aliases = "s",
|
||||
subcommands = HelpCommand.class,
|
||||
description = "Submit issues to OpenAI.")
|
||||
void submit(
|
||||
@Parameters(description = "The subset of issues to submit (default: all)") @Nullable
|
||||
Set<Integer> issueNumbers)
|
||||
throws IOException {
|
||||
if (files.isEmpty()) {
|
||||
out.println(ansi().fgRed().a("No issues.").reset());
|
||||
return;
|
||||
}
|
||||
|
||||
FileIssues fileIssues = files.get(currentIndex);
|
||||
if (issueNumbers != null) {
|
||||
for (int i : issueNumbers) {
|
||||
if (i <= 0 || i > fileIssues.issues().size()) {
|
||||
out.println(ansi().fgRed().a("Invalid issue number: " + i).reset());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImmutableList<Issue<Path>> allIssues = fileIssues.issues();
|
||||
ImmutableList<Issue<Path>> selectedIssues =
|
||||
issueNumbers == null
|
||||
? allIssues
|
||||
: issueNumbers.stream().map(n -> allIssues.get(n - 1)).collect(toImmutableList());
|
||||
|
||||
// XXX: Use `ansi()` and a separate thread to show a spinner.
|
||||
out.println("Submitting issue(s) OpenAI...");
|
||||
|
||||
String originalCode = Files.readString(fileIssues.file());
|
||||
String instruction =
|
||||
Streams.mapWithIndex(
|
||||
selectedIssues.stream(),
|
||||
(description, index) -> String.format("%s. %s", index + 1, description))
|
||||
.collect(joining("\n", "Resolve the following issues:\n", "\n"));
|
||||
String result = openAi.requestEdit(originalCode, instruction);
|
||||
|
||||
// XXX: Here, store the result.
|
||||
|
||||
Diffs.printUnifiedDiff(originalCode, result, fileIssues.relativeFile(), out);
|
||||
}
|
||||
|
||||
@Command(
|
||||
aliases = "a",
|
||||
subcommands = HelpCommand.class,
|
||||
description = "Apply the changes suggested by OpenAI")
|
||||
void apply() {
|
||||
// XXX: Implement.
|
||||
// Verify that there's a proposal.
|
||||
|
||||
// XXX: Apply the result only if it applies cleanly.
|
||||
}
|
||||
|
||||
@Command(
|
||||
aliases = "n",
|
||||
subcommands = HelpCommand.class,
|
||||
description = "Move to the next issue.")
|
||||
void next() {
|
||||
if (currentIndex < files.size() - 1) {
|
||||
currentIndex++;
|
||||
issues();
|
||||
} else {
|
||||
out.println("No next issue.");
|
||||
}
|
||||
}
|
||||
|
||||
@Command(
|
||||
aliases = "p",
|
||||
subcommands = HelpCommand.class,
|
||||
description = "Move to the previous issue.")
|
||||
void previous() {
|
||||
if (currentIndex > 0) {
|
||||
currentIndex--;
|
||||
issues();
|
||||
} else {
|
||||
out.println("No previous issue.");
|
||||
}
|
||||
}
|
||||
|
||||
String prompt() {
|
||||
if (files.isEmpty()) {
|
||||
return ansi().fgRed().a("No issues").reset().a('>').toString();
|
||||
}
|
||||
|
||||
return ansi()
|
||||
.fgCyan()
|
||||
.a(files.get(currentIndex).relativeFile())
|
||||
.reset()
|
||||
.a(" (")
|
||||
.bold()
|
||||
.a(currentIndex + 1)
|
||||
.a('/')
|
||||
.a(files.size())
|
||||
.boldOff()
|
||||
.a(")>")
|
||||
.toString();
|
||||
}
|
||||
|
||||
private void renderIssueDetails(FileIssues fileIssues) {
|
||||
out.println(ansi().a("Issues for ").fgCyan().a(fileIssues.relativeFile()).reset().a(':'));
|
||||
renderIssueContext(fileIssues);
|
||||
renderIssues(fileIssues);
|
||||
// XXX: Here, also list the currently suggested patch, if already generated.
|
||||
// (...and not yet submitted?)
|
||||
}
|
||||
|
||||
private void renderIssueContext(FileIssues fileIssues) {
|
||||
ImmutableMap<Integer, ImmutableSet<Integer>> issueLines =
|
||||
fileIssues.issues().stream()
|
||||
.filter(issue -> issue.line().isPresent())
|
||||
.collect(
|
||||
toImmutableMap(
|
||||
issue -> issue.line().getAsInt(),
|
||||
issue ->
|
||||
issue.column().isPresent()
|
||||
? ImmutableSet.of(issue.column().getAsInt())
|
||||
: ImmutableSet.of(),
|
||||
(a, b) -> ImmutableSet.<Integer>builder().addAll(a).addAll(b).build()));
|
||||
|
||||
// XXX: Make context configurable.
|
||||
// XXX: This would be nicer with a `RangeSet`, but then we'd hit
|
||||
// https://github.com/google/guava/issues/3033.
|
||||
ImmutableSet<Integer> ranges =
|
||||
issueLines.keySet().stream()
|
||||
.flatMap(line -> IntStream.range(line - 3, line + 4).boxed())
|
||||
.collect(toImmutableSet());
|
||||
|
||||
boolean printedCode = false;
|
||||
try {
|
||||
List<String> lines = Files.readAllLines(fileIssues.file(), UTF_8);
|
||||
for (int i = 1; i <= lines.size(); i++) {
|
||||
int salience =
|
||||
(ranges.contains(i - 1) ? 1 : 0)
|
||||
+ (ranges.contains(i) ? 1 : 0)
|
||||
+ (ranges.contains(i + 1) ? 1 : 0);
|
||||
if (salience > 1) {
|
||||
String line = lines.get(i - 1);
|
||||
out.print(ansi().fgYellow().a(String.format(Locale.ROOT, "%4d: ", i)).reset());
|
||||
out.println(
|
||||
issueLines.containsKey(i) ? highlightIssueLine(line, issueLines.get(i)) : line);
|
||||
printedCode = true;
|
||||
} else if (salience > 0 && printedCode) {
|
||||
out.println(ansi().fgBlue().a(".....").reset());
|
||||
printedCode = false;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
// XXX: Review.
|
||||
throw new UncheckedIOException("Failed to read file", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Ansi highlightIssueLine(String line, ImmutableSet<Integer> positions) {
|
||||
Ansi ansi = ansi().fgRed();
|
||||
for (int i = 0; i < line.length(); i++) {
|
||||
if (positions.contains(i + 1)) {
|
||||
ansi.bold().a(line.charAt(i)).boldOff();
|
||||
} else {
|
||||
ansi.a(line.charAt(i));
|
||||
}
|
||||
}
|
||||
return ansi.reset();
|
||||
}
|
||||
|
||||
private void renderIssues(FileIssues fileIssues) {
|
||||
ImmutableList<Issue<Path>> issues = fileIssues.issues();
|
||||
for (int i = 0; i < issues.size(); i++) {
|
||||
out.println(
|
||||
ansi()
|
||||
.fgBlue()
|
||||
.format(String.format(Locale.ROOT, "%4d. ", i + 1))
|
||||
.reset()
|
||||
.a(issues.get(i).description()));
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: Expose an `ImmutableSet` instead?
|
||||
record FileIssues(Path file, ImmutableList<Issue<Path>> issues) {
|
||||
FileIssues(ImmutableList<Issue<Path>> issues) {
|
||||
this(
|
||||
issues.stream()
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("No issues provided"))
|
||||
.file(),
|
||||
issues);
|
||||
}
|
||||
|
||||
FileIssues(Path file, ImmutableList<Issue<Path>> issues) {
|
||||
this.file = file;
|
||||
this.issues =
|
||||
ImmutableList.sortedCopyOf(
|
||||
comparingInt((Issue<Path> issue) -> issue.line().orElse(-1))
|
||||
.thenComparingInt(issue -> issue.column().orElse(-1)),
|
||||
issues);
|
||||
|
||||
checkArgument(!issues.isEmpty(), "No issues provided");
|
||||
checkArgument(
|
||||
issues.stream().allMatch(issue -> issue.file().equals(file)),
|
||||
"Issues must all reference the same file");
|
||||
}
|
||||
|
||||
Path relativeFile() {
|
||||
return file.getFileSystem().getPath("").toAbsolutePath().relativize(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user