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..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 @@ -1,33 +1,55 @@ 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.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.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; -import com.g2forge.alexandria.java.adt.name.IStringNamed; +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.gearbox.jira.fields.KnownField; +import com.g2forge.project.core.HConfig; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.Singular; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -37,30 +59,145 @@ public class Billing implements IStandardCommand { @AllArgsConstructor protected static class Arguments { protected final String issueKey; + + protected final Path request; + } + + @Data + @Builder(toBuilder = true) + @RequiredArgsConstructor + public static class Bill { + public static class BillBuilder implements IBuilder { + 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 getIssues() { + return getAmounts().keySet().stream().map(Key::getIssue).collect(Collectors.toSet()); + } + + 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 void demoLogChanges(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()); - } - } + 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 @@ -68,19 +205,39 @@ public IExit invoke(CommandInvocation invocation) thro 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)) { + 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()); + if (billableComponents.isEmpty()) continue; + + 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) { + for (Map.Entry entry : billableHoursByUserDividedByComponents.entrySet()) { + billBuilder.add(billableComponent, entry.getKey(), issue.getKey(), entry.getValue()); + } + } + } - // 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) + 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: 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 - // 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 // 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 new file mode 100644 index 0000000..6fe573c --- /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 (!foundFinalAssignee && (fromAssignee != null)) { + finalAssignee = fromAssignee; + foundFinalAssignee = true; + } + if (!foundFinalStatus && (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/main/java/com/g2forge/project/report/Request.java b/pj-report/src/main/java/com/g2forge/project/report/Request.java index a2eac09..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 @@ -1,6 +1,8 @@ package com.g2forge.project.report; -import java.util.List; +import java.time.LocalDate; +import java.util.Map; +import java.util.Set; import com.g2forge.gearbox.jira.JiraAPI; @@ -16,5 +18,15 @@ 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; + + protected final LocalDate end; } \ No newline at end of file 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/TestChange.java b/pj-report/src/test/java/com/g2forge/project/report/TestChange.java new file mode 100644 index 0000000..8c68cef --- /dev/null +++ b/pj-report/src/test/java/com/g2forge/project/report/TestChange.java @@ -0,0 +1,74 @@ +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 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))); + } + + 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 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()); + 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); + } +} 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); + } +}