Display all tags for changeset

Display of all tags (as links to the  overview of the specific tag) of a given changeset in the changeset detail view.
This commit is contained in:
Laura Gorzitze
2024-03-11 17:09:59 +01:00
parent 28a2de3cb3
commit 8d12862ff8
18 changed files with 411 additions and 171 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Display of all tags for a given changeset in the changeset detail view

View File

@@ -53,5 +53,11 @@ public enum Feature
*
* @since 2.47.0
*/
FORCE_PUSH
FORCE_PUSH,
/**
* The repository supports computation of tags for a given revision.
*
* @since 3.1.0
*/
TAGS_FOR_REVISION
}

View File

@@ -30,6 +30,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager;
import sonia.scm.repository.Feature;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryCacheKey;
import sonia.scm.repository.Tag;
@@ -50,10 +51,10 @@ import java.util.List;
* TagsCommandBuilder tagsCommand = repositoryService.getTagsCommand();
* Tags tags = tagsCommand.getTags();
* </code></pre>
*
* @since 1.18
*/
public final class TagsCommandBuilder
{
public final class TagsCommandBuilder {
static final String CACHE_NAME = "sonia.cache.cmd.tags";
@@ -61,7 +62,9 @@ public final class TagsCommandBuilder
private static final Logger logger =
LoggerFactory.getLogger(TagsCommandBuilder.class);
/** cache for changesets */
/**
* cache for changesets
*/
private final Cache<CacheKey, Tags> cache;
private final TagsCommand command;
@@ -70,6 +73,8 @@ public final class TagsCommandBuilder
private boolean disableCache = false;
private String revision;
/**
* Constructs a new {@link TagsCommandBuilder}, this constructor should
* only be called from the {@link RepositoryService}.
@@ -79,8 +84,7 @@ public final class TagsCommandBuilder
* @param repository repository
*/
TagsCommandBuilder(CacheManager cacheManager, TagsCommand command,
Repository repository)
{
Repository repository) {
this.cache = cacheManager.getCache(CACHE_NAME);
this.command = command;
this.repository = repository;
@@ -91,97 +95,78 @@ public final class TagsCommandBuilder
* Returns all tags from the repository.
*/
public Tags getTags() throws IOException {
Tags tags;
if (disableCache)
{
if (logger.isDebugEnabled())
{
logger.debug("get tags for repository {} with disabled cache",
repository.getName());
}
tags = getTagsFromCommand();
}
else
{
if (revision != null) {
logger.debug("get tags for repository {} with revision {}", repository, revision);
return getTagsFromCommandForRevision();
} else if (disableCache) {
logger.debug("get tags for repository {} with disabled cache", repository);
return getTagsFromCommand();
} else {
CacheKey key = new CacheKey(repository);
tags = cache.get(key);
if (tags == null)
{
if (logger.isDebugEnabled())
{
Tags tags = cache.get(key);
if (tags == null) {
logger.debug("get tags for repository {}", repository);
}
tags = getTagsFromCommand();
if (tags != null)
{
cache.put(key, tags);
} else {
logger.debug("get tags for repository {} from cache", repository);
}
}
else if (logger.isDebugEnabled())
{
logger.debug("get tags for repository {} from cache",
repository.getName());
}
}
return tags;
}
}
/**
* Disables the cache for tags. This means that every {@link Tag}
* is directly retrieved from the {@link Repository}. <b>Note: </b> Disabling
* the cache cost a lot of performance and could be much slower.
*
*
* @param disableCache true to disable the cache
*
* @return {@code this}
*/
public TagsCommandBuilder setDisableCache(boolean disableCache)
{
public TagsCommandBuilder setDisableCache(boolean disableCache) {
this.disableCache = disableCache;
return this;
}
/**
* Set revision to show all tags containing the given revision. This is only supported for repositories supporting
* feature {@link sonia.scm.repository.Feature#TAGS_FOR_REVISION} (@see {@link RepositoryService#isSupported(Feature)}).
*
* @return {@code this}
* @since 3.1.0
*/
public TagsCommandBuilder forRevision(String revision) {
this.revision = revision;
return this;
}
private Tags getTagsFromCommand() throws IOException
{
private Tags getTagsFromCommand() throws IOException {
List<Tag> tagList = command.getTags();
return new Tags(tagList);
}
private Tags getTagsFromCommandForRevision() throws IOException {
return new Tags(command.getTags(revision));
}
static class CacheKey implements RepositoryCacheKey
{
static class CacheKey implements RepositoryCacheKey {
private final String repositoryId;
public CacheKey(Repository repository)
{
public CacheKey(Repository repository) {
this.repositoryId = repository.getId();
}
@Override
public boolean equals(Object obj)
{
if (obj == null)
{
public boolean equals(Object obj) {
if (obj == null) {
return false;
}
if (getClass() != obj.getClass())
{
if (getClass() != obj.getClass()) {
return false;
}
@@ -192,15 +177,13 @@ public final class TagsCommandBuilder
@Override
public int hashCode()
{
public int hashCode() {
return Objects.hashCode(repositoryId);
}
@Override
public String getRepositoryId()
{
public String getRepositoryId() {
return repositoryId;
}

View File

@@ -25,15 +25,20 @@
package sonia.scm.repository.spi;
import sonia.scm.FeatureNotSupportedException;
import sonia.scm.repository.Feature;
import sonia.scm.repository.Tag;
import java.io.IOException;
import java.util.List;
public interface TagsCommand
{
public interface TagsCommand {
public List<Tag> getTags() throws IOException;
default List<Tag> getTags(String revision) throws IOException{
throw new FeatureNotSupportedException(Feature.TAGS_FOR_REVISION.name());
}
}

View File

@@ -63,7 +63,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider {
protected static final Set<Feature> FEATURES = EnumSet.of(
Feature.INCOMING_REVISION,
Feature.MODIFICATIONS_BETWEEN_REVISIONS,
Feature.FORCE_PUSH
Feature.FORCE_PUSH,
Feature.TAGS_FOR_REVISION
);
private final Injector injector;

View File

@@ -29,8 +29,10 @@ import com.google.inject.assistedinject.Assisted;
import jakarta.inject.Inject;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.revwalk.RevWalk;
import sonia.scm.repository.GitUtil;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Tag;
@@ -54,8 +56,19 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand {
@Override
public List<Tag> getTags() throws IOException {
return getTags(null);
}
@Override
public List<Tag> getTags(String revision) throws IOException {
try (Git git = new Git(open()); RevWalk revWalk = new RevWalk(git.getRepository())) {
List<Ref> tagList = git.tagList().call();
List<Ref> tagList;
if (revision != null) {
tagList = git.tagList().setContains(GitUtil.getRevisionId(git.getRepository(), revision)).call();
} else {
tagList = git.tagList().call();
}
return tagList.stream()
.map(ref -> gitTagConverter.buildTag(git.getRepository(), revWalk, ref))

View File

@@ -70,27 +70,51 @@ public class GitTagsCommandTest extends AbstractGitCommandTestBase {
assertThat(lightweightTag.getRevision()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1");
}
@Test
public void shouldGetTagsForRevision() throws IOException {
GitContext gitContext = createContext();
GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, new GitTagConverter(gpg));
List<Tag> tags = tagsCommand.getTags("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1");
assertThat(tags).hasSize(2);
}
@Test
public void shouldGetTagsForShortenedRevision() throws IOException {
GitContext gitContext = createContext();
GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, new GitTagConverter(gpg));
List<Tag> tags = tagsCommand.getTags("86a6645");
assertThat(tags).hasSize(2);
}
@Test
public void shouldGetSignatures() throws IOException {
when(gpg.findPublicKeyId(ArgumentMatchers.any())).thenReturn("2BA27721F113C005CC16F06BAE63EFBC49F140CF");
when(gpg.findPublicKey("2BA27721F113C005CC16F06BAE63EFBC49F140CF")).thenReturn(Optional.of(publicKey));
String signature = "-----BEGIN PGP SIGNATURE-----\n" +
"\n" +
"iQEzBAABCgAdFiEEK6J3IfETwAXMFvBrrmPvvEnxQM8FAl+9acoACgkQrmPvvEnx\n" +
"QM9abwgAnGP+Y/Ijli+PAsimfOmZQWYepjptoOv9m7i3bnHv8V+Qg6cm51I3E0YV\n" +
"R2QaxxzW9PgS4hcES+L1qs8Lwo18RurF469eZEmNb8DcUFJ3sEWeHlIl5wZNNo/v\n" +
"jJm0d9LNcSmtAIiQ8eDMoGdFXJzHewGickLOSsQGmfZgZus4Qlsh7r3BZTI1Zwd/\n" +
"6jaBFctX13FuepCTxq2SjEfRaQHIYkyFQq2o6mjL5S2qfYJ/S//gcCCzxllQrisF\n" +
"5fRW3LzLI4eXFH0vua7+UzNS2Rwpifg2OENJA/Kn+3R36LWEGxFK9pNqjVPRAcQj\n" +
"1vSkcjK26RqhAqCjNLSagM8ATZrh+g==\n" +
"=kUKm\n" +
"-----END PGP SIGNATURE-----\n";
String signedContent = "object 592d797cd36432e591416e8b2b98154f4f163411\n" +
"type commit\n" +
"tag signedtag\n" +
"tagger Arthur Dent <arthur.dent@hitchhiker.com> 1606248906 +0100\n" +
"\n" +
"this tag is signed\n";
String signature = """
-----BEGIN PGP SIGNATURE-----
iQEzBAABCgAdFiEEK6J3IfETwAXMFvBrrmPvvEnxQM8FAl+9acoACgkQrmPvvEnx
QM9abwgAnGP+Y/Ijli+PAsimfOmZQWYepjptoOv9m7i3bnHv8V+Qg6cm51I3E0YV
R2QaxxzW9PgS4hcES+L1qs8Lwo18RurF469eZEmNb8DcUFJ3sEWeHlIl5wZNNo/v
jJm0d9LNcSmtAIiQ8eDMoGdFXJzHewGickLOSsQGmfZgZus4Qlsh7r3BZTI1Zwd/
6jaBFctX13FuepCTxq2SjEfRaQHIYkyFQq2o6mjL5S2qfYJ/S//gcCCzxllQrisF
5fRW3LzLI4eXFH0vua7+UzNS2Rwpifg2OENJA/Kn+3R36LWEGxFK9pNqjVPRAcQj
1vSkcjK26RqhAqCjNLSagM8ATZrh+g==
=kUKm
-----END PGP SIGNATURE-----
""";
String signedContent = """
object 592d797cd36432e591416e8b2b98154f4f163411
type commit
tag signedtag
tagger Arthur Dent <arthur.dent@hitchhiker.com> 1606248906 +0100
this tag is signed
""";
when(publicKey.verify(signedContent.getBytes(), signature.getBytes())).thenReturn(true);
final GitContext gitContext = createContext();

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import { Changeset, Link, NamespaceAndName, Repository, Tag, TagCollection } from "@scm-manager/ui-types";
import { requiredLink } from "./links";
import { objectLink, requiredLink } from "./links";
import { QueryClient, useMutation, useQuery, useQueryClient } from "react-query";
import { ApiResult } from "./base";
import { repoQueryKey } from "./keys";
@@ -44,6 +44,23 @@ export const useTags = (repository: Repository): ApiResult<TagCollection> => {
);
};
export const useContainedInTags = (changeset: Changeset, repository: Repository): ApiResult<TagCollection> => {
const link = objectLink(changeset, "containedInTags");
return useQuery<TagCollection, Error>(repoQueryKey(repository, "tags", changeset.id), () => {
if (link === null) {
return {
_embedded: {
tags: [],
},
_links: {},
};
}
return apiClient.get(link).then((response) => response.json());
});
};
export const useTag = (repository: Repository, name: string): ApiResult<Tag> => {
const link = requiredLink(repository, "tags");
return useQuery<Tag, Error>(tagQueryKey(repository, name), () =>
@@ -62,10 +79,14 @@ const invalidateCacheForTag = (queryClient: QueryClient, repository: NamespaceAn
const createTag = (changeset: Changeset, link: string) => {
return (name: string) => {
return apiClient
.post(link, {
.post(
link,
{
name,
revision: changeset.id,
})
},
"application/vnd.scmm-tagRequest+json;v=2"
)
.then((response) => {
const location = response.headers.get("Location");
if (!location) {

View File

@@ -307,6 +307,13 @@
},
"tag": {
"create": "Tag erstellen"
},
"containedInTags": {
"containedInTag_one": "Enthalten in {{count}} Tag",
"containedInTag_other": "Enthalten in {{count}} Tags",
"allTags": "Alle Tags, in denen der Commit enthalten ist",
"showAllTags": "Alle Tags, in denen der Commit enthalten ist, einblenden",
"hideAllTags": "Alle Tags, in denen der Commit enthalten ist, ausblenden"
}
},
"commit": {

View File

@@ -307,6 +307,13 @@
},
"tag": {
"create": "Create Tag"
},
"containedInTags": {
"containedInTag_one": "Contained in {{count}} tag",
"containedInTag_other": "Contained in {{count}} tags",
"allTags": "All tags the commit is contained in",
"showAllTags": "Show all tags the commit is contained in",
"hideAllTags": "Hide all tags the commit is contained in"
}
},
"commit": {

View File

@@ -42,12 +42,13 @@ import {
Icon,
Level,
SignatureIcon,
Tooltip,
SubSubtitle,
Tooltip,
} from "@scm-manager/ui-components";
import ContributorTable from "./ContributorTable";
import { Link as ReactLink } from "react-router-dom";
import { Link, Link as ReactLink } from "react-router-dom";
import CreateTagModal from "./CreateTagModal";
import { useContainedInTags } from "@scm-manager/ui-api";
type Props = {
changeset: Changeset;
@@ -74,7 +75,7 @@ const countContributors = (changeset: Changeset) => {
return 1;
};
const ContributorColumn = styled.p`
const ContributorColumn = styled.div`
flex-grow: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -82,7 +83,7 @@ const ContributorColumn = styled.p`
min-width: 0;
`;
const CountColumn = styled.p`
const CountColumn = styled.div`
text-align: right;
white-space: nowrap;
`;
@@ -97,8 +98,11 @@ const SeparatedParents = styled.div`
const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
const [t] = useTranslation("repos");
const [open, setOpen] = useState(false);
const signatureIcon = changeset?.signatures && changeset.signatures.length > 0 && (
const signatureIcon =
changeset?.signatures && changeset.signatures.length > 0 ? (
<SignatureIcon className="mx-2" signatures={changeset.signatures} />
) : (
<>&nbsp;</>
);
if (open) {
@@ -116,22 +120,62 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
}
return (
<>
<div className="is-flex is-clickable" onClick={(e) => setOpen(!open)}>
<ContributorColumn className="is-ellipsis-overflow">
<Icon name="angle-right" alt={t("changeset.contributors.showList")} />{" "}
<ChangesetAuthor changeset={changeset} />
<Icon name="angle-right" alt={t("changeset.contributors.showList")} /> <ChangesetAuthor changeset={changeset} />
</ContributorColumn>
{signatureIcon}
<CountColumn className="is-hidden-mobile is-hidden-tablet-only is-hidden-desktop-only">
(
<span className="has-text-link">
{t("changeset.contributors.count", { count: countContributors(changeset) })}
</span>
)
(<span>{t("changeset.contributors.count", { count: countContributors(changeset) })}</span>)
</CountColumn>
</div>
</>
);
};
const ContainedInTags: FC<{ changeset: Changeset; repository: Repository }> = ({ changeset, repository }) => {
const [t] = useTranslation("repos");
const [open, setOpen] = useState(false);
const { data, isLoading } = useContainedInTags(changeset, repository);
const tags = data?._embedded?.tags;
if (!tags || tags.length === 0 || isLoading) {
return <div className="mb-5"></div>;
}
if (open) {
return (
<div className="is-flex is-flex-direction-column mb-4">
<div className="is-flex">
<p className="is-ellipsis-overflow is-clickable mb-2" onClick={(e) => setOpen(!open)}>
<Icon name="angle-down" alt={t("changeset.containedInTags.hideAllTags")} />{" "}
{t("changeset.containedInTags.allTags")}
</p>
</div>
<div>
{" "}
{tags.map((tag) => (
<span className="tag is-info is-normal m-1">
<Link
to={`/repo/${repository.namespace}/${repository.name}/tag/${tag.name}`}
className="has-text-inherit"
>
{tag.name}
</Link>
</span>
))}
</div>
</div>
);
}
return (
<div className="is-flex is-clickable" onClick={() => setOpen(!open)}>
<ContributorColumn className="is-ellipsis-overflow">
<Icon name="angle-right" alt={t("changeset.containedInTags.showAllTags")} />{" "}
{t("changeset.containedInTags.containedInTag", { count: tags.length })}
</ContributorColumn>
</div>
);
};
@@ -177,6 +221,7 @@ const ChangesetDetails: FC<Props> = ({ changeset, repository, fileControlFactory
</AvatarWrapper>
<div className="media-content">
<Contributors changeset={changeset} />
<ContainedInTags changeset={changeset} repository={repository} />
<div className="is-flex is-ellipsis-overflow">
<p>
<Trans i18nKey="repos:changeset.summary" components={[id, date]} />

View File

@@ -148,7 +148,7 @@ public class ChangesetRootResource {
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("id") String id) throws IOException {
public ChangesetDto get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("id") String id) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Repository repository = repositoryService.getRepository();
RepositoryPermissions.read(repository).check();
@@ -156,7 +156,7 @@ public class ChangesetRootResource {
if (changeset == null) {
throw notFound(entity(Changeset.class, id).in(repository));
}
return Response.ok(changesetToChangesetDtoMapper.map(changeset, repository)).build();
return changesetToChangesetDtoMapper.map(changeset, repository);
}
}
}

View File

@@ -35,6 +35,7 @@ import org.mapstruct.ObjectFactory;
import sonia.scm.repository.Branch;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.Contributor;
import sonia.scm.repository.Feature;
import sonia.scm.repository.Person;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
@@ -125,6 +126,9 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
if (repositoryService.isSupported(Command.TAG) && RepositoryPermissions.push(repository).isPermitted()) {
linksBuilder.single(link("tag", resourceLinks.tag().create(namespace, name)));
}
if (repositoryService.isSupported(Command.TAGS) && repositoryService.isSupported(Feature.TAGS_FOR_REVISION)) {
linksBuilder.single(link("containedInTags", resourceLinks.tag().getForChangeset(namespace, name, source.getId())));
}
if (repositoryService.isSupported(Command.BRANCHES)) {
embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(repository,
getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId()))));

View File

@@ -570,6 +570,10 @@ class ResourceLinks {
String all(String namespace, String name) {
return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("getAll").parameters().href();
}
String getForChangeset(String namespace, String name, String changeset) {
return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("getForChangeset").parameters(changeset).href();
}
}
public DiffLinks diff() {

View File

@@ -28,6 +28,7 @@ import com.google.inject.Inject;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.Repository;
import sonia.scm.repository.Tag;
@@ -53,6 +54,10 @@ public class TagCollectionToDtoMapper {
return new HalRepresentation(createLinks(repository.getNamespace(), repository.getName()), embedDtos(getTagDtoList(tags, repository)));
}
public HalRepresentation map(Collection<Tag> tags, Repository repository, String changeset) {
return new HalRepresentation(createLinks(repository.getNamespace(), repository.getName(), changeset), embedDtos(getTagDtoList(tags, repository)));
}
public List<TagDto> getTagDtoList(Collection<Tag> tags, Repository repository) {
return tags.stream().map(tag -> tagToTagDtoMapper.map(tag, repository)).collect(toList());
}
@@ -75,6 +80,13 @@ public class TagCollectionToDtoMapper {
.build();
}
private Links createLinks(String namespace, String name, String changeset) {
return
linkingTo()
.self(resourceLinks.tag().getForChangeset(namespace, name, changeset))
.build();
}
private Embedded embedDtos(List<TagDto> dtos) {
return embeddedBuilder()
.with("tags", dtos)

View File

@@ -24,6 +24,7 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
@@ -33,6 +34,7 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
@@ -49,6 +51,7 @@ import sonia.scm.repository.Tags;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.api.TagCommandBuilder;
import sonia.scm.repository.api.TagsCommandBuilder;
import sonia.scm.web.VndMediaType;
import java.io.IOException;
@@ -90,6 +93,13 @@ public class TagRootResource {
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags")
@ApiResponse(
responseCode = "404",
description = "not found, repository does not exist",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
@@ -112,7 +122,7 @@ public class TagRootResource {
@POST
@Path("")
@Produces(VndMediaType.TAG_REQUEST)
@Consumes(VndMediaType.TAG_REQUEST)
@Operation(summary = "Create tag",
description = "Creates a new tag.",
tags = "Repository",
@@ -140,11 +150,12 @@ public class TagRootResource {
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags")
@ApiResponse(
responseCode = "404",
description = "not found, no tag with the specified name available in the repository",
description = "not found, repository does not exist",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "409", description = "conflict, tag with given id already exists in repository")
@ApiResponse(
responseCode = "500",
description = "internal server error",
@@ -187,7 +198,7 @@ public class TagRootResource {
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags")
@ApiResponse(
responseCode = "404",
description = "not found, no tag with the specified name available in the repository",
description = "not found, no tag with the specified name available in the repository or the repository does not exist",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
@@ -217,9 +228,43 @@ public class TagRootResource {
}
}
@GET
@Path("contains/{changeset}")
@Produces(VndMediaType.TAG_COLLECTION)
@Operation(summary = "Get tags for specific revision", description = "Returns all tags related to a given revision", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.TAG_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags")
@ApiResponse(
responseCode = "404",
description = "not found, repository does not exist",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getForChangeset(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("changeset") String changeset) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
TagsCommandBuilder tagsCommandBuilder = repositoryService.getTagsCommand();
return tagCollectionToDtoMapper.map(tagsCommandBuilder.forRevision(changeset).getTags().getTags(), repositoryService.getRepository(), changeset);
}
}
@DELETE
@Path("{tagName}")
@Produces(VndMediaType.TAG)
@Operation(summary = "Delete tag", description = "Deletes the tag provided in the path", tags = "Repository")
@ApiResponse(
responseCode = "200",

View File

@@ -25,7 +25,6 @@
package sonia.scm.api.v2.resources;
import com.google.inject.util.Providers;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
@@ -34,68 +33,80 @@ import org.apache.shiro.util.ThreadState;
import org.assertj.core.util.Lists;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult;
import sonia.scm.repository.Feature;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Person;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.Command;
import sonia.scm.repository.api.LogCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.web.JsonMockHttpResponse;
import sonia.scm.web.RestDispatcher;
import sonia.scm.web.VndMediaType;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mock.Strictness.LENIENT;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.Silent.class)
@Slf4j
public class ChangesetRootResourceTest extends RepositoryTestBase {
@ExtendWith(MockitoExtension.class)
class ChangesetRootResourceTest extends RepositoryTestBase {
public static final String CHANGESET_PATH = "space/repo/changesets/";
public static final String CHANGESET_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + CHANGESET_PATH;
static final String CHANGESET_PATH = "space/repo/changesets/";
static final String CHANGESET_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + CHANGESET_PATH;
private RestDispatcher dispatcher = new RestDispatcher();
private final RestDispatcher dispatcher = new RestDispatcher();
private final URI baseUri = URI.create("/");
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@Mock
@Mock(strictness = LENIENT)
private RepositoryServiceFactory serviceFactory;
@Mock
@Mock(strictness = LENIENT)
private RepositoryService repositoryService;
@Mock
@Mock(strictness = LENIENT)
private LogCommandBuilder logCommandBuilder;
@Mock
private TagCollectionToDtoMapper tagCollectionToDtoMapper;
@InjectMocks
private ChangesetCollectionToDtoMapper changesetCollectionToDtoMapper;
@InjectMocks
private DefaultChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper;
private final Subject subject = mock(Subject.class);
private final ThreadState subjectThreadState = new SubjectThreadState(subject);
@Before
public void prepareEnvironment() {
@BeforeEach
void prepareEnvironment() {
changesetCollectionToDtoMapper = new ChangesetCollectionToDtoMapper(changesetToChangesetDtoMapper, resourceLinks);
changesetRootResource = new ChangesetRootResource(serviceFactory, changesetCollectionToDtoMapper, changesetToChangesetDtoMapper);
dispatcher.addSingletonResource(getRepositoryRootResource());
@@ -108,13 +119,13 @@ public class ChangesetRootResourceTest extends RepositoryTestBase {
when(subject.isPermitted(any(String.class))).thenReturn(true);
}
@After
public void cleanupContext() {
@AfterEach
void cleanupContext() {
ThreadContext.unbindSubject();
}
@Test
public void shouldGetChangeSets() throws Exception {
void shouldGetChangeSets() throws Exception {
String id = "revision_123";
Instant creationDate = Instant.now();
String authorName = "name";
@@ -142,7 +153,7 @@ public class ChangesetRootResourceTest extends RepositoryTestBase {
}
@Test
public void shouldGetSinglePageOfChangeSets() throws Exception {
void shouldGetSinglePageOfChangeSets() throws Exception {
String id = "revision_123";
Instant creationDate = Instant.now();
String authorName = "name";
@@ -169,33 +180,67 @@ public class ChangesetRootResourceTest extends RepositoryTestBase {
assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit)));
}
@Test
public void shouldGetChangeSet() throws Exception {
String id = "revision_123";
Instant creationDate = Instant.now();
String authorName = "name";
String authorEmail = "em@i.l";
String commit = "my branch commit";
@Nested
class ForExistingChangeset {
private final String id = "revision_123";
private final Instant creationDate = Instant.now();
private final String authorName = "name";
private final String authorEmail = "em@i.l";
private final String commit = "my branch commit";
private final JsonMockHttpResponse response = new JsonMockHttpResponse();
@BeforeEach
void prepareExistingChangeset() throws URISyntaxException, IOException {
when(logCommandBuilder.getChangeset(id)).thenReturn(
new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)
);
}
private void executeRequest() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest
.get(CHANGESET_URL + id)
.accept(VndMediaType.CHANGESET);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(200, response.getStatus());
assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", id)));
assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", authorName)));
assertTrue(response.getContentAsString().contains(String.format("\"mail\":\"%s\"", authorEmail)));
assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit)));
}
@Test
public void shouldReturnNotFoundForNonExistingChangeset() throws Exception {
void shouldGetChangeSet() throws URISyntaxException {
executeRequest();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsJson().get("id").asText()).isEqualTo(id);
assertThat(response.getContentAsJson().get("author").get("name").asText()).isEqualTo(authorName);
assertThat(response.getContentAsJson().get("author").get("mail").asText()).isEqualTo(authorEmail);
assertThat(response.getContentAsJson().get("description").asText()).isEqualTo(commit);
}
@Test
void shouldContainLinkForTagCreation() throws URISyntaxException {
when(subject.isPermitted("repository:push:repoId")).thenReturn(true);
when(repositoryService.isSupported(Command.TAG)).thenReturn(true);
executeRequest();
assertThat(response.getContentAsJson().get("_links").get("tag").get("href").asText())
.isEqualTo("/v2/repositories/space/repo/tags/");
}
@Test
void shouldContainLinkForTagsForRevision() throws URISyntaxException {
when(repositoryService.isSupported(Command.TAGS)).thenReturn(true);
when(repositoryService.isSupported(Feature.TAGS_FOR_REVISION)).thenReturn(true);
executeRequest();
assertThat(response.getContentAsJson().get("_links").get("containedInTags").get("href").asText())
.isEqualTo("/v2/repositories/space/repo/tags/contains/revision_123");
}
}
@Test
void shouldReturnNotFoundForNonExistingChangeset() throws Exception {
MockHttpRequest request = MockHttpRequest
.get(CHANGESET_URL + "abcd")
.accept(VndMediaType.CHANGESET);

View File

@@ -24,7 +24,6 @@
package sonia.scm.api.v2.resources;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
@@ -37,6 +36,7 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
@@ -48,6 +48,7 @@ import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.api.TagCommandBuilder;
import sonia.scm.repository.api.TagsCommandBuilder;
import sonia.scm.web.JsonMockHttpResponse;
import sonia.scm.web.RestDispatcher;
import sonia.scm.web.VndMediaType;
@@ -56,7 +57,7 @@ import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -66,7 +67,6 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Slf4j
@RunWith(MockitoJUnitRunner.Silent.class)
public class TagRootResourceTest extends RepositoryTestBase {
@@ -84,7 +84,7 @@ public class TagRootResourceTest extends RepositoryTestBase {
@Mock
private RepositoryService repositoryService;
@Mock
@Mock(answer = Answers.RETURNS_SELF)
private TagsCommandBuilder tagsCommandBuilder;
@Mock
private TagCommandBuilder tagCommandBuilder;
@@ -175,6 +175,23 @@ public class TagRootResourceTest extends RepositoryTestBase {
assertEquals(500, response.getStatus());
}
@Test
public void shouldGetTagsForRevision() throws Exception {
final JsonMockHttpResponse response = new JsonMockHttpResponse();
Tags tags = new Tags();
String tag1 = "v1.0";
String revision1 = "revision_1234";
tags.setTags(Lists.newArrayList(new Tag(tag1, revision1)));
when(tagsCommandBuilder.forRevision(revision1).getTags()).thenReturn(tags);
MockHttpRequest request = MockHttpRequest
.get(TAG_URL+ "contains/" + revision1)
.accept(VndMediaType.TAG_COLLECTION);
dispatcher.invoke(request, response);
assertThat(response.getContentAsJson().get("_embedded").get("tags").get(0).get("name").asText()).isEqualTo(tag1);
assertThat(response.getContentAsJson().get("_links").get("self").get("href").asText()).isEqualTo("/v2/repositories/space/repo/tags/contains/revision_1234");
}
@Test
public void shouldGetTags() throws Exception {
@@ -191,8 +208,8 @@ public class TagRootResourceTest extends RepositoryTestBase {
.accept(VndMediaType.TAG_COLLECTION);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(200, response.getStatus());
log.info("the content: ", response.getContentAsString());
assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", tag1)));
assertTrue(response.getContentAsString().contains(String.format("\"revision\":\"%s\"", revision1)));
assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", tag2)));
@@ -224,7 +241,6 @@ public class TagRootResourceTest extends RepositoryTestBase {
response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(200, response.getStatus());
log.info("the content: ", response.getContentAsString());
assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", tag2)));
assertTrue(response.getContentAsString().contains(String.format("\"revision\":\"%s\"", revision2)));
}