b76e6af8970a39e86739c474b6b84533ffe42b43
[utils.git] / src / budget_sync / main.py
1 import os
2 import re
3 import sys
4 import json
5 from typing import Optional
6 from budget_sync.ordered_set import OrderedSet
7 from budget_sync.write_budget_csv import write_budget_csv
8 from bugzilla import Bugzilla
9 import logging
10 import argparse
11 from pathlib import Path
12 from budget_sync.util import all_bugs, tty_out
13 from budget_sync.config import Config, ConfigParseError
14 from budget_sync.budget_graph import BudgetGraph, PaymentSummary
15 from budget_sync.write_budget_markdown import (write_budget_markdown,
16 markdown_for_person)
17
18
19 def main():
20 parser = argparse.ArgumentParser(
21 description="Check for errors in "
22 "Libre-SOC's style of budget tracking in Bugzilla.")
23 parser.add_argument(
24 "-c", "--config", type=argparse.FileType('r'),
25 required=True, help="The path to the configuration TOML file",
26 dest="config", metavar="<path/to/budget-sync-config.toml>")
27 parser.add_argument(
28 "-o", "--output-dir", type=Path, default=None,
29 help="The path to the output directory, will be created if it "
30 "doesn't exist",
31 dest="output_dir", metavar="<path/to/output/dir>")
32 parser.add_argument('--subset',
33 help="write the output for this subset of bugs",
34 metavar="<bug-id>,<bug-id>,...")
35 parser.add_argument('--subset-person',
36 help="write the output for this person",
37 dest="person")
38 parser.add_argument('--username',
39 help="Log in with this Bugzilla username")
40 parser.add_argument('--password',
41 help="Log in with this Bugzilla password")
42 parser.add_argument('--comments', action='store_true',
43 help="Put JSON into comments")
44 args = parser.parse_args()
45 try:
46 with args.config as config_file:
47 config = Config.from_file(config_file)
48 except (IOError, ConfigParseError) as e:
49 logging.error("Failed to parse config file: %s", e)
50 return
51 logging.info("Using Bugzilla instance at %s", config.bugzilla_url)
52 # download all bugs and create a summary report
53 reportdir = "./" if args.output_dir is None else str(args.output_dir)
54 reportdir = "/".join(reportdir.split("/")[:-1]) # strip /mdwn
55 reportname = reportdir + "/report.mdwn"
56 with open(reportname, "w") as f:
57 print("```", file=f) # for using the output as markdown
58 bz = Bugzilla(config.bugzilla_url)
59 if args.username:
60 logging.debug("logging in...")
61 bz.interactive_login(args.username, args.password)
62 logging.debug("Connected to Bugzilla")
63 budget_graph = BudgetGraph(all_bugs(bz), config)
64 for error in budget_graph.get_errors():
65 logging.error("%s", error)
66 if args.person or args.subset:
67 if not args.person:
68 logging.fatal("must use --subset-person with --subset option")
69 sys.exit(1)
70 print_markdown_for_person(f, budget_graph, config,
71 args.person, args.subset)
72 return
73 if args.output_dir is not None:
74 write_budget_markdown(budget_graph, args.output_dir)
75 write_budget_csv(budget_graph, args.output_dir)
76 summarize_milestones(f, budget_graph)
77 print("```", file=f) # for using the output as markdown
78 # now create the JSON milestone files for putting into NLnet RFP system
79 json_milestones(budget_graph, args.comments, args.output_dir)
80
81
82 def print_markdown_for_person(f, budget_graph, config, person_str, subset_str):
83 person = config.all_names.get(person_str)
84 if person is None:
85 logging.fatal("--subset-person: unknown person: %s", person_str)
86 sys.exit(1)
87 nodes_subset = None
88 if subset_str:
89 nodes_subset = OrderedSet()
90 for bug_id in re.split(r"[\s,]+", subset_str):
91 try:
92 node = budget_graph.nodes[int(bug_id)]
93 except (ValueError, KeyError):
94 logging.fatal("--subset: unknown bug: %s", bug_id)
95 sys.exit(1)
96 nodes_subset.add(node)
97 print(markdown_for_person(budget_graph, person, nodes_subset), file=f)
98
99
100 def print_budget_then_children(f, indent, nodes, bug_id):
101 """recursive indented printout of budgets
102 """
103
104 bug = nodes[bug_id]
105 b_incl = str(bug.fixed_budget_including_subtasks)
106 b_excl = str(bug.fixed_budget_excluding_subtasks)
107 s_incl = str(bug.submitted_including_subtasks)
108 p_incl = str(bug.paid_including_subtasks)
109 if b_incl == s_incl and b_incl == p_incl:
110 descr = "(s+p)"
111 elif b_incl == s_incl:
112 descr = "(s) p %s" % p_incl
113 elif b_incl == p_incl:
114 descr = "(p) s %s" % s_incl
115 elif s_incl == p_incl:
116 descr = " s,p %s" % (p_incl)
117 else:
118 descr = "s %s p %s" % (s_incl, p_incl)
119 excl_desc = " "
120 if b_incl != b_excl:
121 excl_desc = "excltasks %6s" % b_excl
122 print("bug #%5d %s budget %6s %s %s" %
123 (bug.bug.id, ' | ' * indent,
124 b_incl,
125 excl_desc,
126 descr
127 ), file=f)
128 # print(repr(bug))
129
130 for child in bug.immediate_children:
131 if (str(child.budget_including_subtasks) == "0" and
132 str(child.budget_excluding_subtasks) == "0"):
133 continue
134 print_budget_then_children(f, indent+1, nodes, child.bug.id)
135
136
137 def summarize_milestones(f, budget_graph):
138 for milestone, payments in budget_graph.milestone_payments.items():
139 summary = PaymentSummary(payments)
140 print(f"{milestone.identifier}", file=f)
141 print(f"\t{summary.total} submitted: "
142 f"{summary.total_submitted} paid: {summary.total_paid}", file=f)
143 not_submitted = summary.get_not_submitted()
144 if not_submitted:
145 print("not submitted", not_submitted, file=f)
146
147 # and one to display people
148 for person in budget_graph.milestone_people[milestone]:
149 print("\t%-30s - %s" % (person.identifier, person.full_name),
150 file=f)
151 print(file=f)
152
153 # now do trees
154 for milestone, payments in budget_graph.milestone_payments.items():
155 print("%s %d" % (milestone.identifier, milestone.canonical_bug_id),
156 file=f)
157 print_budget_then_children(f, 0, budget_graph.nodes,
158 milestone.canonical_bug_id)
159 print(file=f)
160
161
162 def json_milestones(budget_graph: BudgetGraph, add_comments: bool,
163 output_dir: Path):
164 """reports milestones as json format
165 """
166 bug_comments_map = {}
167 if add_comments:
168 need_set = set()
169 bugzilla = None
170 for nodes in budget_graph.assigned_nodes_for_milestones.values():
171 for node in nodes:
172 need_set.add(node.bug.id)
173 bugzilla = node.bug.bugzilla
174 need_list = sorted(need_set)
175 total = len(need_list)
176 with tty_out() as term:
177 step = 100
178 i = 0
179 while i < total:
180 cur_need = need_list[i:i + step]
181 stop = i + len(cur_need)
182 print("loading comments %d:%d of %d" % (i, stop, total),
183 flush=True, file=term)
184 comments = bugzilla.get_comments(cur_need)['bugs']
185 if len(comments) < len(cur_need) and len(cur_need) > 1:
186 step = max(1, step // 2)
187 print("failed, trying smaller step of %d" % step,
188 flush=True, file=term)
189 continue
190 bug_comments_map.update(comments)
191 i += len(cur_need)
192 for milestone, payments in budget_graph.milestone_payments.items():
193 summary = PaymentSummary(payments)
194 # and one to display people
195 ppl = []
196 for person in budget_graph.milestone_people[milestone]:
197 p = {'name': person.full_name, 'email': person.email}
198 ppl.append(p)
199
200 tasks = []
201 canonical = budget_graph.nodes[milestone.canonical_bug_id]
202 for child in canonical.immediate_children:
203 milestones = []
204 # include the task itself as a milestone
205 for st in list(child.children()) + [child]:
206 amount = st.fixed_budget_excluding_subtasks.int()
207 if amount == 0: # skip anything at zero
208 continue
209 # if "task itself" then put the milestone as "wrapup"
210 if st.bug == child.bug:
211 description = 'wrapup'
212 intro = []
213 else:
214 # otherwise create a description and get comment #0
215 description = "%d %s" % (st.bug.id, st.bug.summary)
216 # add parent and MoU top-level
217 parent_id = st.parent.bug.id
218 if parent_id != child.bug.id:
219 description += "\n(Sub-sub-task of %d)" % parent_id
220 task = {'description': description,
221 'amount': amount,
222 }
223 #mou_bug = st.closest_bug_in_mou
224 # if mou_bug is not None:
225 # task['mou_task'] = mou_bug.bug.id
226 milestones.append(task)
227 # create MoU task: get comment #0
228 intro = []
229 comment = "%s\n " % child.bug_url
230 if add_comments:
231 comment += "\n"
232 comments = bug_comments_map[str(child.bug.id)]['comments']
233 lines = comments[0]['text'].splitlines()
234 for i, line in enumerate(lines):
235 # look for a line with only 2 or more `-` as the
236 # standard way in markdown of having a "break" (<hl />)
237 # this truncates the comment so that the RFP database
238 # has only the "summary description" but the rest may
239 # be used for "TODO" lists
240 l = line.strip()
241 if len(l) >= 2 and l == "-" * len(l):
242 lines[i:] = []
243 break
244 comment += "\n".join(lines)
245 intro.append(comment)
246 # print (description, intro)
247 # sys.stdout.flush()
248 task = {'title': "%d %s" % (child.bug.id, child.bug.summary),
249 'intro': intro,
250 'amount': child.fixed_budget_including_subtasks.int(),
251 'url': "{{ %s }} " % child.bug_url,
252 'milestones': milestones
253 }
254 tasks.append(task)
255
256 d = {'participants': ppl,
257 'preamble': '',
258 'type': 'Group',
259 'url': canonical.bug_url,
260 'plan': {'intro': [''],
261 'tasks': tasks,
262 'rfp_secret': '',
263 }
264 }
265
266 output_file = output_dir / f"report.{milestone.identifier}.json"
267 output_file.write_text(json.dumps(d, indent=2), encoding="utf-8")
268
269
270 if __name__ == "__main__":
271 main()