Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,28 @@ public Response retrievePromptVersion(
return Response.ok(promptVersion).build();
}

@POST
@Path("/{promptId}/versions/{versionId}/restore")
@Operation(operationId = "restorePromptVersion", summary = "Restore prompt version", description = "Restore a prompt version by creating a new version with the content from the specified version", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = PromptVersion.class))),
@ApiResponse(responseCode = "404", description = "Not Found", content = @Content(schema = @Schema(implementation = io.dropwizard.jersey.errors.ErrorMessage.class))),
@ApiResponse(responseCode = "400", description = "Bad Request", content = @Content(schema = @Schema(implementation = ErrorMessage.class))),
})
@RateLimited
@JsonView({PromptVersion.View.Detail.class})
public Response restorePromptVersion(@PathParam("promptId") UUID promptId, @PathParam("versionId") UUID versionId) {

String workspaceId = requestContext.get().getWorkspaceId();

log.info("Restoring prompt version with id '{}' for prompt id '{}' on workspace_id '{}'",
versionId, promptId, workspaceId);

PromptVersion restoredVersion = promptService.restorePromptVersion(promptId, versionId);

log.info("Successfully restored prompt version with id '{}' for prompt id '{}' on workspace_id '{}'",
versionId, promptId, workspaceId);

return Response.ok(restoredVersion).build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ public interface PromptService {

PromptVersion retrievePromptVersion(String name, String commit);

PromptVersion restorePromptVersion(UUID promptId, UUID versionId);

Mono<Map<UUID, String>> getVersionsCommitByVersionsIds(Set<UUID> versionsIds);
}

Expand Down Expand Up @@ -519,6 +521,51 @@ public PromptVersion retrievePromptVersion(@NonNull String name, String commit)
});
}

@Override
public PromptVersion restorePromptVersion(@NonNull UUID promptId, @NonNull UUID versionId) {
String workspaceId = requestContext.get().getWorkspaceId();
String userName = requestContext.get().getUserName();

log.info("Restoring prompt version with id '{}' for prompt id '{}' on workspace_id '{}'",
versionId, promptId, workspaceId);

// Get the version to restore
PromptVersion versionToRestore = getVersionById(versionId);

// Verify the version belongs to the specified prompt
if (!versionToRestore.promptId().equals(promptId)) {
throw new NotFoundException("Prompt version not found for the specified prompt");
}

// Get the prompt to get its name
Prompt prompt = getById(promptId);

// Create a new version with the content from the old version
UUID newVersionId = idGenerator.generateId();
String newCommit = CommitUtils.getCommit(newVersionId);

PromptVersion newVersion = versionToRestore.toBuilder()
.id(newVersionId)
.commit(newCommit)
.createdBy(userName)
.changeDescription("Restored from version " + versionToRestore.commit())
.build();

PromptVersion restoredVersion = EntityConstraintHandler
.handle(() -> savePromptVersion(workspaceId, newVersion))
.onErrorDo(() -> retryableCreateVersion(workspaceId,
CreatePromptVersion.builder()
.name(prompt.name())
.version(newVersion)
.build(),
prompt, userName));

log.info("Successfully restored prompt version with id '{}' for prompt id '{}' on workspace_id '{}'",
versionId, promptId, workspaceId);

return restoredVersion;
}

@Override
public Mono<Map<UUID, String>> getVersionsCommitByVersionsIds(@NonNull Set<UUID> versionsIds) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,93 @@ void retrievePromptVersionsByNameAndCommit__whenApiKeyIsPresent__thenReturnPrope
}
}
}

@ParameterizedTest
@MethodSource("credentials")
@DisplayName("Restore prompt version: when api key is present, then return proper response")
void restorePromptVersion__whenApiKeyIsPresent__thenReturnProperResponse(String apiKey, boolean success,
io.dropwizard.jersey.errors.ErrorMessage errorMessage) {
String workspaceName = UUID.randomUUID().toString();

mockTargetWorkspace(okApikey, workspaceName, WORKSPACE_ID);

var prompt = factory.manufacturePojo(Prompt.class).toBuilder()
.id(null)
.createdAt(null)
.lastUpdatedAt(null)
.createdBy(null)
.lastUpdatedBy(null)
.latestVersion(null)
.build();

UUID promptId = createPrompt(prompt, okApikey, workspaceName);

// Create first version to restore from
var promptVersion1 = factory.manufacturePojo(PromptVersion.class).toBuilder()
.id(null)
.promptId(null)
.commit(null)
.createdAt(null)
.createdBy(null)
.variables(null)
.template("Original template content")
.changeDescription("First version")
.build();

CreatePromptVersion request1 = new CreatePromptVersion(prompt.name(), promptVersion1);
promptVersion1 = createPromptVersion(request1, okApikey, workspaceName);

// Create second version
var promptVersion2 = factory.manufacturePojo(PromptVersion.class).toBuilder()
.id(null)
.promptId(null)
.commit(null)
.createdAt(null)
.createdBy(null)
.variables(null)
.template("Modified template content")
.changeDescription("Second version")
.build();

CreatePromptVersion request2 = new CreatePromptVersion(prompt.name(), promptVersion2);
promptVersion2 = createPromptVersion(request2, okApikey, workspaceName);

// Now restore the first version
try (var actualResponse = client.target(RESOURCE_PATH.formatted(baseURI) + "/%s/versions/%s/restore"
.formatted(promptId, promptVersion1.id()))
.request()
.header(HttpHeaders.AUTHORIZATION, apiKey)
.header(RequestContext.WORKSPACE_HEADER, workspaceName)
.post(Entity.json(""))) {

if (success) {
assertThat(actualResponse.getStatus()).isEqualTo(HttpStatus.SC_OK);

var restoredVersion = actualResponse.readEntity(PromptVersion.class);

// Verify the restored version has the same content as the original
assertThat(restoredVersion).isNotNull();
assertThat(restoredVersion.template()).isEqualTo(promptVersion1.template());
assertThat(restoredVersion.metadata()).isEqualTo(promptVersion1.metadata());
assertThat(restoredVersion.type()).isEqualTo(promptVersion1.type());
assertThat(restoredVersion.promptId()).isEqualTo(promptId);
assertThat(restoredVersion.createdBy()).isEqualTo(USER);
assertThat(restoredVersion.changeDescription()).contains("Restored from version");

// Verify it's a new version (different ID and commit)
assertThat(restoredVersion.id()).isNotEqualTo(promptVersion1.id());
assertThat(restoredVersion.id()).isNotEqualTo(promptVersion2.id());
assertThat(restoredVersion.commit()).isNotEqualTo(promptVersion1.commit());
assertThat(restoredVersion.commit()).isNotEqualTo(promptVersion2.commit());
} else {
assertThat(actualResponse.getStatus()).isEqualTo(errorMessage.getCode());

var actualErrorMessage = actualResponse.readEntity(io.dropwizard.jersey.errors.ErrorMessage.class);

assertThat(actualErrorMessage).isEqualTo(errorMessage);
}
}
}
}

@Nested
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import get from "lodash/get";

import api, { PROMPTS_REST_ENDPOINT } from "@/api/api";
import { useToast } from "@/components/ui/use-toast";

type UseRestorePromptVersionMutationParams = {
promptId: string;
versionId: string;
};

const useRestorePromptVersionMutation = () => {
const queryClient = useQueryClient();
const { toast } = useToast();

return useMutation({
mutationFn: async ({
promptId,
versionId,
}: UseRestorePromptVersionMutationParams) => {
const { data } = await api.post(
`${PROMPTS_REST_ENDPOINT}${promptId}/versions/${versionId}/restore`,
);

return data;
},
onError: (error: AxiosError) => {
const message = get(
error,
["response", "data", "message"],
error.message,
);

toast({
title: "Error",
description: message,
variant: "destructive",
});
},
onSuccess: async ({ versionId }: UseRestorePromptVersionMutationParams) => {
toast({
description: `Version ${versionId} has been restored successfully`,
});
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({
queryKey: ["prompt", { promptId: variables.promptId }],
});
return queryClient.invalidateQueries({ queryKey: ["prompts"] });
},
});
};

export default useRestorePromptVersionMutation;
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Copy, GitCommitVertical } from "lucide-react";
import { Copy, GitCommitVertical, Undo2 } from "lucide-react";
import copy from "clipboard-copy";
import { cn } from "@/lib/utils";

Expand All @@ -13,12 +13,14 @@ interface CommitHistoryProps {
versions: PromptVersion[];
onVersionClick: (version: PromptVersion) => void;
activeVersionId: string;
onRestoreVersionClick: (version: PromptVersion) => void;
}

const CommitHistory = ({
versions,
onVersionClick,
activeVersionId,
onRestoreVersionClick,
}: CommitHistoryProps) => {
const { toast } = useToast();
const [hoveredVersionId, setHoveredVersionId] = useState<string | null>(null);
Expand All @@ -32,49 +34,69 @@ const CommitHistory = ({
};

return (
<ul className="max-h-[500px] overflow-y-auto rounded border bg-background p-1">
{versions?.map((version) => {
return (
<li
key={version.id}
className={cn(
"cursor-pointer hover:bg-primary-foreground rounded-sm px-4 py-2.5 flex flex-col",
{
"bg-primary-foreground": activeVersionId === version.id,
},
)}
onMouseEnter={() => setHoveredVersionId(version.id)}
onMouseLeave={() => setHoveredVersionId(null)}
onClick={() => onVersionClick(version)}
>
<div className="flex items-center gap-2">
<GitCommitVertical className="mt-auto size-4 shrink-0 text-muted-slate" />
<span
className={cn("comet-body-s truncate", {
"comet-body-s-accented": activeVersionId === version.id,
})}
>
{version.commit}
</span>
{hoveredVersionId == version.id && (
<TooltipWrapper content="Copy code">
<Button
size="icon-3xs"
variant="minimal"
onClick={() => handleCopyClick(version.commit)}
>
<Copy />
</Button>
</TooltipWrapper>
<>
<ul className="max-h-[500px] overflow-y-auto rounded border bg-background p-1">
<h2 className="comet-title-s">Commit history</h2>
{versions?.map((version) => {
return (
<li
key={version.id}
className={cn(
"cursor-pointer hover:bg-primary-foreground rounded-sm px-4 py-2.5 flex flex-col",
{
"bg-primary-foreground": activeVersionId === version.id,
},
)}
</div>
<p className="comet-body-s pl-6 text-light-slate">
{formatDate(version.created_at)}
</p>
</li>
);
})}
</ul>
onMouseEnter={() => setHoveredVersionId(version.id)}
onMouseLeave={() => setHoveredVersionId(null)}
onClick={() => onVersionClick(version)}
>
<div className="flex items-center gap-2">
<GitCommitVertical className="mt-auto size-4 shrink-0 text-muted-slate" />
<span
className={cn("comet-body-s truncate", {
"comet-body-s-accented": activeVersionId === version.id,
})}
>
{version.commit}
</span>
{hoveredVersionId == version.id && (
<div className="flex gap-1">
<TooltipWrapper content="Copy code">
<Button
size="icon-3xs"
variant="minimal"
onClick={(e) => {
e.stopPropagation();
handleCopyClick(version.commit);
}}
>
<Copy />
</Button>
</TooltipWrapper>
<TooltipWrapper content="Restore this version">
<Button
size="icon-3xs"
variant="minimal"
onClick={(e) => {
e.stopPropagation();
onRestoreVersionClick(version);
}}
>
<Undo2 />
</Button>
</TooltipWrapper>
</div>
)}
</div>
<p className="comet-body-s pl-6 text-light-slate">
{formatDate(version.created_at)}
</p>
</li>
);
})}
</ul>
</>
);
};

Expand Down
Loading
Loading