CommitAnalysisScheduler.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.jdt.internal.ui.views.mining;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.jobs.ISchedulingRule;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.jface.viewers.TableViewer;
import org.eclipse.swt.widgets.Display;
import org.sandbox.jdt.triggerpattern.mining.git.GitHistoryProvider;
/**
* Orchestrates AI-powered commit analysis through one bounded queue.
*
* <p>Only one commit is analyzed at a time. This deliberately trades raw
* parallelism for predictable provider usage, cancellation, and rate-limit
* behavior. The queue itself is one user-visible Eclipse {@link Job}, while the
* commit table continues to expose per-commit state.</p>
*
* @since 1.2.6
*/
public class CommitAnalysisScheduler {
/** Aggregate state suitable for a view status line or tests. */
public record Progress(int completed, int total, int active, int queued,
boolean running, boolean cancelled) {
}
private final GitHistoryProvider gitProvider;
private final Path repositoryPath;
private final TableViewer tableViewer;
private final Display display;
private final Consumer<Progress> progressListener;
private final AtomicInteger completed = new AtomicInteger();
private final Object stateLock = new Object();
private final ISchedulingRule analysisRule = new ISchedulingRule() {
@Override
public boolean contains(ISchedulingRule rule) {
return rule == this;
}
@Override
public boolean isConflicting(ISchedulingRule rule) {
return rule == this;
}
};
private volatile boolean running;
private volatile boolean cancelled;
private volatile int total;
private volatile long generation;
private volatile Thread workerThread;
private Job analysisJob;
public CommitAnalysisScheduler(GitHistoryProvider gitProvider, Path repositoryPath,
TableViewer tableViewer) {
this(gitProvider, repositoryPath, tableViewer, progress -> {
});
}
public CommitAnalysisScheduler(GitHistoryProvider gitProvider, Path repositoryPath,
TableViewer tableViewer, Consumer<Progress> progressListener) {
this.gitProvider = gitProvider;
this.repositoryPath = repositoryPath;
this.tableViewer = tableViewer;
this.display = tableViewer.getTable().getDisplay();
this.progressListener = progressListener != null ? progressListener : progress -> {
};
}
/** Starts a bounded, user-visible analysis queue. */
public void startAnalysis(List<CommitTableEntry> entries) {
synchronized (stateLock) {
cancelAnalysisLocked();
long runGeneration = ++generation;
List<CommitTableEntry> queue = List.copyOf(entries);
total = queue.size();
completed.set(0);
cancelled = false;
running = !queue.isEmpty();
if (queue.isEmpty()) {
notifyProgress(new Progress(0, 0, 0, 0, false, false), runGeneration);
return;
}
notifyProgress(progress(0), runGeneration);
analysisJob = new Job("Refactoring Mining: " + total + " commits") { //$NON-NLS-1$ //$NON-NLS-2$
@Override
protected IStatus run(IProgressMonitor monitor) {
workerThread = Thread.currentThread();
monitor.beginTask("Inferring TriggerPattern rules from commit history", total); //$NON-NLS-1$
try {
for (int index = 0; index < queue.size(); index++) {
if (runGeneration != generation || monitor.isCanceled()
|| Thread.currentThread().isInterrupted()) {
if (runGeneration == generation) {
cancelled = true;
}
return Status.CANCEL_STATUS;
}
CommitTableEntry entry = queue.get(index);
monitor.subTask("Analyzing " + entry.getCommitInfo().shortId() + ": " //$NON-NLS-1$ //$NON-NLS-2$
+ entry.getCommitInfo().message());
notifyProgress(progress(1), runGeneration);
CommitAnalysisJob commitJob = new CommitAnalysisJob(entry, gitProvider,
repositoryPath, () -> notifyUpdate(entry));
IStatus status = commitJob.analyze(monitor);
if (runGeneration != generation || status.matches(IStatus.CANCEL) || monitor.isCanceled()
|| Thread.currentThread().isInterrupted()) {
if (runGeneration == generation) {
cancelled = true;
}
return Status.CANCEL_STATUS;
}
completed.incrementAndGet();
monitor.worked(1);
notifyProgress(progress(0), runGeneration);
}
return Status.OK_STATUS;
} finally {
workerThread = null;
monitor.done();
if (runGeneration == generation) {
synchronized (stateLock) {
if (runGeneration == generation) {
running = false;
analysisJob = null;
}
}
notifyProgress(progress(0), runGeneration);
}
// The job already decided its result; do not leak our cancellation
// interrupt into a worker thread that the Eclipse job manager may reuse.
Thread.interrupted();
}
}
@Override
protected void canceling() {
Thread thread = workerThread;
if (thread != null) {
thread.interrupt();
}
}
};
analysisJob.setRule(analysisRule);
analysisJob.setUser(true);
analysisJob.schedule();
}
}
/** Cancels the active commit and all queued commits. */
public void cancelAnalysis() {
synchronized (stateLock) {
cancelAnalysisLocked();
}
}
private void cancelAnalysisLocked() {
long cancelledGeneration = ++generation;
if (analysisJob != null) {
cancelled = true;
analysisJob.cancel();
analysisJob = null;
}
if (running) {
running = false;
notifyProgress(progress(0), cancelledGeneration);
}
}
public boolean isRunning() {
return running;
}
public Progress getProgress() {
return progress(running ? 1 : 0);
}
private Progress progress(int active) {
int boundedCompleted = Math.min(completed.get(), total);
int queued = Math.max(0, total - boundedCompleted - active);
return new Progress(boundedCompleted, total, active, queued, running, cancelled);
}
private void notifyUpdate(CommitTableEntry entry) {
if (!display.isDisposed()) {
display.asyncExec(() -> {
if (!tableViewer.getTable().isDisposed()) {
tableViewer.update(entry, null);
}
});
}
}
private void notifyProgress(Progress progress, long progressGeneration) {
if (!display.isDisposed()) {
display.asyncExec(() -> {
if (progressGeneration == generation) {
progressListener.accept(progress);
}
});
}
}
}