From 2ee54a99674a85fa0d3d96666d48e601561e9f62 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Tue, 18 Mar 2025 16:24:25 -0700 Subject: [PATCH 1/5] G2-1740 Find relevant issues --- pj-core/pom.xml | 16 ++++++ .../com/g2forge/project/core/HConfig.java | 31 +++++++++++ .../g2forge/project/plan/create/Create.java | 17 ++---- .../com/g2forge/project/report/Billing.java | 53 ++++++++++++++----- .../com/g2forge/project/report/Request.java | 5 ++ 5 files changed, 94 insertions(+), 28 deletions(-) create mode 100644 pj-core/src/main/java/com/g2forge/project/core/HConfig.java diff --git a/pj-core/pom.xml b/pj-core/pom.xml index 20b589a..e5a5039 100644 --- a/pj-core/pom.xml +++ b/pj-core/pom.xml @@ -24,5 +24,21 @@ gb-jira ${gearbox.version} + + + com.fasterxml.jackson.module + jackson-module-parameter-names + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson.version} + diff --git a/pj-core/src/main/java/com/g2forge/project/core/HConfig.java b/pj-core/src/main/java/com/g2forge/project/core/HConfig.java new file mode 100644 index 0000000..f92b30c --- /dev/null +++ b/pj-core/src/main/java/com/g2forge/project/core/HConfig.java @@ -0,0 +1,31 @@ +package com.g2forge.project.core; + +import java.io.IOException; +import java.io.InputStream; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.g2forge.alexandria.java.io.RuntimeIOException; +import com.g2forge.alexandria.java.io.dataaccess.IDataSource; +import com.g2forge.alexandria.java.type.ref.ITypeRef; + +import lombok.Getter; + +public class HConfig { + @Getter(lazy = true) + private static final ObjectMapper mapper = createObjectMapper(); + + protected static ObjectMapper createObjectMapper() { + final ObjectMapper retVal = new ObjectMapper(new YAMLFactory()); + retVal.findAndRegisterModules(); + return retVal; + } + + public static T load(IDataSource source, Class type) { + try (final InputStream stream = source.getStream(ITypeRef.of(InputStream.class))) { + return getMapper().readValue(stream, type); + } catch (IOException exception) { + throw new RuntimeIOException("Failed to load " + source + " as " + type.getSimpleName(), exception); + } + } +} diff --git a/pj-create/src/main/java/com/g2forge/project/plan/create/Create.java b/pj-create/src/main/java/com/g2forge/project/plan/create/Create.java index 22e9a7e..ecd09f6 100644 --- a/pj-create/src/main/java/com/g2forge/project/plan/create/Create.java +++ b/pj-create/src/main/java/com/g2forge/project/plan/create/Create.java @@ -33,19 +33,17 @@ import com.atlassian.jira.rest.client.api.domain.input.TransitionInput; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.g2forge.alexandria.command.command.IStandardCommand; import com.g2forge.alexandria.command.exit.IExit; import com.g2forge.alexandria.command.invocation.CommandInvocation; import com.g2forge.alexandria.java.core.error.HError; import com.g2forge.alexandria.java.io.dataaccess.IDataSource; import com.g2forge.alexandria.java.io.dataaccess.PathDataSource; -import com.g2forge.alexandria.java.type.ref.ITypeRef; import com.g2forge.alexandria.log.HLog; import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; import com.g2forge.gearbox.jira.fields.KnownField; +import com.g2forge.project.core.HConfig; import com.g2forge.project.plan.create.CreateIssue.CreateIssueBuilder; import com.google.common.base.Objects; @@ -217,21 +215,12 @@ protected static void verifyChanges(final Changes changes) { protected final Map> projectComponentsCache = new LinkedHashMap<>(); public List createIssues(IDataSource serverDataSource, IDataSource configDataSource) throws JsonParseException, JsonMappingException, IOException, URISyntaxException, InterruptedException, ExecutionException { - final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); - // Load the config, but if it's empty, don't bother - final CreateConfig config; - try (final InputStream stream = configDataSource.getStream(ITypeRef.of(InputStream.class))) { - config = mapper.readValue(stream, CreateConfig.class); - } + final CreateConfig config = HConfig.load(configDataSource, CreateConfig.class); if ((config.getIssues() == null) || config.getIssues().isEmpty()) return Collections.emptyList(); // Load the server if one is specified; - final Server server; - if (serverDataSource != null) try (final InputStream stream = serverDataSource.getStream(ITypeRef.of(InputStream.class))) { - server = mapper.readValue(stream, Server.class); - } - else server = null; + final Server server = (serverDataSource != null) ? HConfig.load(serverDataSource, Server.class) : null; config.validateFlags(); final Changes changes = computeChanges(server, config); diff --git a/pj-report/src/main/java/com/g2forge/project/report/Billing.java b/pj-report/src/main/java/com/g2forge/project/report/Billing.java index 8c986ce..f942507 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Billing.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Billing.java @@ -4,6 +4,10 @@ import java.io.InputStream; import java.io.PrintStream; import java.net.URISyntaxException; +import java.nio.file.Path; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @@ -14,16 +18,20 @@ import com.atlassian.jira.rest.client.api.domain.ChangelogGroup; import com.atlassian.jira.rest.client.api.domain.ChangelogItem; import com.atlassian.jira.rest.client.api.domain.Issue; +import com.atlassian.jira.rest.client.api.domain.SearchResult; import com.g2forge.alexandria.command.command.IStandardCommand; import com.g2forge.alexandria.command.exit.IExit; import com.g2forge.alexandria.command.invocation.CommandInvocation; import com.g2forge.alexandria.java.adt.name.IStringNamed; import com.g2forge.alexandria.java.core.helpers.HCollection; +import com.g2forge.alexandria.java.core.helpers.HCollector; +import com.g2forge.alexandria.java.io.dataaccess.PathDataSource; import com.g2forge.alexandria.log.HLog; import com.g2forge.gearbox.argparse.ArgumentParser; import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; import com.g2forge.gearbox.jira.fields.KnownField; +import com.g2forge.project.core.HConfig; import lombok.AllArgsConstructor; import lombok.Builder; @@ -32,43 +40,60 @@ @Slf4j public class Billing implements IStandardCommand { + protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); + @Data @Builder(toBuilder = true) @AllArgsConstructor protected static class Arguments { protected final String issueKey; + + protected final Path request; } public static void main(String[] args) throws Throwable { IStandardCommand.main(args, new Billing()); } - protected void demoLogChanges(final String issueKey) throws InterruptedException, ExecutionException, IOException, URISyntaxException { + protected void demoLogChanges(ExtendedJiraRestClient client, final String issueKey) throws InterruptedException, ExecutionException, IOException, URISyntaxException { final Set fields = HCollection.asList(KnownField.Status).stream().map(IStringNamed::getName).collect(Collectors.toSet()); - try (final ExtendedJiraRestClient client = JiraAPI.load().connect(true)) { - final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get(); - log.info("Created at {}", issue.getCreationDate()); - for (ChangelogGroup changelogGroup : issue.getChangelog()) { - boolean printedGroupLabel = false; - for (ChangelogItem changelogItem : changelogGroup.getItems()) { - if ((fields == null) || fields.contains(changelogItem.getField())) { - if (!printedGroupLabel) { - log.info("{} {}", changelogGroup.getCreated(), changelogGroup.getAuthor().getDisplayName()); - printedGroupLabel = true; - } - log.info("\t{}: {} -> {}", changelogItem.getField(), changelogItem.getFromString(), changelogItem.getToString()); + final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get(); + log.info("Created at {}", issue.getCreationDate()); + for (ChangelogGroup changelogGroup : issue.getChangelog()) { + boolean printedGroupLabel = false; + for (ChangelogItem changelogItem : changelogGroup.getItems()) { + if ((fields == null) || fields.contains(changelogItem.getField())) { + if (!printedGroupLabel) { + log.info("{} {}", changelogGroup.getCreated(), changelogGroup.getAuthor().getDisplayName()); + printedGroupLabel = true; } + log.info("\t{}: {} -> {}", changelogItem.getField(), changelogItem.getFromString(), changelogItem.getToString()); } } } } + protected List findRelevantIssues(ExtendedJiraRestClient client, Request request) throws InterruptedException, ExecutionException { + final List retVal = new ArrayList<>(); + for (String user : request.getUsers()) { + final SearchResult result = client.getSearchClient().searchJql(String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", user, request.getStart().format(DATE_FORMAT), request.getEnd().format(DATE_FORMAT))).get(); + retVal.addAll(HCollection.asList(result.getIssues())); + } + return retVal; + } + @Override public IExit invoke(CommandInvocation invocation) throws Throwable { HLog.getLogControl().setLogLevel(Level.INFO); final Arguments arguments = ArgumentParser.parse(Arguments.class, invocation.getArguments()); - demoLogChanges(arguments.getIssueKey()); + final Request request = HConfig.load(new PathDataSource(arguments.getRequest()), Request.class); + final JiraAPI api = JiraAPI.createFromPropertyInput(request == null ? null : request.getApi(), null); + try (final ExtendedJiraRestClient client = api.connect(true)) { + demoLogChanges(client, arguments.getIssueKey()); + + log.info("Found: {}", findRelevantIssues(client, request).stream().map(Issue::getKey).collect(HCollector.joining(", ", ", & "))); + } // Progressing: Input - API info, list of users // TODO: Search for all relevant issues (anything updatedBy a relevant user in the given time range https://confluence.atlassian.com/jirasoftwareserver/advanced-searching-functions-reference-939938746.html, might have to search across all users) diff --git a/pj-report/src/main/java/com/g2forge/project/report/Request.java b/pj-report/src/main/java/com/g2forge/project/report/Request.java index a2eac09..443a9a3 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Request.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Request.java @@ -1,5 +1,6 @@ package com.g2forge.project.report; +import java.time.LocalDate; import java.util.List; import com.g2forge.gearbox.jira.JiraAPI; @@ -17,4 +18,8 @@ public class Request { @Singular protected final List users; + + protected final LocalDate start; + + protected final LocalDate end; } \ No newline at end of file From fde2fc380cf6847ce4b8ae47529a946735f5b6f7 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Tue, 18 Mar 2025 16:24:37 -0700 Subject: [PATCH 2/5] G2-1740 Compute issue status change log --- .../com/g2forge/project/report/Billing.java | 112 +++++++++++++----- .../g2forge/project/report/TestBilling.java | 56 +++++++++ 2 files changed, 136 insertions(+), 32 deletions(-) create mode 100644 pj-report/src/test/java/com/g2forge/project/report/TestBilling.java diff --git a/pj-report/src/main/java/com/g2forge/project/report/Billing.java b/pj-report/src/main/java/com/g2forge/project/report/Billing.java index f942507..2e526da 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Billing.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Billing.java @@ -1,17 +1,20 @@ package com.g2forge.project.report; -import java.io.IOException; import java.io.InputStream; import java.io.PrintStream; -import java.net.URISyntaxException; import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.concurrent.ExecutionException; -import java.util.stream.Collectors; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; import org.slf4j.event.Level; import com.atlassian.jira.rest.client.api.IssueRestClient; @@ -22,7 +25,6 @@ import com.g2forge.alexandria.command.command.IStandardCommand; import com.g2forge.alexandria.command.exit.IExit; import com.g2forge.alexandria.command.invocation.CommandInvocation; -import com.g2forge.alexandria.java.adt.name.IStringNamed; import com.g2forge.alexandria.java.core.helpers.HCollection; import com.g2forge.alexandria.java.core.helpers.HCollector; import com.g2forge.alexandria.java.io.dataaccess.PathDataSource; @@ -36,12 +38,11 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j public class Billing implements IStandardCommand { - protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); - @Data @Builder(toBuilder = true) @AllArgsConstructor @@ -51,32 +52,78 @@ protected static class Arguments { protected final Path request; } - public static void main(String[] args) throws Throwable { - IStandardCommand.main(args, new Billing()); + @Data + @Builder(toBuilder = true) + @RequiredArgsConstructor + protected static class StatusChange { + protected final ZonedDateTime start; + + protected final String status; } - protected void demoLogChanges(ExtendedJiraRestClient client, final String issueKey) throws InterruptedException, ExecutionException, IOException, URISyntaxException { - final Set fields = HCollection.asList(KnownField.Status).stream().map(IStringNamed::getName).collect(Collectors.toSet()); - final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get(); - log.info("Created at {}", issue.getCreationDate()); - for (ChangelogGroup changelogGroup : issue.getChangelog()) { - boolean printedGroupLabel = false; + public static ZonedDateTime convert(DateTime dateTime) { + final Instant instant = Instant.ofEpochMilli(dateTime.getMillis()); + final ZoneId zoneId = ZoneId.of(dateTime.getZone().getID(), ZoneId.SHORT_IDS); + return ZonedDateTime.ofInstant(instant, zoneId); + } + + public static DateTime convert(ZonedDateTime zonedDateTime) { + final long millis = zonedDateTime.toInstant().toEpochMilli(); + final DateTimeZone dateTimeZone = DateTimeZone.forID(zonedDateTime.getZone().getId()); + return new DateTime(millis, dateTimeZone); + } + + protected static List convertToStatusChanges(final Iterable changelog, ZonedDateTime start, ZonedDateTime end, String status) { + final List retVal = new ArrayList<>(); + String finalStatus = status; + for (ChangelogGroup changelogGroup : changelog) { + final ZonedDateTime created = convert(changelogGroup.getCreated()); + // Ignore changes before the start, and stop processing after the end + if (created.isBefore(start)) continue; + + // Extract the from and to status from any changes to the status field (take the last change if there are multiple which should never happen) + String fromStatus = null, toStatus = null; for (ChangelogItem changelogItem : changelogGroup.getItems()) { - if ((fields == null) || fields.contains(changelogItem.getField())) { - if (!printedGroupLabel) { - log.info("{} {}", changelogGroup.getCreated(), changelogGroup.getAuthor().getDisplayName()); - printedGroupLabel = true; - } - log.info("\t{}: {} -> {}", changelogItem.getField(), changelogItem.getFromString(), changelogItem.getToString()); + if (!KnownField.Status.getName().equals(changelogItem.getField())) continue; + fromStatus = changelogItem.getFromString(); + toStatus = changelogItem.getToString(); + } + + // IF the status changed (not all change log groups include a chance to the status), then... + if (toStatus != null) { + if (created.isAfter(end)) { + finalStatus = fromStatus; + break; } + + // If this is the first change, record the starting statu + if (retVal.isEmpty()) retVal.add(new StatusChange(start, fromStatus)); + retVal.add(new StatusChange(created, toStatus)); } } + // Add a start marker if we didn't get a chance to already + if (retVal.isEmpty()) retVal.add(new StatusChange(start, finalStatus)); + // Add an end marker if we didn't get a chance at exactly the right time + if (!retVal.get(retVal.size() - 1).getStart().isEqual(end)) retVal.add(new StatusChange(end, finalStatus)); + return retVal; + } + + public static void main(String[] args) throws Throwable { + IStandardCommand.main(args, new Billing()); } - protected List findRelevantIssues(ExtendedJiraRestClient client, Request request) throws InterruptedException, ExecutionException { + protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); + + protected List computeStatusChanges(ExtendedJiraRestClient client, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { + final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get(); + final Iterable changelog = issue.getChangelog(); + return convertToStatusChanges(changelog, start, end, issue.getStatus().toString()); + } + + protected List findRelevantIssues(ExtendedJiraRestClient client, List users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { final List retVal = new ArrayList<>(); - for (String user : request.getUsers()) { - final SearchResult result = client.getSearchClient().searchJql(String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", user, request.getStart().format(DATE_FORMAT), request.getEnd().format(DATE_FORMAT))).get(); + for (String user : users) { + final SearchResult result = client.getSearchClient().searchJql(String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", user, start.format(DATE_FORMAT), end.format(DATE_FORMAT))).get(); retVal.addAll(HCollection.asList(result.getIssues())); } return retVal; @@ -90,16 +137,17 @@ public IExit invoke(CommandInvocation invocation) thro final Request request = HConfig.load(new PathDataSource(arguments.getRequest()), Request.class); final JiraAPI api = JiraAPI.createFromPropertyInput(request == null ? null : request.getApi(), null); try (final ExtendedJiraRestClient client = api.connect(true)) { - demoLogChanges(client, arguments.getIssueKey()); - - log.info("Found: {}", findRelevantIssues(client, request).stream().map(Issue::getKey).collect(HCollector.joining(", ", ", & "))); + final List relevantIssues = findRelevantIssues(client, request.getUsers(), request.getStart(), request.getEnd()); + log.info("Found: {}", relevantIssues.stream().map(Issue::getKey).collect(HCollector.joining(", ", ", & "))); + for (Issue issue : relevantIssues) { + final List changes = computeStatusChanges(client, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault()).plus(5, ChronoUnit.DAYS)); + log.info("Changes to {}", issue.getKey()); + for (StatusChange change : changes) { + log.info("\t{} -> {}", change.getStart(), change.getStatus()); + } + } } - // Progressing: Input - API info, list of users - // TODO: Search for all relevant issues (anything updatedBy a relevant user in the given time range https://confluence.atlassian.com/jirasoftwareserver/advanced-searching-functions-reference-939938746.html, might have to search across all users) - - // TODO: I/O - Start time and end time for the report, and the exact time we ran in - // TODO: Build a status history for an issue (Limit to the queried time range, Infer initial status from first status change, and create a timestamp of "now" for the end if needed) // TODO: Input - working hours for a person (just start/stop times & days of week for now, add support for exceptions later) // TODO: Input - mapping of issues to accounts (e.g. by epic, by component, etc) // TODO: Construct a per-person timeline diff --git a/pj-report/src/test/java/com/g2forge/project/report/TestBilling.java b/pj-report/src/test/java/com/g2forge/project/report/TestBilling.java new file mode 100644 index 0000000..88c3f47 --- /dev/null +++ b/pj-report/src/test/java/com/g2forge/project/report/TestBilling.java @@ -0,0 +1,56 @@ +package com.g2forge.project.report; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.junit.Test; + +import com.atlassian.jira.rest.client.api.domain.ChangelogGroup; +import com.atlassian.jira.rest.client.api.domain.ChangelogItem; +import com.g2forge.alexandria.java.core.helpers.HCollection; +import com.g2forge.alexandria.test.HAssert; +import com.g2forge.gearbox.jira.fields.KnownField; +import com.g2forge.project.report.Billing.StatusChange; + +public class TestBilling { + protected static final ZonedDateTime START = ZonedDateTime.parse("2025-01-01T13:00:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime END = ZonedDateTime.parse("2025-01-01T14:00:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime START_MINUS20 = ZonedDateTime.parse("2025-01-01T12:40:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime START_PLUS20 = ZonedDateTime.parse("2025-01-01T13:20:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime START_PLUS40 = ZonedDateTime.parse("2025-01-01T13:40:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime END_PLUS20 = ZonedDateTime.parse("2025-01-01T14:20:00-07:00[America/Los_Angeles]"); + + protected ChangelogGroup change(ZonedDateTime when, String fromStatus, String toStatus) { + return new ChangelogGroup(null, Billing.convert(when), HCollection.asList(new ChangelogItem(null, KnownField.Status.getName(), fromStatus, fromStatus, toStatus, toStatus))); + } + + @Test + public void testConvertToStatusChangesAllAfter() { + final List actual = Billing.convertToStatusChanges(HCollection.asList(change(END_PLUS20, "State", "Ignored")), START, END, "Ignored"); + HAssert.assertEquals(HCollection.asList(new StatusChange(START, "State"), new StatusChange(END, "State")), actual); + } + + @Test + public void testConvertToStatusChangesAllBefore() { + final List actual = Billing.convertToStatusChanges(HCollection.asList(change(START_MINUS20, "Ignored", "State")), START, END, "State"); + HAssert.assertEquals(HCollection.asList(new StatusChange(START, "State"), new StatusChange(END, "State")), actual); + } + + @Test + public void testConvertToStatusChangesEmpty() { + final List actual = Billing.convertToStatusChanges(HCollection.emptyList(), START, END, "State"); + HAssert.assertEquals(HCollection.asList(new StatusChange(START, "State"), new StatusChange(END, "State")), actual); + } + + @Test + public void testConvertToStatusChangesOne() { + final List actual = Billing.convertToStatusChanges(HCollection.asList(change(START_PLUS20, "Initial", "Final")), START, END, "Final"); + HAssert.assertEquals(HCollection.asList(new StatusChange(START, "Initial"), new StatusChange(START_PLUS20, "Final"), new StatusChange(END, "Final")), actual); + } + + @Test + public void testConvertToStatusChangesTwo() { + final List actual = Billing.convertToStatusChanges(HCollection.asList(change(START_PLUS20, "Initial", "Middle"), change(START_PLUS40, "Middle", "Final")), START, END, "Final"); + HAssert.assertEquals(HCollection.asList(new StatusChange(START, "Initial"), new StatusChange(START_PLUS20, "Middle"), new StatusChange(START_PLUS40, "Final"), new StatusChange(END, "Final")), actual); + } +} From b71fbe2b91a1987f7a9c62ab0ecbfe10f0020128 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Tue, 18 Mar 2025 16:24:48 -0700 Subject: [PATCH 3/5] G2-1740 Track assignee in changes --- .../com/g2forge/project/report/Billing.java | 73 +++++----------- .../com/g2forge/project/report/Change.java | 87 +++++++++++++++++++ .../g2forge/project/report/TestBilling.java | 56 ------------ .../g2forge/project/report/TestChange.java | 67 ++++++++++++++ 4 files changed, 175 insertions(+), 108 deletions(-) create mode 100644 pj-report/src/main/java/com/g2forge/project/report/Change.java delete mode 100644 pj-report/src/test/java/com/g2forge/project/report/TestBilling.java create mode 100644 pj-report/src/test/java/com/g2forge/project/report/TestChange.java diff --git a/pj-report/src/main/java/com/g2forge/project/report/Billing.java b/pj-report/src/main/java/com/g2forge/project/report/Billing.java index 2e526da..60ac089 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Billing.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Billing.java @@ -19,9 +19,10 @@ import com.atlassian.jira.rest.client.api.IssueRestClient; import com.atlassian.jira.rest.client.api.domain.ChangelogGroup; -import com.atlassian.jira.rest.client.api.domain.ChangelogItem; import com.atlassian.jira.rest.client.api.domain.Issue; import com.atlassian.jira.rest.client.api.domain.SearchResult; +import com.g2forge.alexandria.adt.associative.cache.Cache; +import com.g2forge.alexandria.adt.associative.cache.NeverCacheEvictionPolicy; import com.g2forge.alexandria.command.command.IStandardCommand; import com.g2forge.alexandria.command.exit.IExit; import com.g2forge.alexandria.command.invocation.CommandInvocation; @@ -32,13 +33,11 @@ import com.g2forge.gearbox.argparse.ArgumentParser; import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; -import com.g2forge.gearbox.jira.fields.KnownField; import com.g2forge.project.core.HConfig; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -52,15 +51,6 @@ protected static class Arguments { protected final Path request; } - @Data - @Builder(toBuilder = true) - @RequiredArgsConstructor - protected static class StatusChange { - protected final ZonedDateTime start; - - protected final String status; - } - public static ZonedDateTime convert(DateTime dateTime) { final Instant instant = Instant.ofEpochMilli(dateTime.getMillis()); final ZoneId zoneId = ZoneId.of(dateTime.getZone().getID(), ZoneId.SHORT_IDS); @@ -73,51 +63,24 @@ public static DateTime convert(ZonedDateTime zonedDateTime) { return new DateTime(millis, dateTimeZone); } - protected static List convertToStatusChanges(final Iterable changelog, ZonedDateTime start, ZonedDateTime end, String status) { - final List retVal = new ArrayList<>(); - String finalStatus = status; - for (ChangelogGroup changelogGroup : changelog) { - final ZonedDateTime created = convert(changelogGroup.getCreated()); - // Ignore changes before the start, and stop processing after the end - if (created.isBefore(start)) continue; - - // Extract the from and to status from any changes to the status field (take the last change if there are multiple which should never happen) - String fromStatus = null, toStatus = null; - for (ChangelogItem changelogItem : changelogGroup.getItems()) { - if (!KnownField.Status.getName().equals(changelogItem.getField())) continue; - fromStatus = changelogItem.getFromString(); - toStatus = changelogItem.getToString(); - } - - // IF the status changed (not all change log groups include a chance to the status), then... - if (toStatus != null) { - if (created.isAfter(end)) { - finalStatus = fromStatus; - break; - } - - // If this is the first change, record the starting statu - if (retVal.isEmpty()) retVal.add(new StatusChange(start, fromStatus)); - retVal.add(new StatusChange(created, toStatus)); - } - } - // Add a start marker if we didn't get a chance to already - if (retVal.isEmpty()) retVal.add(new StatusChange(start, finalStatus)); - // Add an end marker if we didn't get a chance at exactly the right time - if (!retVal.get(retVal.size() - 1).getStart().isEqual(end)) retVal.add(new StatusChange(end, finalStatus)); - return retVal; - } - public static void main(String[] args) throws Throwable { IStandardCommand.main(args, new Billing()); } protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); - protected List computeStatusChanges(ExtendedJiraRestClient client, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { + protected List computeChanges(ExtendedJiraRestClient client, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get(); final Iterable changelog = issue.getChangelog(); - return convertToStatusChanges(changelog, start, end, issue.getStatus().toString()); + final Cache users = new Cache<>(id -> { + if (id == null) return null; + try { + return client.getUserClient().getUserByKey(id).get().getName(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException("Failed to look up user: " + id, e); + } + }, NeverCacheEvictionPolicy.create()); + return Change.toChanges(changelog, start, end, issue.getAssignee().getName(), issue.getStatus().getName(), users); } protected List findRelevantIssues(ExtendedJiraRestClient client, List users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { @@ -140,14 +103,20 @@ public IExit invoke(CommandInvocation invocation) thro final List relevantIssues = findRelevantIssues(client, request.getUsers(), request.getStart(), request.getEnd()); log.info("Found: {}", relevantIssues.stream().map(Issue::getKey).collect(HCollector.joining(", ", ", & "))); for (Issue issue : relevantIssues) { - final List changes = computeStatusChanges(client, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault()).plus(5, ChronoUnit.DAYS)); + final List changes = computeChanges(client, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault()).plus(5, ChronoUnit.DAYS)); log.info("Changes to {}", issue.getKey()); - for (StatusChange change : changes) { - log.info("\t{} -> {}", change.getStart(), change.getStatus()); + for (Change change : changes) { + log.info("\t{} -> {} - {}", change.getStart(), change.getAssignee(), change.getStatus()); } } } + // TODO: Log changes in assignee, along with status + // TODO: Compute intersection of working hours with issue in billable status + // Walk through the issue timeline + // For each interval project it against the assignees working hours + // If there's an overlap, then convert to billable time + // TODO: Input - working hours for a person (just start/stop times & days of week for now, add support for exceptions later) // TODO: Input - mapping of issues to accounts (e.g. by epic, by component, etc) // TODO: Construct a per-person timeline diff --git a/pj-report/src/main/java/com/g2forge/project/report/Change.java b/pj-report/src/main/java/com/g2forge/project/report/Change.java new file mode 100644 index 0000000..f3decd3 --- /dev/null +++ b/pj-report/src/main/java/com/g2forge/project/report/Change.java @@ -0,0 +1,87 @@ +package com.g2forge.project.report; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +import com.atlassian.jira.rest.client.api.domain.ChangelogGroup; +import com.atlassian.jira.rest.client.api.domain.ChangelogItem; +import com.g2forge.alexandria.java.function.IFunction1; +import com.g2forge.gearbox.jira.fields.KnownField; + +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +@Data +@Builder(toBuilder = true) +@RequiredArgsConstructor +public class Change { + protected final ZonedDateTime start; + + protected final String assignee; + + protected final String status; + + public static List toChanges(final Iterable changelog, ZonedDateTime start, ZonedDateTime end, String assignee, String status, IFunction1 assigneeResolver) { + final List retVal = new ArrayList<>(); + String finalAssignee = assignee, finalStatus = status; + boolean foundFinalAssignee = false, foundFinalStatus = false; + for (ChangelogGroup changelogGroup : changelog) { + final ZonedDateTime created = Billing.convert(changelogGroup.getCreated()); + // Ignore changes before the start, and stop processing after the end + if (created.isBefore(start)) continue; + + // Extract the from and to status from any changes to the status field (take the last change if there are multiple which should never happen) + String fromAssignee = null, toAssignee = null; + String fromStatus = null, toStatus = null; + for (ChangelogItem changelogItem : changelogGroup.getItems()) { + if (KnownField.Assignee.getName().equals(changelogItem.getField())) { + fromAssignee = assigneeResolver.apply(changelogItem.getFrom()); + toAssignee = assigneeResolver.apply(changelogItem.getTo()); + } else if (KnownField.Status.getName().equals(changelogItem.getField())) { + fromStatus = changelogItem.getFromString(); + toStatus = changelogItem.getToString(); + } + } + + // IF the status changed (not all change log groups include a chance to the status), then... + if ((toAssignee != null) || (toStatus != null)) { + if (created.isAfter(end)) { + if (fromAssignee != null) { + finalAssignee = fromAssignee; + foundFinalAssignee = true; + } + if (fromStatus != null) { + finalStatus = fromStatus; + foundFinalStatus = true; + } + if (foundFinalAssignee && foundFinalStatus) break; + } else { + // If this is the first change, record the starting info, otherwise back propagate any new information we just learned + if (retVal.isEmpty()) retVal.add(new Change(start, fromAssignee, fromStatus)); + else backPropagate(retVal, fromAssignee, fromStatus); + retVal.add(new Change(created, toAssignee, toStatus)); + } + } + } + // Add a start marker if we didn't get a chance to already + if (retVal.isEmpty()) retVal.add(new Change(start, finalAssignee, finalStatus)); + else backPropagate(retVal, finalAssignee, finalStatus); + // Add an end marker if we didn't get a chance at exactly the right time + if (!retVal.get(retVal.size() - 1).getStart().isEqual(end)) retVal.add(new Change(end, finalAssignee, finalStatus)); + return retVal; + } + + protected static void backPropagate(final List retVal, String fromAssignee, String fromStatus) { + for (int i = retVal.size() - 1; i >= 0; i--) { + final Change prev = retVal.get(i); + if (prev.getAssignee() != null && prev.getStatus() != null) break; + + final Change.ChangeBuilder builder = prev.toBuilder(); + if (prev.getAssignee() == null) builder.assignee(fromAssignee); + if (prev.getStatus() == null) builder.status(fromStatus); + retVal.set(i, builder.build()); + } + } +} \ No newline at end of file diff --git a/pj-report/src/test/java/com/g2forge/project/report/TestBilling.java b/pj-report/src/test/java/com/g2forge/project/report/TestBilling.java deleted file mode 100644 index 88c3f47..0000000 --- a/pj-report/src/test/java/com/g2forge/project/report/TestBilling.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.g2forge.project.report; - -import java.time.ZonedDateTime; -import java.util.List; - -import org.junit.Test; - -import com.atlassian.jira.rest.client.api.domain.ChangelogGroup; -import com.atlassian.jira.rest.client.api.domain.ChangelogItem; -import com.g2forge.alexandria.java.core.helpers.HCollection; -import com.g2forge.alexandria.test.HAssert; -import com.g2forge.gearbox.jira.fields.KnownField; -import com.g2forge.project.report.Billing.StatusChange; - -public class TestBilling { - protected static final ZonedDateTime START = ZonedDateTime.parse("2025-01-01T13:00:00-07:00[America/Los_Angeles]"); - protected static final ZonedDateTime END = ZonedDateTime.parse("2025-01-01T14:00:00-07:00[America/Los_Angeles]"); - protected static final ZonedDateTime START_MINUS20 = ZonedDateTime.parse("2025-01-01T12:40:00-07:00[America/Los_Angeles]"); - protected static final ZonedDateTime START_PLUS20 = ZonedDateTime.parse("2025-01-01T13:20:00-07:00[America/Los_Angeles]"); - protected static final ZonedDateTime START_PLUS40 = ZonedDateTime.parse("2025-01-01T13:40:00-07:00[America/Los_Angeles]"); - protected static final ZonedDateTime END_PLUS20 = ZonedDateTime.parse("2025-01-01T14:20:00-07:00[America/Los_Angeles]"); - - protected ChangelogGroup change(ZonedDateTime when, String fromStatus, String toStatus) { - return new ChangelogGroup(null, Billing.convert(when), HCollection.asList(new ChangelogItem(null, KnownField.Status.getName(), fromStatus, fromStatus, toStatus, toStatus))); - } - - @Test - public void testConvertToStatusChangesAllAfter() { - final List actual = Billing.convertToStatusChanges(HCollection.asList(change(END_PLUS20, "State", "Ignored")), START, END, "Ignored"); - HAssert.assertEquals(HCollection.asList(new StatusChange(START, "State"), new StatusChange(END, "State")), actual); - } - - @Test - public void testConvertToStatusChangesAllBefore() { - final List actual = Billing.convertToStatusChanges(HCollection.asList(change(START_MINUS20, "Ignored", "State")), START, END, "State"); - HAssert.assertEquals(HCollection.asList(new StatusChange(START, "State"), new StatusChange(END, "State")), actual); - } - - @Test - public void testConvertToStatusChangesEmpty() { - final List actual = Billing.convertToStatusChanges(HCollection.emptyList(), START, END, "State"); - HAssert.assertEquals(HCollection.asList(new StatusChange(START, "State"), new StatusChange(END, "State")), actual); - } - - @Test - public void testConvertToStatusChangesOne() { - final List actual = Billing.convertToStatusChanges(HCollection.asList(change(START_PLUS20, "Initial", "Final")), START, END, "Final"); - HAssert.assertEquals(HCollection.asList(new StatusChange(START, "Initial"), new StatusChange(START_PLUS20, "Final"), new StatusChange(END, "Final")), actual); - } - - @Test - public void testConvertToStatusChangesTwo() { - final List actual = Billing.convertToStatusChanges(HCollection.asList(change(START_PLUS20, "Initial", "Middle"), change(START_PLUS40, "Middle", "Final")), START, END, "Final"); - HAssert.assertEquals(HCollection.asList(new StatusChange(START, "Initial"), new StatusChange(START_PLUS20, "Middle"), new StatusChange(START_PLUS40, "Final"), new StatusChange(END, "Final")), actual); - } -} diff --git a/pj-report/src/test/java/com/g2forge/project/report/TestChange.java b/pj-report/src/test/java/com/g2forge/project/report/TestChange.java new file mode 100644 index 0000000..167a178 --- /dev/null +++ b/pj-report/src/test/java/com/g2forge/project/report/TestChange.java @@ -0,0 +1,67 @@ +package com.g2forge.project.report; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.junit.Test; + +import com.atlassian.jira.rest.client.api.domain.ChangelogGroup; +import com.atlassian.jira.rest.client.api.domain.ChangelogItem; +import com.g2forge.alexandria.java.core.helpers.HCollection; +import com.g2forge.alexandria.java.function.IFunction1; +import com.g2forge.alexandria.test.HAssert; +import com.g2forge.gearbox.jira.fields.KnownField; + +public class TestChange { + protected static final ZonedDateTime START = ZonedDateTime.parse("2025-01-01T13:00:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime END = ZonedDateTime.parse("2025-01-01T14:00:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime START_MINUS20 = ZonedDateTime.parse("2025-01-01T12:40:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime START_PLUS15 = ZonedDateTime.parse("2025-01-01T13:15:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime START_PLUS30 = ZonedDateTime.parse("2025-01-01T13:30:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime START_PLUS45 = ZonedDateTime.parse("2025-01-01T13:45:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime END_PLUS20 = ZonedDateTime.parse("2025-01-01T14:20:00-07:00[America/Los_Angeles]"); + + protected ChangelogGroup changeAssignee(ZonedDateTime when, String fromAssignee, String toAssignee) { + return new ChangelogGroup(null, Billing.convert(when), HCollection.asList(new ChangelogItem(null, KnownField.Assignee.getName(), fromAssignee, fromAssignee, toAssignee, toAssignee))); + } + + protected ChangelogGroup changeStatus(ZonedDateTime when, String fromStatus, String toStatus) { + return new ChangelogGroup(null, Billing.convert(when), HCollection.asList(new ChangelogItem(null, KnownField.Status.getName(), fromStatus, fromStatus, toStatus, toStatus))); + } + + @Test + public void testToChangesAllAfter() { + final List actual = Change.toChanges(HCollection.asList(changeStatus(END_PLUS20, "State", "Ignored")), START, END, "user", "Ignored", IFunction1.identity()); + HAssert.assertEquals(HCollection.asList(new Change(START, "user", "State"), new Change(END, "user", "State")), actual); + } + + @Test + public void testToChangesAllBefore() { + final List actual = Change.toChanges(HCollection.asList(changeStatus(START_MINUS20, "Ignored", "State")), START, END, "user", "State", IFunction1.identity()); + HAssert.assertEquals(HCollection.asList(new Change(START, "user", "State"), new Change(END, "user", "State")), actual); + } + + @Test + public void testToChangesEmpty() { + final List actual = Change.toChanges(HCollection.emptyList(), START, END, "user", "State", IFunction1.identity()); + HAssert.assertEquals(HCollection.asList(new Change(START, "user", "State"), new Change(END, "user", "State")), actual); + } + + @Test + public void testToChangesOne() { + final List actual = Change.toChanges(HCollection.asList(changeStatus(START_PLUS15, "Initial", "Final")), START, END, "user", "Final", IFunction1.identity()); + HAssert.assertEquals(HCollection.asList(new Change(START, "user", "Initial"), new Change(START_PLUS15, "user", "Final"), new Change(END, "user", "Final")), actual); + } + + @Test + public void testToChangesThree() { + final List actual = Change.toChanges(HCollection.asList(changeStatus(START_PLUS15, "Initial", "Middle"), changeAssignee(START_PLUS30, "user1", "user2"), changeStatus(START_PLUS45, "Middle", "Final")), START, END, "user2", "Final", IFunction1.identity()); + HAssert.assertEquals(HCollection.asList(new Change(START, "user1", "Initial"), new Change(START_PLUS15, "user1", "Middle"), new Change(START_PLUS30, "user2", "Middle"), new Change(START_PLUS45, "user2", "Final"), new Change(END, "user2", "Final")), actual); + } + + @Test + public void testToChangesTwo() { + final List actual = Change.toChanges(HCollection.asList(changeStatus(START_PLUS15, "Initial", "Middle"), changeStatus(START_PLUS45, "Middle", "Final")), START, END, "user", "Final", IFunction1.identity()); + HAssert.assertEquals(HCollection.asList(new Change(START, "user", "Initial"), new Change(START_PLUS15, "user", "Middle"), new Change(START_PLUS45, "user", "Final"), new Change(END, "user", "Final")), actual); + } +} From dbd6807225fad60fd668e2a6ae3a5ec6df0a8c73 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Tue, 18 Mar 2025 16:24:59 -0700 Subject: [PATCH 4/5] G2-1740 Implement working hours & billing --- .../com/g2forge/project/report/Billing.java | 143 +++++++++++++++--- .../com/g2forge/project/report/Request.java | 11 +- .../g2forge/project/report/WorkingHours.java | 44 ++++++ .../project/report/TestWorkingHours.java | 48 ++++++ 4 files changed, 226 insertions(+), 20 deletions(-) create mode 100644 pj-report/src/main/java/com/g2forge/project/report/WorkingHours.java create mode 100644 pj-report/src/test/java/com/g2forge/project/report/TestWorkingHours.java diff --git a/pj-report/src/main/java/com/g2forge/project/report/Billing.java b/pj-report/src/main/java/com/g2forge/project/report/Billing.java index 60ac089..3261958 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Billing.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Billing.java @@ -10,14 +10,20 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.slf4j.event.Level; import com.atlassian.jira.rest.client.api.IssueRestClient; +import com.atlassian.jira.rest.client.api.domain.BasicComponent; import com.atlassian.jira.rest.client.api.domain.ChangelogGroup; import com.atlassian.jira.rest.client.api.domain.Issue; import com.atlassian.jira.rest.client.api.domain.SearchResult; @@ -26,18 +32,26 @@ import com.g2forge.alexandria.command.command.IStandardCommand; import com.g2forge.alexandria.command.exit.IExit; import com.g2forge.alexandria.command.invocation.CommandInvocation; +import com.g2forge.alexandria.java.adt.compare.IComparable; +import com.g2forge.alexandria.java.core.error.UnreachableCodeError; import com.g2forge.alexandria.java.core.helpers.HCollection; import com.g2forge.alexandria.java.core.helpers.HCollector; +import com.g2forge.alexandria.java.function.IFunction1; +import com.g2forge.alexandria.java.function.IPredicate1; +import com.g2forge.alexandria.java.function.builder.IBuilder; import com.g2forge.alexandria.java.io.dataaccess.PathDataSource; import com.g2forge.alexandria.log.HLog; import com.g2forge.gearbox.argparse.ArgumentParser; import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; import com.g2forge.project.core.HConfig; +import com.g2forge.project.report.Billing.Bill.Key; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.Singular; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -51,6 +65,22 @@ protected static class Arguments { protected final Path request; } + protected static Map computeBillableHoursByUser(List changes, IPredicate1 isStatusBillable, IFunction1 workingHoursFunction) { + final Map retVal = new TreeMap<>(); + for (int i = 0; i < changes.size() - 1; i++) { + final Change change = changes.get(i); + if (!isStatusBillable.test(change.getStatus())) continue; + final WorkingHours workingHours = workingHoursFunction.apply(change.getAssignee()); + final Double billable = workingHours.computeBillableHours(change.getStart(), changes.get(i + 1).getStart()); + if (billable < 0) throw new UnreachableCodeError(); + if (billable > 0) { + final Double previous = retVal.get(change.getAssignee()); + retVal.put(change.getAssignee(), (previous == null ? 0.0 : previous) + billable); + } + } + return retVal; + } + public static ZonedDateTime convert(DateTime dateTime) { final Instant instant = Instant.ofEpochMilli(dateTime.getMillis()); final ZoneId zoneId = ZoneId.of(dateTime.getZone().getID(), ZoneId.SHORT_IDS); @@ -83,7 +113,7 @@ protected List computeChanges(ExtendedJiraRestClient client, String issu return Change.toChanges(changelog, start, end, issue.getAssignee().getName(), issue.getStatus().getName(), users); } - protected List findRelevantIssues(ExtendedJiraRestClient client, List users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { + protected List findRelevantIssues(ExtendedJiraRestClient client, Collection users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { final List retVal = new ArrayList<>(); for (String user : users) { final SearchResult result = client.getSearchClient().searchJql(String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", user, start.format(DATE_FORMAT), end.format(DATE_FORMAT))).get(); @@ -92,6 +122,77 @@ protected List findRelevantIssues(ExtendedJiraRestClient client, List { + public BillBuilder add(String component, String user, String issue, double amount) { + final Key key = new Key(component, user, issue); + if (amounts$key != null) { + final int index = amounts$key.indexOf(key); + if (index >= 0) { + amounts$value.set(index, amounts$value.get(index) + amount); + return this; + } + } + return amount(key, amount); + } + } + + @Data + @Builder(toBuilder = true) + @RequiredArgsConstructor + public static class Key implements IComparable { + protected final String component; + + protected final String user; + + protected final String issue; + + @Override + public int compareTo(Key o) { + final int component = getComponent().compareTo(o.getComponent()); + if (component != 0) return component; + + final int user = getUser().compareTo(o.getUser()); + if (user != 0) return user; + + final int issue = getIssue().compareTo(o.getIssue()); + return issue; + } + } + + @Singular + protected final Map amounts; + + public Bill filterBy(String component, String user, String issue) { + return new Bill(getAmounts().entrySet().stream().filter(entry -> { + final Key key = entry.getKey(); + if ((component != null) && !key.getComponent().equals(component)) return false; + if ((user != null) && !key.getUser().equals(user)) return false; + if ((issue != null) && !key.getIssue().equals(issue)) return false; + return true; + }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + } + + public Set getComponents() { + return getAmounts().keySet().stream().map(Key::getComponent).collect(Collectors.toSet()); + } + + public Set getUsers() { + return getAmounts().keySet().stream().map(Key::getUser).collect(Collectors.toSet()); + } + + public Set getIssues() { + return getAmounts().keySet().stream().map(Key::getIssue).collect(Collectors.toSet()); + } + + public double getTotal() { + return getAmounts().values().stream().mapToDouble(Double::doubleValue).sum(); + } + } + @Override public IExit invoke(CommandInvocation invocation) throws Throwable { HLog.getLogControl().setLogLevel(Level.INFO); @@ -100,29 +201,35 @@ public IExit invoke(CommandInvocation invocation) thro final Request request = HConfig.load(new PathDataSource(arguments.getRequest()), Request.class); final JiraAPI api = JiraAPI.createFromPropertyInput(request == null ? null : request.getApi(), null); try (final ExtendedJiraRestClient client = api.connect(true)) { - final List relevantIssues = findRelevantIssues(client, request.getUsers(), request.getStart(), request.getEnd()); + final Bill.BillBuilder billBuilder = Bill.builder(); + final List relevantIssues = findRelevantIssues(client, request.getUsers().keySet(), request.getStart(), request.getEnd()); log.info("Found: {}", relevantIssues.stream().map(Issue::getKey).collect(HCollector.joining(", ", ", & "))); for (Issue issue : relevantIssues) { + final Set components = HCollection.asList(issue.getComponents()).stream().map(BasicComponent::getName).collect(Collectors.toSet()); + final Set billableComponents = HCollection.intersection(components, request.getBillableComponents()); + + // TODO: REMOVE TIME DELTA final List changes = computeChanges(client, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault()).plus(5, ChronoUnit.DAYS)); - log.info("Changes to {}", issue.getKey()); - for (Change change : changes) { - log.info("\t{} -> {} - {}", change.getStart(), change.getAssignee(), change.getStatus()); + final Map billableHoursByUser = computeBillableHoursByUser(changes, status -> request.getBillableStatuses().contains(status), request.getUsers()::get); + final Map billableHoursByUserDividedByComponents = billableHoursByUser.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue() / billableComponents.size())); + for (String billableComponent : billableComponents) { + for (Map.Entry entry : billableHoursByUserDividedByComponents.entrySet()) { + billBuilder.add(billableComponent, entry.getKey(), issue.getKey(), entry.getValue()); + } } } - } - // TODO: Log changes in assignee, along with status - // TODO: Compute intersection of working hours with issue in billable status - // Walk through the issue timeline - // For each interval project it against the assignees working hours - // If there's an overlap, then convert to billable time - - // TODO: Input - working hours for a person (just start/stop times & days of week for now, add support for exceptions later) - // TODO: Input - mapping of issues to accounts (e.g. by epic, by component, etc) - // TODO: Construct a per-person timeline - // what accounts were they working on at all times (what issues, then group issues by account, two accounts can be double billed, or split) - // Reduce issue timeline to "active" statuses, and project those times against working hours - // Abstract the projection, so I can add filters/exceptions/days-off later + final Map issues = relevantIssues.stream().collect(Collectors.toMap(Issue::getKey, IFunction1.identity())); + final Bill bill = billBuilder.build(); + for (String component : bill.getComponents()) { + final Bill byComponent = bill.filterBy(component, null, null); + log.info("{}: {}h", component, Math.ceil(byComponent.getTotal())); + for (String issue : byComponent.getIssues()) { + final Bill byIssue = byComponent.filterBy(null, null, issue); + log.info("\t{} {}: {}h", issue, issues.get(issue).getSummary(), Math.round(byIssue.getTotal() * 100.0) / 100.0); + } + } + } // TODO: Report on any times where a person was not billing to anything, but was working // TODO: Report on any times an issue changed status outside working hours diff --git a/pj-report/src/main/java/com/g2forge/project/report/Request.java b/pj-report/src/main/java/com/g2forge/project/report/Request.java index 443a9a3..7e738f8 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Request.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Request.java @@ -1,7 +1,8 @@ package com.g2forge.project.report; import java.time.LocalDate; -import java.util.List; +import java.util.Map; +import java.util.Set; import com.g2forge.gearbox.jira.JiraAPI; @@ -17,7 +18,13 @@ public class Request { protected final JiraAPI api; @Singular - protected final List users; + protected final Map users; + + @Singular + protected final Set billableStatuses; + + @Singular + protected final Set billableComponents; protected final LocalDate start; diff --git a/pj-report/src/main/java/com/g2forge/project/report/WorkingHours.java b/pj-report/src/main/java/com/g2forge/project/report/WorkingHours.java new file mode 100644 index 0000000..1a8c4d7 --- /dev/null +++ b/pj-report/src/main/java/com/g2forge/project/report/WorkingHours.java @@ -0,0 +1,44 @@ +package com.g2forge.project.report; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +@Data +@Builder(toBuilder = true) +@RequiredArgsConstructor +public class WorkingHours { + protected final Set workdays; + + protected final ZoneId zone; + + protected final LocalTime start, end; + + public double computeBillableHours(ZonedDateTime start, ZonedDateTime end) { + final LocalDate startDate = start.withZoneSameInstant(getZone()).toLocalDate(); + final LocalDate endDate = end.withZoneSameInstant(getZone()).toLocalDate(); + + double retVal = 0.0; + for (LocalDate current = startDate; !current.isAfter(endDate); current = current.plus(1, ChronoUnit.DAYS)) { + final DayOfWeek dayOfWeek = current.getDayOfWeek(); + if (!getWorkdays().contains(dayOfWeek)) continue; + + final ZonedDateTime workdayStart = getStart().atDate(current).atZone(zone); + final ZonedDateTime workdayEnd = getEnd().atDate(current).atZone(zone); + + final ZonedDateTime dayStart = workdayStart.isAfter(start) ? workdayStart : start; + final ZonedDateTime dayEnd = workdayEnd.isBefore(end) ? workdayEnd : end; + final long seconds = ChronoUnit.SECONDS.between(dayStart, dayEnd); + if (seconds > 0) retVal += seconds / (60.0 * 60.0); + } + return retVal; + } +} \ No newline at end of file diff --git a/pj-report/src/test/java/com/g2forge/project/report/TestWorkingHours.java b/pj-report/src/test/java/com/g2forge/project/report/TestWorkingHours.java new file mode 100644 index 0000000..9cff058 --- /dev/null +++ b/pj-report/src/test/java/com/g2forge/project/report/TestWorkingHours.java @@ -0,0 +1,48 @@ +package com.g2forge.project.report; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.EnumSet; + +import org.junit.Test; + +import com.g2forge.alexandria.test.HAssert; + +public class TestWorkingHours { + @Test + public void inner() { + final WorkingHours workingHours = new WorkingHours(EnumSet.complementOf(EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)), ZoneId.of("America/Los_Angeles"), LocalTime.parse("07:00:00"), LocalTime.parse("15:00:00")); + final double actual = workingHours.computeBillableHours(ZonedDateTime.parse("2025-03-18T08:00:00-07:00[America/Los_Angeles]"), ZonedDateTime.parse("2025-03-18T14:00:00-07:00[America/Los_Angeles]")); + HAssert.assertEquals(6, actual, 0.0); + } + + @Test + public void outer() { + final WorkingHours workingHours = new WorkingHours(EnumSet.complementOf(EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)), ZoneId.of("America/Los_Angeles"), LocalTime.parse("07:00:00"), LocalTime.parse("15:00:00")); + final double actual = workingHours.computeBillableHours(ZonedDateTime.parse("2025-03-18T06:00:00-07:00[America/Los_Angeles]"), ZonedDateTime.parse("2025-03-18T16:00:00-07:00[America/Los_Angeles]")); + HAssert.assertEquals(8, actual, 0.0); + } + + @Test + public void early() { + final WorkingHours workingHours = new WorkingHours(EnumSet.complementOf(EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)), ZoneId.of("America/Los_Angeles"), LocalTime.parse("07:00:00"), LocalTime.parse("15:00:00")); + final double actual = workingHours.computeBillableHours(ZonedDateTime.parse("2025-03-18T06:00:00-07:00[America/Los_Angeles]"), ZonedDateTime.parse("2025-03-18T14:00:00-07:00[America/Los_Angeles]")); + HAssert.assertEquals(7, actual, 0.0); + } + + @Test + public void late() { + final WorkingHours workingHours = new WorkingHours(EnumSet.complementOf(EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)), ZoneId.of("America/Los_Angeles"), LocalTime.parse("07:00:00"), LocalTime.parse("15:00:00")); + final double actual = workingHours.computeBillableHours(ZonedDateTime.parse("2025-03-18T08:00:00-07:00[America/Los_Angeles]"), ZonedDateTime.parse("2025-03-18T16:00:00-07:00[America/Los_Angeles]")); + HAssert.assertEquals(7, actual, 0.0); + } + + @Test + public void split() { + final WorkingHours workingHours = new WorkingHours(EnumSet.complementOf(EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)), ZoneId.of("America/Los_Angeles"), LocalTime.parse("07:00:00"), LocalTime.parse("15:00:00")); + final double actual = workingHours.computeBillableHours(ZonedDateTime.parse("2025-03-14T12:00:00-07:00[America/Los_Angeles]"), ZonedDateTime.parse("2025-03-18T12:00:00-07:00[America/Los_Angeles]")); + HAssert.assertEquals(16, actual, 0.0); + } +} From 8e1b4163ce24c90c2668ddeda1a2817ee7e8a08e Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Tue, 18 Mar 2025 16:25:11 -0700 Subject: [PATCH 5/5] G2-1740 Cleanup debug code, fix bugs, and add missing unit test --- .../com/g2forge/project/report/Billing.java | 138 +++++++++--------- .../com/g2forge/project/report/Change.java | 4 +- .../com/g2forge/project/report/Request.java | 2 +- .../g2forge/project/report/TestChange.java | 7 + 4 files changed, 83 insertions(+), 68 deletions(-) diff --git a/pj-report/src/main/java/com/g2forge/project/report/Billing.java b/pj-report/src/main/java/com/g2forge/project/report/Billing.java index 3261958..08e1dc2 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Billing.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Billing.java @@ -8,7 +8,6 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -45,7 +44,6 @@ import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; import com.g2forge.project.core.HConfig; -import com.g2forge.project.report.Billing.Bill.Key; import lombok.AllArgsConstructor; import lombok.Builder; @@ -65,63 +63,6 @@ protected static class Arguments { protected final Path request; } - protected static Map computeBillableHoursByUser(List changes, IPredicate1 isStatusBillable, IFunction1 workingHoursFunction) { - final Map retVal = new TreeMap<>(); - for (int i = 0; i < changes.size() - 1; i++) { - final Change change = changes.get(i); - if (!isStatusBillable.test(change.getStatus())) continue; - final WorkingHours workingHours = workingHoursFunction.apply(change.getAssignee()); - final Double billable = workingHours.computeBillableHours(change.getStart(), changes.get(i + 1).getStart()); - if (billable < 0) throw new UnreachableCodeError(); - if (billable > 0) { - final Double previous = retVal.get(change.getAssignee()); - retVal.put(change.getAssignee(), (previous == null ? 0.0 : previous) + billable); - } - } - return retVal; - } - - public static ZonedDateTime convert(DateTime dateTime) { - final Instant instant = Instant.ofEpochMilli(dateTime.getMillis()); - final ZoneId zoneId = ZoneId.of(dateTime.getZone().getID(), ZoneId.SHORT_IDS); - return ZonedDateTime.ofInstant(instant, zoneId); - } - - public static DateTime convert(ZonedDateTime zonedDateTime) { - final long millis = zonedDateTime.toInstant().toEpochMilli(); - final DateTimeZone dateTimeZone = DateTimeZone.forID(zonedDateTime.getZone().getId()); - return new DateTime(millis, dateTimeZone); - } - - public static void main(String[] args) throws Throwable { - IStandardCommand.main(args, new Billing()); - } - - protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); - - protected List computeChanges(ExtendedJiraRestClient client, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { - final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get(); - final Iterable changelog = issue.getChangelog(); - final Cache users = new Cache<>(id -> { - if (id == null) return null; - try { - return client.getUserClient().getUserByKey(id).get().getName(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException("Failed to look up user: " + id, e); - } - }, NeverCacheEvictionPolicy.create()); - return Change.toChanges(changelog, start, end, issue.getAssignee().getName(), issue.getStatus().getName(), users); - } - - protected List findRelevantIssues(ExtendedJiraRestClient client, Collection users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { - final List retVal = new ArrayList<>(); - for (String user : users) { - final SearchResult result = client.getSearchClient().searchJql(String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", user, start.format(DATE_FORMAT), end.format(DATE_FORMAT))).get(); - retVal.addAll(HCollection.asList(result.getIssues())); - } - return retVal; - } - @Data @Builder(toBuilder = true) @RequiredArgsConstructor @@ -180,10 +121,6 @@ public Set getComponents() { return getAmounts().keySet().stream().map(Key::getComponent).collect(Collectors.toSet()); } - public Set getUsers() { - return getAmounts().keySet().stream().map(Key::getUser).collect(Collectors.toSet()); - } - public Set getIssues() { return getAmounts().keySet().stream().map(Key::getIssue).collect(Collectors.toSet()); } @@ -191,6 +128,76 @@ public Set getIssues() { public double getTotal() { return getAmounts().values().stream().mapToDouble(Double::doubleValue).sum(); } + + public Set getUsers() { + return getAmounts().keySet().stream().map(Key::getUser).collect(Collectors.toSet()); + } + } + + protected static Map computeBillableHoursByUser(List changes, IPredicate1 isStatusBillable, IFunction1 workingHoursFunction) { + final Map retVal = new TreeMap<>(); + for (int i = 0; i < changes.size() - 1; i++) { + final Change change = changes.get(i); + if (!isStatusBillable.test(change.getStatus())) continue; + final WorkingHours workingHours = workingHoursFunction.apply(change.getAssignee()); + final Double billable = workingHours.computeBillableHours(change.getStart(), changes.get(i + 1).getStart()); + if (billable < 0) throw new UnreachableCodeError(); + if (billable > 0) { + final Double previous = retVal.get(change.getAssignee()); + retVal.put(change.getAssignee(), (previous == null ? 0.0 : previous) + billable); + } + } + return retVal; + } + + public static ZonedDateTime convert(DateTime dateTime) { + final Instant instant = Instant.ofEpochMilli(dateTime.getMillis()); + final ZoneId zoneId = ZoneId.of(dateTime.getZone().getID(), ZoneId.SHORT_IDS); + return ZonedDateTime.ofInstant(instant, zoneId); + } + + public static DateTime convert(ZonedDateTime zonedDateTime) { + final long millis = zonedDateTime.toInstant().toEpochMilli(); + final DateTimeZone dateTimeZone = DateTimeZone.forID(zonedDateTime.getZone().getId()); + return new DateTime(millis, dateTimeZone); + } + + public static void main(String[] args) throws Throwable { + IStandardCommand.main(args, new Billing()); + } + + protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); + + protected List computeChanges(ExtendedJiraRestClient client, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { + final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get(); + final Iterable changelog = issue.getChangelog(); + final Cache users = new Cache<>(id -> { + if (id == null) return null; + try { + return client.getUserClient().getUserByKey(id).get().getName(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException("Failed to look up user: " + id, e); + } + }, NeverCacheEvictionPolicy.create()); + return Change.toChanges(changelog, start, end, issue.getAssignee().getName(), issue.getStatus().getName(), users); + } + + protected List findRelevantIssues(ExtendedJiraRestClient client, Collection users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { + final List retVal = new ArrayList<>(); + for (String user : users) { + final String jql = String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", user, start.format(DATE_FORMAT), end.format(DATE_FORMAT)); + final int max = 500; + int base = 0; + while (true) { + final SearchResult searchResult = client.getSearchClient().searchJql(jql, max, base, null).get(); + log.info("Got issues {} to {} of {}", base, base + Math.min(searchResult.getMaxResults(), searchResult.getTotal() - base), searchResult.getTotal()); + + retVal.addAll(HCollection.asList(searchResult.getIssues())); + if ((base + max) >= searchResult.getTotal()) break; + else base += max; + } + } + return retVal; } @Override @@ -207,9 +214,9 @@ public IExit invoke(CommandInvocation invocation) thro for (Issue issue : relevantIssues) { final Set components = HCollection.asList(issue.getComponents()).stream().map(BasicComponent::getName).collect(Collectors.toSet()); final Set billableComponents = HCollection.intersection(components, request.getBillableComponents()); + if (billableComponents.isEmpty()) continue; - // TODO: REMOVE TIME DELTA - final List changes = computeChanges(client, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault()).plus(5, ChronoUnit.DAYS)); + final List changes = computeChanges(client, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault())); final Map billableHoursByUser = computeBillableHoursByUser(changes, status -> request.getBillableStatuses().contains(status), request.getUsers()::get); final Map billableHoursByUserDividedByComponents = billableHoursByUser.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue() / billableComponents.size())); for (String billableComponent : billableComponents) { @@ -230,6 +237,7 @@ public IExit invoke(CommandInvocation invocation) thro } } } + // TODO: Report on any times where a person was not billing to anything, but was working // TODO: Report on any times an issue changed status outside working hours diff --git a/pj-report/src/main/java/com/g2forge/project/report/Change.java b/pj-report/src/main/java/com/g2forge/project/report/Change.java index f3decd3..6fe573c 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Change.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Change.java @@ -48,11 +48,11 @@ public static List toChanges(final Iterable changelog, Z // IF the status changed (not all change log groups include a chance to the status), then... if ((toAssignee != null) || (toStatus != null)) { if (created.isAfter(end)) { - if (fromAssignee != null) { + if (!foundFinalAssignee && (fromAssignee != null)) { finalAssignee = fromAssignee; foundFinalAssignee = true; } - if (fromStatus != null) { + if (!foundFinalStatus && (fromStatus != null)) { finalStatus = fromStatus; foundFinalStatus = true; } diff --git a/pj-report/src/main/java/com/g2forge/project/report/Request.java b/pj-report/src/main/java/com/g2forge/project/report/Request.java index 7e738f8..6970171 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/Request.java +++ b/pj-report/src/main/java/com/g2forge/project/report/Request.java @@ -22,7 +22,7 @@ public class Request { @Singular protected final Set billableStatuses; - + @Singular protected final Set billableComponents; diff --git a/pj-report/src/test/java/com/g2forge/project/report/TestChange.java b/pj-report/src/test/java/com/g2forge/project/report/TestChange.java index 167a178..8c68cef 100644 --- a/pj-report/src/test/java/com/g2forge/project/report/TestChange.java +++ b/pj-report/src/test/java/com/g2forge/project/report/TestChange.java @@ -20,6 +20,7 @@ public class TestChange { protected static final ZonedDateTime START_PLUS30 = ZonedDateTime.parse("2025-01-01T13:30:00-07:00[America/Los_Angeles]"); protected static final ZonedDateTime START_PLUS45 = ZonedDateTime.parse("2025-01-01T13:45:00-07:00[America/Los_Angeles]"); protected static final ZonedDateTime END_PLUS20 = ZonedDateTime.parse("2025-01-01T14:20:00-07:00[America/Los_Angeles]"); + protected static final ZonedDateTime END_PLUS40 = ZonedDateTime.parse("2025-01-01T14:40:00-07:00[America/Los_Angeles]"); protected ChangelogGroup changeAssignee(ZonedDateTime when, String fromAssignee, String toAssignee) { return new ChangelogGroup(null, Billing.convert(when), HCollection.asList(new ChangelogItem(null, KnownField.Assignee.getName(), fromAssignee, fromAssignee, toAssignee, toAssignee))); @@ -35,6 +36,12 @@ public void testToChangesAllAfter() { HAssert.assertEquals(HCollection.asList(new Change(START, "user", "State"), new Change(END, "user", "State")), actual); } + @Test + public void testToChangesDoubleAfter() { + final List actual = Change.toChanges(HCollection.asList(changeStatus(END_PLUS20, "State", "Ignored1"), changeStatus(END_PLUS40, "Ignored1", "Ignored2")), START, END, "user", "Ignored", IFunction1.identity()); + HAssert.assertEquals(HCollection.asList(new Change(START, "user", "State"), new Change(END, "user", "State")), actual); + } + @Test public void testToChangesAllBefore() { final List actual = Change.toChanges(HCollection.asList(changeStatus(START_MINUS20, "Ignored", "State")), START, END, "user", "State", IFunction1.identity());