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 super String, ? extends WorkingHours> 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 extends String> 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);
+ }
+}