From 8d12862ff89a5c67b05a75414e12177b187b39d1 Mon Sep 17 00:00:00 2001 From: Laura Gorzitze Date: Mon, 11 Mar 2024 17:09:59 +0100 Subject: [PATCH] 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. --- gradle/changelog/tags_for_revision.yaml | 2 + .../java/sonia/scm/repository/Feature.java | 8 +- .../repository/api/TagsCommandBuilder.java | 119 +++++++--------- .../sonia/scm/repository/spi/TagsCommand.java | 11 +- .../spi/GitRepositoryServiceProvider.java | 3 +- .../scm/repository/spi/GitTagsCommand.java | 15 ++- .../repository/spi/GitTagsCommandTest.java | 58 +++++--- scm-ui/ui-api/src/tags.ts | 31 ++++- scm-ui/ui-webapp/public/locales/de/repos.json | 7 + scm-ui/ui-webapp/public/locales/en/repos.json | 7 + .../changesets/ChangesetDetails.tsx | 89 +++++++++--- .../v2/resources/ChangesetRootResource.java | 4 +- .../DefaultChangesetToChangesetDtoMapper.java | 4 + .../scm/api/v2/resources/ResourceLinks.java | 4 + .../resources/TagCollectionToDtoMapper.java | 12 ++ .../scm/api/v2/resources/TagRootResource.java | 53 +++++++- .../resources/ChangesetRootResourceTest.java | 127 ++++++++++++------ .../api/v2/resources/TagRootResourceTest.java | 28 +++- 18 files changed, 411 insertions(+), 171 deletions(-) create mode 100644 gradle/changelog/tags_for_revision.yaml diff --git a/gradle/changelog/tags_for_revision.yaml b/gradle/changelog/tags_for_revision.yaml new file mode 100644 index 0000000000..fc9413d598 --- /dev/null +++ b/gradle/changelog/tags_for_revision.yaml @@ -0,0 +1,2 @@ +- type: added + description: Display of all tags for a given changeset in the changeset detail view diff --git a/scm-core/src/main/java/sonia/scm/repository/Feature.java b/scm-core/src/main/java/sonia/scm/repository/Feature.java index e47c469529..2e7b907736 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Feature.java +++ b/scm-core/src/main/java/sonia/scm/repository/Feature.java @@ -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 } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/TagsCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/TagsCommandBuilder.java index 00961c83a7..9c7e289423 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/TagsCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/TagsCommandBuilder.java @@ -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,18 +51,20 @@ import java.util.List; * TagsCommandBuilder tagsCommand = repositoryService.getTagsCommand(); * Tags tags = tagsCommand.getTags(); * + * * @since 1.18 */ -public final class TagsCommandBuilder -{ +public final class TagsCommandBuilder { static final String CACHE_NAME = "sonia.cache.cmd.tags"; - + private static final Logger logger = LoggerFactory.getLogger(TagsCommandBuilder.class); - /** cache for changesets */ + /** + * cache for changesets + */ private final Cache cache; private final TagsCommand command; @@ -70,17 +73,18 @@ 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}. * * @param cacheManager cache manager - * @param command implementation of the {@link TagsCommand} - * @param repository repository + * @param command implementation of the {@link TagsCommand} + * @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()) - { - logger.debug("get tags for repository {}", repository); - } - + 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 if (logger.isDebugEnabled()) - { - logger.debug("get tags for repository {} from cache", - repository.getName()); + cache.put(key, tags); + } else { + logger.debug("get tags for repository {} from cache", repository); } + return tags; } - - return tags; } - /** * Disables the cache for tags. This means that every {@link Tag} * is directly retrieved from the {@link Repository}. Note: 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 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; } @@ -190,17 +175,15 @@ public final class TagsCommandBuilder return Objects.equal(repositoryId, other.repositoryId); } - + @Override - public int hashCode() - { + public int hashCode() { return Objects.hashCode(repositoryId); } @Override - public String getRepositoryId() - { + public String getRepositoryId() { return repositoryId; } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/TagsCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/TagsCommand.java index 9b29dcabe8..9bf8551800 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/TagsCommand.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/TagsCommand.java @@ -21,19 +21,24 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + 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 getTags() throws IOException; + + default List getTags(String revision) throws IOException{ + throw new FeatureNotSupportedException(Feature.TAGS_FOR_REVISION.name()); + } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index e26e6b3f1b..e79b8a2d81 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java @@ -63,7 +63,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { protected static final Set FEATURES = EnumSet.of( Feature.INCOMING_REVISION, Feature.MODIFICATIONS_BETWEEN_REVISIONS, - Feature.FORCE_PUSH + Feature.FORCE_PUSH, + Feature.TAGS_FOR_REVISION ); private final Injector injector; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java index 98df010c0e..3024dac958 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java @@ -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 getTags() throws IOException { + return getTags(null); + } + + @Override + public List getTags(String revision) throws IOException { try (Git git = new Git(open()); RevWalk revWalk = new RevWalk(git.getRepository())) { - List tagList = git.tagList().call(); + List 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)) diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java index d19d7568bb..9eb38da407 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java @@ -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 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 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 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 1606248906 +0100 + + this tag is signed + """; when(publicKey.verify(signedContent.getBytes(), signature.getBytes())).thenReturn(true); final GitContext gitContext = createContext(); diff --git a/scm-ui/ui-api/src/tags.ts b/scm-ui/ui-api/src/tags.ts index fe722dbcdd..15d53265c1 100644 --- a/scm-ui/ui-api/src/tags.ts +++ b/scm-ui/ui-api/src/tags.ts @@ -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 => { ); }; +export const useContainedInTags = (changeset: Changeset, repository: Repository): ApiResult => { + const link = objectLink(changeset, "containedInTags"); + + return useQuery(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 => { const link = requiredLink(repository, "tags"); return useQuery(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, { - name, - revision: changeset.id, - }) + .post( + link, + { + name, + revision: changeset.id, + }, + "application/vnd.scmm-tagRequest+json;v=2" + ) .then((response) => { const location = response.headers.get("Location"); if (!location) { diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index a3554a590e..dec8bfb5fb 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -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": { diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index cb99ff843e..0346648a9c 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -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": { diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx index 600bdf1705..e49ca4c044 100644 --- a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx @@ -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,9 +98,12 @@ 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 ? ( + + ) : ( + <>  + ); if (open) { return ( @@ -116,22 +120,62 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => { } return ( - <> -
setOpen(!open)}> - - {" "} - - - {signatureIcon} - - ( - - {t("changeset.contributors.count", { count: countContributors(changeset) })} - - ) - +
setOpen(!open)}> + + + + {signatureIcon} + + ({t("changeset.contributors.count", { count: countContributors(changeset) })}) + +
+ ); +}; + +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
; + } + + if (open) { + return ( +
+
+

setOpen(!open)}> + {" "} + {t("changeset.containedInTags.allTags")} +

+
+
+ {" "} + {tags.map((tag) => ( + + + {tag.name} + + + ))} +
- + ); + } + + return ( +
setOpen(!open)}> + + {" "} + {t("changeset.containedInTags.containedInTag", { count: tags.length })} + +
); }; @@ -177,6 +221,7 @@ const ChangesetDetails: FC = ({ changeset, repository, fileControlFactory
+

diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetRootResource.java index 5dbe3076c6..57cbcddadf 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetRootResource.java @@ -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); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java index ddebdc84c2..2fb25bb12e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java @@ -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())))); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index dd61371190..c1927117d4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -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() { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionToDtoMapper.java index eed2446533..d082497add 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionToDtoMapper.java @@ -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 tags, Repository repository, String changeset) { + return new HalRepresentation(createLinks(repository.getNamespace(), repository.getName(), changeset), embedDtos(getTagDtoList(tags, repository))); + } + public List getTagDtoList(Collection 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 dtos) { return embeddedBuilder() .with("tags", dtos) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java index 9b59fb821c..37e3ad48c3 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java @@ -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", diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetRootResourceTest.java index 55ad6871c7..34c70d2549 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ChangesetRootResourceTest.java @@ -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 { - when(logCommandBuilder.getChangeset(id)).thenReturn( - new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit) - ); + 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"; - MockHttpRequest request = MockHttpRequest - .get(CHANGESET_URL + id) - .accept(VndMediaType.CHANGESET); - MockHttpResponse response = new MockHttpResponse(); - dispatcher.invoke(request, response); + private final JsonMockHttpResponse response = new JsonMockHttpResponse(); - 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))); + @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); + dispatcher.invoke(request, response); + } + + @Test + 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 - public void shouldReturnNotFoundForNonExistingChangeset() throws Exception { + void shouldReturnNotFoundForNonExistingChangeset() throws Exception { MockHttpRequest request = MockHttpRequest .get(CHANGESET_URL + "abcd") .accept(VndMediaType.CHANGESET); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java index 62183c5b09..a6f9bb5c5c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/TagRootResourceTest.java @@ -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))); }