Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,28 @@ If the rule is triggered, the following actions will be executed:

If the workflow is not approved, it will be left untouched, for a human approver to look at.

=== Retest failed workflow jobs

Users with GitHub `WRITE` permission can retrigger failed GitHub Actions jobs for a pull request by commenting:

* `@quarkusbot retest`
* alias: `@quarkus-bot retest`

Syntax of the `.github/quarkus-github-bot.yml` file is as follows:

[source, yaml]
----
features: [ RETEST_PULL_REQUEST_WORKFLOWS ]
----

Only failed jobs from the latest run of each workflow are retriggered. If the latest run is still in progress, the bot asks you to retry once it completes.
When reruns are started, the bot posts a confirmation comment linking to the workflow runs.

This feature requires:

* Actions - `Read & Write`
* Issue comment event subscription

=== Mark closed pull requests as invalid

If a pull request is closed without being merged, we automatically add the `triage/invalid` label to the pull request.
Expand Down Expand Up @@ -270,6 +292,7 @@ Events to subscribe to:

* Discussions
* Issues
* Issue comment
* Label
* Pull Request
* Workflow run
Expand Down
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
<artifactId>quarkus-github-app</artifactId>
<version>${quarkus-github-app.version}</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.githubapp</groupId>
<artifactId>quarkus-github-app-command-airline</artifactId>
<version>${quarkus-github-app.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-qute</artifactId>
Expand Down
40 changes: 12 additions & 28 deletions src/main/java/io/quarkus/bot/ApproveWorkflow.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import io.quarkus.bot.config.Feature;
import io.quarkus.bot.config.QuarkusGitHubBotConfig;
import io.quarkus.bot.config.QuarkusGitHubBotConfigFile;
import io.quarkus.bot.util.GHPullRequests;
import io.quarkus.bot.util.PullRequestFilesMatcher;
import io.quarkus.cache.CacheKey;
import io.quarkus.cache.CacheResult;
Expand Down Expand Up @@ -90,36 +91,19 @@ private void checkUser(GHEventPayload.WorkflowRun workflowPayload, QuarkusGitHub
}

private void checkFiles(QuarkusGitHubBotConfigFile quarkusBotConfigFile, GHWorkflowRun workflowRun,
ApprovalStatus approval) {
ApprovalStatus approval) throws IOException {
String sha = workflowRun.getHeadSha();

// Now we want to get the pull request we're supposed to be checking.
// It would be nice to use commit.listPullRequests() but that only returns something if the
// base and head of the PR are from the same repository, which rules out most scenarios where we would want to do an approval

String fullyQualifiedBranchName = workflowRun.getHeadRepository().getOwnerName() + ":" + workflowRun.getHeadBranch();

PagedIterable<GHPullRequest> pullRequestsForThisBranch = workflowRun.getRepository().queryPullRequests()
.head(fullyQualifiedBranchName)
.list();

// The number of PRs with matching branch name should be exactly one, but if the PR
// has been closed it sometimes disappears from the list; also, if two branch names
// start with the same string, both will turn up in the query.
for (GHPullRequest pullRequest : pullRequestsForThisBranch) {

// Only look at PRs whose commit sha matches
if (sha.equals(pullRequest.getHead().getSha())) {

for (QuarkusGitHubBotConfigFile.WorkflowApprovalRule rule : quarkusBotConfigFile.workflows.rules) {
// We allow if the files or directories match the allow rule ...
if (matchRuleFromChangedFiles(pullRequest, rule.allow)) {
approval.shouldApprove = true;
}
// ... unless we also match the unless rule
if (matchRuleFromChangedFiles(pullRequest, rule.unless)) {
approval.shouldNotApprove = true;
}
for (GHPullRequest pullRequest : GHPullRequests.matchingHeadPullRequests(workflowRun.getRepository(),
workflowRun.getHeadRepository(), workflowRun.getHeadBranch(), sha)) {
for (QuarkusGitHubBotConfigFile.WorkflowApprovalRule rule : quarkusBotConfigFile.workflows.rules) {
// We allow if the files or directories match the allow rule ...
if (matchRuleFromChangedFiles(pullRequest, rule.allow)) {
approval.shouldApprove = true;
}
// ... unless we also match the unless rule
if (matchRuleFromChangedFiles(pullRequest, rule.unless)) {
approval.shouldNotApprove = true;
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/io/quarkus/bot/config/Feature.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public enum Feature {
TRIAGE_ISSUES_AND_PULL_REQUESTS,
TRIAGE_DISCUSSIONS,
PUSH_TO_PROJECTS,
APPROVE_WORKFLOWS;
APPROVE_WORKFLOWS,
RETEST_PULL_REQUEST_WORKFLOWS;

public boolean isEnabled(QuarkusGitHubBotConfigFile quarkusBotConfigFile) {
if (quarkusBotConfigFile == null) {
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/io/quarkus/bot/retest/FailedJobsRerunner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.quarkus.bot.retest;

import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHWorkflowRun;

interface FailedJobsRerunner {

/**
* Retriggers only the failed jobs for the given workflow run.
*/
void rerunFailedJobs(GHEventPayload.IssueComment issueCommentPayload, GHWorkflowRun workflowRun);
}
86 changes: 86 additions & 0 deletions src/main/java/io/quarkus/bot/retest/GitHubFailedJobsRerunner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package io.quarkus.bot.retest;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

import jakarta.annotation.PostConstruct;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;

import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHWorkflowRun;

import io.quarkiverse.githubapp.InstallationTokenProvider;
import io.quarkiverse.githubapp.JavaHttpClientFactory;

/**
* GitHub-backed {@link FailedJobsRerunner} implementation using the REST endpoint that reruns only failed jobs.
*/
@Singleton
class GitHubFailedJobsRerunner implements FailedJobsRerunner {

private static final String ACCEPT_HEADER = "application/vnd.github+json";
private static final String USER_AGENT = "quarkus-github-bot";
private static final Duration RERUN_FAILED_JOBS_TIMEOUT = Duration.ofSeconds(30);

@Inject
InstallationTokenProvider installationTokenProvider;

@Inject
JavaHttpClientFactory javaHttpClientFactory;

private HttpClient httpClient;

@PostConstruct
void init() {
httpClient = javaHttpClientFactory.create();
}

@Override
public void rerunFailedJobs(GHEventPayload.IssueComment issueCommentPayload, GHWorkflowRun workflowRun) {
String installationToken = installationTokenProvider.getInstallationToken(issueCommentPayload.getInstallation().getId())
.token();

HttpRequest request = HttpRequest.newBuilder(rerunFailedJobsUri(issueCommentPayload, workflowRun))
.header("Accept", ACCEPT_HEADER)
.header("Authorization", "Bearer " + installationToken)
.header("User-Agent", USER_AGENT)
.timeout(RERUN_FAILED_JOBS_TIMEOUT)
.POST(HttpRequest.BodyPublishers.noBody())
.build();

try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw RetestCommandException.rerunFailedJobsFailed(workflowRun.getId(), response.statusCode(), null);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw RetestCommandException.rerunFailedJobsFailed(workflowRun.getId(), null, e);
} catch (IOException e) {
throw RetestCommandException.rerunFailedJobsFailed(workflowRun.getId(), null, e);
}
}

private URI rerunFailedJobsUri(GHEventPayload.IssueComment issueCommentPayload, GHWorkflowRun workflowRun) {
String normalizedApiUrl = normalizeApiUrl(gitHubApiUrl(issueCommentPayload));

// TODO switch to GHWorkflowRun.rerunFailedJobs() when Hub4j adds native support for rerunning failed jobs.
return URI.create(normalizedApiUrl + "repos/" + issueCommentPayload.getRepository().getOwnerName() + "/"
+ issueCommentPayload.getRepository().getName()
+ "/actions/runs/" + workflowRun.getId() + "/rerun-failed-jobs");
}

@SuppressWarnings("deprecation")
private static String gitHubApiUrl(GHEventPayload.IssueComment issueCommentPayload) {
return issueCommentPayload.getRoot().getApiUrl();
}

private static String normalizeApiUrl(String apiUrl) {
return apiUrl.endsWith("/") ? apiUrl : apiUrl + "/";
}
}
28 changes: 28 additions & 0 deletions src/main/java/io/quarkus/bot/retest/RetestCli.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.quarkus.bot.retest;

import org.kohsuke.github.GHEventPayload;

import com.github.rvesse.airline.annotations.Cli;

import io.quarkiverse.githubapp.ConfigFile;
import io.quarkiverse.githubapp.command.airline.CliOptions;
import io.quarkiverse.githubapp.command.airline.CliOptions.ParseErrorStrategy;
import io.quarkus.bot.config.QuarkusGitHubBotConfigFile;

/**
* Command-airline entry point for comment commands handled by the bot.
*/
@Cli(name = "@quarkusbot", commands = RetestCommand.class)
@CliOptions(aliases = {
"@quarkus-bot" }, parseErrorStrategy = ParseErrorStrategy.NONE)
class RetestCli {
}

/**
* Shared command shape required by the command-airline processor.
*/
interface RetestCommandHandler {

void run(@ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile,
GHEventPayload.IssueComment issueCommentPayload);
}
135 changes: 135 additions & 0 deletions src/main/java/io/quarkus/bot/retest/RetestCommand.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package io.quarkus.bot.retest;

import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

import jakarta.inject.Inject;

import org.jboss.logging.Logger;
import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHIssueState;
import org.kohsuke.github.GHPermissionType;
import org.kohsuke.github.GHPullRequest;
import org.kohsuke.github.GHWorkflowRun;

import com.github.rvesse.airline.annotations.Command;

import io.quarkiverse.githubapp.command.airline.CommandOptions;
import io.quarkiverse.githubapp.command.airline.CommandOptions.CommandScope;
import io.quarkiverse.githubapp.command.airline.CommandOptions.ExecutionErrorStrategy;
import io.quarkiverse.githubapp.command.airline.CommandOptions.ReactionStrategy;
import io.quarkiverse.githubapp.command.airline.Permission;
import io.quarkus.bot.config.Feature;
import io.quarkus.bot.config.QuarkusGitHubBotConfig;
import io.quarkus.bot.config.QuarkusGitHubBotConfigFile;
import io.quarkus.bot.service.GHIssueCommentService;

/**
* Handles {@code @quarkusbot retest} comments on pull requests.
*/
@Command(name = "retest")
@Permission(GHPermissionType.WRITE)
@CommandOptions(scope = CommandScope.PULL_REQUESTS, executionErrorStrategy = ExecutionErrorStrategy.COMMENT_MESSAGE, executionErrorHandler = RetestExecutionErrorHandler.class, reactionStrategy = ReactionStrategy.NONE)
class RetestCommand implements RetestCommandHandler {

private static final Logger LOG = Logger.getLogger(RetestCommand.class);

@Inject
QuarkusGitHubBotConfig quarkusBotConfig;

@Inject
RetestWorkflowRunSelector workflowRunSelector;

@Inject
FailedJobsRerunner failedJobsRerunner;

@Inject
GHIssueCommentService issueCommentService;

@Override
public void run(QuarkusGitHubBotConfigFile quarkusBotConfigFile, GHEventPayload.IssueComment issueCommentPayload) {
if (!Feature.RETEST_PULL_REQUEST_WORKFLOWS.isEnabled(quarkusBotConfigFile)) {
throw RetestCommandException.featureDisabled();
}

GHPullRequest pullRequest = getPullRequest(issueCommentPayload);
if (pullRequest.getState() != GHIssueState.OPEN) {
throw RetestCommandException.pullRequestNotOpen();
}

RetestWorkflowSelection workflowSelection = getWorkflowSelection(pullRequest);

if (workflowSelection.eligibleRuns().isEmpty()) {
throw RetestCommandException.noEligibleWorkflowRuns(workflowSelection.noEligibleReason());
}

List<GHWorkflowRun> startedWorkflowRuns = new ArrayList<>();
for (GHWorkflowRun workflowRun : workflowSelection.eligibleRuns()) {
if (quarkusBotConfig.isDryRun()) {
LOG.infof("Pull request #%d - Retest failed jobs for workflow run #%d (dry-run)",
pullRequest.getNumber(), workflowRun.getId());
continue;
}

try {
failedJobsRerunner.rerunFailedJobs(issueCommentPayload, workflowRun);
startedWorkflowRuns.add(workflowRun);
} catch (RuntimeException e) {
if (startedWorkflowRuns.isEmpty()) {
throw e;
}

throw RetestCommandException.partialRerunFailure(
startedWorkflowRuns.stream().map(GHWorkflowRun::getId).toList(), workflowRun.getId(), e);
}
}

if (!startedWorkflowRuns.isEmpty()) {
issueCommentService.addComment(issueCommentPayload.getIssue(), successMessage(startedWorkflowRuns), false,
quarkusBotConfig.isDryRun());
}
}

private static GHPullRequest getPullRequest(GHEventPayload.IssueComment issueCommentPayload) {
try {
return issueCommentPayload.getRepository()
.getPullRequest(issueCommentPayload.getIssue().getNumber());
} catch (IOException e) {
throw RetestCommandException.unableToInspectWorkflowRuns(e);
}
}

private RetestWorkflowSelection getWorkflowSelection(GHPullRequest pullRequest) {
try {
return workflowRunSelector.selectWorkflowRuns(pullRequest);
} catch (IOException e) {
throw RetestCommandException.unableToInspectWorkflowRuns(e);
}
}

private static String successMessage(List<GHWorkflowRun> startedWorkflowRuns) {
String workflowRunLinks = startedWorkflowRuns.stream()
.map(RetestCommand::workflowRunReference)
.collect(Collectors.joining(", "));
String label = startedWorkflowRuns.size() == 1 ? "workflow run " : "workflow runs ";
return ":arrows_counterclockwise: Retest started for failed jobs in " + label + workflowRunLinks + ".";
}

private static String workflowRunReference(GHWorkflowRun workflowRun) {
String label = "#" + workflowRun.getId();
URL htmlUrl;
try {
htmlUrl = workflowRun.getHtmlUrl();
} catch (IOException e) {
return label;
}
if (htmlUrl == null) {
return label;
}

return "[" + label + "](" + htmlUrl + ")";
}
}
Loading
Loading