From 664d79dc42b88196984811b95ef8ce1354bc11dd Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Wed, 11 Mar 2026 08:43:31 -0700 Subject: [PATCH 1/2] Generalize sprint mapping & avoid API calls for dry runs --- .../java/com/g2forge/project/core/Server.java | 17 +++++++++- .../g2forge/project/plan/create/Create.java | 32 +++++++++++-------- 2 files changed, 34 insertions(+), 15 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 e67ffd4..840f27a 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 @@ -22,14 +22,29 @@ public class Server implements IFieldConfig { protected final Integer sprintOffset; + protected final Map sprintMap; + @Singular protected final Map users; - protected final UserPrimaryKey userPrimaryKey; + protected final UserPrimaryKey userPrimaryKey; protected final JiraAPI api; public UserPrimaryKey getUserPrimaryKey() { return userPrimaryKey == null ? UserPrimaryKey.NAME : userPrimaryKey; } + + public Integer modifySprint(Integer sprint) { + // No matter how things are configured, this is the correct result + if (sprint == null) return null; + + if ((getSprintOffset() != null) && (getSprintMap() != null)) throw new IllegalArgumentException("Both offset and map are non-null, please specify either a map from sprint numbers to IDs, or an offset!"); + + if (getSprintMap() != null) { + final Integer retVal = getSprintMap().get(sprint); + if (retVal == null) throw new IllegalArgumentException(String.format("Sprint number %1$d was not mapped, please update your sprint number to ID map!", sprint)); + return retVal; + } else return sprint + getSprintOffset(); + } } 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 52780a8..087fe8e 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 @@ -37,6 +37,8 @@ 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.function.ISupplier; +import com.g2forge.alexandria.java.function.cache.FixedCachingSupplier; import com.g2forge.alexandria.java.io.dataaccess.IDataSource; import com.g2forge.alexandria.java.io.dataaccess.PathDataSource; import com.g2forge.alexandria.log.HLog; @@ -165,7 +167,7 @@ protected static class LinkType { protected static Changes computeChanges(Server server, CreateConfig config) { final SprintConfig sprintWithDefault = config.getSprintConfig() == null ? SprintConfig.getDEFAULT() : config.getSprintConfig().fallback(SprintConfig.getDEFAULT()); - final SprintConfig sprintWithOffset = ((server == null) || (server.getSprintOffset() == null)) ? sprintWithDefault : sprintWithDefault.toBuilder().offset(sprintWithDefault.getOffset() + server.getSprintOffset()).build(); + final SprintConfig sprintWithOffset = (server == null) ? sprintWithDefault : sprintWithDefault.toBuilder().offset(server.modifySprint(sprintWithDefault.getOffset())).build(); final Changes.ChangesBuilder retVal = Changes.builder(); final Set disabledSummaries = config.getDisabledIssues().stream().map(CreateIssue::getSummary).collect(Collectors.toSet()); @@ -249,13 +251,6 @@ protected Map implementChanges(Server server, Changes changes) t final boolean dryrun = ProjectCreateFlag.DRYRUN.getAccessor().get(); HLog.getLogControl().setLogLevel(Level.INFO); try (final ExtendedJiraRestClient client = JiraAPI.createFromPropertyInput(server == null ? null : server.getApi(), null).connect(true)) { - final Map linkTypes = new HashMap<>(); - for (IssuelinksType linkType : client.getMetadataClient().getIssueLinkTypes().get()) { - linkTypes.put(linkType.getName(), new LinkType(linkType.getName(), false)); - linkTypes.put(linkType.getInward(), new LinkType(linkType.getName(), true)); - linkTypes.put(linkType.getOutward(), new LinkType(linkType.getName(), false)); - } - final IssueRestClient issueClient = client.getIssueClient(); final Map issues = new LinkedHashMap<>(); for (CreateIssue issue : changes.getIssues()) { @@ -324,12 +319,21 @@ protected Map implementChanges(Server server, Changes changes) t } } - if (!dryrun) for (LinkIssuesInput link : changes.getLinks()) { - final LinkType linkType = linkTypes.get(link.getLinkType()); - final String from = issues.get(link.getFromIssueKey()); - final String to = issues.getOrDefault(link.getToIssueKey(), link.getToIssueKey()); - // TODO: Handle it when an issue we're linking wasn't created - issueClient.linkIssue(new LinkIssuesInput(linkType.isReverse() ? to : from, linkType.isReverse() ? from : to, linkType.getName(), link.getComment())).get(); + if (!dryrun && !changes.getLinks().isEmpty()) { + final Map linkTypes = new HashMap<>(); + for (IssuelinksType linkType : client.getMetadataClient().getIssueLinkTypes().get()) { + linkTypes.put(linkType.getName(), new LinkType(linkType.getName(), false)); + linkTypes.put(linkType.getInward(), new LinkType(linkType.getName(), true)); + linkTypes.put(linkType.getOutward(), new LinkType(linkType.getName(), false)); + } + + for (LinkIssuesInput link : changes.getLinks()) { + final LinkType linkType = linkTypes.get(link.getLinkType()); + final String from = issues.get(link.getFromIssueKey()); + final String to = issues.getOrDefault(link.getToIssueKey(), link.getToIssueKey()); + // TODO: Handle it when an issue we're linking wasn't created + issueClient.linkIssue(new LinkIssuesInput(linkType.isReverse() ? to : from, linkType.isReverse() ? from : to, linkType.getName(), link.getComment())).get(); + } } return issues; From a1cad9c0dcef439021e16a39b3a46e41613e51d1 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Wed, 11 Mar 2026 14:22:45 -0700 Subject: [PATCH 2/2] Add helpful error on missing user timezone & name output file with dates --- .../com/g2forge/project/report/Billing.java | 31 +++++++++++++------ .../com/g2forge/project/report/Request.java | 5 ++- 2 files changed, 25 insertions(+), 11 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 cc5e0ef..7e727d9 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 @@ -25,6 +25,7 @@ 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.BasicUser; 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.Comment; @@ -181,14 +182,17 @@ 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, Request request, IFunction1 userToFriendly, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException { + protected final DateTimeFormatter DATE_FORMAT_FILENAME = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + protected List computeChanges(ExtendedJiraRestClient client, Server server, Request request, 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 List changelog = new ArrayList<>(HCollection.asListIterable(issue.getChangelog())); for (Comment comment : issue.getComments()) { final String body = comment.getBody(); final List adjustments = StatusAdjustment.parse(body); if (!adjustments.isEmpty()) { - final ZoneId zone = request.getZone(comment.getAuthor().getName()); + + final ZoneId zone = request.getZone(userToFriendly.apply(comment.getAuthor())); for (StatusAdjustment adjustment : adjustments) { final ZonedDateTime when = adjustment.getWhen().atZone(zone); changelog.add(new ChangelogGroup(comment.getAuthor(), convert(when), HCollection.asList(new ChangelogItem(FieldType.JIRA, KnownField.Status.getName(), adjustment.getFrom(), adjustment.getFrom(), adjustment.getTo(), adjustment.getTo())))); @@ -208,11 +212,12 @@ protected List computeChanges(ExtendedJiraRestClient client, Server serv 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 { + protected List findRelevantIssues(ExtendedJiraRestClient client, String jql, Collection users, Map userMap, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException { final List retVal = new ArrayList<>(); for (String user : users) { 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 String username = (userMap == null) ? user : userMap.getOrDefault(user, user); + final String compositeJQL = String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", username, start.format(DATE_FORMAT), end.format(DATE_FORMAT)) + ((jql == null) ? "" : (" AND " + jql)); final int desiredMax = 500; int base = 0; while (true) { @@ -228,7 +233,7 @@ protected List findRelevantIssues(ExtendedJiraRestClient client, String j return retVal; } - protected List examineIssue(final ExtendedJiraRestClient client, Server server, Request request, IPredicate1 isStatusBillable, IPredicate1 isComponentBillable, IFunction1 userToFriendly, 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.asListIterable(issue.getComponents()).stream().map(BasicComponent::getName).distinct().filter(isComponentBillable).collect(Collectors.toSet()); if (billableComponents.isEmpty()) return null; @@ -257,7 +262,7 @@ public IExit invoke(CommandInvocation invocation) t 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 IFunction1 userToFriendly = user -> { final String primaryKey = server.getUserPrimaryKey().getValue(user); return userReverseMap.getOrDefault(primaryKey, primaryKey); }; @@ -269,7 +274,7 @@ public IExit invoke(CommandInvocation invocation) t final Map issues; final Map> changes = new TreeMap<>(); { - final List relevantIssues = findRelevantIssues(client, request.getJql(), request.getUsers().keySet(), request.getStart(), request.getEnd()); + final List relevantIssues = findRelevantIssues(client, request.getJql(), request.getUsers().keySet(), server.getUsers(), 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.joiningHuman())); @@ -315,9 +320,15 @@ public IExit invoke(CommandInvocation invocation) t billLines.add(new BillLine(component, assignees, issue, summary, hours, ranges.toString().strip(), link)); } } - final Path outputFile = Filename.replaceExtension(arguments.getRequest(), "csv"); - log.info("Writing bill to {}", outputFile); - BillLine.getMapper().write(billLines, outputFile); + + { + + final Path outputDirectory = arguments.getRequest().getParent(); + final String filename = Filename.fromPath(arguments.getRequest()).getPrefix().toString() + " (" + DATE_FORMAT_FILENAME.format(request.getStart()) + " to " + DATE_FORMAT_FILENAME.format(request.getEnd()) + ").csv"; + final Path outputFile = outputDirectory.resolve(filename); + log.info("Writing bill to {}", outputFile); + BillLine.getMapper().write(billLines, outputFile); + } log.info("Bill by user"); for (String user : bill.getUsers()) { 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 c07ae34..c71c2b8 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 @@ -30,6 +30,9 @@ public class Request { protected final LocalDate end; public ZoneId getZone(String user) { - return user == null ? ZoneId.systemDefault() : getUsers().get(user).getZone(); + if (user == null) return ZoneId.systemDefault(); + final WorkingHours workingHours = getUsers().get(user); + if (workingHours == null) throw new IllegalArgumentException("No working hours specified for user " + user); + return workingHours.getZone(); } } \ No newline at end of file