-
Notifications
You must be signed in to change notification settings - Fork 1.4k
[NA] [BE] First build of Local Runner backend #5406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
89cdd8e
Back-end for runner first build
collincunn d287b12
Fixed assorted bugs and tests
collincunn eeb863a
Added async for long polling
collincunn 8e0e93f
Added more info to agents
collincunn e617f6d
Fixed baz comments
collincunn 8bf8865
Timeout underspecified
collincunn f2c38cb
minor validation feedback
collincunn 725b2fc
Addressed comments
collincunn 6873c55
more pr comments
collincunn 71191d8
Merge remote-tracking branch 'origin/main' into collinc/local-runner-…
collincunn 91853e2
Addressed PR comments; added integration test
collincunn ec449e5
Extracted helper function
collincunn 4c081e8
Merge branch 'main' into collinc/local-runner-impl
collincunn a519c8c
PR feedback
collincunn cd5514e
Merge branch 'collinc/local-runner-impl' of github.com:comet-ml/opik …
collincunn 16ed26e
missed two comments
collincunn 0b4b4c5
Merge branch 'main' into collinc/local-runner-impl
collincunn 4ddac68
regressions
collincunn e681305
Merge branch 'collinc/local-runner-impl' of github.com:comet-ml/opik …
collincunn 49da026
more regressions
collincunn 1e74333
more regressions
collincunn 1cc7016
improved logging
collincunn fbae570
PR comments
collincunn b89aee8
Merge branch 'main' into collinc/local-runner-impl
collincunn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 50 additions & 0 deletions
50
apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/jobs/RunnerReaperJob.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package com.comet.opik.api.resources.v1.jobs; | ||
|
|
||
| import com.comet.opik.domain.RunnerService; | ||
| import com.comet.opik.infrastructure.RunnerConfig; | ||
| import com.comet.opik.infrastructure.lock.LockService; | ||
| import io.dropwizard.jobs.Job; | ||
| import io.dropwizard.jobs.annotations.Every; | ||
| import jakarta.inject.Inject; | ||
| import jakarta.inject.Singleton; | ||
| import lombok.NonNull; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.quartz.DisallowConcurrentExecution; | ||
| import org.quartz.JobExecutionContext; | ||
| import reactor.core.publisher.Mono; | ||
|
|
||
| import java.time.Duration; | ||
|
|
||
| import static com.comet.opik.infrastructure.lock.LockService.Lock; | ||
|
|
||
| @Slf4j | ||
| @Singleton | ||
| @DisallowConcurrentExecution | ||
| @Every("60s") | ||
| @RequiredArgsConstructor(onConstructor_ = @Inject) | ||
| public class RunnerReaperJob extends Job { | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| private static final Lock REAPER_LOCK = new Lock("runner-reaper"); | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| private final @NonNull RunnerService runnerService; | ||
| private final @NonNull LockService lockService; | ||
| private final @NonNull RunnerConfig runnerConfig; | ||
|
|
||
| @Override | ||
| public void doJob(JobExecutionContext context) { | ||
| if (!runnerConfig.isEnabled()) { | ||
| return; | ||
| } | ||
|
|
||
| lockService.bestEffortLock( | ||
| REAPER_LOCK, | ||
| Mono.fromRunnable(() -> runnerService.reapDeadRunners()), | ||
| Mono.fromRunnable(() -> log.debug("Could not acquire reaper lock, skipping")), | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| Duration.ofSeconds(55), | ||
| Duration.ofSeconds(5)) | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| .subscribe( | ||
| __ -> log.debug("Runner reaper completed"), | ||
| error -> log.error("Runner reaper failed", error)); | ||
| } | ||
| } | ||
228 changes: 228 additions & 0 deletions
228
apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/RunnersResource.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| package com.comet.opik.api.resources.v1.priv; | ||
|
|
||
| import com.codahale.metrics.annotation.Timed; | ||
| import com.comet.opik.api.runner.ConnectRequest; | ||
| import com.comet.opik.api.runner.ConnectResponse; | ||
| import com.comet.opik.api.runner.CreateJobRequest; | ||
| import com.comet.opik.api.runner.HeartbeatResponse; | ||
| import com.comet.opik.api.runner.JobResultRequest; | ||
| import com.comet.opik.api.runner.LogEntry; | ||
| import com.comet.opik.api.runner.PairResponse; | ||
| import com.comet.opik.api.runner.Runner; | ||
| import com.comet.opik.api.runner.RunnerJob; | ||
| import com.comet.opik.domain.RunnerService; | ||
| import com.comet.opik.infrastructure.RunnerConfig; | ||
| import com.comet.opik.infrastructure.auth.RequestContext; | ||
| import com.comet.opik.infrastructure.ratelimit.RateLimited; | ||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.inject.Inject; | ||
| import jakarta.inject.Provider; | ||
| import jakarta.validation.Valid; | ||
| import jakarta.validation.constraints.Min; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.ws.rs.Consumes; | ||
| import jakarta.ws.rs.DefaultValue; | ||
| import jakarta.ws.rs.GET; | ||
| import jakarta.ws.rs.NotFoundException; | ||
| import jakarta.ws.rs.POST; | ||
| import jakarta.ws.rs.PUT; | ||
| import jakarta.ws.rs.Path; | ||
| import jakarta.ws.rs.PathParam; | ||
| import jakarta.ws.rs.Produces; | ||
| import jakarta.ws.rs.QueryParam; | ||
| import jakarta.ws.rs.container.AsyncResponse; | ||
| import jakarta.ws.rs.container.Suspended; | ||
| import jakarta.ws.rs.core.MediaType; | ||
| import jakarta.ws.rs.core.Response; | ||
| import lombok.NonNull; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| @Path("/v1/private/runners") | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| @Produces(MediaType.APPLICATION_JSON) | ||
| @Consumes(MediaType.APPLICATION_JSON) | ||
| @Timed | ||
| @Slf4j | ||
| @RequiredArgsConstructor(onConstructor_ = @Inject) | ||
| @Tag(name = "Runners", description = "Local runner management endpoints") | ||
| public class RunnersResource { | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| private final @NonNull Provider<RequestContext> requestContext; | ||
| private final @NonNull RunnerService runnerService; | ||
| private final @NonNull RunnerConfig runnerConfig; | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| @POST | ||
| @Path("/pair") | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| @Operation(operationId = "generatePairingCode", summary = "Generate a pairing code for the current workspace") | ||
| public Response generatePairingCode() { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| String userName = requestContext.get().getUserName(); | ||
| PairResponse response = runnerService.generatePairingCode(workspaceId, userName); | ||
| return Response.ok(response).build(); | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @POST | ||
| @Path("/connect") | ||
| @Operation(operationId = "connectRunner", summary = "Exchange a pairing code or API key for runner credentials") | ||
| public Response connect(@NotNull @Valid ConnectRequest request) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| String userName = requestContext.get().getUserName(); | ||
| ConnectResponse response = runnerService.connect(workspaceId, userName, request); | ||
| return Response.ok(response).build(); | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @GET | ||
| @Operation(operationId = "listRunners", summary = "List all runners in the current workspace") | ||
| public Response listRunners() { | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| List<Runner> runners = runnerService.listRunners(workspaceId); | ||
| return Response.ok(runners).build(); | ||
| } | ||
|
|
||
| @GET | ||
| @Path("/{runnerId}") | ||
| @Operation(operationId = "getRunner", summary = "Get a single runner with its registered agents") | ||
| public Response getRunner(@PathParam("runnerId") String runnerId) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| Runner runner = runnerService.getRunner(workspaceId, runnerId); | ||
| return Response.ok(runner).build(); | ||
| } | ||
|
|
||
| @PUT | ||
| @Path("/{runnerId}/agents") | ||
| @RateLimited | ||
| @Operation(operationId = "registerAgents", summary = "Register or update the runner's agent list") | ||
| public Response registerAgents(@PathParam("runnerId") String runnerId, | ||
| @NotNull Map<String, JsonNode> agents) { | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| runnerService.registerAgents(runnerId, workspaceId, agents); | ||
| return Response.noContent().build(); | ||
| } | ||
|
|
||
| @POST | ||
| @Path("/{runnerId}/heartbeat") | ||
| @RateLimited | ||
| @Operation(operationId = "heartbeat", summary = "Refresh runner heartbeat") | ||
| public Response heartbeat(@PathParam("runnerId") String runnerId) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| HeartbeatResponse response = runnerService.heartbeat(runnerId, workspaceId); | ||
| return Response.ok(response).build(); | ||
| } | ||
|
|
||
| @POST | ||
| @Path("/jobs") | ||
| @Operation(operationId = "createJob", summary = "Create a job and enqueue it for execution") | ||
| @RateLimited | ||
| public Response createJob(@NotNull @Valid CreateJobRequest request) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| String userName = requestContext.get().getUserName(); | ||
| RunnerJob job = runnerService.createJob(workspaceId, userName, request); | ||
| return Response.status(Response.Status.CREATED).entity(job).build(); | ||
| } | ||
|
|
||
| @GET | ||
| @Path("/{runnerId}/jobs") | ||
| @Operation(operationId = "listJobs", summary = "List jobs for a runner") | ||
| public Response listJobs(@PathParam("runnerId") String runnerId, | ||
| @QueryParam("project") String project, | ||
| @QueryParam("page") @DefaultValue("0") @Min(0) int page, | ||
| @QueryParam("size") @DefaultValue("25") @Min(1) int size) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| RunnerJob.RunnerJobPage jobPage = runnerService.listJobs(runnerId, project, workspaceId, page, size); | ||
| return Response.ok(jobPage).build(); | ||
| } | ||
|
|
||
| @GET | ||
| @Path("/{runnerId}/jobs/next") | ||
| @Operation(operationId = "nextJob", summary = "Long-poll for the next pending job") | ||
| public void nextJob(@PathParam("runnerId") String runnerId, | ||
| @Suspended AsyncResponse asyncResponse) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| runnerService.nextJob(runnerId, workspaceId) | ||
| .thenAccept(job -> { | ||
| if (job == null) { | ||
| asyncResponse.resume(Response.noContent().build()); | ||
| } else { | ||
| asyncResponse.resume(Response.ok(job).build()); | ||
| } | ||
| }) | ||
| .exceptionally(e -> { | ||
| asyncResponse.resume(e); | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return null; | ||
| }); | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @GET | ||
| @Path("/jobs/{jobId}") | ||
| @Operation(operationId = "getJob", summary = "Get a single job's status and results") | ||
| public Response getJob(@PathParam("jobId") String jobId) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| RunnerJob job = runnerService.getJob(jobId, workspaceId); | ||
| return Response.ok(job).build(); | ||
| } | ||
|
|
||
| @GET | ||
| @Path("/jobs/{jobId}/logs") | ||
| @Operation(operationId = "getJobLogs", summary = "Get log entries for a job") | ||
| public Response getJobLogs(@PathParam("jobId") String jobId, | ||
| @QueryParam("offset") @DefaultValue("0") @Min(0) int offset) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| List<LogEntry> logs = runnerService.getJobLogs(jobId, offset, workspaceId); | ||
| return Response.ok(logs).build(); | ||
| } | ||
|
|
||
| @POST | ||
| @Path("/jobs/{jobId}/logs") | ||
| @RateLimited | ||
| @Operation(operationId = "appendJobLogs", summary = "Append log entries for a running job") | ||
| public Response appendLogs(@PathParam("jobId") String jobId, | ||
| @NotNull List<LogEntry> entries) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| runnerService.appendLogs(jobId, workspaceId, entries); | ||
| return Response.noContent().build(); | ||
| } | ||
|
|
||
| @POST | ||
| @Path("/jobs/{jobId}/result") | ||
| @Operation(operationId = "reportJobResult", summary = "Report job completion or failure") | ||
| public Response reportResult(@PathParam("jobId") String jobId, | ||
| @NotNull @Valid JobResultRequest result) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| runnerService.reportResult(jobId, workspaceId, result); | ||
| return Response.noContent().build(); | ||
| } | ||
|
|
||
| @POST | ||
| @Path("/jobs/{jobId}/cancel") | ||
| @Operation(operationId = "cancelJob", summary = "Cancel a pending or running job") | ||
| public Response cancelJob(@PathParam("jobId") String jobId) { | ||
| ensureEnabled(); | ||
| String workspaceId = requestContext.get().getWorkspaceId(); | ||
| runnerService.cancelJob(jobId, workspaceId); | ||
| return Response.noContent().build(); | ||
| } | ||
|
|
||
| private void ensureEnabled() { | ||
| if (!runnerConfig.isEnabled()) { | ||
| throw new NotFoundException(); | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
| } | ||
15 changes: 15 additions & 0 deletions
15
apps/opik-backend/src/main/java/com/comet/opik/api/runner/ConnectRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.comet.opik.api.runner; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
| import com.fasterxml.jackson.databind.PropertyNamingStrategies; | ||
| import com.fasterxml.jackson.databind.annotation.JsonNaming; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import lombok.Builder; | ||
|
|
||
| @Builder(toBuilder = true) | ||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) | ||
| public record ConnectRequest( | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| String pairingCode, | ||
| @NotBlank String runnerName) { | ||
| } | ||
14 changes: 14 additions & 0 deletions
14
apps/opik-backend/src/main/java/com/comet/opik/api/runner/ConnectResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.comet.opik.api.runner; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
| import com.fasterxml.jackson.databind.PropertyNamingStrategies; | ||
| import com.fasterxml.jackson.databind.annotation.JsonNaming; | ||
| import lombok.Builder; | ||
|
|
||
| @Builder(toBuilder = true) | ||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) | ||
| public record ConnectResponse( | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| String runnerId, | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| String workspaceId) { | ||
| } | ||
18 changes: 18 additions & 0 deletions
18
apps/opik-backend/src/main/java/com/comet/opik/api/runner/CreateJobRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.comet.opik.api.runner; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.PropertyNamingStrategies; | ||
| import com.fasterxml.jackson.databind.annotation.JsonNaming; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import lombok.Builder; | ||
|
|
||
| @Builder(toBuilder = true) | ||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) | ||
| public record CreateJobRequest( | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| @NotBlank String agentName, | ||
| JsonNode inputs, | ||
| String project, | ||
| String runnerId) { | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
15 changes: 15 additions & 0 deletions
15
apps/opik-backend/src/main/java/com/comet/opik/api/runner/HeartbeatResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.comet.opik.api.runner; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
| import com.fasterxml.jackson.databind.PropertyNamingStrategies; | ||
| import com.fasterxml.jackson.databind.annotation.JsonNaming; | ||
| import lombok.Builder; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Builder(toBuilder = true) | ||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) | ||
| public record HeartbeatResponse( | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| List<String> cancelledJobIds) { | ||
| } | ||
18 changes: 18 additions & 0 deletions
18
apps/opik-backend/src/main/java/com/comet/opik/api/runner/JobResultRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.comet.opik.api.runner; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.PropertyNamingStrategies; | ||
| import com.fasterxml.jackson.databind.annotation.JsonNaming; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import lombok.Builder; | ||
|
|
||
| @Builder(toBuilder = true) | ||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) | ||
| public record JobResultRequest( | ||
collincunn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| @NotBlank String status, | ||
| JsonNode result, | ||
| String error, | ||
| String traceId) { | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.