diff --git a/CHANGELOG.md b/CHANGELOG.md index db5da7a8ce..223f58ed74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Option to configure jvm parameter of docker container with env JAVA_OPTS or with arguments ([#1175](https://github.com/scm-manager/scm-manager/pull/1175)) - Added links in diff views to expand the gaps between "hunks" ([#1178](https://github.com/scm-manager/scm-manager/pull/1178)) +- Show commit contributors in table on changeset details view ([#1169](https://github.com/scm-manager/scm-manager/pull/1169)) ### Fixed - Avoid caching of detected browser language ([#1176](https://github.com/scm-manager/scm-manager/pull/1176)) diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java index 95d02eba28..bd94f9e33f 100644 --- a/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import de.otto.edison.hal.Embedded; @@ -32,6 +32,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import java.time.Instant; +import java.util.List; @Getter @Setter @@ -58,6 +59,8 @@ public class ChangesetDto extends HalRepresentation { */ private String description; + private List contributors; + public ChangesetDto(Links links, Embedded embedded) { super(links, embedded); } diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/ContributorDto.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/ContributorDto.java new file mode 100644 index 0000000000..3d22db879a --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/ContributorDto.java @@ -0,0 +1,37 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ContributorDto { + private String type; + private PersonDto person; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/Changeset.java b/scm-core/src/main/java/sonia/scm/repository/Changeset.java index 6e770139df..e2c980db37 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Changeset.java +++ b/scm-core/src/main/java/sonia/scm/repository/Changeset.java @@ -31,6 +31,7 @@ import sonia.scm.util.Util; import sonia.scm.util.ValidationUtil; import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.List; @@ -79,6 +80,11 @@ public class Changeset extends BasicPropertiesAware implements ModelObject { */ private List tags; + /** + * Trailers for this changeset like reviewers or co-authors + */ + private Collection contributors; + public Changeset() {} public Changeset(String id, Long date, Person author) @@ -225,6 +231,15 @@ public class Changeset extends BasicPropertiesAware implements ModelObject { return tags; } + /** + * Returns collection of contributors for this changeset. + * @return collection of contributors + * @since 2.1.0 + */ + public Collection getContributors() { + return contributors; + } + /** * Returns true if the changeset is valid. * @@ -300,4 +315,37 @@ public class Changeset extends BasicPropertiesAware implements ModelObject { this.tags = tags; } + /** + * Sets the collection of contributors. + * @param contributors collection of contributors + * @since 2.1.0 + */ + public void setContributors(Collection contributors) { + this.contributors = new ArrayList<>(contributors); + } + + /** + * Adds a contributor to the list of contributors. + * @param contributor contributor to add + * @since 2.1.0 + */ + public void addContributor(Contributor contributor) { + if (contributors == null) { + contributors = new ArrayList<>(); + } + contributors.add(contributor); + } + + /** + * Adds all contributors from the given collection to the list of contributors. + * @param contributors collection of contributor + * @since 2.1.0 + */ + public void addContributors(Collection contributors) { + if (this.contributors == null) { + this.contributors = new ArrayList<>(contributors); + } else { + this.contributors.addAll(contributors); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/Contributor.java b/scm-core/src/main/java/sonia/scm/repository/Contributor.java new file mode 100644 index 0000000000..9616695638 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/Contributor.java @@ -0,0 +1,35 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import lombok.Value; + +import java.io.Serializable; + +@Value +public class Contributor implements Serializable { + private String type; + private Person person; +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java index 6e19e10d6f..0f08f54aaa 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- @@ -171,8 +171,8 @@ public class GitChangesetConverter implements Closeable long date = GitUtil.getCommitTime(commit); PersonIdent authorIndent = commit.getAuthorIdent(); - Person author = new Person(authorIndent.getName(), - authorIndent.getEmailAddress()); + PersonIdent committerIdent = commit.getCommitterIdent(); + Person author = createPersonFor(authorIndent); String message = commit.getFullMessage(); if (message != null) @@ -181,6 +181,9 @@ public class GitChangesetConverter implements Closeable } Changeset changeset = new Changeset(id, date, author, message); + if (!committerIdent.equals(authorIndent)) { + changeset.addContributor(new Contributor("Committed-by", createPersonFor(committerIdent))); + } if (parentList != null) { @@ -201,6 +204,9 @@ public class GitChangesetConverter implements Closeable return changeset; } + public Person createPersonFor(PersonIdent personIndent) { + return new Person(personIndent.getName(), personIndent.getEmailAddress()); + } //~--- fields --------------------------------------------------------------- diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java index 61c2009405..2a8c40db58 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java @@ -25,7 +25,6 @@ package sonia.scm.repository.spi; import com.google.common.io.Files; -import org.assertj.core.api.Assertions; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; @@ -34,6 +33,7 @@ import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.Modifications; +import sonia.scm.repository.Person; import java.io.File; import java.io.IOException; @@ -271,6 +271,20 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase assertEquals("master", changesets.getBranchName()); } + @Test + public void shouldAppendCommitterAsContributor() { + LogCommandRequest request = new LogCommandRequest(); + request.setStartChangeset("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); + request.setEndChangeset("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); + + ChangesetPagingResult changesets = createCommand().getChangesets(request); + Changeset changeset = changesets.getChangesets().get(0); + + assertThat(changeset.getContributors()).hasSize(1); + assertThat(changeset.getContributors().iterator().next().getPerson()) + .isEqualTo(new Person("Sebastian Sdorra", "s.sdorra@ostfalia.de")); + } + private void setRepositoryHeadReference(String s) throws IOException { Files.write(s, repositoryHeadReferenceFile(), defaultCharset()); } diff --git a/scm-ui/ui-components/src/CommaSeparatedList.tsx b/scm-ui/ui-components/src/CommaSeparatedList.tsx new file mode 100644 index 0000000000..a597ed0dc0 --- /dev/null +++ b/scm-ui/ui-components/src/CommaSeparatedList.tsx @@ -0,0 +1,51 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; + +const CommaSeparatedList: FC = ({ children }) => { + const [t] = useTranslation("commons"); + const childArray = React.Children.toArray(children); + return ( + <> + {childArray.map((p, i) => { + if (i === 0) { + return {p}; + } else if (i + 1 === childArray.length) { + return ( + + {" "} + {t("commaSeparatedList.lastDivider")} {p}{" "} + + ); + } else { + return , {p}; + } + })} + + ); +}; + +export default CommaSeparatedList; diff --git a/scm-ui/ui-components/src/Image.tsx b/scm-ui/ui-components/src/Image.tsx index 570252fc89..56f32cde7d 100644 --- a/scm-ui/ui-components/src/Image.tsx +++ b/scm-ui/ui-components/src/Image.tsx @@ -27,6 +27,7 @@ import { withContextPath } from "./urls"; type Props = { src: string; alt: string; + title?: string; className?: string; }; @@ -40,8 +41,8 @@ class Image extends React.Component { }; render() { - const { alt, className } = this.props; - return {alt}; + const { alt, title, className } = this.props; + return {alt}; } } diff --git a/scm-ui/ui-components/src/__resources__/changesets.tsx b/scm-ui/ui-components/src/__resources__/changesets.tsx new file mode 100644 index 0000000000..e53f6a9545 --- /dev/null +++ b/scm-ui/ui-components/src/__resources__/changesets.tsx @@ -0,0 +1,250 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Changeset, PagedCollection } from "@scm-manager/ui-types"; + +const one: Changeset = { + id: "a88567ef1e9528a700555cad8c4576b72fc7c6dd", + author: { mail: "scm-admin@scm-manager.org", name: "SCM Administrator" }, + date: new Date("2020-06-09T06:34:47Z"), + description: + "The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive. The craft was stolen by then-President Zaphod Beeblebrox at the official launch of the ship, as he was supposed to be officiating the launch. Later, during the use of the Infinite Improbability Drive, the ship picked up Arthur Dent and Ford Prefect, who were floating unprotected in deep space in the same star sector, having just escaped the destruction of the same planet.\n\n", + contributors: [ + { + type: "Committed-by", + person: { mail: "zaphod.beeblebrox@hitchhiker.cm", name: "Zaphod Beeblebrox" } + }, + { type: "Co-authored-by", person: { mail: "ford.prefect@hitchhiker.com", name: "Ford Prefect" } } + ], + _links: { + self: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/a88567ef1e9528a700555cad8c4576b72fc7c6dd" + }, + diff: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/a88567ef1e9528a700555cad8c4576b72fc7c6dd" + }, + sources: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/sources/a88567ef1e9528a700555cad8c4576b72fc7c6dd" + }, + modifications: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/modifications/a88567ef1e9528a700555cad8c4576b72fc7c6dd" + }, + diffParsed: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/a88567ef1e9528a700555cad8c4576b72fc7c6dd/parsed" + } + }, + _embedded: { + tags: [], + branches: [], + parents: [ + { + id: "d21cc6c359270aef2196796f4d96af65f51866dc", + _links: { + self: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/d21cc6c359270aef2196796f4d96af65f51866dc" + }, + diff: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/d21cc6c359270aef2196796f4d96af65f51866dc" + } + } + } + ] + } +}; + +const two: Changeset = { + id: "d21cc6c359270aef2196796f4d96af65f51866dc", + author: { mail: "scm-admin@scm-manager.org", name: "SCM Administrator" }, + date: new Date("2020-06-09T05:39:50Z"), + description: 'Change heading to "Heart Of Gold"\n\n', + contributors: [ + { + type: "Committed-by", + person: { mail: "zaphod.beeblebrox@hitchhiker.cm", name: "Zaphod Beeblebrox" } + } + ], + _links: { + self: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/d21cc6c359270aef2196796f4d96af65f51866dc" + }, + diff: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/d21cc6c359270aef2196796f4d96af65f51866dc" + }, + sources: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/sources/d21cc6c359270aef2196796f4d96af65f51866dc" + }, + modifications: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/modifications/d21cc6c359270aef2196796f4d96af65f51866dc" + }, + diffParsed: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/d21cc6c359270aef2196796f4d96af65f51866dc/parsed" + } + }, + _embedded: { + tags: [], + branches: [], + parents: [ + { + id: "e163c8f632db571c9aa51a8eb440e37cf550b825", + _links: { + self: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/e163c8f632db571c9aa51a8eb440e37cf550b825" + }, + diff: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/e163c8f632db571c9aa51a8eb440e37cf550b825" + } + } + } + ] + } +}; + +const three: Changeset = { + id: "e163c8f632db571c9aa51a8eb440e37cf550b825", + author: { mail: "scm-admin@scm-manager.org", name: "SCM Administrator" }, + date: new Date("2020-06-09T05:25:16Z"), + description: "initialize repository", + contributors: [], + _links: { + self: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/e163c8f632db571c9aa51a8eb440e37cf550b825" + }, + diff: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/e163c8f632db571c9aa51a8eb440e37cf550b825" + }, + sources: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/sources/e163c8f632db571c9aa51a8eb440e37cf550b825" + }, + modifications: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/modifications/e163c8f632db571c9aa51a8eb440e37cf550b825" + }, + diffParsed: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/e163c8f632db571c9aa51a8eb440e37cf550b825/parsed" + } + }, + _embedded: { tags: [], branches: [], parents: [] } +}; + +const four: Changeset = { + id: "b6c6f8fbd0d490936fae7d26ffdd4695cc2a0930", + author: { mail: "scm-admin@scm-manager.org", name: "SCM Administrator" }, + date: new Date("2020-06-09T09:23:49Z"), + description: "Added design docs\n\n", + contributors: [ + { type: "Co-authored-by", person: { mail: "ford.prefect@hitchhiker.com", name: "Ford Prefect" } }, + { type: "Co-authored-by", person: { mail: "zaphod.beeblebrox@hitchhiker.cm", name: "Zaphod Beeblebrox" } }, + { type: "Co-authored-by", person: { mail: "trillian@hitchhiker.cm", name: "Tricia Marie McMillan" } } + ], + _links: { + self: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/b6c6f8fbd0d490936fae7d26ffdd4695cc2a0930" + }, + diff: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/b6c6f8fbd0d490936fae7d26ffdd4695cc2a0930" + }, + sources: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/sources/b6c6f8fbd0d490936fae7d26ffdd4695cc2a0930" + }, + modifications: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/modifications/b6c6f8fbd0d490936fae7d26ffdd4695cc2a0930" + }, + diffParsed: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/b6c6f8fbd0d490936fae7d26ffdd4695cc2a0930/parsed" + } + }, + _embedded: { + tags: [], + branches: [], + parents: [ + { + id: "a88567ef1e9528a700555cad8c4576b72fc7c6dd", + _links: { + self: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/changesets/a88567ef1e9528a700555cad8c4576b72fc7c6dd" + }, + diff: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/diff/a88567ef1e9528a700555cad8c4576b72fc7c6dd" + } + } + } + ] + } +}; + +const changesets: PagedCollection = { + page: 0, + pageTotal: 1, + _links: { + self: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/branches/master/changesets/?page=0&pageSize=10" + }, + first: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/branches/master/changesets/?page=0&pageSize=10" + }, + last: { + href: + "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/branches/master/changesets/?page=0&pageSize=10" + } + }, + _embedded: { + changesets: [one, two, three, four], + branch: { + name: "master", + _links: { + self: { href: "http://localhost:8081/scm/api/v2/repositories/hitchhiker/heart-of-gold/branches/master" } + } + } + } +}; + +export { one, two, three, four }; +export default changesets; diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index f0486525f0..7406de61c8 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -831,6 +831,937 @@ exports[`Storyshots CardColumnSmall Minimal 1`] = ` `; +exports[`Storyshots Changesets Co-Authors with avatar 1`] = ` +
+
+
+
+
+
+
+
+
+ SCM Administrator +
+
+
+

+ Added design docs +

+

+

+

+ changeset.contributors.authoredBy + + + SCM Administrator + + + commaSeparatedList.lastDivider + + changeset.contributors.coAuthoredBy + + + + Ford Prefect + + + Zaphod Beeblebrox + + + Tricia Marie McMillan + + + +

+
+
+
+
+
+
+
+
+

+ +

+

+ +

+
+
+
+
+
+`; + +exports[`Storyshots Changesets Commiter and Co-Authors with avatar 1`] = ` +
+
+
+
+
+
+
+
+
+ SCM Administrator +
+
+
+

+ The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive. The craft was stolen by then-President Zaphod Beeblebrox at the official launch of the ship, as he was supposed to be officiating the launch. Later, during the use of the Infinite Improbability Drive, the ship picked up Arthur Dent and Ford Prefect, who were floating unprotected in deep space in the same star sector, having just escaped the destruction of the same planet. +

+

+

+

+ changeset.contributors.authoredBy + + + SCM Administrator + + , + changeset.contributors.committedBy + + + Zaphod Beeblebrox + + + commaSeparatedList.lastDivider + + changeset.contributors.coAuthoredBy + + + Ford Prefect + + +

+
+
+
+
+
+
+
+
+

+ +

+

+ +

+
+
+
+
+
+`; + +exports[`Storyshots Changesets Default 1`] = ` +
+
+
+
+
+
+
+
+

+ initialize repository +

+

+

+

+ changeset.contributors.authoredBy + + + SCM Administrator + +

+
+
+
+
+
+
+
+
+

+ +

+

+ +

+
+
+
+
+
+`; + +exports[`Storyshots Changesets With Committer 1`] = ` +
+
+
+
+
+
+
+
+

+ Change heading to "Heart Of Gold" +

+

+

+

+ changeset.contributors.authoredBy + + + SCM Administrator + + + commaSeparatedList.lastDivider + + changeset.contributors.committedBy + + + Zaphod Beeblebrox + + +

+
+
+
+
+
+
+
+
+

+ +

+

+ +

+
+
+
+
+
+`; + +exports[`Storyshots Changesets With Committer and Co-Author 1`] = ` +
+
+
+
+
+
+
+
+

+ The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive. The craft was stolen by then-President Zaphod Beeblebrox at the official launch of the ship, as he was supposed to be officiating the launch. Later, during the use of the Infinite Improbability Drive, the ship picked up Arthur Dent and Ford Prefect, who were floating unprotected in deep space in the same star sector, having just escaped the destruction of the same planet. +

+

+

+

+ changeset.contributors.authoredBy + + + SCM Administrator + + , + changeset.contributors.committedBy + + + Zaphod Beeblebrox + + + commaSeparatedList.lastDivider + + changeset.contributors.coAuthoredBy + + + Ford Prefect + + +

+
+
+
+
+
+
+
+
+

+ +

+

+ +

+
+
+
+
+
+`; + +exports[`Storyshots Changesets With avatar 1`] = ` +
+
+
+
+
+
+
+
+
+ SCM Administrator +
+
+
+

+ initialize repository +

+

+

+

+ changeset.contributors.authoredBy + + + SCM Administrator + +

+
+
+
+
+
+
+
+
+

+ +

+

+ +

+
+
+
+
+
+`; + +exports[`Storyshots Changesets With multiple Co-Authors 1`] = ` +
+
+
+
+
+
+
+
+

+ Added design docs +

+

+

+

+ changeset.contributors.authoredBy + + + SCM Administrator + + + commaSeparatedList.lastDivider + + changeset.contributors.coAuthoredBy + + + changeset.contributors.more + + +

+
+
+
+
+
+
+
+
+

+ +

+

+ +

+
+
+
+
+
+`; + exports[`Storyshots DateFromNow Default 1`] = `

diff --git a/scm-ui/ui-components/src/avatar/Avatar.ts b/scm-ui/ui-components/src/avatar/Avatar.ts index 362a00210d..9690c37ad6 100644 --- a/scm-ui/ui-components/src/avatar/Avatar.ts +++ b/scm-ui/ui-components/src/avatar/Avatar.ts @@ -22,9 +22,10 @@ * SOFTWARE. */ -export type Person = { - name: string; - mail?: string; -}; +import { Person } from "@scm-manager/ui-types"; + +// re export type to avoid breaking changes, +// after the type was moved to ui-types +export { Person }; export const EXTENSION_POINT = "avatar.factory"; diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index c31066edc1..68ec1ec2ef 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -76,6 +76,7 @@ export { default as OverviewPageActions } from "./OverviewPageActions"; export { default as CardColumnGroup } from "./CardColumnGroup"; export { default as CardColumn } from "./CardColumn"; export { default as CardColumnSmall } from "./CardColumnSmall"; +export { default as CommaSeparatedList } from "./CommaSeparatedList"; export { default as comparators } from "./comparators"; diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.tsx index c70ce4ca56..017d468888 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetAuthor.tsx @@ -21,53 +21,157 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { Changeset } from "@scm-manager/ui-types"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; -import { WithTranslation, withTranslation } from "react-i18next"; +import React, { FC } from "react"; +import { Changeset, Person } from "@scm-manager/ui-types"; +import { useTranslation } from "react-i18next"; +import { useBinder } from "@scm-manager/ui-extensions"; +import { EXTENSION_POINT } from "../../avatar/Avatar"; +import styled from "styled-components"; +import CommaSeparatedList from "../../CommaSeparatedList"; +import ContributorAvatar from "./ContributorAvatar"; -type Props = WithTranslation & { +type Props = { changeset: Changeset; }; -class ChangesetAuthor extends React.Component { - render() { - const { changeset } = this.props; - if (!changeset.author) { - return null; - } +type PersonProps = { + person: Person; + displayTextOnly?: boolean; +}; - const { name, mail } = changeset.author; - if (mail) { - return this.withExtensionPoint(this.renderWithMail(name, mail)); - } - return this.withExtensionPoint(<>{name}); +const useAvatar = (person: Person): string | undefined => { + const binder = useBinder(); + const factory: (person: Person) => string | undefined = binder.getExtension(EXTENSION_POINT); + if (factory) { + return factory(person); } +}; - renderWithMail(name: string, mail: string) { - const { t } = this.props; +const AvatarList = styled.span` + & > :not(:last-child) { + margin-right: 0.25em; + } +`; + +type PersonAvatarProps = { + person: Person; + avatar: string; +}; + +const ContributorWithAvatar: FC = ({ person, avatar }) => { + const [t] = useTranslation("repos"); + if (person.mail) { return ( - - {name} + + ); } + return ; +}; - withExtensionPoint(child: any) { - const { t } = this.props; +const SingleContributor: FC = ({ person, displayTextOnly }) => { + const [t] = useTranslation("repos"); + const avatar = useAvatar(person); + if (!displayTextOnly && avatar) { + return ; + } + if (person.mail) { + return ( + + {person.name} + + ); + } + return <>{person.name}; +}; + +type PersonsProps = { + persons: Person[]; + label: string; + displayTextOnly?: boolean; +}; + +const Contributors: FC = ({ persons, label, displayTextOnly }) => { + const binder = useBinder(); + + const [t] = useTranslation("repos"); + if (persons.length === 1) { return ( <> - {t("changeset.author.prefix")} {child} - + {t(label)} ); } -} -export default withTranslation("repos")(ChangesetAuthor); + const avatarFactory = binder.getExtension(EXTENSION_POINT); + if (avatarFactory) { + return ( + <> + {t(label)}{" "} + + {persons.map(p => ( + + ))} + + + ); + } else { + return ( + <> + {t(label)}{" "} + "- " + person.name).join("\n")}> + {t("changeset.contributors.more", { count: persons.length })} + + + ); + } +}; + +const emptyListOfContributors: Person[] = []; + +const ChangesetAuthor: FC = ({ changeset }) => { + const binder = useBinder(); + + const getCoAuthors = () => { + return filterContributorsByType("Co-authored-by"); + }; + + const getCommitters = () => { + return filterContributorsByType("Committed-by"); + }; + + const filterContributorsByType = (type: string) => { + if (changeset.contributors) { + return changeset.contributors.filter(p => p.type === type).map(contributor => contributor.person); + } + return emptyListOfContributors; + }; + + const authorLine = []; + if (changeset.author) { + authorLine.push( + + ); + } + + const commiters = getCommitters(); + if (commiters.length > 0) { + authorLine.push(); + } + + const coAuthors = getCoAuthors(); + if (coAuthors.length > 0) { + authorLine.push(); + } + + // extensions + const extensions = binder.getExtensions("changesets.author.suffix", { changeset }); + if (extensions) { + coAuthors.push(...extensions); + } + + return {authorLine}; +}; + +export default ChangesetAuthor; diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx index b6163ab3f9..0956f7f131 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx @@ -123,7 +123,7 @@ class ChangesetRow extends React.Component {

- + diff --git a/scm-ui/ui-components/src/repos/changesets/Changesets.stories.tsx b/scm-ui/ui-components/src/repos/changesets/Changesets.stories.tsx new file mode 100644 index 0000000000..7408e24dc1 --- /dev/null +++ b/scm-ui/ui-components/src/repos/changesets/Changesets.stories.tsx @@ -0,0 +1,79 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { storiesOf } from "@storybook/react"; +import * as React from "react"; +import styled from "styled-components"; +import { MemoryRouter } from "react-router-dom"; +import repository from "../../__resources__/repository"; +import ChangesetRow from "./ChangesetRow"; +import {one, two, three, four} from "../../__resources__/changesets"; +import {Binder, BinderContext} from "@scm-manager/ui-extensions"; +// @ts-ignore +import hitchhiker from "../../__resources__/hitchhiker.png"; +import {Person} from "../../avatar/Avatar"; +import {Changeset} from "@scm-manager/ui-types/src"; + +const Wrapper = styled.div` + margin: 2rem; +`; + +const robohash = (person: Person) => { + return `https://robohash.org/${person.mail}`; +} + +const withAvatarFactory = (factory: (person: Person) => string, changeset: Changeset) => { + const binder = new Binder("changeset stories"); + binder.bind("avatar.factory", factory); + return ( + + + + ); +}; + +storiesOf("Changesets", module) + .addDecorator(story => {story()}) + .addDecorator(storyFn => {storyFn()}) + .add("Default", () => ( + + )) + .add("With Committer", () => ( + + )) + .add("With Committer and Co-Author", () => ( + + )) + .add("With multiple Co-Authors", () => ( + + )) + .add("With avatar", () => { + return withAvatarFactory(person => hitchhiker, three); + }) + .add("Commiter and Co-Authors with avatar", () => { + return withAvatarFactory(robohash, one); + }) + .add("Co-Authors with avatar", () => { + return withAvatarFactory(robohash, four); + }); diff --git a/scm-ui/ui-components/src/repos/changesets/ContributorAvatar.tsx b/scm-ui/ui-components/src/repos/changesets/ContributorAvatar.tsx new file mode 100644 index 0000000000..f9ec7695e0 --- /dev/null +++ b/scm-ui/ui-components/src/repos/changesets/ContributorAvatar.tsx @@ -0,0 +1,36 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import styled from "styled-components"; +import Image from "../../Image"; + +const ContributorAvatar = styled(Image)` + width: 1em; + height: 1em; + vertical-align: middle; + border-radius: 0.25em; + margin-bottom: 0.2em; +`; + +export default ContributorAvatar; diff --git a/scm-ui/ui-components/src/repos/changesets/index.ts b/scm-ui/ui-components/src/repos/changesets/index.ts index 271dbde2f8..68e483cde8 100644 --- a/scm-ui/ui-components/src/repos/changesets/index.ts +++ b/scm-ui/ui-components/src/repos/changesets/index.ts @@ -34,3 +34,4 @@ export { default as ChangesetRow } from "./ChangesetRow"; export { default as ChangesetTag } from "./ChangesetTag"; export { default as ChangesetTags } from "./ChangesetTags"; export { default as ChangesetTagsCollapsed } from "./ChangesetTagsCollapsed"; +export { default as ContributorAvatar } from "./ContributorAvatar"; diff --git a/scm-ui/ui-types/src/Changesets.ts b/scm-ui/ui-types/src/Changesets.ts index 7eeb9eb27e..74adfd8a78 100644 --- a/scm-ui/ui-types/src/Changesets.ts +++ b/scm-ui/ui-types/src/Changesets.ts @@ -26,14 +26,17 @@ import { Collection, Links } from "./hal"; import { Tag } from "./Tags"; import { Branch } from "./Branches"; +export type Person = { + name: string; + mail?: string; +}; + export type Changeset = Collection & { id: string; date: Date; - author: { - name: string; - mail?: string; - }; + author: Person; description: string; + contributors?: Contributor[]; _links: Links; _embedded: { tags?: Tag[]; @@ -42,6 +45,11 @@ export type Changeset = Collection & { }; }; +export type Contributor = { + person: Person; + type: string; +}; + export type ParentChangeset = { id: string; _links: Links; diff --git a/scm-ui/ui-types/src/index.ts b/scm-ui/ui-types/src/index.ts index e98b81f4f2..4b7fdc694d 100644 --- a/scm-ui/ui-types/src/index.ts +++ b/scm-ui/ui-types/src/index.ts @@ -29,16 +29,12 @@ export { Me } from "./Me"; export { DisplayedUser, User } from "./User"; export { Group, Member } from "./Group"; -export { - Repository, - RepositoryCollection, - RepositoryGroup -} from "./Repositories"; +export { Repository, RepositoryCollection, RepositoryGroup } from "./Repositories"; export { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes"; export { Branch, BranchRequest } from "./Branches"; -export { Changeset } from "./Changesets"; +export { Changeset, Person, Contributor, ParentChangeset } from "./Changesets"; export { Tag } from "./Tags"; @@ -46,22 +42,13 @@ export { Config } from "./Config"; export { IndexResources } from "./IndexResources"; -export { - Permission, - PermissionCreateEntry, - PermissionCollection -} from "./RepositoryPermissions"; +export { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; export { SubRepository, File } from "./Sources"; export { SelectValue, AutocompleteObject } from "./Autocomplete"; -export { - Plugin, - PluginCollection, - PluginGroup, - PendingPlugins -} from "./Plugin"; +export { Plugin, PluginCollection, PluginGroup, PendingPlugins } from "./Plugin"; export { RepositoryRole } from "./RepositoryRole"; diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index d88b77b850..158d5e6420 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -104,5 +104,8 @@ "community": "Community", "enterprise": "Enterprise" } + }, + "commaSeparatedList": { + "lastDivider": "und" } } diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index a25020f0cb..a4be334204 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -87,9 +87,15 @@ "shortSummary": "Committet <0/> <1/>", "tags": "Tags", "diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt", - "author": { - "prefix": "Verfasst von", - "mailto": "Mail senden an" + "contributors": { + "mailto": "Mail senden an", + "list": "Liste der Mitwirkenden", + "authoredBy": "Verfasst von", + "committedBy": "Committed von", + "coAuthoredBy": "Co-Autoren", + "more": "{{count}} mehr", + "count": "{{count}} Mitwirkender", + "count_plural": "{{count}} Mitwirkende" }, "buttons": { "details": "Details", diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index 3d5af75f78..444de0bea5 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -105,5 +105,8 @@ "community": "Community", "enterprise": "Enterprise" } + }, + "commaSeparatedList": { + "lastDivider": "and" } } diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 3ae77c8781..e7884bff57 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -87,13 +87,19 @@ "shortSummary": "Committed <0/> <1/>", "tags": "Tags", "diffNotSupported": "Diff of changesets is not supported by the type of repository", - "author": { - "prefix": "Authored by", - "mailto": "Send mail to" - }, "buttons": { "details": "Details", "sources": "Sources" + }, + "contributors": { + "mailto": "Send mail to", + "list": "List of contributors", + "authoredBy": "Authored by", + "committedBy": "committed by", + "coAuthoredBy": "co authored by", + "more": "{{count}} more", + "count": "{{count}} Contributor", + "count_plural": "{{count}} Contributors" } }, "repositoryForm": { diff --git a/scm-ui/ui-webapp/public/locales/es/commons.json b/scm-ui/ui-webapp/public/locales/es/commons.json index 47638c1d6e..33c8b70dd5 100644 --- a/scm-ui/ui-webapp/public/locales/es/commons.json +++ b/scm-ui/ui-webapp/public/locales/es/commons.json @@ -89,5 +89,8 @@ "passwordConfirmFailed": "Las contraseñas deben ser identicas", "submit": "Guardar", "changedSuccessfully": "Contraseña cambiada correctamente" + }, + "commaSeparatedList": { + "lastDivider": "y" } } diff --git a/scm-ui/ui-webapp/public/locales/es/repos.json b/scm-ui/ui-webapp/public/locales/es/repos.json index 6758a4841c..ddada3f8c3 100644 --- a/scm-ui/ui-webapp/public/locales/es/repos.json +++ b/scm-ui/ui-webapp/public/locales/es/repos.json @@ -79,7 +79,8 @@ "errorSubtitle": "No se han podido recuperar los changesets", "noChangesets": "No se han encontrado changesets para esta rama branch. Los commits podrían haber sido eliminados.", "branchSelectorLabel": "Ramas", - "collapseDiffs": "Colapso" + "collapseDiffs": "Colapso", + "contributors": "Lista de contribuyentes" }, "changeset": { "description": "Descripción", 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 cbfe3ae067..fbd192111a 100644 --- a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx @@ -21,8 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { Trans, WithTranslation, withTranslation } from "react-i18next"; +import React, { FC, useState } from "react"; +import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; @@ -37,8 +37,10 @@ import { changesets, ChangesetTag, DateFromNow, - Level + Level, + Icon } from "@scm-manager/ui-components"; +import ContributorTable from "./ContributorTable"; type Props = WithTranslation & { changeset: Changeset; @@ -63,6 +65,74 @@ const BottomMarginLevel = styled(Level)` margin-bottom: 1rem !important; `; +const countContributors = (changeset: Changeset) => { + if (changeset.contributors) { + return changeset.contributors.length + 1; + } + return 1; +}; + +const ContributorLine = styled.div` + display: flex; + cursor: pointer; +`; + +const ContributorColumn = styled.p` + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +`; + +const CountColumn = styled.p` + text-align: right; + white-space: nowrap; +`; + +const ContributorDetails = styled.div` + display: flex; + flex-direction: column; + margin-bottom: 1rem; +`; + +const ContributorToggleLine = styled.p` + cursor: pointer; + /** maring-bottom is inherit from content p **/ + margin-bottom: 0.5rem !important; +`; + +const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => { + const [t] = useTranslation("repos"); + const [open, setOpen] = useState(false); + if (open) { + return ( + + setOpen(!open)}> + {t("changeset.contributors.list")} + + + + ); + } + return ( + <> + setOpen(!open)}> + + + + + ( + + {t("changeset.contributors.count", { count: countContributors(changeset) })} + + ) + + + + ); +}; + class ChangesetDetails extends React.Component { constructor(props: Props) { super(props); @@ -100,17 +170,14 @@ class ChangesetDetails extends React.Component { -
-

- -

+
+

{this.renderTags()}
-

{description.message.split("\n").map((item, key) => { return ( diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ContributorTable.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ContributorTable.tsx new file mode 100644 index 0000000000..2aee44c3be --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/changesets/ContributorTable.tsx @@ -0,0 +1,114 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React, { FC } from "react"; +import { Changeset, Person } from "@scm-manager/ui-types"; +import styled from "styled-components"; +import { useTranslation } from "react-i18next"; +import { useBinder } from "@scm-manager/ui-extensions"; +import { ContributorAvatar, CommaSeparatedList } from "@scm-manager/ui-components"; + +type Props = { + changeset: Changeset; +}; + +const SizedTd = styled.td` + width: 10rem; +`; + +const Contributor: FC<{ person: Person }> = ({ person }) => { + const [t] = useTranslation("repos"); + const binder = useBinder(); + const avatarFactory = binder.getExtension("avatar.factory"); + let prefix = null; + if (avatarFactory) { + const avatar = avatarFactory(person); + if (avatar) { + prefix = ( + <> + {" "} + + ); + } + } + if (person.mail) { + return ( + + {prefix} + {person.name} + + ); + } + return <>{person.name}; +}; + +const ContributorTable: FC = ({ changeset }) => { + const [t] = useTranslation("plugins"); + + const collectAvailableContributorTypes = () => { + if (!changeset.contributors) { + return []; + } + // @ts-ignore + return [...new Set(changeset.contributors.map(contributor => contributor.type))]; + }; + + const getPersonsByContributorType = (type: string) => { + return changeset.contributors?.filter(contributor => contributor.type === type).map(t => t.person); + }; + + const getContributorsByType = () => { + const availableContributorTypes: string[] = collectAvailableContributorTypes(); + + const personsByContributorType = []; + for (const type of availableContributorTypes) { + personsByContributorType.push({ type, persons: getPersonsByContributorType(type) }); + } + return personsByContributorType; + }; + + return ( + + + {t("changeset.contributor.type.author")}: + + + {getContributorsByType().map(contributor => ( + + {t("changeset.contributor.type." + contributor.type)}: + + + ))} +
+ +
+ + {contributor.persons!.map(person => ( + + ))} + +
+ ); +}; + +export default ContributorTable; 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 a9ba0758a6..2314cd0aa3 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 @@ -21,19 +21,20 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; import org.mapstruct.Context; import org.mapstruct.Mapper; -import org.mapstruct.Mapping; import org.mapstruct.ObjectFactory; import sonia.scm.repository.Branch; import sonia.scm.repository.Changeset; +import sonia.scm.repository.Person; import sonia.scm.repository.Repository; import sonia.scm.repository.Tag; +import sonia.scm.repository.Contributor; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -49,7 +50,7 @@ import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; @Mapper -public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMapper implements InstantAttributeMapper, ChangesetToChangesetDtoMapper{ +public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMapper implements InstantAttributeMapper, ChangesetToChangesetDtoMapper { @Inject private RepositoryServiceFactory serviceFactory; @@ -66,10 +67,9 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa @Inject private TagCollectionToDtoMapper tagCollectionToDtoMapper; + abstract ContributorDto map(Contributor contributor); - @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes - public abstract ChangesetDto map(Changeset changeset, @Context Repository repository); - + abstract PersonDto map(Person person); @ObjectFactory ChangesetDto createDto(@Context Repository repository, Changeset source) { diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index ba83f5faea..6874d2cbd1 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -111,7 +111,6 @@ import sonia.scm.web.security.DefaultAdministrationContext; import javax.net.ssl.SSLContext; /** - * * @author Sebastian Sdorra */ class ScmServletModule extends ServletModule { diff --git a/scm-webapp/src/main/java/sonia/scm/repository/ChangesetDescriptionContributorProvider.java b/scm-webapp/src/main/java/sonia/scm/repository/ChangesetDescriptionContributorProvider.java new file mode 100644 index 0000000000..c1d0a16c22 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/ChangesetDescriptionContributorProvider.java @@ -0,0 +1,114 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import com.google.common.collect.ImmutableSet; +import sonia.scm.plugin.Extension; + +import java.util.Collection; +import java.util.Scanner; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Extension +public class ChangesetDescriptionContributorProvider implements ChangesetPreProcessorFactory { + + private static final Collection SUPPORTED_CONTRIBUTOR_TYPES = ImmutableSet.of("Co-authored-by", "Reviewed-by", "Signed-off-by", "Committed-by"); + private static final Pattern CONTRIBUTOR_PATTERN = Pattern.compile("^([\\w-]*):\\W*(.*)\\W+<(.*)>\\W*$"); + + @Override + public ChangesetPreProcessor createPreProcessor(Repository repository) { + return new ContributorChangesetPreProcessor(); + } + + private static class ContributorChangesetPreProcessor implements ChangesetPreProcessor { + @Override + public void process(Changeset changeset) { + new Worker(changeset).process(); + } + } + + private static class Worker { + private final StringBuilder newDescription = new StringBuilder(); + + private final Changeset changeset; + + boolean foundEmptyLine; + + private Worker(Changeset changeset) { + this.changeset = changeset; + } + private void process() { + try (Scanner scanner = new Scanner(changeset.getDescription())) { + while (scanner.hasNextLine()) { + handleLine(scanner, scanner.nextLine()); + } + } + changeset.setDescription(newDescription.toString()); + } + + private void handleLine(Scanner scanner, String line) { + if (line.trim().isEmpty()) { + handleEmptyLine(scanner, line); + return; + } + + if (foundEmptyLine && checkForContributor(line)) { + return; + } + appendLine(scanner, line); + } + + private boolean checkForContributor(String line) { + Matcher matcher = CONTRIBUTOR_PATTERN.matcher(line); + if (matcher.matches()) { + String type = matcher.group(1); + String name = matcher.group(2); + String mail = matcher.group(3); + if (SUPPORTED_CONTRIBUTOR_TYPES.contains(type)) { + createContributor(type, name, mail); + return true; + } + } + return false; + } + + private void handleEmptyLine(Scanner scanner, String line) { + foundEmptyLine = true; + appendLine(scanner, line); + } + + private void appendLine(Scanner scanner, String line) { + newDescription.append(line); + if (scanner.hasNextLine()) { + newDescription.append('\n'); + } + } + + private void createContributor(String type, String name, String mail) { + changeset.addContributor(new Contributor(type, new Person(name, mail))); + } + } +} diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index 77b6aadb8a..4bf694129b 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -1,4 +1,15 @@ { + "changeset": { + "contributor": { + "type": { + "author": "Autor", + "Reviewed-by": "Reviewer", + "Co-authored-by": "Co-Autoren", + "Committed-by": "Committed von", + "Signed-off-by": "Signiert von" + } + } + }, "permissions": { "*": { "displayName": "Globaler Administrator", diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 1cce4f266e..b75eb268c6 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -1,4 +1,15 @@ { + "changeset": { + "contributor": { + "type": { + "author": "Author", + "Reviewed-by": "Reviewers", + "Co-authored-by": "Co-Authors", + "Committed-by": "Commit by", + "Signed-off-by": "Signed-off" + } + } + }, "permissions": { "*": { "displayName": "Global administrator", diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java index e17660fcbb..2be9c6cdef 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.inject.util.Providers; @@ -30,7 +30,6 @@ import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.support.SubjectThreadState; import org.apache.shiro.util.ThreadContext; import org.apache.shiro.util.ThreadState; -import org.assertj.core.api.Assertions; import org.assertj.core.util.Lists; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; @@ -61,7 +60,9 @@ import java.net.URI; import java.time.Instant; import java.util.Date; import java.util.List; +import java.util.Set; +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; @@ -113,7 +114,6 @@ public class BranchRootResourceTest extends RepositoryTestBase { @Mock private TagCollectionToDtoMapper tagCollectionToDtoMapper; - @InjectMocks private DefaultChangesetToChangesetDtoMapperImpl changesetToChangesetDtoMapper; @@ -156,7 +156,7 @@ public class BranchRootResourceTest extends RepositoryTestBase { assertEquals(404, response.getStatus()); MediaType contentType = (MediaType) response.getOutputHeaders().getFirst("Content-Type"); - Assertions.assertThat(response.getContentAsString()).contains("branch", "master", "space/repo"); + assertThat(response.getContentAsString()).contains("branch", "master", "space/repo"); } @Test @@ -196,10 +196,10 @@ public class BranchRootResourceTest extends RepositoryTestBase { dispatcher.invoke(request, response); assertEquals(200, response.getStatus()); log.info("Response :{}", response.getContentAsString()); - assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", REVISION))); - 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))); + assertThat(response.getContentAsString()).contains(String.format("\"id\":\"%s\"", REVISION)); + assertThat(response.getContentAsString()).contains(String.format("\"name\":\"%s\"", authorName)); + assertThat(response.getContentAsString()).contains(String.format("\"mail\":\"%s\"", authorEmail)); + assertThat(response.getContentAsString()).contains(String.format("\"description\":\"%s\"", commit)); } @Test 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 0cb626b7d3..14b26396d4 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 @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; @@ -68,7 +68,6 @@ import static org.mockito.Mockito.when; @Slf4j public 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; @@ -86,6 +85,7 @@ public class ChangesetRootResourceTest extends RepositoryTestBase { @Mock private LogCommandBuilder logCommandBuilder; + @InjectMocks private ChangesetCollectionToDtoMapper changesetCollectionToDtoMapper; @InjectMocks @@ -93,11 +93,9 @@ public class ChangesetRootResourceTest extends RepositoryTestBase { private ChangesetRootResource changesetRootResource; - private final Subject subject = mock(Subject.class); private final ThreadState subjectThreadState = new SubjectThreadState(subject); - @Before public void prepareEnvironment() { changesetCollectionToDtoMapper = new ChangesetCollectionToDtoMapper(changesetToChangesetDtoMapper, resourceLinks); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileHistoryResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileHistoryResourceTest.java index e1c0ae746b..77c25dd0a2 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileHistoryResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/FileHistoryResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.inject.util.Providers; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java index 5e2dafae79..b2ef5ad319 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; @@ -78,7 +78,6 @@ import static sonia.scm.repository.api.DiffFormat.NATIVE; @Slf4j public class IncomingRootResourceTest extends RepositoryTestBase { - public static final String INCOMING_PATH = "space/repo/incoming/"; public static final String INCOMING_CHANGESETS_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + INCOMING_PATH; public static final String INCOMING_DIFF_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + INCOMING_PATH; diff --git a/scm-webapp/src/test/java/sonia/scm/repository/ChangesetDescriptionContributorProviderTest.java b/scm-webapp/src/test/java/sonia/scm/repository/ChangesetDescriptionContributorProviderTest.java new file mode 100644 index 0000000000..2c272bba24 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/ChangesetDescriptionContributorProviderTest.java @@ -0,0 +1,201 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collection; +import java.util.Iterator; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class ChangesetDescriptionContributorProviderTest { + + private static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold(); + + private final ChangesetDescriptionContributorProvider changesetDescriptionContributors = new ChangesetDescriptionContributorProvider(); + + @Test + void shouldReturnEmptyList() { + Changeset changeset = createChangeset("zaphod beeblebrox"); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + assertThat(contributors).isNullOrEmpty(); + assertThat(changeset.getDescription()).isEqualTo("zaphod beeblebrox"); + } + + @Test + void shouldConvertTrailerWithCoAuthors() { + Person person = createPerson("Arthur Dent", "dent@hitchhiker.org"); + Changeset changeset = createChangeset("zaphod beeblebrox\n\nCo-authored-by: Arthur Dent "); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + Contributor contributor = contributors.iterator().next(); + assertThat(contributor.getType()).isEqualTo("Co-authored-by"); + assertThat(contributor.getPerson()).isEqualTo(person); + assertThat(changeset.getDescription()).isEqualTo("zaphod beeblebrox\n\n"); + } + + @Test + void shouldConvertTrailerWithReviewers() { + Person person = createPerson("Tricia McMillan", "trillian@hitchhiker.org"); + Changeset changeset = createChangeset("zaphod beeblebrox\n\nReviewed-by: Tricia McMillan "); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + Contributor contributor = contributors.iterator().next(); + + assertThat(contributor.getType()).isEqualTo("Reviewed-by"); + assertThat(contributor.getPerson()).isEqualTo(person); + assertThat(changeset.getDescription()).isEqualTo("zaphod beeblebrox\n\n"); + } + + @Test + void shouldConvertTrailerWithSigner() { + Person person = createPerson("Tricia McMillan", "trillian@hitchhiker.org"); + Changeset changeset = createChangeset("zaphod beeblebrox\n\n\nSigned-off-by: Tricia McMillan "); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + Contributor contributor = contributors.iterator().next(); + + assertThat(contributor.getType()).isEqualTo("Signed-off-by"); + assertThat(contributor.getPerson()).isEqualTo(person); + assertThat(changeset.getDescription()).isEqualTo("zaphod beeblebrox\n\n\n"); + } + + @Test + void shouldConvertTrailerWithCommitter() { + Person person = createPerson("Tricia McMillan", "trillian@hitchhiker.org"); + Changeset changeset = createChangeset("zaphod beeblebrox\n\nCommitted-by: Tricia McMillan "); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + Contributor contributor = contributors.iterator().next(); + + assertThat(contributor.getType()).isEqualTo("Committed-by"); + assertThat(contributor.getPerson()).isEqualTo(person); + assertThat(changeset.getDescription()).isEqualTo("zaphod beeblebrox\n\n"); + } + + @Test + void shouldConvertMixedTrailers() { + Changeset changeset = createChangeset("zaphod beeblebrox\n\n" + + "Committed-by: Tricia McMillan \n" + + "Signed-off-by: Artur Dent "); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + Iterator contributorIterator = contributors.iterator(); + Contributor firstContributor = contributorIterator.next(); + Contributor secondContributor = contributorIterator.next(); + + assertThat(firstContributor.getType()).isEqualTo("Committed-by"); + assertThat(firstContributor.getPerson()) + .isEqualTo(createPerson("Tricia McMillan", "trillian@hitchhiker.org")); + + assertThat(secondContributor.getType()).isEqualTo("Signed-off-by"); + assertThat(secondContributor.getPerson()) + .isEqualTo(createPerson("Artur Dent", "dent@hitchhiker.org")); + + assertThat(changeset.getDescription()).isEqualTo("zaphod beeblebrox\n\n"); + } + + @Test + void shouldNotTouchUnknownTrailers() { + String originalCommitMessage = "zaphod beeblebrox\n\n" + + "Some-strange-tag: Tricia McMillan "; + Changeset changeset = createChangeset(originalCommitMessage); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + assertThat(contributors).isNullOrEmpty(); + assertThat(changeset.getDescription()).isEqualTo(originalCommitMessage); + } + + @Test + void shouldIgnoreKnownTrailersWithIllegalNameFormat() { + String originalCommitMessage = "zaphod beeblebrox\n\n" + + "Committed-by: Tricia McMillan"; + Changeset changeset = createChangeset(originalCommitMessage); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + assertThat(contributors).isNullOrEmpty(); + assertThat(changeset.getDescription()).isEqualTo(originalCommitMessage); + } + + @Test + void shouldIgnoreWhitespacesInEmptyLines() { + String originalCommitMessage = "zaphod beeblebrox\n \n" + + "Committed-by: Tricia McMillan "; + Changeset changeset = createChangeset(originalCommitMessage); + + changesetDescriptionContributors.createPreProcessor(REPOSITORY).process(changeset); + Collection contributors = changeset.getContributors(); + + assertThat(contributors).isNotEmpty(); + } + + @Test + void shouldProcessChangesetsSeparately() { + Changeset changeset1 = createChangeset("message one\n\n" + + "Committed-by: Tricia McMillan "); + Changeset changeset2 = createChangeset("message two"); + + ChangesetPreProcessor preProcessor = changesetDescriptionContributors.createPreProcessor(REPOSITORY); + preProcessor.process(changeset1); + preProcessor.process(changeset2); + + assertThat(changeset1.getDescription()).isEqualTo("message one\n\n"); + assertThat(changeset1.getContributors()).isNotEmpty(); + + assertThat(changeset2.getDescription()).isEqualTo("message two"); + assertThat(changeset2.getContributors()).isNullOrEmpty(); + } + + private Changeset createChangeset(String commitMessage) { + Changeset changeset = new Changeset(); + changeset.setDescription(commitMessage); + return changeset; + } + + private Person createPerson(String name, String mail) { + return new Person(name, mail); + } +}