-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathschedule.ml
568 lines (527 loc) · 19.2 KB
/
schedule.ml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
(* Merge data sources and provide a unified view *)
open Domain
(* ---------------------------------------------------------------------- *)
(* LOGGING *)
type schedule_event =
| MissingGithubProjectError of Forecast.project (* E3001 *)
| FinanceCodeNotMatchingError of project (* E3002 *)
| MissingForecastProjectWarning of project (* W3001 *)
| AllocationEndsTooLateWarning of assignment (* W3002 *)
| AllocationStartsTooEarlyWarning of assignment (* W3003 *)
| FTEDiscrepancyWarning of project (* W3004 *)
| ActiveProjectWithoutAssignmentWarning of project (* W3007 *)
| AssignmentsToInactiveProjectWarning of project (* W3008 *)
| ProjectStartOverdueWarning of project (* W3009 *)
| NoMatchingGithubUserWarning of Forecast.person (* W3010 *)
| DifferentClientWarning of project * string * string option (* W3011 *)
| DifferentNameWarning of project * string * string (* W3012 *)
| MultipleMatchingGithubUsersWarning of Forecast.person * string list (* W3013 *)
| AssignmentWithoutProjectDebug of Forecast.assignment
| AssignmentWithoutPersonDebug of Forecast.person
| GithubUserGuessedInfo of Forecast.person * string
| GithubUserObtainedFromConfigInfo of Forecast.person * string
let log_event (error : schedule_event) =
match error with
| MissingGithubProjectError fc_proj ->
Log.log'
{ level = Log.Error' 3001
; entity = Log.Project fc_proj.number
; message =
Printf.sprintf
"No matching GitHub issue for Forecast project <%s>."
fc_proj.name
}
| FinanceCodeNotMatchingError proj ->
Log.log'
{ level = Log.Error' 3002
; entity = Log.Project proj.number
; message = "Finance codes on Forecast and GitHub do not match."
}
| MissingForecastProjectWarning proj ->
Log.log'
{ level = Log.Warning 3001
; entity = Log.Project proj.number
; message = "No matching Forecast project found."
}
| AllocationEndsTooLateWarning asn ->
Log.log'
{ level = Log.Warning 3002
; entity = Log.ForecastProject asn.project.number
; message =
Printf.sprintf
"Assignment of <%s> ends after project latest end."
(get_entity_name asn.entity)
}
| AllocationStartsTooEarlyWarning asn ->
Log.log'
{ level = Log.Warning 3003
; entity = Log.ForecastProject asn.project.number
; message =
Printf.sprintf
"Assignment of <%s> begins before project earliest start."
(get_entity_name asn.entity)
}
| FTEDiscrepancyWarning proj ->
Log.log'
{ level = Log.Warning 3004
; entity = Log.Project proj.number
; message = "Total allocations in Forecast differ from GitHub metadata."
}
| ActiveProjectWithoutAssignmentWarning proj ->
Log.log'
{ level = Log.Warning 3007
; entity = Log.Project proj.number
; message = "Project is Active but has no current assignments."
}
| AssignmentsToInactiveProjectWarning proj ->
Log.log'
{ level = Log.Warning 3008
; entity = Log.Project proj.number
; message = "Project is not Active but has current assignments."
}
| ProjectStartOverdueWarning proj ->
Log.log'
{ level = Log.Warning 3009
; entity = Log.Project proj.number
; message = "Project is past latest start date but not yet Active."
}
| NoMatchingGithubUserWarning person ->
Log.log'
{ level = Log.Warning 3010
; entity = Log.ForecastPerson person.email
; message =
Printf.sprintf "Could not find matching GitHub user for <%s>." person.full_name
}
| DifferentClientWarning (proj, fc_name, gh_name) ->
Log.log'
{ level = Log.Warning 3011
; entity = Log.Project proj.number
; message =
Printf.sprintf
"Project programmes on Forecast (%s) and GitHub (%s) do not match."
fc_name
(match gh_name with
| Some s -> s
| None -> "absent")
}
| DifferentNameWarning (proj, fc_name, gh_name) ->
Log.log'
{ level = Log.Warning 3012
; entity = Log.Project proj.number
; message =
Printf.sprintf
"Project names on Forecast (%s) and GitHub (%s) do not match."
fc_name
gh_name
}
| MultipleMatchingGithubUsersWarning (person, usernames) ->
Log.log'
{ level = Log.Warning 3013
; entity = Log.ForecastPerson person.email
; message =
Printf.sprintf
"Multiple possible matching GitHub users for <%s> found (%s)."
person.full_name
(usernames |> List.map (fun s -> "@" ^ s) |> String.concat ", ")
}
| GithubUserGuessedInfo (psn, uname) ->
Log.log'
{ level = Log.Info
; entity = Log.ForecastPerson psn.full_name
; message =
Printf.sprintf
"Guessed GitHub username <@%s> for Forecast person <%s>."
uname
psn.full_name
}
| GithubUserObtainedFromConfigInfo (psn, uname) ->
Log.log'
{ level = Log.Info
; entity = Log.ForecastPerson psn.full_name
; message =
Printf.sprintf
"Obtained GitHub username <@%s> for Forecast person <%s> from configuration \
file."
uname
psn.full_name
}
| AssignmentWithoutProjectDebug asn ->
Log.log'
{ level = Log.Debug
; entity = Log.ForecastProject asn.project.number
; message = "Assignment made to project that has been deleted."
}
| AssignmentWithoutPersonDebug psn ->
Log.log'
{ level = Log.Debug
; entity = Log.ForecastPerson psn.full_name
; message = "Assignment made to person that has been deleted."
}
;;
(* ---------------------------------------------------------------------- *)
(* MERGE PEOPLE FROM FORECAST AND GITHUB *)
(* I don't understand why OCaml makes me write this boilerplate. *)
module FcSet = Set.Make (struct
type t = Forecast.person
let compare = compare
end)
module GhSet = Set.Make (struct
type t = Github.person
let compare = compare
end)
module PsnSet = Set.Make (struct
type t = Domain.person
let compare = compare
end)
(** Create a list of all people, merging data from Forecast and Github. Our
approach here is generally to map over Forecast people, because Forecast is
considered authoritative for people.
TODO: It would be nice to get Slack handles for people.
*)
let merge_people
(fc_people : Forecast.person list)
(gh_people : Github.person list)
(fc_assignments : Forecast.assignment list)
=
(* In a first pass, we remove anyone who is not in REG (as determined by
roles) or who is only assigned to UNAVAILABLE, as we probably don't need to
care about these. *)
let is_available (fc_p : Forecast.person) =
(* Has REG role *)
List.mem "REG" fc_p.roles
&& (* Is currently assigned to something that isn't just UNAVAILABLE *)
List.exists
(fun (a : Forecast.assignment) ->
get_first_day a.allocation < CalendarLib.Date.today ()
&& get_last_day a.allocation > CalendarLib.Date.today ()
&& a.entity = Person fc_p
&& a.project.programme <> "UNAVAILABLE")
fc_assignments
in
let fc_all = List.filter is_available fc_people |> FcSet.of_list in
let gh_all = GhSet.of_list gh_people in
let make_new_person (fc_p : Forecast.person) (gh_p_opt : Github.person option) =
{ email = fc_p.email
; full_name = fc_p.full_name
; github_handle = Option.map (fun (p : Github.person) -> p.login) gh_p_opt
; slack_handle = None
}
in
(* Then, we fetch data from the config file (as this should override all other
'automatic' checks. *)
let accum1 (name, login) (fc_found, gh_remaining, ppl_found) =
match
( FcSet.filter (fun (p : Forecast.person) -> p.full_name = name) fc_all
|> FcSet.elements
, GhSet.filter (fun (p : Github.person) -> p.login = login) gh_all |> GhSet.elements
)
with
| [ fc_p ], [ gh_p ] ->
log_event (GithubUserObtainedFromConfigInfo (fc_p, gh_p.login));
let new_person = make_new_person fc_p (Some gh_p) in
( FcSet.add fc_p fc_found
, GhSet.remove gh_p gh_remaining
, PsnSet.add new_person ppl_found )
| _ -> fc_found, gh_remaining, ppl_found
in
let fc_found, gh_remaining, ppl_found =
List.fold_right accum1 (Config.get_extra_users ()) (FcSet.empty, gh_all, PsnSet.empty)
in
let fc_remaining = FcSet.diff fc_all fc_found in
(* Then we try to match Forecast people to GitHub accounts, based on an exact
match between their names. *)
let person_matches_perfectly (fc_p : Forecast.person) (gh_p : Github.person) =
gh_p.email = Some fc_p.email || gh_p.name = Some fc_p.full_name
in
let accum2 fc_p (fc_found, gh_remaining, ppl_found) =
match GhSet.filter (person_matches_perfectly fc_p) gh_remaining |> GhSet.elements with
| [] -> fc_found, gh_remaining, ppl_found
| [ gh_p ] ->
let new_person = make_new_person fc_p (Some gh_p) in
( FcSet.add fc_p fc_found
, GhSet.remove gh_p gh_remaining
, PsnSet.add new_person ppl_found )
| gh_ps ->
(* Multiple exact matches, EXTREMELY unlikely *)
let unames = List.map (fun (p : Github.person) -> p.login) gh_ps in
log_event (MultipleMatchingGithubUsersWarning (fc_p, unames));
fc_found, gh_remaining, ppl_found
in
let fc_found, gh_remaining, ppl_found =
FcSet.fold accum2 fc_remaining (fc_found, gh_remaining, ppl_found)
in
(* Next, for anyone who wasn't found yet, we try to match Forecast people
to GitHub accounts, based on some weaker heuristics. *)
let fc_remaining = FcSet.diff fc_all fc_found in
let person_matches_fuzzily (fc_p : Forecast.person) (gh_p : Github.person) =
let names = fc_p.full_name |> String.split_on_char ' ' in
let first_name = List.hd names in
let last_name = List.hd (List.rev names) in
Utils.contains ~case_sensitive:false gh_p.login last_name
|| Utils.contains ~case_sensitive:false gh_p.login first_name
||
match gh_p.name with
| Some n ->
Utils.contains ~case_sensitive:false n last_name
|| Utils.contains ~case_sensitive:false n first_name
| None -> false
in
let accum' fc_p (fc_found, gh_remaining, ppl_found) =
match GhSet.filter (person_matches_fuzzily fc_p) gh_remaining |> GhSet.elements with
| [] ->
log_event (NoMatchingGithubUserWarning fc_p);
fc_found, gh_remaining, ppl_found
| [ gh_p ] ->
log_event (GithubUserGuessedInfo (fc_p, gh_p.login));
let new_person = make_new_person fc_p (Some gh_p) in
( FcSet.add fc_p fc_found
, GhSet.remove gh_p gh_remaining
, PsnSet.add new_person ppl_found )
| gh_ps ->
(* Multiple fuzzy matches *)
let unames = List.map (fun (p : Github.person) -> p.login) gh_ps in
log_event (MultipleMatchingGithubUsersWarning (fc_p, unames));
fc_found, gh_remaining, ppl_found
in
let _, _, ppl_found =
FcSet.fold accum' fc_remaining (fc_found, gh_remaining, ppl_found)
in
PsnSet.elements ppl_found
;;
(* ---------------------------------------------------------------------- *)
(* MERGE PROJECTS FROM FORECAST AND GITHUB *)
type project_pair = Pair of (Forecast.project option * project option)
(* Check that each Forecast project has a hut23 code which matches that of a
GitHub project. Additionally, check that the programmes and names of the
projects are the same on both platforms.
*)
let merge_projects
(fc_projects : Forecast.project IntMap.t)
(gh_issues : Github.issue IntMap.t)
(people : person list)
=
(* First, add in the assignees from Forecast *)
let map_assignees (gh_p : Github.issue) : project =
let new_assignees =
List.filter_map
(fun a -> List.find_opt (fun p -> p.github_handle = Some a) people)
gh_p.assignees
in
{ number = gh_p.number
; name = gh_p.name
; state = gh_p.state
; programme = gh_p.programme
; plan = gh_p.plan
; assignees = new_assignees
}
in
let gh_projects = IntMap.map map_assignees gh_issues in
(* Pair the Forecast and GitHub projects *)
let pair_projects _ fc_opt gh_opt =
match fc_opt, gh_opt with
| None, None -> None
| x, y -> Some (Pair (x, y))
in
let combined_map = IntMap.merge pair_projects fc_projects gh_projects in
(* Check the pairs for any inconsistencies and log events as necessary *)
let check_projects _ (pair : project_pair) : unit =
(* Check that they both exist *)
match pair with
| Pair (None, None) -> ()
| Pair (Some fc_p, None) -> log_event (MissingGithubProjectError fc_p)
| Pair (None, Some gh_p) -> log_event (MissingForecastProjectWarning gh_p)
| Pair (Some fc_p, Some gh_p) ->
(* Check that their client/programme match *)
if Some fc_p.programme <> gh_p.programme
then log_event (DifferentClientWarning (gh_p, fc_p.programme, gh_p.programme));
(* Check that their names match *)
if fc_p.name <> gh_p.name
then log_event (DifferentNameWarning (gh_p, fc_p.name, gh_p.name));
(* Check that their project codes match *)
let finance_codes_match =
match fc_p.finance_code, gh_p.plan with
| Some cd, Some plan -> List.mem cd plan.finance_codes
| _ -> false
in
if not finance_codes_match then log_event (FinanceCodeNotMatchingError gh_p)
in
IntMap.iter check_projects combined_map;
(* Return only the GitHub issues *)
gh_projects
;;
(* ---------------------------------------------------------------------- *)
(* MERGE ASSIGNMENTS FROM FORECAST AND GITHUB *)
let check_end_date (prj : project) (asg : assignment) =
match asg.entity with
| Placeholder _ -> ()
| Person _ ->
(match prj.plan with
| None -> ()
| Some plan ->
(match plan.latest_end_date with
| None -> ()
| Some latest_end_date ->
if get_last_day asg.allocation > latest_end_date
then log_event (AllocationEndsTooLateWarning asg)))
;;
let check_start_date (prj : project) (asg : assignment) =
match asg.entity with
| Placeholder _ -> ()
| Person _ ->
(match prj.plan with
| None -> ()
| Some plan ->
(match plan.earliest_start_date with
| None -> ()
| Some earliest_start_date ->
if get_first_day asg.allocation < earliest_start_date
then log_event (AllocationStartsTooEarlyWarning asg)))
;;
(* Convert a Forecast.project to a Domain.project without using any info from
GitHub. *)
let upconvert (prj : Forecast.project) : project =
{ number = prj.number
; name = prj.name
; state = Other
; programme = Some prj.programme
; plan = None
; assignees = []
}
;;
let merge_assignment people projects (asn : Forecast.assignment) : assignment option =
match IntMap.find_opt asn.project.number projects with
| None ->
log_event (AssignmentWithoutProjectDebug asn);
(match asn.entity with
| Placeholder pl ->
Some
{ project = upconvert asn.project
; entity = Placeholder pl
; allocation = asn.allocation
}
| Person asn_p ->
(match List.find_opt (fun p -> p.full_name = asn_p.full_name) people with
| None ->
log_event (AssignmentWithoutPersonDebug asn_p);
None
| Some psn ->
Some
{ project = upconvert asn.project
; entity = Person psn
; allocation = asn.allocation
}))
| Some prj ->
(match asn.entity with
| Placeholder p ->
let new_asn =
{ project = prj; entity = Placeholder p; allocation = asn.allocation }
in
check_start_date prj new_asn;
check_end_date prj new_asn;
Some new_asn
| Person asn_p ->
(match List.find_opt (fun p -> p.full_name = asn_p.full_name) people with
| None ->
log_event (AssignmentWithoutPersonDebug asn_p);
None
| Some psn ->
let new_asn =
{ project = prj; entity = Person psn; allocation = asn.allocation }
in
check_start_date prj new_asn;
check_end_date prj new_asn;
Some new_asn))
;;
(* ---------------------------------------------------------------------- *)
(* CHECKS ON SCHEDULE *)
module IntMap = Map.Make (Int)
let today = CalendarLib.Date.today ()
(* Checks that projects that have been scheduled to start (as per
earliest-start-date) are active or later *)
let check_is_overdue prj =
match prj.plan with
| None -> ()
| Some plan ->
if plan.latest_start_date < today && prj.state < Active
then log_event (ProjectStartOverdueWarning prj)
;;
(* Checks that active (or later) projects have assignments currently scheduled
on Forecast, and vice versa *)
let check_projects_active asns prj =
let today = CalendarLib.Date.today () in
let has_active_assignments =
List.exists
(fun a -> get_first_day a.allocation <= today && get_last_day a.allocation >= today)
asns
in
match prj.state = Active, has_active_assignments with
| true, false -> log_event (ActiveProjectWithoutAssignmentWarning prj)
| false, true -> log_event (AssignmentsToInactiveProjectWarning prj)
| _ -> ()
;;
(* Checks that the sum of FTEs assigned on Forecast matches the number of
FTE-weeks or FTE-months specified on GitHub metadata *)
let check_assignment_sum asns prj =
match prj.plan with
| None -> ()
| Some plan ->
let total_fte_time = asns |> List.map Domain.Assignment.to_fte_weeks |> FTE.sum in
let budget = plan.budget in
let discrepancy = FTE.div (FTE.sub total_fte_time budget) budget in
if discrepancy < -0.1 || discrepancy > 0.1
then log_event (FTEDiscrepancyWarning prj)
else ()
;;
(* Checks for People Required placeholders *)
(* TODO: Implement *)
let check_people_required asns prj =
ignore asns;
ignore prj;
()
;;
(* Aggregates all the checks above *)
let check_projects projects assignments =
let asns_map : assignment list IntMap.t =
assignments
|> Utils.sort_and_group_by (fun a -> a.project.number)
|> List.to_seq
|> IntMap.of_seq
in
let run_all_checks _ p =
let this_proj_asns =
match IntMap.find_opt p.number asns_map with
| Some asns -> asns
| None -> []
in
check_is_overdue p;
check_projects_active this_proj_asns p;
check_assignment_sum this_proj_asns p;
check_people_required this_proj_asns p
in
IntMap.iter run_all_checks projects
;;
(* ---------------------------------------------------------------------- *)
(* BUILD SCHEDULE *)
let get_the_schedule_async ~start_date ~end_date =
let open Lwt.Syntax in
let* fc_projects, fc_people', fc_assignments =
Forecast.get_the_schedule_async ~start_date ~end_date
in
let fc_people = fc_people' |> Forecast.StringMap.bindings |> List.map snd in
let* gh_issues = Github.get_project_issues_async () in
let gh_issues_map =
gh_issues
|> List.map (fun (i : Github.issue) -> i.number, i)
|> List.to_seq
|> IntMap.of_seq
in
let* gh_people = Github.get_all_users_async in
let people = merge_people fc_people gh_people fc_assignments in
let projects = merge_projects fc_projects gh_issues_map people in
let assignments = List.filter_map (merge_assignment people projects) fc_assignments in
check_projects projects assignments;
Lwt.return (people, projects, assignments)
;;
let get_the_schedule ~start_date ~end_date =
Lwt_main.run (get_the_schedule_async ~start_date ~end_date)
;;