From 0244a9b99f3e3477146bcb11fb5dbabb31362e91 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Wed, 19 Mar 2025 11:55:41 -0700 Subject: [PATCH 1/6] Component regex/glob matching, issue filtering, logging, and minor bug fixes --- pj-report/pom.xml | 5 ++ .../com/g2forge/project/report/Billing.java | 64 +++++++++++++------ .../com/g2forge/project/report/Request.java | 4 ++ 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/pj-report/pom.xml b/pj-report/pom.xml index b4e4240..f90cb12 100644 --- a/pj-report/pom.xml +++ b/pj-report/pom.xml @@ -19,5 +19,10 @@ pj-core ${project.version} + + com.g2forge.alexandria + ax-match + ${alexandria.version} + 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 08e1dc2..95865fc 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 @@ -40,6 +40,7 @@ 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.alexandria.match.HMatch; import com.g2forge.gearbox.argparse.ArgumentParser; import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; @@ -58,8 +59,6 @@ public class Billing implements IStandardCommand { @Builder(toBuilder = true) @AllArgsConstructor protected static class Arguments { - protected final String issueKey; - protected final Path request; } @@ -138,8 +137,11 @@ protected static Map computeBillableHoursByUser(List cha 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; + if ((change.getAssignee() == null) || !isStatusBillable.test(change.getStatus())) continue; + final WorkingHours workingHours = workingHoursFunction.apply(change.getAssignee()); + if (workingHours == null) throw new IllegalArgumentException("No working hours found for user \"" + change.getAssignee() + "\", please configure the billing report to include the working hours for that user!"); + final Double billable = workingHours.computeBillableHours(change.getStart(), changes.get(i + 1).getStart()); if (billable < 0) throw new UnreachableCodeError(); if (billable > 0) { @@ -168,13 +170,13 @@ public static void main(String[] args) throws Throwable { protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); - protected List computeChanges(ExtendedJiraRestClient client, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { + protected List computeChanges(ExtendedJiraRestClient client, String userQueryParameter, 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(); + return client.getUserClient().getUserByQueryParam(userQueryParameter, id).get().getName(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException("Failed to look up user: " + id, e); } @@ -182,19 +184,21 @@ 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, Collection users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { + protected List findRelevantIssues(ExtendedJiraRestClient client, String jql, 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; + log.info("Finding issues for {}", user); + final String compositeJQL = String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", user, start.format(DATE_FORMAT), end.format(DATE_FORMAT)) + ((jql == null) ? "" : (" AND " + jql)); + final int desiredMax = 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()); + final SearchResult searchResult = client.getSearchClient().searchJql(compositeJQL, desiredMax, base, null).get(); + final int actualMax = searchResult.getMaxResults(); + log.info("\tGot issues {} to {} of {}", base, base + Math.min(actualMax, searchResult.getTotal() - base), searchResult.getTotal()); retVal.addAll(HCollection.asList(searchResult.getIssues())); - if ((base + max) >= searchResult.getTotal()) break; - else base += max; + if ((base + actualMax) >= searchResult.getTotal()) break; + else base += actualMax; } } return retVal; @@ -206,17 +210,25 @@ public IExit invoke(CommandInvocation invocation) thro final Arguments arguments = ArgumentParser.parse(Arguments.class, invocation.getArguments()); final Request request = HConfig.load(new PathDataSource(arguments.getRequest()), Request.class); + final String userQueryParameter = request.getUserQueryParameter() == null ? "key" : request.getUserQueryParameter(); + final IPredicate1 isComponentBillable = HMatch.createPredicate(true, request.getBillableComponents()); + 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()); + + final Map issues; + { + final List relevantIssues = findRelevantIssues(client, request.getJql(), request.getUsers().keySet(), request.getStart(), request.getEnd()); + issues = relevantIssues.stream().collect(Collectors.toMap(Issue::getKey, IFunction1.identity(), (i0, i1) -> i0)); + } + log.info("Found: {}", issues.keySet().stream().collect(HCollector.joining(", ", ", & "))); + for (Issue issue : issues.values()) { + log.info("Examining {}", issue.getKey()); + final Set billableComponents = HCollection.asList(issue.getComponents()).stream().map(BasicComponent::getName).distinct().filter(isComponentBillable).collect(Collectors.toSet()); if (billableComponents.isEmpty()) continue; - final List changes = computeChanges(client, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault())); + final List changes = computeChanges(client, userQueryParameter, 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) { @@ -226,14 +238,24 @@ public IExit invoke(CommandInvocation invocation) thro } } - final Map issues = relevantIssues.stream().collect(Collectors.toMap(Issue::getKey, IFunction1.identity())); final Bill bill = billBuilder.build(); + log.info("Bill by component"); for (String component : bill.getComponents()) { final Bill byComponent = bill.filterBy(component, null, null); - log.info("{}: {}h", component, Math.ceil(byComponent.getTotal())); + log.info("\t{}: {}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); + log.info("\t\t{} {}: {}h", issue, issues.get(issue).getSummary(), Math.round(byIssue.getTotal() * 100.0) / 100.0); + } + } + + log.info("Bill by user"); + for (String user : bill.getUsers()) { + final Bill byUser = bill.filterBy(null, user, null); + log.info("\t{}: {}h", user, Math.ceil(byUser.getTotal())); + for (String issue : byUser.getIssues()) { + final Bill byIssue = byUser.filterBy(null, null, issue); + log.info("\t\t{} {}: {}h", issue, issues.get(issue).getSummary(), Math.round(byIssue.getTotal() * 100.0) / 100.0); } } } 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 6970171..0372e1c 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 @@ -17,6 +17,10 @@ public class Request { protected final JiraAPI api; + protected final String userQueryParameter; + + protected final String jql; + @Singular protected final Map users; From 93923defbb8df5af54964bdd9cea29b290a943b0 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Wed, 19 Mar 2025 12:56:17 -0700 Subject: [PATCH 2/6] Primary user key mapping support --- .../com/g2forge/project/core}/Server.java | 5 +- .../g2forge/project/plan/create/Create.java | 1 + .../com/g2forge/project/report/Billing.java | 57 ++++++++++++------- .../com/g2forge/project/report/Request.java | 6 -- 4 files changed, 42 insertions(+), 27 deletions(-) rename {pj-create/src/main/java/com/g2forge/project/plan/create => pj-core/src/main/java/com/g2forge/project/core}/Server.java (81%) diff --git a/pj-create/src/main/java/com/g2forge/project/plan/create/Server.java b/pj-core/src/main/java/com/g2forge/project/core/Server.java similarity index 81% rename from pj-create/src/main/java/com/g2forge/project/plan/create/Server.java rename to pj-core/src/main/java/com/g2forge/project/core/Server.java index 2c9a754..0808a01 100644 --- a/pj-create/src/main/java/com/g2forge/project/plan/create/Server.java +++ b/pj-core/src/main/java/com/g2forge/project/core/Server.java @@ -1,4 +1,4 @@ -package com.g2forge.project.plan.create; +package com.g2forge.project.core; import java.util.Map; @@ -6,6 +6,7 @@ import com.g2forge.gearbox.jira.fields.Field; import com.g2forge.gearbox.jira.fields.IFieldConfig; import com.g2forge.gearbox.jira.fields.KnownField; +import com.g2forge.gearbox.jira.user.UserPrimaryKey; import lombok.AllArgsConstructor; import lombok.Builder; @@ -24,5 +25,7 @@ public class Server implements IFieldConfig { @Singular protected final Map users; + protected final UserPrimaryKey userPrimaryKey; + protected final JiraAPI api; } 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 ecd09f6..ae8b427 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 @@ -44,6 +44,7 @@ import com.g2forge.gearbox.jira.JiraAPI; import com.g2forge.gearbox.jira.fields.KnownField; import com.g2forge.project.core.HConfig; +import com.g2forge.project.core.Server; import com.g2forge.project.plan.create.CreateIssue.CreateIssueBuilder; import com.google.common.base.Objects; 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 95865fc..6f44976 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 @@ -26,6 +26,7 @@ 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; +import com.atlassian.jira.rest.client.api.domain.User; import com.g2forge.alexandria.adt.associative.cache.Cache; import com.g2forge.alexandria.adt.associative.cache.NeverCacheEvictionPolicy; import com.g2forge.alexandria.command.command.IStandardCommand; @@ -44,7 +45,9 @@ import com.g2forge.gearbox.argparse.ArgumentParser; import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; +import com.g2forge.gearbox.jira.user.UserPrimaryKey; import com.g2forge.project.core.HConfig; +import com.g2forge.project.core.Server; import lombok.AllArgsConstructor; import lombok.Builder; @@ -59,6 +62,8 @@ public class Billing implements IStandardCommand { @Builder(toBuilder = true) @AllArgsConstructor protected static class Arguments { + protected final Path server; + protected final Path request; } @@ -170,18 +175,26 @@ public static void main(String[] args) throws Throwable { protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); - protected List computeChanges(ExtendedJiraRestClient client, String userQueryParameter, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { + protected List computeChanges(ExtendedJiraRestClient client, Server server, 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; + + final UserPrimaryKey userPrimaryKey = server.getUserPrimaryKey() == null ? UserPrimaryKey.NAME : server.getUserPrimaryKey(); + final Map userReverseMap = server.getUsers().entrySet().stream().collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); + final IFunction1 toFriendly = user -> { + final String primaryKey = userPrimaryKey.getValue(user); + return userReverseMap.getOrDefault(primaryKey, primaryKey); + }; + final Cache users = new Cache<>(primaryKey -> { + if (primaryKey == null) return null; try { - return client.getUserClient().getUserByQueryParam(userQueryParameter, id).get().getName(); + final User user = client.getUserClient().getUserByQueryParam(userPrimaryKey.getQueryParameter(), primaryKey).get(); + return toFriendly.apply(user); } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException("Failed to look up user: " + id, e); + throw new RuntimeException("Failed to look up user: " + primaryKey, e); } }, NeverCacheEvictionPolicy.create()); - return Change.toChanges(changelog, start, end, issue.getAssignee().getName(), issue.getStatus().getName(), users); + return Change.toChanges(changelog, start, end, toFriendly.apply(issue.getAssignee()), issue.getStatus().getName(), users); } protected List findRelevantIssues(ExtendedJiraRestClient client, String jql, Collection users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { @@ -204,16 +217,31 @@ protected List findRelevantIssues(ExtendedJiraRestClient client, String j return retVal; } + protected void examineIssue(final ExtendedJiraRestClient client, Server server, Request request, IPredicate1 isComponentBillable, Issue issue, Bill.BillBuilder billBuilder) throws InterruptedException, ExecutionException { + log.info("Examining {}", issue.getKey()); + final Set billableComponents = HCollection.asList(issue.getComponents()).stream().map(BasicComponent::getName).distinct().filter(isComponentBillable).collect(Collectors.toSet()); + if (billableComponents.isEmpty()) return; + + final List changes = computeChanges(client, server, 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()); + } + } + } + @Override public IExit invoke(CommandInvocation invocation) throws Throwable { HLog.getLogControl().setLogLevel(Level.INFO); final Arguments arguments = ArgumentParser.parse(Arguments.class, invocation.getArguments()); + final Server server = HConfig.load(new PathDataSource(arguments.getServer()), Server.class); final Request request = HConfig.load(new PathDataSource(arguments.getRequest()), Request.class); - final String userQueryParameter = request.getUserQueryParameter() == null ? "key" : request.getUserQueryParameter(); final IPredicate1 isComponentBillable = HMatch.createPredicate(true, request.getBillableComponents()); - final JiraAPI api = JiraAPI.createFromPropertyInput(request == null ? null : request.getApi(), null); + final JiraAPI api = JiraAPI.createFromPropertyInput(server == null ? null : server.getApi(), null); try (final ExtendedJiraRestClient client = api.connect(true)) { final Bill.BillBuilder billBuilder = Bill.builder(); @@ -224,18 +252,7 @@ public IExit invoke(CommandInvocation invocation) thro } log.info("Found: {}", issues.keySet().stream().collect(HCollector.joining(", ", ", & "))); for (Issue issue : issues.values()) { - log.info("Examining {}", issue.getKey()); - final Set billableComponents = HCollection.asList(issue.getComponents()).stream().map(BasicComponent::getName).distinct().filter(isComponentBillable).collect(Collectors.toSet()); - if (billableComponents.isEmpty()) continue; - - final List changes = computeChanges(client, userQueryParameter, 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()); - } - } + examineIssue(client, server, request, isComponentBillable, issue, billBuilder); } final Bill bill = billBuilder.build(); 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 0372e1c..025be9c 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 @@ -4,8 +4,6 @@ import java.util.Map; import java.util.Set; -import com.g2forge.gearbox.jira.JiraAPI; - import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -15,10 +13,6 @@ @Builder(toBuilder = true) @AllArgsConstructor public class Request { - protected final JiraAPI api; - - protected final String userQueryParameter; - protected final String jql; @Singular From 95d84bb4be75a3e7dffa5d78224c970caa118051 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Wed, 19 Mar 2025 13:03:42 -0700 Subject: [PATCH 3/6] Sort changelog groups by creation order --- .../src/main/java/com/g2forge/project/report/Change.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 6fe573c..b3e3e0d 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 @@ -3,9 +3,13 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import com.atlassian.jira.rest.client.api.domain.ChangelogGroup; import com.atlassian.jira.rest.client.api.domain.ChangelogItem; +import com.g2forge.alexandria.java.adt.compare.ComparableComparator; +import com.g2forge.alexandria.java.adt.compare.MappedComparator; +import com.g2forge.alexandria.java.core.helpers.HCollection; import com.g2forge.alexandria.java.function.IFunction1; import com.g2forge.gearbox.jira.fields.KnownField; @@ -27,7 +31,8 @@ public static List toChanges(final Iterable changelog, Z final List retVal = new ArrayList<>(); String finalAssignee = assignee, finalStatus = status; boolean foundFinalAssignee = false, foundFinalStatus = false; - for (ChangelogGroup changelogGroup : changelog) { + final List sorted = HCollection.asList(changelog).stream().sorted(new MappedComparator<>(ChangelogGroup::getCreated, ComparableComparator.create())).collect(Collectors.toList()); + for (ChangelogGroup changelogGroup : sorted) { final ZonedDateTime created = Billing.convert(changelogGroup.getCreated()); // Ignore changes before the start, and stop processing after the end if (created.isBefore(start)) continue; From aa1ae25b415626de40f90635e208f1732c165e1f Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Wed, 19 Mar 2025 14:30:17 -0700 Subject: [PATCH 4/6] Add CSV output --- pj-report/pom.xml | 5 ++++ .../com/g2forge/project/report/BillLine.java | 27 +++++++++++++++++++ .../com/g2forge/project/report/Billing.java | 12 ++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 pj-report/src/main/java/com/g2forge/project/report/BillLine.java diff --git a/pj-report/pom.xml b/pj-report/pom.xml index f90cb12..233e411 100644 --- a/pj-report/pom.xml +++ b/pj-report/pom.xml @@ -24,5 +24,10 @@ ax-match ${alexandria.version} + + com.g2forge.gearbox + gb-csv + ${gearbox.version} + diff --git a/pj-report/src/main/java/com/g2forge/project/report/BillLine.java b/pj-report/src/main/java/com/g2forge/project/report/BillLine.java new file mode 100644 index 0000000..e50ffeb --- /dev/null +++ b/pj-report/src/main/java/com/g2forge/project/report/BillLine.java @@ -0,0 +1,27 @@ +package com.g2forge.project.report; + +import com.g2forge.gearbox.csv.CSVMapper; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder(toBuilder = true) +public class BillLine { + @Getter(lazy = true) + private static final CSVMapper mapper = new CSVMapper<>(BillLine.class, "component", "key", "summary", "hours"); + + protected String component; + + protected String key; + + protected String summary; + + protected double hours; + +} 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 6f44976..be98cec 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 @@ -3,6 +3,7 @@ import java.io.InputStream; import java.io.PrintStream; import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; @@ -42,6 +43,7 @@ import com.g2forge.alexandria.java.io.dataaccess.PathDataSource; import com.g2forge.alexandria.log.HLog; import com.g2forge.alexandria.match.HMatch; +import com.g2forge.alexandria.path.path.filename.Filename; import com.g2forge.gearbox.argparse.ArgumentParser; import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; @@ -256,15 +258,22 @@ public IExit invoke(CommandInvocation invocation) thro } final Bill bill = billBuilder.build(); + final List billLines = new ArrayList<>(); log.info("Bill by component"); for (String component : bill.getComponents()) { final Bill byComponent = bill.filterBy(component, null, null); log.info("\t{}: {}h", component, Math.ceil(byComponent.getTotal())); for (String issue : byComponent.getIssues()) { final Bill byIssue = byComponent.filterBy(null, null, issue); - log.info("\t\t{} {}: {}h", issue, issues.get(issue).getSummary(), Math.round(byIssue.getTotal() * 100.0) / 100.0); + final String summary = issues.get(issue).getSummary(); + final double hours = Math.round(byIssue.getTotal() * 100.0) / 100.0; + log.info("\t\t{} {}: {}h", issue, summary, hours); + billLines.add(new BillLine(component, issue, summary, hours)); } } + final Path outputFile = Filename.replaceExtension(arguments.getRequest(), "csv"); + log.info("Writing bill to {}", outputFile); + BillLine.getMapper().write(billLines, outputFile); log.info("Bill by user"); for (String user : bill.getUsers()) { @@ -275,6 +284,7 @@ public IExit invoke(CommandInvocation invocation) thro log.info("\t\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 From e564240afa47d5a7980ada5b85b5cb9a5d0fc3bf Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Thu, 20 Mar 2025 09:39:16 -0700 Subject: [PATCH 5/6] Expand report to include details --- .../java/com/g2forge/project/core/Server.java | 4 ++ .../com/g2forge/project/report/BillLine.java | 7 ++- .../com/g2forge/project/report/Billing.java | 60 +++++++++++++------ 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/pj-core/src/main/java/com/g2forge/project/core/Server.java b/pj-core/src/main/java/com/g2forge/project/core/Server.java index 0808a01..e67ffd4 100644 --- a/pj-core/src/main/java/com/g2forge/project/core/Server.java +++ b/pj-core/src/main/java/com/g2forge/project/core/Server.java @@ -28,4 +28,8 @@ public class Server implements IFieldConfig { protected final UserPrimaryKey userPrimaryKey; protected final JiraAPI api; + + public UserPrimaryKey getUserPrimaryKey() { + return userPrimaryKey == null ? UserPrimaryKey.NAME : userPrimaryKey; + } } diff --git a/pj-report/src/main/java/com/g2forge/project/report/BillLine.java b/pj-report/src/main/java/com/g2forge/project/report/BillLine.java index e50ffeb..cd15d7c 100644 --- a/pj-report/src/main/java/com/g2forge/project/report/BillLine.java +++ b/pj-report/src/main/java/com/g2forge/project/report/BillLine.java @@ -14,14 +14,19 @@ @Builder(toBuilder = true) public class BillLine { @Getter(lazy = true) - private static final CSVMapper mapper = new CSVMapper<>(BillLine.class, "component", "key", "summary", "hours"); + private static final CSVMapper mapper = new CSVMapper<>(BillLine.class, "component", "assignee", "key", "summary", "hours", "ranges", "link"); protected String component; + protected String assignee; + protected String key; protected String summary; protected double hours; + protected String ranges; + + protected String link; } 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 be98cec..3bc9c86 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 @@ -3,9 +3,9 @@ import java.io.InputStream; import java.io.PrintStream; import java.nio.file.Path; -import java.nio.file.Paths; import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -47,7 +47,6 @@ import com.g2forge.gearbox.argparse.ArgumentParser; import com.g2forge.gearbox.jira.ExtendedJiraRestClient; import com.g2forge.gearbox.jira.JiraAPI; -import com.g2forge.gearbox.jira.user.UserPrimaryKey; import com.g2forge.project.core.HConfig; import com.g2forge.project.core.Server; @@ -177,26 +176,20 @@ public static void main(String[] args) throws Throwable { protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd"); - protected List computeChanges(ExtendedJiraRestClient client, Server server, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { + protected List computeChanges(ExtendedJiraRestClient client, Server server, IFunction1 userToFriendly, 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 UserPrimaryKey userPrimaryKey = server.getUserPrimaryKey() == null ? UserPrimaryKey.NAME : server.getUserPrimaryKey(); - final Map userReverseMap = server.getUsers().entrySet().stream().collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); - final IFunction1 toFriendly = user -> { - final String primaryKey = userPrimaryKey.getValue(user); - return userReverseMap.getOrDefault(primaryKey, primaryKey); - }; - final Cache users = new Cache<>(primaryKey -> { + final IFunction1 users = new Cache<>(primaryKey -> { if (primaryKey == null) return null; try { - final User user = client.getUserClient().getUserByQueryParam(userPrimaryKey.getQueryParameter(), primaryKey).get(); - return toFriendly.apply(user); + final User user = client.getUserClient().getUserByQueryParam(server.getUserPrimaryKey().getQueryParameter(), primaryKey).get(); + return userToFriendly.apply(user); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException("Failed to look up user: " + primaryKey, e); } }, NeverCacheEvictionPolicy.create()); - return Change.toChanges(changelog, start, end, toFriendly.apply(issue.getAssignee()), issue.getStatus().getName(), users); + return Change.toChanges(changelog, start, end, userToFriendly.apply(issue.getAssignee()), issue.getStatus().getName(), users); } protected List findRelevantIssues(ExtendedJiraRestClient client, String jql, Collection users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { @@ -219,21 +212,24 @@ protected List findRelevantIssues(ExtendedJiraRestClient client, String j return retVal; } - protected void examineIssue(final ExtendedJiraRestClient client, Server server, Request request, IPredicate1 isComponentBillable, Issue issue, Bill.BillBuilder billBuilder) throws InterruptedException, ExecutionException { + protected List examineIssue(final ExtendedJiraRestClient client, Server server, Request request, IPredicate1 isStatusBillable, IPredicate1 isComponentBillable, IFunction1 userToFriendly, Issue issue, Bill.BillBuilder billBuilder) throws InterruptedException, ExecutionException { log.info("Examining {}", issue.getKey()); final Set billableComponents = HCollection.asList(issue.getComponents()).stream().map(BasicComponent::getName).distinct().filter(isComponentBillable).collect(Collectors.toSet()); - if (billableComponents.isEmpty()) return; + if (billableComponents.isEmpty()) return null; - final List changes = computeChanges(client, server, 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 List changes = computeChanges(client, server, userToFriendly, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault())); + final Map billableHoursByUser = computeBillableHoursByUser(changes, isStatusBillable, 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()); } } + return changes; } + protected static final DateTimeFormatter RANGE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd/ HH:mm:ss"); + @Override public IExit invoke(CommandInvocation invocation) throws Throwable { HLog.getLogControl().setLogLevel(Level.INFO); @@ -241,20 +237,28 @@ public IExit invoke(CommandInvocation invocation) thro final Server server = HConfig.load(new PathDataSource(arguments.getServer()), Server.class); final Request request = HConfig.load(new PathDataSource(arguments.getRequest()), Request.class); + final IPredicate1 isStatusBillable = status -> request.getBillableStatuses().contains(status); final IPredicate1 isComponentBillable = HMatch.createPredicate(true, request.getBillableComponents()); + final Map userReverseMap = server.getUsers().entrySet().stream().collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); + final IFunction1 userToFriendly = user -> { + final String primaryKey = server.getUserPrimaryKey().getValue(user); + return userReverseMap.getOrDefault(primaryKey, primaryKey); + }; + final JiraAPI api = JiraAPI.createFromPropertyInput(server == null ? null : server.getApi(), null); try (final ExtendedJiraRestClient client = api.connect(true)) { final Bill.BillBuilder billBuilder = Bill.builder(); final Map issues; + final Map> changes = new TreeMap<>(); { final List relevantIssues = findRelevantIssues(client, request.getJql(), request.getUsers().keySet(), request.getStart(), request.getEnd()); issues = relevantIssues.stream().collect(Collectors.toMap(Issue::getKey, IFunction1.identity(), (i0, i1) -> i0)); } log.info("Found: {}", issues.keySet().stream().collect(HCollector.joining(", ", ", & "))); for (Issue issue : issues.values()) { - examineIssue(client, server, request, isComponentBillable, issue, billBuilder); + changes.put(issue.getKey(), examineIssue(client, server, request, isStatusBillable, isComponentBillable, userToFriendly, issue, billBuilder)); } final Bill bill = billBuilder.build(); @@ -268,7 +272,25 @@ public IExit invoke(CommandInvocation invocation) thro final String summary = issues.get(issue).getSummary(); final double hours = Math.round(byIssue.getTotal() * 100.0) / 100.0; log.info("\t\t{} {}: {}h", issue, summary, hours); - billLines.add(new BillLine(component, issue, summary, hours)); + + final String assignees = byIssue.getUsers().stream().collect(HCollector.joining(", ", ", & ")); + final String link = server.getApi().createIssueLink(issue); + final StringBuilder ranges = new StringBuilder(); + final List issueChanges = changes.get(issue); + + boolean currentBillable = false; + for (int i = 0; i < issueChanges.size(); i++) { + final Change change = issueChanges.get(i); + final boolean newBillable = isStatusBillable.test(change.getStatus()); + if (newBillable != currentBillable) { + currentBillable = newBillable; + final ZoneId zone = change.getAssignee() == null ? ZoneId.systemDefault() : request.getUsers().get(change.getAssignee()).getZone(); + final LocalDateTime local = change.getStart().withZoneSameInstant(zone).toLocalDateTime(); + ranges.append(RANGE_FORMAT.format(local)).append(" (@").append(change.getAssignee() == null ? zone : change.getAssignee()).append(')'); + ranges.append(' ').append(((issueChanges.size() - 1) == i) ? "End" : (newBillable ? "Start" : "Stop")).append('\n'); + } + } + billLines.add(new BillLine(component, assignees, issue, summary, hours, ranges.toString().strip(), link)); } } final Path outputFile = Filename.replaceExtension(arguments.getRequest(), "csv"); From cd9af1a400d74acbea8b0902583043ff3f6ae574 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Thu, 20 Mar 2025 14:03:33 -0700 Subject: [PATCH 6/6] Fix compiler errors --- .../src/main/java/com/g2forge/project/report/Billing.java | 4 ++-- .../src/main/java/com/g2forge/project/report/Change.java | 2 +- 2 files changed, 3 insertions(+), 3 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 3bc9c86..8b69127 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 @@ -204,7 +204,7 @@ protected List findRelevantIssues(ExtendedJiraRestClient client, String j final int actualMax = searchResult.getMaxResults(); log.info("\tGot issues {} to {} of {}", base, base + Math.min(actualMax, searchResult.getTotal() - base), searchResult.getTotal()); - retVal.addAll(HCollection.asList(searchResult.getIssues())); + retVal.addAll(HCollection.asListIterable(searchResult.getIssues())); if ((base + actualMax) >= searchResult.getTotal()) break; else base += actualMax; } @@ -214,7 +214,7 @@ protected List findRelevantIssues(ExtendedJiraRestClient client, String j protected List examineIssue(final ExtendedJiraRestClient client, Server server, Request request, IPredicate1 isStatusBillable, IPredicate1 isComponentBillable, IFunction1 userToFriendly, Issue issue, Bill.BillBuilder billBuilder) throws InterruptedException, ExecutionException { log.info("Examining {}", issue.getKey()); - final Set billableComponents = HCollection.asList(issue.getComponents()).stream().map(BasicComponent::getName).distinct().filter(isComponentBillable).collect(Collectors.toSet()); + final Set billableComponents = HCollection.asListIterable(issue.getComponents()).stream().map(BasicComponent::getName).distinct().filter(isComponentBillable).collect(Collectors.toSet()); if (billableComponents.isEmpty()) return null; final List changes = computeChanges(client, server, userToFriendly, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault())); 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 b3e3e0d..0a60c66 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 @@ -31,7 +31,7 @@ public static List toChanges(final Iterable changelog, Z final List retVal = new ArrayList<>(); String finalAssignee = assignee, finalStatus = status; boolean foundFinalAssignee = false, foundFinalStatus = false; - final List sorted = HCollection.asList(changelog).stream().sorted(new MappedComparator<>(ChangelogGroup::getCreated, ComparableComparator.create())).collect(Collectors.toList()); + final List sorted = HCollection.asListIterable(changelog).stream().sorted(new MappedComparator<>(ChangelogGroup::getCreated, ComparableComparator.create())).collect(Collectors.toList()); for (ChangelogGroup changelogGroup : sorted) { final ZonedDateTime created = Billing.convert(changelogGroup.getCreated()); // Ignore changes before the start, and stop processing after the end