Skip to content

Commit 27057d0

Browse files
committed
feat: added job webhook getting job completion
Signed-off-by: Andrea Lamparelli <[email protected]>
1 parent 85aa3b3 commit 27057d0

File tree

8 files changed

+192
-30
lines changed

8 files changed

+192
-30
lines changed

deploy/jenkins/job.Jenkinsfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ pipeline {
237237
post {
238238
always {
239239
archiveArtifacts artifacts: '**/*.json', fingerprint: false
240+
script {
241+
sh "curl http://local.perf-bot:8081/job -H \"Content-Type: application/json\" -d '{\"jobId\":\"getting-started\",\"buildNumber\":\"$BUILD_NUMBER\",\"repoFullName\":\"${params.REPO_FULL_NAME}\",\"pullRequestNumber\":${params.PULL_REQUEST_NUMBER}}'"
242+
}
240243
}
241244
}
242245
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package io.perf.tools.bot.handler;
2+
3+
import io.perf.tools.bot.service.ConfigService;
4+
import io.quarkiverse.githubapp.runtime.github.GitHubService;
5+
import jakarta.inject.Inject;
6+
import org.eclipse.microprofile.config.inject.ConfigProperty;
7+
8+
public abstract class GitHubAwareResource {
9+
@ConfigProperty(name = "perf.bot.installation.id")
10+
Long installationId;
11+
12+
@Inject
13+
GitHubService gitHubService;
14+
15+
@Inject
16+
ConfigService configService;
17+
}

src/main/java/io/perf/tools/bot/handler/HorreumResource.java

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,13 @@
33
import com.fasterxml.jackson.databind.node.ObjectNode;
44
import io.hyperfoil.tools.horreum.api.data.LabelValueMap;
55
import io.perf.tools.bot.model.config.ProjectConfig;
6-
import io.perf.tools.bot.service.ConfigService;
76
import io.perf.tools.bot.service.datastore.horreum.HorreumService;
8-
import io.quarkiverse.githubapp.runtime.github.GitHubService;
97
import io.quarkus.logging.Log;
108
import jakarta.inject.Inject;
119
import jakarta.ws.rs.POST;
1210
import jakarta.ws.rs.Path;
1311
import jakarta.ws.rs.Produces;
1412
import jakarta.ws.rs.core.MediaType;
15-
import org.eclipse.microprofile.config.inject.ConfigProperty;
1613
import org.jboss.resteasy.reactive.ResponseStatus;
1714
import org.kohsuke.github.GHIssue;
1815

@@ -24,24 +21,15 @@
2421
* </p>
2522
*/
2623
@Path("/horreum")
27-
public class HorreumResource {
24+
public class HorreumResource extends GitHubAwareResource {
2825

2926
private static final String REPO_FULL_NAME_LABEL_VALUE = "pb.repo_full_name";
3027
private static final String PULL_REQUEST_NUMBER_LABEL_VALUE = "pb.pull_request_number";
3128
private static final String JOB_ID_LABEL_VALUE = "pb.job_id";
3229

33-
@ConfigProperty(name = "perf.bot.installation.id")
34-
Long installationId;
35-
36-
@Inject
37-
GitHubService gitHubService;
38-
3930
@Inject
4031
HorreumService horreumService;
4132

42-
@Inject
43-
ConfigService configService;
44-
4533
/**
4634
* Handles incoming webhook payloads from Horreum.
4735
* <p>
@@ -60,7 +48,7 @@ public class HorreumResource {
6048
public void webhook(ObjectNode payload) throws InterruptedException {
6149
// when a new run is uploaded to Horreum we will check whether we have an existing "start benchmark" event in the queue
6250
// if so, we will get it and send back the results to the original pull request
63-
Log.trace("Received webhook: " + payload.toString());
51+
Log.trace("Received Horreum webhook: " + payload.toString());
6452

6553
// use this to check whether we have a configuration for that test id, and retrieve the repo full name
6654
String horreumTestId = payload.get("testid").asText();
Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,65 @@
11
package io.perf.tools.bot.handler;
22

3-
import io.perf.tools.bot.model.config.ProjectConfig;
4-
import io.perf.tools.bot.service.ConfigService;
3+
import io.perf.tools.bot.model.JobStatus;
4+
import io.perf.tools.bot.service.job.BuildStatus;
5+
import io.perf.tools.bot.service.job.JobExecutor;
6+
import io.quarkus.logging.Log;
7+
import jakarta.inject.Inject;
8+
import jakarta.ws.rs.POST;
9+
import jakarta.ws.rs.Path;
10+
import jakarta.ws.rs.Produces;
11+
import jakarta.ws.rs.core.MediaType;
12+
import org.jboss.resteasy.reactive.ResponseStatus;
13+
import org.kohsuke.github.GHIssue;
14+
15+
import java.io.IOException;
516

617
/**
7-
* REST resource for managing performance bot project configurations.
8-
* <p>
9-
* Exposes endpoints for loading and retrieving project configurations via HTTP.
10-
* </p>
11-
* <ul>
12-
* <li>{@code POST /config} — Loads a new {@link ProjectConfig} into the system.</li>
13-
* <li>{@code GET /config} — Returns all currently loaded configurations.</li>
14-
* </ul>
18+
* REST resource to handle webhook callbacks from Job executor.
1519
* <p>
16-
* This resource delegates all configuration logic to the injected {@link ConfigService}.
20+
* Receives notifications about job completion and post failures
21+
* back to the corresponding GitHub pull request.
1722
* </p>
18-
*
19-
* @see ConfigService
20-
* @see ProjectConfig
2123
*/
22-
public class JobResource {
24+
@Path("/job")
25+
public class JobResource extends GitHubAwareResource {
26+
27+
@Inject
28+
JobExecutor jobExecutor;
29+
30+
/**
31+
* Handles incoming webhook payloads from the Job executor platform.
32+
* <p>
33+
* Validates the payload, retrieves the original repository and pull request, and
34+
* then it provides the results of the job back to the PR.
35+
* </p>
36+
* <p>
37+
* In this case the results is either the job has failed or unstable, skip posting
38+
* the message if the job has completed successfully
39+
* </p>
40+
*
41+
* @param payload the {@link JobStatus} object
42+
*/
43+
@POST
44+
@ResponseStatus(204)
45+
@Produces(MediaType.APPLICATION_JSON)
46+
public void webhook(JobStatus payload) {
47+
// expecting to get called by the job executor when the job is finished
48+
// regardless of the result
49+
Log.trace("Received job webhook: " + payload.toString());
50+
51+
try {
52+
GHIssue issue = gitHubService.getInstallationClient(installationId).getRepository(payload.repoFullName)
53+
.getIssue(payload.pullRequestNumber);
54+
55+
BuildStatus resultStatus = jobExecutor.getJobStatus(payload.repoFullName, payload.jobId, payload.buildNumber);
56+
// TODO: do not post any message if the result is a success
57+
if (!BuildStatus.SUCCESS.equals(resultStatus)) {
58+
issue.comment(
59+
"Job " + payload.jobId + "/" + payload.buildNumber + " completed with: " + resultStatus + "\nPlease check with the administrators.");
60+
}
61+
} catch (IOException e) {
62+
throw new RuntimeException(e);
63+
}
64+
}
2365
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,40 @@
11
package io.perf.tools.bot.model;
22

3+
/**
4+
* Represents the job completion object.
5+
* This class holds all necessary details to properly retrieve the
6+
* associated GitHub repository and pull request so that the bot
7+
* can update them with the success/failure of the job
8+
*/
39
public class JobStatus {
10+
/**
11+
* Job identifier
12+
*/
13+
public String jobId;
14+
15+
/**
16+
* Identifier of the specific build of the job
17+
*/
18+
public String buildNumber;
19+
20+
/**
21+
* Repository full name in the form of
22+
* owner/repository
23+
*/
24+
public String repoFullName;
25+
26+
/**
27+
* Number of the pull request that triggered the job
28+
*/
29+
public Integer pullRequestNumber;
30+
31+
@Override
32+
public String toString() {
33+
return "JobStatus{" +
34+
"jobId='" + jobId + '\'' +
35+
", buildNumber='" + buildNumber + '\'' +
36+
", repoFullName='" + repoFullName + '\'' +
37+
", pullRequestNumber=" + pullRequestNumber +
38+
'}';
39+
}
440
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package io.perf.tools.bot.service.job;
2+
3+
import com.offbytwo.jenkins.model.BuildResult;
4+
5+
/**
6+
* Represent the status of a specific job's build
7+
* Inspired by the Jenkins build status (see {@link com.offbytwo.jenkins.model.BuildResult})
8+
*/
9+
public enum BuildStatus {
10+
FAILURE,
11+
UNSTABLE,
12+
REBUILDING,
13+
BUILDING,
14+
/**
15+
* This means a job was already running and has been aborted.
16+
*/
17+
ABORTED,
18+
SUCCESS,
19+
UNKNOWN,
20+
/**
21+
* This is returned if a job has never been built.
22+
*/
23+
NOT_BUILT,
24+
/**
25+
* This will be the result of a job in cases where it has been cancelled
26+
* during the time in the queue.
27+
*/
28+
CANCELLED;
29+
30+
public static BuildStatus fromJenkins(BuildResult jenkinsBuildResult) {
31+
if (jenkinsBuildResult == null) {
32+
return BuildStatus.UNKNOWN;
33+
}
34+
35+
return switch (jenkinsBuildResult) {
36+
case SUCCESS -> BuildStatus.SUCCESS;
37+
case CANCELLED -> BuildStatus.CANCELLED;
38+
case FAILURE -> BuildStatus.FAILURE;
39+
case ABORTED -> BuildStatus.ABORTED;
40+
case UNSTABLE -> BuildStatus.UNSTABLE;
41+
case REBUILDING -> BuildStatus.REBUILDING;
42+
case BUILDING -> BuildStatus.BUILDING;
43+
default -> BuildStatus.UNKNOWN;
44+
};
45+
}
46+
}

src/main/java/io/perf/tools/bot/service/job/JobExecutor.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,15 @@ public interface JobExecutor {
2424
* @throws IOException if something goes wrong
2525
*/
2626
String buildJob(String repoFullName, String jobId, Map<String, String> params) throws IOException;
27+
28+
/**
29+
* Retrieves the job's build status
30+
*
31+
* @param repoFullName repository full name
32+
* @param jobId identifier of the job
33+
* @param buildNumber number of the build
34+
* @return the build status
35+
* @throws IOException is something goes wrong
36+
*/
37+
BuildStatus getJobStatus(String repoFullName, String jobId, String buildNumber) throws IOException;
2738
}

src/main/java/io/perf/tools/bot/service/job/jenkins/JenkinsService.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import com.offbytwo.jenkins.JenkinsServer;
44
import com.offbytwo.jenkins.client.JenkinsHttpClient;
55
import com.offbytwo.jenkins.helper.JenkinsVersion;
6+
import com.offbytwo.jenkins.model.Build;
67
import com.offbytwo.jenkins.model.JobWithDetails;
78
import com.offbytwo.jenkins.model.QueueReference;
89
import io.perf.tools.bot.model.config.JobDef;
910
import io.perf.tools.bot.model.config.ProjectConfig;
1011
import io.perf.tools.bot.service.ConfigService;
12+
import io.perf.tools.bot.service.job.BuildStatus;
1113
import io.perf.tools.bot.service.job.JobExecutor;
1214
import io.quarkus.logging.Log;
1315
import jakarta.enterprise.context.ApplicationScoped;
@@ -73,8 +75,25 @@ public String buildJob(String repoFullName, String jobId, Map<String, String> pa
7375
queueReference = jenkinsJob.build(params);
7476
}
7577

76-
Log.debug("Job" + jobId + " queued at " + queueReference.getQueueItemUrlPart());
78+
Log.debug("Job " + jobId + " queued at " + queueReference.getQueueItemUrlPart());
7779
return Integer.toString(nextBuildNumber);
7880
}
7981
}
82+
83+
@Override
84+
public BuildStatus getJobStatus(String repoFullName, String jobId, String buildNumber) throws IOException {
85+
Log.debug("Getting job " + jobId + " / " + buildNumber);
86+
ProjectConfig config = configService.getConfig(repoFullName);
87+
if (config == null) {
88+
throw new IllegalArgumentException("Config not found with `" + repoFullName + "`");
89+
}
90+
91+
JobDef jobDef = config.jobs.get(jobId);
92+
try (JenkinsServer jenkinsServer = createJenkinsServer(config)) {
93+
JobWithDetails jenkinsJob = jenkinsServer.getJob(jobDef.platformJobId);
94+
95+
Build build = jenkinsJob.getBuildByNumber(Integer.parseInt(buildNumber));
96+
return BuildStatus.fromJenkins(build.details().getResult());
97+
}
98+
}
8099
}

0 commit comments

Comments
 (0)