Skip to content

Commit 5356400

Browse files
authored
Merge pull request #13 from gdgib/G2-1740-Billing
G2-1740 Billing
2 parents 8ba68b7 + 8e1b416 commit 5356400

9 files changed

Lines changed: 506 additions & 48 deletions

File tree

pj-core/pom.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,21 @@
2424
<artifactId>gb-jira</artifactId>
2525
<version>${gearbox.version}</version>
2626
</dependency>
27+
28+
<dependency>
29+
<groupId>com.fasterxml.jackson.module</groupId>
30+
<artifactId>jackson-module-parameter-names</artifactId>
31+
<version>${jackson.version}</version>
32+
</dependency>
33+
<dependency>
34+
<groupId>com.fasterxml.jackson.datatype</groupId>
35+
<artifactId>jackson-datatype-jsr310</artifactId>
36+
<version>${jackson.version}</version>
37+
</dependency>
38+
<dependency>
39+
<groupId>com.fasterxml.jackson.datatype</groupId>
40+
<artifactId>jackson-datatype-jdk8</artifactId>
41+
<version>${jackson.version}</version>
42+
</dependency>
2743
</dependencies>
2844
</project>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.g2forge.project.core;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
8+
import com.g2forge.alexandria.java.io.RuntimeIOException;
9+
import com.g2forge.alexandria.java.io.dataaccess.IDataSource;
10+
import com.g2forge.alexandria.java.type.ref.ITypeRef;
11+
12+
import lombok.Getter;
13+
14+
public class HConfig {
15+
@Getter(lazy = true)
16+
private static final ObjectMapper mapper = createObjectMapper();
17+
18+
protected static ObjectMapper createObjectMapper() {
19+
final ObjectMapper retVal = new ObjectMapper(new YAMLFactory());
20+
retVal.findAndRegisterModules();
21+
return retVal;
22+
}
23+
24+
public static <T> T load(IDataSource source, Class<T> type) {
25+
try (final InputStream stream = source.getStream(ITypeRef.of(InputStream.class))) {
26+
return getMapper().readValue(stream, type);
27+
} catch (IOException exception) {
28+
throw new RuntimeIOException("Failed to load " + source + " as " + type.getSimpleName(), exception);
29+
}
30+
}
31+
}

pj-create/src/main/java/com/g2forge/project/plan/create/Create.java

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,17 @@
3333
import com.atlassian.jira.rest.client.api.domain.input.TransitionInput;
3434
import com.fasterxml.jackson.core.JsonParseException;
3535
import com.fasterxml.jackson.databind.JsonMappingException;
36-
import com.fasterxml.jackson.databind.ObjectMapper;
37-
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
3836
import com.g2forge.alexandria.command.command.IStandardCommand;
3937
import com.g2forge.alexandria.command.exit.IExit;
4038
import com.g2forge.alexandria.command.invocation.CommandInvocation;
4139
import com.g2forge.alexandria.java.core.error.HError;
4240
import com.g2forge.alexandria.java.io.dataaccess.IDataSource;
4341
import com.g2forge.alexandria.java.io.dataaccess.PathDataSource;
44-
import com.g2forge.alexandria.java.type.ref.ITypeRef;
4542
import com.g2forge.alexandria.log.HLog;
4643
import com.g2forge.gearbox.jira.ExtendedJiraRestClient;
4744
import com.g2forge.gearbox.jira.JiraAPI;
4845
import com.g2forge.gearbox.jira.fields.KnownField;
46+
import com.g2forge.project.core.HConfig;
4947
import com.g2forge.project.plan.create.CreateIssue.CreateIssueBuilder;
5048
import com.google.common.base.Objects;
5149

@@ -217,21 +215,12 @@ protected static void verifyChanges(final Changes changes) {
217215
protected final Map<String, Map<String, BasicComponent>> projectComponentsCache = new LinkedHashMap<>();
218216

219217
public List<String> createIssues(IDataSource serverDataSource, IDataSource configDataSource) throws JsonParseException, JsonMappingException, IOException, URISyntaxException, InterruptedException, ExecutionException {
220-
final ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
221-
222218
// Load the config, but if it's empty, don't bother
223-
final CreateConfig config;
224-
try (final InputStream stream = configDataSource.getStream(ITypeRef.of(InputStream.class))) {
225-
config = mapper.readValue(stream, CreateConfig.class);
226-
}
219+
final CreateConfig config = HConfig.load(configDataSource, CreateConfig.class);
227220
if ((config.getIssues() == null) || config.getIssues().isEmpty()) return Collections.emptyList();
228221

229222
// Load the server if one is specified;
230-
final Server server;
231-
if (serverDataSource != null) try (final InputStream stream = serverDataSource.getStream(ITypeRef.of(InputStream.class))) {
232-
server = mapper.readValue(stream, Server.class);
233-
}
234-
else server = null;
223+
final Server server = (serverDataSource != null) ? HConfig.load(serverDataSource, Server.class) : null;
235224

236225
config.validateFlags();
237226
final Changes changes = computeChanges(server, config);

pj-report/src/main/java/com/g2forge/project/report/Billing.java

Lines changed: 189 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,55 @@
11
package com.g2forge.project.report;
22

3-
import java.io.IOException;
43
import java.io.InputStream;
54
import java.io.PrintStream;
6-
import java.net.URISyntaxException;
5+
import java.nio.file.Path;
6+
import java.time.Instant;
7+
import java.time.LocalDate;
8+
import java.time.ZoneId;
9+
import java.time.ZonedDateTime;
10+
import java.time.format.DateTimeFormatter;
11+
import java.util.ArrayList;
12+
import java.util.Collection;
13+
import java.util.List;
14+
import java.util.Map;
715
import java.util.Set;
16+
import java.util.TreeMap;
817
import java.util.concurrent.ExecutionException;
918
import java.util.stream.Collectors;
1019

20+
import org.joda.time.DateTime;
21+
import org.joda.time.DateTimeZone;
1122
import org.slf4j.event.Level;
1223

1324
import com.atlassian.jira.rest.client.api.IssueRestClient;
25+
import com.atlassian.jira.rest.client.api.domain.BasicComponent;
1426
import com.atlassian.jira.rest.client.api.domain.ChangelogGroup;
15-
import com.atlassian.jira.rest.client.api.domain.ChangelogItem;
1627
import com.atlassian.jira.rest.client.api.domain.Issue;
28+
import com.atlassian.jira.rest.client.api.domain.SearchResult;
29+
import com.g2forge.alexandria.adt.associative.cache.Cache;
30+
import com.g2forge.alexandria.adt.associative.cache.NeverCacheEvictionPolicy;
1731
import com.g2forge.alexandria.command.command.IStandardCommand;
1832
import com.g2forge.alexandria.command.exit.IExit;
1933
import com.g2forge.alexandria.command.invocation.CommandInvocation;
20-
import com.g2forge.alexandria.java.adt.name.IStringNamed;
34+
import com.g2forge.alexandria.java.adt.compare.IComparable;
35+
import com.g2forge.alexandria.java.core.error.UnreachableCodeError;
2136
import com.g2forge.alexandria.java.core.helpers.HCollection;
37+
import com.g2forge.alexandria.java.core.helpers.HCollector;
38+
import com.g2forge.alexandria.java.function.IFunction1;
39+
import com.g2forge.alexandria.java.function.IPredicate1;
40+
import com.g2forge.alexandria.java.function.builder.IBuilder;
41+
import com.g2forge.alexandria.java.io.dataaccess.PathDataSource;
2242
import com.g2forge.alexandria.log.HLog;
2343
import com.g2forge.gearbox.argparse.ArgumentParser;
2444
import com.g2forge.gearbox.jira.ExtendedJiraRestClient;
2545
import com.g2forge.gearbox.jira.JiraAPI;
26-
import com.g2forge.gearbox.jira.fields.KnownField;
46+
import com.g2forge.project.core.HConfig;
2747

2848
import lombok.AllArgsConstructor;
2949
import lombok.Builder;
3050
import lombok.Data;
51+
import lombok.RequiredArgsConstructor;
52+
import lombok.Singular;
3153
import lombok.extern.slf4j.Slf4j;
3254

3355
@Slf4j
@@ -37,50 +59,185 @@ public class Billing implements IStandardCommand {
3759
@AllArgsConstructor
3860
protected static class Arguments {
3961
protected final String issueKey;
62+
63+
protected final Path request;
64+
}
65+
66+
@Data
67+
@Builder(toBuilder = true)
68+
@RequiredArgsConstructor
69+
public static class Bill {
70+
public static class BillBuilder implements IBuilder<Bill> {
71+
public BillBuilder add(String component, String user, String issue, double amount) {
72+
final Key key = new Key(component, user, issue);
73+
if (amounts$key != null) {
74+
final int index = amounts$key.indexOf(key);
75+
if (index >= 0) {
76+
amounts$value.set(index, amounts$value.get(index) + amount);
77+
return this;
78+
}
79+
}
80+
return amount(key, amount);
81+
}
82+
}
83+
84+
@Data
85+
@Builder(toBuilder = true)
86+
@RequiredArgsConstructor
87+
public static class Key implements IComparable<Key> {
88+
protected final String component;
89+
90+
protected final String user;
91+
92+
protected final String issue;
93+
94+
@Override
95+
public int compareTo(Key o) {
96+
final int component = getComponent().compareTo(o.getComponent());
97+
if (component != 0) return component;
98+
99+
final int user = getUser().compareTo(o.getUser());
100+
if (user != 0) return user;
101+
102+
final int issue = getIssue().compareTo(o.getIssue());
103+
return issue;
104+
}
105+
}
106+
107+
@Singular
108+
protected final Map<Key, Double> amounts;
109+
110+
public Bill filterBy(String component, String user, String issue) {
111+
return new Bill(getAmounts().entrySet().stream().filter(entry -> {
112+
final Key key = entry.getKey();
113+
if ((component != null) && !key.getComponent().equals(component)) return false;
114+
if ((user != null) && !key.getUser().equals(user)) return false;
115+
if ((issue != null) && !key.getIssue().equals(issue)) return false;
116+
return true;
117+
}).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
118+
}
119+
120+
public Set<String> getComponents() {
121+
return getAmounts().keySet().stream().map(Key::getComponent).collect(Collectors.toSet());
122+
}
123+
124+
public Set<String> getIssues() {
125+
return getAmounts().keySet().stream().map(Key::getIssue).collect(Collectors.toSet());
126+
}
127+
128+
public double getTotal() {
129+
return getAmounts().values().stream().mapToDouble(Double::doubleValue).sum();
130+
}
131+
132+
public Set<String> getUsers() {
133+
return getAmounts().keySet().stream().map(Key::getUser).collect(Collectors.toSet());
134+
}
135+
}
136+
137+
protected static Map<String, Double> computeBillableHoursByUser(List<Change> changes, IPredicate1<String> isStatusBillable, IFunction1<? super String, ? extends WorkingHours> workingHoursFunction) {
138+
final Map<String, Double> retVal = new TreeMap<>();
139+
for (int i = 0; i < changes.size() - 1; i++) {
140+
final Change change = changes.get(i);
141+
if (!isStatusBillable.test(change.getStatus())) continue;
142+
final WorkingHours workingHours = workingHoursFunction.apply(change.getAssignee());
143+
final Double billable = workingHours.computeBillableHours(change.getStart(), changes.get(i + 1).getStart());
144+
if (billable < 0) throw new UnreachableCodeError();
145+
if (billable > 0) {
146+
final Double previous = retVal.get(change.getAssignee());
147+
retVal.put(change.getAssignee(), (previous == null ? 0.0 : previous) + billable);
148+
}
149+
}
150+
return retVal;
151+
}
152+
153+
public static ZonedDateTime convert(DateTime dateTime) {
154+
final Instant instant = Instant.ofEpochMilli(dateTime.getMillis());
155+
final ZoneId zoneId = ZoneId.of(dateTime.getZone().getID(), ZoneId.SHORT_IDS);
156+
return ZonedDateTime.ofInstant(instant, zoneId);
157+
}
158+
159+
public static DateTime convert(ZonedDateTime zonedDateTime) {
160+
final long millis = zonedDateTime.toInstant().toEpochMilli();
161+
final DateTimeZone dateTimeZone = DateTimeZone.forID(zonedDateTime.getZone().getId());
162+
return new DateTime(millis, dateTimeZone);
40163
}
41164

42165
public static void main(String[] args) throws Throwable {
43166
IStandardCommand.main(args, new Billing());
44167
}
45168

46-
protected void demoLogChanges(final String issueKey) throws InterruptedException, ExecutionException, IOException, URISyntaxException {
47-
final Set<String> fields = HCollection.asList(KnownField.Status).stream().map(IStringNamed::getName).collect(Collectors.toSet());
48-
try (final ExtendedJiraRestClient client = JiraAPI.load().connect(true)) {
49-
final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get();
50-
log.info("Created at {}", issue.getCreationDate());
51-
for (ChangelogGroup changelogGroup : issue.getChangelog()) {
52-
boolean printedGroupLabel = false;
53-
for (ChangelogItem changelogItem : changelogGroup.getItems()) {
54-
if ((fields == null) || fields.contains(changelogItem.getField())) {
55-
if (!printedGroupLabel) {
56-
log.info("{} {}", changelogGroup.getCreated(), changelogGroup.getAuthor().getDisplayName());
57-
printedGroupLabel = true;
58-
}
59-
log.info("\t{}: {} -> {}", changelogItem.getField(), changelogItem.getFromString(), changelogItem.getToString());
60-
}
61-
}
169+
protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd");
170+
171+
protected List<Change> computeChanges(ExtendedJiraRestClient client, String issueKey, ZonedDateTime start, ZonedDateTime end) throws InterruptedException, ExecutionException {
172+
final Issue issue = client.getIssueClient().getIssue(issueKey, HCollection.asList(IssueRestClient.Expandos.CHANGELOG)).get();
173+
final Iterable<ChangelogGroup> changelog = issue.getChangelog();
174+
final Cache<String, String> users = new Cache<>(id -> {
175+
if (id == null) return null;
176+
try {
177+
return client.getUserClient().getUserByKey(id).get().getName();
178+
} catch (InterruptedException | ExecutionException e) {
179+
throw new RuntimeException("Failed to look up user: " + id, e);
180+
}
181+
}, NeverCacheEvictionPolicy.create());
182+
return Change.toChanges(changelog, start, end, issue.getAssignee().getName(), issue.getStatus().getName(), users);
183+
}
184+
185+
protected List<Issue> findRelevantIssues(ExtendedJiraRestClient client, Collection<? extends String> users, LocalDate start, LocalDate end) throws InterruptedException, ExecutionException {
186+
final List<Issue> retVal = new ArrayList<>();
187+
for (String user : users) {
188+
final String jql = String.format("issuekey IN updatedBy(%1$s, \"%2$s\", \"%3$s\")", user, start.format(DATE_FORMAT), end.format(DATE_FORMAT));
189+
final int max = 500;
190+
int base = 0;
191+
while (true) {
192+
final SearchResult searchResult = client.getSearchClient().searchJql(jql, max, base, null).get();
193+
log.info("Got issues {} to {} of {}", base, base + Math.min(searchResult.getMaxResults(), searchResult.getTotal() - base), searchResult.getTotal());
194+
195+
retVal.addAll(HCollection.asList(searchResult.getIssues()));
196+
if ((base + max) >= searchResult.getTotal()) break;
197+
else base += max;
62198
}
63199
}
200+
return retVal;
64201
}
65202

66203
@Override
67204
public IExit invoke(CommandInvocation<InputStream, PrintStream> invocation) throws Throwable {
68205
HLog.getLogControl().setLogLevel(Level.INFO);
69206
final Arguments arguments = ArgumentParser.parse(Arguments.class, invocation.getArguments());
70207

71-
demoLogChanges(arguments.getIssueKey());
208+
final Request request = HConfig.load(new PathDataSource(arguments.getRequest()), Request.class);
209+
final JiraAPI api = JiraAPI.createFromPropertyInput(request == null ? null : request.getApi(), null);
210+
try (final ExtendedJiraRestClient client = api.connect(true)) {
211+
final Bill.BillBuilder billBuilder = Bill.builder();
212+
final List<Issue> relevantIssues = findRelevantIssues(client, request.getUsers().keySet(), request.getStart(), request.getEnd());
213+
log.info("Found: {}", relevantIssues.stream().map(Issue::getKey).collect(HCollector.joining(", ", ", & ")));
214+
for (Issue issue : relevantIssues) {
215+
final Set<String> components = HCollection.asList(issue.getComponents()).stream().map(BasicComponent::getName).collect(Collectors.toSet());
216+
final Set<String> billableComponents = HCollection.intersection(components, request.getBillableComponents());
217+
if (billableComponents.isEmpty()) continue;
218+
219+
final List<Change> changes = computeChanges(client, issue.getKey(), request.getStart().atStartOfDay(ZoneId.systemDefault()), request.getEnd().atStartOfDay(ZoneId.systemDefault()));
220+
final Map<String, Double> billableHoursByUser = computeBillableHoursByUser(changes, status -> request.getBillableStatuses().contains(status), request.getUsers()::get);
221+
final Map<String, Double> billableHoursByUserDividedByComponents = billableHoursByUser.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue() / billableComponents.size()));
222+
for (String billableComponent : billableComponents) {
223+
for (Map.Entry<String, Double> entry : billableHoursByUserDividedByComponents.entrySet()) {
224+
billBuilder.add(billableComponent, entry.getKey(), issue.getKey(), entry.getValue());
225+
}
226+
}
227+
}
72228

73-
// Progressing: Input - API info, list of users
74-
// 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)
229+
final Map<String, Issue> issues = relevantIssues.stream().collect(Collectors.toMap(Issue::getKey, IFunction1.identity()));
230+
final Bill bill = billBuilder.build();
231+
for (String component : bill.getComponents()) {
232+
final Bill byComponent = bill.filterBy(component, null, null);
233+
log.info("{}: {}h", component, Math.ceil(byComponent.getTotal()));
234+
for (String issue : byComponent.getIssues()) {
235+
final Bill byIssue = byComponent.filterBy(null, null, issue);
236+
log.info("\t{} {}: {}h", issue, issues.get(issue).getSummary(), Math.round(byIssue.getTotal() * 100.0) / 100.0);
237+
}
238+
}
239+
}
75240

76-
// TODO: I/O - Start time and end time for the report, and the exact time we ran in
77-
// 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)
78-
// TODO: Input - working hours for a person (just start/stop times & days of week for now, add support for exceptions later)
79-
// TODO: Input - mapping of issues to accounts (e.g. by epic, by component, etc)
80-
// TODO: Construct a per-person timeline
81-
// what accounts were they working on at all times (what issues, then group issues by account, two accounts can be double billed, or split)
82-
// Reduce issue timeline to "active" statuses, and project those times against working hours
83-
// Abstract the projection, so I can add filters/exceptions/days-off later
84241
// TODO: Report on any times where a person was not billing to anything, but was working
85242
// TODO: Report on any times an issue changed status outside working hours
86243

0 commit comments

Comments
 (0)