11package com .g2forge .project .report ;
22
3- import java .io .IOException ;
43import java .io .InputStream ;
54import 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 ;
715import java .util .Set ;
16+ import java .util .TreeMap ;
817import java .util .concurrent .ExecutionException ;
918import java .util .stream .Collectors ;
1019
20+ import org .joda .time .DateTime ;
21+ import org .joda .time .DateTimeZone ;
1122import org .slf4j .event .Level ;
1223
1324import com .atlassian .jira .rest .client .api .IssueRestClient ;
25+ import com .atlassian .jira .rest .client .api .domain .BasicComponent ;
1426import com .atlassian .jira .rest .client .api .domain .ChangelogGroup ;
15- import com .atlassian .jira .rest .client .api .domain .ChangelogItem ;
1627import 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 ;
1731import com .g2forge .alexandria .command .command .IStandardCommand ;
1832import com .g2forge .alexandria .command .exit .IExit ;
1933import 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 ;
2136import 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 ;
2242import com .g2forge .alexandria .log .HLog ;
2343import com .g2forge .gearbox .argparse .ArgumentParser ;
2444import com .g2forge .gearbox .jira .ExtendedJiraRestClient ;
2545import com .g2forge .gearbox .jira .JiraAPI ;
26- import com .g2forge .gearbox . jira . fields . KnownField ;
46+ import com .g2forge .project . core . HConfig ;
2747
2848import lombok .AllArgsConstructor ;
2949import lombok .Builder ;
3050import lombok .Data ;
51+ import lombok .RequiredArgsConstructor ;
52+ import lombok .Singular ;
3153import 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