add support for reporting the closest task that is in a signed MoU
authorJacob Lifshay <programmerjake@gmail.com>
Tue, 5 Jul 2022 07:58:52 +0000 (00:58 -0700)
committerJacob Lifshay <programmerjake@gmail.com>
Tue, 5 Jul 2022 07:58:52 +0000 (00:58 -0700)
src/budget_sync/budget_graph.py
src/budget_sync/test/mock_bug.py
src/budget_sync/test/test_budget_graph.py
src/budget_sync/test/test_write_budget_csv.py
src/budget_sync/test/test_write_budget_markdown.py
src/budget_sync/write_budget_markdown.py

index d0ce97eacfee13e1bef23c17bfc0d0a719fd8a75..3fa2d72bfce8af431377f490da949094aea9f457 100644 (file)
@@ -11,9 +11,9 @@ from collections import deque
 from datetime import date, time, datetime
 try:
     from functools import cached_property
-except ImportError:
-    # compatability with python < 3.8
-    from cached_property import cached_property
+except ImportError:  # :nocov:
+    # compatibility with python < 3.8
+    from cached_property import cached_property  # :nocov:
 
 
 class BudgetGraphBaseError(Exception):
@@ -314,6 +314,7 @@ class Node:
     fixed_budget_excluding_subtasks: Money
     fixed_budget_including_subtasks: Money
     milestone_str: Optional[str]
+    is_in_nlnet_mou: bool
 
     def __init__(self, graph: "BudgetGraph", bug: Bug):
         self.graph = graph
@@ -327,6 +328,7 @@ class Node:
         self.milestone_str = bug.cf_nlnet_milestone
         if self.milestone_str == "---":
             self.milestone_str = None
+        self.is_in_nlnet_mou = bug.cf_is_in_nlnet_mou2 == "Yes"
 
     @property
     def status(self) -> BugStatus:
@@ -451,6 +453,18 @@ class Node:
                 self._raise_loop_error()
         return retval
 
+    @cached_property
+    def closest_bug_in_mou(self) -> Optional["Node"]:
+        """returns the closest bug that is in a NLNet MoU, searching only in
+        this bug and parents.
+        """
+        if self.is_in_nlnet_mou:
+            return self
+        for parent in self.parents():
+            if parent.is_in_nlnet_mou:
+                return parent
+        return None
+
     def children(self) -> Iterable["Node"]:
         def visitor(node: Node) -> Iterable[Node]:
             for i in node.immediate_children:
@@ -492,6 +506,7 @@ class Node:
             tpp.field("fixed_budget_including_subtasks",
                       self.fixed_budget_including_subtasks)
             tpp.field("milestone_str", self.milestone_str)
+            tpp.field("is_in_nlnet_mou", self.is_in_nlnet_mou)
             tpp.try_field("milestone", lambda: self.milestone,
                           BudgetGraphBaseError)
             immediate_children = [_NodeSimpleReprWrapper(i)
@@ -551,6 +566,7 @@ class Node:
                 f"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
                 f"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
                 f"milestone_str={self.milestone_str!r}, "
+                f"is_in_nlnet_mou={self.is_in_nlnet_mou!r}, "
                 f"milestone={milestone}, "
                 f"immediate_children={immediate_children!r}, "
                 f"payments={payments!r}, "
@@ -651,6 +667,36 @@ class BudgetGraphIncorrectRootForMilestone(BudgetGraphError):
                 f"#{self.milestone_canonical_bug_id}")
 
 
+class BudgetGraphRootWithMilestoneNotInMoU(BudgetGraphError):
+    def __init__(self, bug_id: int, milestone: str):
+        super().__init__(bug_id, bug_id)
+        self.milestone = milestone
+
+    def __str__(self):
+        return (f"Bug #{self.bug_id} has no parent bug set and has an "
+                f"assigned milestone {self.milestone!r} but isn't set "
+                f"to be part of the signed MoU")
+
+
+class BudgetGraphInMoUButParentNotInMoU(BudgetGraphError):
+    def __init__(self, bug_id: int, parent_bug_id: int, root_bug_id: int,
+                 milestone: str):
+        super().__init__(bug_id, root_bug_id)
+        self.parent_bug_id = parent_bug_id
+        self.milestone = milestone
+
+    def __str__(self):
+        return (f"Bug #{self.bug_id} is set to be part of the signed MoU for "
+                f"milestone {self.milestone!r}, but its parent bug isn't set "
+                f"to be part of the signed MoU")
+
+
+class BudgetGraphInMoUWithoutMilestone(BudgetGraphError):
+    def __str__(self):
+        return (f"Bug #{self.bug_id} is set to be part of a signed MoU but "
+                f"has no milestone set")
+
+
 class BudgetGraph:
     nodes: Dict[int, Node]
 
@@ -688,14 +734,17 @@ class BudgetGraph:
         try:
             # check for milestone errors
             node.milestone
-            if root == node and node.milestone is not None \
-                    and node.milestone.canonical_bug_id != node.bug.id:
-                if node.budget_including_subtasks != 0 \
-                        or node.budget_excluding_subtasks != 0:
-                    errors.append(BudgetGraphIncorrectRootForMilestone(
-                        node.bug.id, node.milestone.identifier,
-                        node.milestone.canonical_bug_id
-                    ))
+            if root == node and node.milestone is not None:
+                if node.milestone.canonical_bug_id != node.bug.id:
+                    if node.budget_including_subtasks != 0 \
+                            or node.budget_excluding_subtasks != 0:
+                        errors.append(BudgetGraphIncorrectRootForMilestone(
+                            node.bug.id, node.milestone.identifier,
+                            node.milestone.canonical_bug_id
+                        ))
+                elif not node.is_in_nlnet_mou:
+                    errors.append(BudgetGraphRootWithMilestoneNotInMoU(
+                        node.bug.id, node.milestone_str))
         except BudgetGraphBaseError as e:
             errors.append(e)
 
@@ -715,6 +764,16 @@ class BudgetGraph:
             errors.append(BudgetGraphMilestoneMismatch(
                 node.bug.id, root.bug.id))
 
+        if node.is_in_nlnet_mou:
+            if node.milestone_str is None:
+                errors.append(BudgetGraphInMoUWithoutMilestone(node.bug.id,
+                                                               root.bug.id))
+            elif node.parent is not None and \
+                    not node.parent.is_in_nlnet_mou:
+                errors.append(BudgetGraphInMoUButParentNotInMoU(
+                    node.bug.id, node.parent.bug.id, root.bug.id,
+                    node.milestone_str))
+
         if node.budget_excluding_subtasks < 0 \
                 or node.budget_including_subtasks < 0:
             errors.append(BudgetGraphNegativeMoney(
index 17be594ebaa4b9de94eaa27ff6158ca55dfb2113..4f926fcb10ddd91f8cb4911fb4bafdf4519491a3 100644 (file)
@@ -12,7 +12,8 @@ class MockBug:
                  cf_payees_list: str = "",
                  summary: str = "<default summary>",
                  status: Union[str, BugStatus] = BugStatus.CONFIRMED,
-                 assigned_to: str = "user@example.com"):
+                 assigned_to: str = "user@example.com",
+                 cf_is_in_nlnet_mou2: str = ""):
         self.id = bug_id
         self.__budget_parent = cf_budget_parent
         self.cf_budget = cf_budget
@@ -24,6 +25,7 @@ class MockBug:
         self.summary = summary
         self.status = str(status)
         self.assigned_to = assigned_to
+        self.cf_is_in_nlnet_mou2 = cf_is_in_nlnet_mou2
 
     @property
     def cf_budget_parent(self) -> int:
index 7aaadf5ae04865a2ac2a03b881d28ffa915bf05a..b0f94948aa3a6e17ad5dbb9be3c917605346965d 100644 (file)
@@ -8,7 +8,9 @@ from budget_sync.budget_graph import (
     BudgetGraphNegativePayeeMoney, BudgetGraphPayeesParseError,
     BudgetGraphPayeesMoneyMismatch, BudgetGraphUnknownMilestone,
     BudgetGraphIncorrectRootForMilestone,
-    BudgetGraphUnknownStatus, BudgetGraphUnknownAssignee)
+    BudgetGraphUnknownStatus, BudgetGraphUnknownAssignee,
+    BudgetGraphRootWithMilestoneNotInMoU, BudgetGraphInMoUButParentNotInMoU,
+    BudgetGraphInMoUWithoutMilestone)
 from budget_sync.money import Money
 from budget_sync.util import BugStatus
 from typing import List, Type
@@ -94,6 +96,25 @@ class TestErrorFormatting(unittest.TestCase):
             "Total budget assigned to payees (cf_payees_list) doesn't match "
             "expected value: bug #1, calculated total 123, expected value 456")
 
+    def test_budget_graph_root_with_milestone_not_in_mou(self):
+        self.assertEqual(str(
+            BudgetGraphRootWithMilestoneNotInMoU(1, "milestone 1")),
+            "Bug #1 has no parent bug set and has an assigned milestone "
+            "'milestone 1' but isn't set to be part of the signed MoU")
+
+    def test_budget_graph_in_mou_but_parent_not_in_mou(self):
+        self.assertEqual(str(
+            BudgetGraphInMoUButParentNotInMoU(5, 3, 1, "milestone 1")),
+            "Bug #5 is set to be part of the signed MoU for milestone "
+            "'milestone 1', but its parent bug isn't set to be part of "
+            "the signed MoU")
+
+    def test_budget_graph_in_mou_without_milestone(self):
+        self.assertEqual(str(
+            BudgetGraphInMoUWithoutMilestone(1, 5)),
+            "Bug #1 is set to be part of a signed MoU but has no "
+            "milestone set")
+
 
 EXAMPLE_BUG1 = MockBug(bug_id=1,
                        cf_budget_parent=None,
@@ -129,7 +150,8 @@ EXAMPLE_PARENT_BUG1 = MockBug(bug_id=1,
                               cf_total_budget="20",
                               cf_nlnet_milestone="milestone 1",
                               cf_payees_list="",
-                              summary="")
+                              summary="",
+                              cf_is_in_nlnet_mou2="Yes")
 EXAMPLE_CHILD_BUG2 = MockBug(bug_id=2,
                              cf_budget_parent=1,
                              cf_budget="10",
@@ -186,7 +208,8 @@ class TestBudgetGraph(unittest.TestCase):
             "budget_excluding_subtasks=10, budget_including_subtasks=20, "
             "fixed_budget_excluding_subtasks=10, "
             "fixed_budget_including_subtasks=20, milestone_str='milestone "
-            "1', milestone=Milestone(config=..., identifier='milestone 1', "
+            "1', is_in_nlnet_mou=True, "
+            "milestone=Milestone(config=..., identifier='milestone 1', "
             "canonical_bug_id=1), immediate_children=[#2], payments=[], "
             "status=BugStatus.CONFIRMED, assignee=Person<'person3'>, "
             "resolved_payments={}, payment_summaries={}), Node(graph=..., "
@@ -194,7 +217,8 @@ class TestBudgetGraph(unittest.TestCase):
             "budget_including_subtasks=10, "
             "fixed_budget_excluding_subtasks=10, "
             "fixed_budget_including_subtasks=10, milestone_str='milestone "
-            "1', milestone=Milestone(config=..., identifier='milestone 1', "
+            "1', is_in_nlnet_mou=False, "
+            "milestone=Milestone(config=..., identifier='milestone 1', "
             "canonical_bug_id=1), immediate_children=[], payments=[], "
             "status=BugStatus.CONFIRMED, assignee=Person<'person3'>, "
             "resolved_payments={}, payment_summaries={})], roots=[#1], "
@@ -244,6 +268,7 @@ class TestBudgetGraph(unittest.TestCase):
             "budget_excluding_subtasks=0, budget_including_subtasks=0, "
             "fixed_budget_excluding_subtasks=0, "
             "fixed_budget_including_subtasks=0, milestone_str=None, "
+            "is_in_nlnet_mou=False, "
             "milestone=None, immediate_children=[], payments=[], "
             "status=<unknown status: 'blah'>, assignee=<unknown assignee: "
             "'unknown@example.com'>, resolved_payments={}, "
@@ -292,6 +317,7 @@ alias2 = {paid=2020-03-16,amount=23}
             "budget_excluding_subtasks=0, budget_including_subtasks=0, "
             "fixed_budget_excluding_subtasks=0, "
             "fixed_budget_including_subtasks=0, milestone_str=None, "
+            "is_in_nlnet_mou=False, "
             "milestone=None, immediate_children=[], "
             "payments=[Payment(node=#1, payee=Person<'person1'>, "
             "payee_key='person1', amount=5, state=Paid, paid=2020-03-15, "
@@ -565,13 +591,13 @@ alias2 = {paid=2020-03-16,amount=23}
                         cf_total_budget=total_budget,
                         cf_nlnet_milestone="milestone 1",
                         cf_payees_list=payees_list,
-                        summary=""),
+                        summary="",
+                        cf_is_in_nlnet_mou2="Yes"),
                 MockBug(bug_id=2,
                         cf_budget_parent=1,
                         cf_budget=child_budget,
                         cf_total_budget=child_budget,
                         cf_nlnet_milestone="milestone 1",
-                        cf_payees_list="",
                         summary=""),
             ], EXAMPLE_CONFIG)
             node1: Node = bg.nodes[1]
@@ -588,7 +614,8 @@ alias2 = {paid=2020-03-16,amount=23}
                             node1.fixed_budget_including_subtasks),
                         cf_nlnet_milestone="milestone 1",
                         cf_payees_list=payees_list,
-                        summary=""),
+                        summary="",
+                        cf_is_in_nlnet_mou2="Yes"),
                 MockBug(bug_id=2,
                         cf_budget_parent=1,
                         cf_budget=child_budget,
@@ -888,7 +915,8 @@ alias2 = {paid=2020-03-16,amount=23}
                     cf_total_budget="-10",
                     cf_nlnet_milestone="milestone 1",
                     cf_payees_list="",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [
@@ -906,7 +934,8 @@ alias2 = {paid=2020-03-16,amount=23}
                     cf_total_budget="0",
                     cf_nlnet_milestone="milestone 1",
                     cf_payees_list="",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [
@@ -924,7 +953,8 @@ alias2 = {paid=2020-03-16,amount=23}
                     cf_total_budget="-10",
                     cf_nlnet_milestone="milestone 1",
                     cf_payees_list="",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors,
@@ -940,7 +970,8 @@ alias2 = {paid=2020-03-16,amount=23}
                                       cf_total_budget="0",
                                       cf_nlnet_milestone="milestone 1",
                                       cf_payees_list=cf_payees_list,
-                                      summary=""),
+                                      summary="",
+                                      cf_is_in_nlnet_mou2="Yes"),
                               ], EXAMPLE_CONFIG)
             self.assertErrorTypesMatches(bg.get_errors(), error_types)
             self.assertEqual(len(bg.nodes), 1)
@@ -1168,7 +1199,8 @@ alias2 = {paid=2020-03-16,amount=23}
                     cf_total_budget="10",
                     cf_nlnet_milestone="milestone 1",
                     cf_payees_list="person1 = 5\nperson2 = 10",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors,
@@ -1186,7 +1218,8 @@ alias2 = {paid=2020-03-16,amount=23}
                         cf_total_budget="0",
                         cf_nlnet_milestone="milestone 1",
                         cf_payees_list=cf_payees_list,
-                        summary=""),
+                        summary="",
+                        cf_is_in_nlnet_mou2="Yes"),
             ], EXAMPLE_CONFIG).get_errors()
             self.assertErrorTypesMatches(errors,
                                          [BudgetGraphPayeesParseError])
@@ -1282,7 +1315,8 @@ alias2 = {paid=2020-03-16,amount=23}
                     cf_total_budget="10",
                     cf_nlnet_milestone="milestone 1",
                     cf_payees_list="""person1 = -10""",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors,
@@ -1306,7 +1340,8 @@ alias2 = {paid=2020-03-16,amount=23}
                     person1 = 5
                     alias1 = 5
                     """,
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [])
@@ -1348,7 +1383,8 @@ alias2 = {paid=2020-03-16,amount=23}
                     cf_total_budget="10",
                     cf_nlnet_milestone="milestone 2",
                     cf_payees_list="",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors,
@@ -1364,7 +1400,8 @@ alias2 = {paid=2020-03-16,amount=23}
                     cf_total_budget="0",
                     cf_nlnet_milestone="milestone 2",
                     cf_payees_list="",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         errors = bg.get_errors()
         self.assertErrorTypesMatches(errors, [])
@@ -1377,14 +1414,16 @@ alias2 = {paid=2020-03-16,amount=23}
                     cf_total_budget="10",
                     cf_nlnet_milestone="milestone 1",
                     cf_payees_list="person1 = 3\nperson2 = 7",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
             MockBug(bug_id=2,
                     cf_budget_parent=None,
                     cf_budget="10",
                     cf_total_budget="10",
                     cf_nlnet_milestone="milestone 2",
                     cf_payees_list="person3 = 5\nperson2 = 5",
-                    summary=""),
+                    summary="",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], EXAMPLE_CONFIG)
         self.assertErrorTypesMatches(bg.get_errors(), [])
         person1 = EXAMPLE_CONFIG.people["person1"]
@@ -1443,6 +1482,68 @@ alias2 = {paid=2020-03-16,amount=23}
         self.assertEqual(bg.nodes[1].assignee,
                          EXAMPLE_CONFIG.people["person2"])
 
+    def test_closest_bug_in_mou(self):
+        bg = BudgetGraph([
+            MockBug(bug_id=1, cf_nlnet_milestone="milestone 1",
+                    cf_is_in_nlnet_mou2="Yes"),
+            MockBug(bug_id=2, cf_budget_parent=1,
+                    cf_nlnet_milestone="milestone 1",
+                    cf_is_in_nlnet_mou2="Yes"),
+            MockBug(bug_id=3, cf_budget_parent=2,
+                    cf_nlnet_milestone="milestone 1",
+                    cf_is_in_nlnet_mou2="Yes"),
+            MockBug(bug_id=4, cf_budget_parent=2,
+                    cf_nlnet_milestone="milestone 1"),
+            MockBug(bug_id=5, cf_budget_parent=4,
+                    cf_nlnet_milestone="milestone 1"),
+            MockBug(bug_id=6),
+        ], EXAMPLE_CONFIG)
+        errors = bg.get_errors()
+        self.assertErrorTypesMatches(errors, [])
+        self.assertEqual(bg.nodes[1].closest_bug_in_mou, bg.nodes[1])
+        self.assertEqual(bg.nodes[2].closest_bug_in_mou, bg.nodes[2])
+        self.assertEqual(bg.nodes[3].closest_bug_in_mou, bg.nodes[3])
+        self.assertEqual(bg.nodes[4].closest_bug_in_mou, bg.nodes[2])
+        self.assertEqual(bg.nodes[5].closest_bug_in_mou, bg.nodes[2])
+        self.assertEqual(bg.nodes[6].closest_bug_in_mou, None)
+
+    def test_root_with_milestone_not_in_mou(self):
+        bg = BudgetGraph([
+            MockBug(bug_id=1, cf_nlnet_milestone="milestone 1"),
+        ], EXAMPLE_CONFIG)
+        errors = bg.get_errors()
+        self.assertErrorTypesMatches(errors,
+                                     [BudgetGraphRootWithMilestoneNotInMoU])
+        self.assertEqual(errors[0].bug_id, 1)
+        self.assertEqual(errors[0].root_bug_id, 1)
+        self.assertEqual(errors[0].milestone, "milestone 1")
+
+    def test_budget_graph_in_mou_without_milestone(self):
+        bg = BudgetGraph([
+            MockBug(bug_id=1, cf_is_in_nlnet_mou2="Yes"),
+        ], EXAMPLE_CONFIG)
+        errors = bg.get_errors()
+        self.assertErrorTypesMatches(errors,
+                                     [BudgetGraphInMoUWithoutMilestone])
+        self.assertEqual(errors[0].bug_id, 1)
+        self.assertEqual(errors[0].root_bug_id, 1)
+
+    def test_in_mou_but_parent_not_in_mou(self):
+        bg = BudgetGraph([
+            MockBug(bug_id=1, cf_nlnet_milestone="milestone 1",
+                    cf_is_in_nlnet_mou2="Yes"),
+            MockBug(bug_id=2, cf_nlnet_milestone="milestone 1",
+                    cf_budget_parent=1),
+            MockBug(bug_id=3, cf_nlnet_milestone="milestone 1",
+                    cf_budget_parent=2, cf_is_in_nlnet_mou2="Yes"),
+        ], EXAMPLE_CONFIG)
+        errors = bg.get_errors()
+        self.assertErrorTypesMatches(errors,
+                                     [BudgetGraphInMoUButParentNotInMoU])
+        self.assertEqual(errors[0].bug_id, 3)
+        self.assertEqual(errors[0].root_bug_id, 1)
+        self.assertEqual(errors[0].parent_bug_id, 2)
+
 
 if __name__ == "__main__":
     unittest.main()
index a95a686d54382d29b603b6a2b271451c7a0e04c3..08182fd75a4d884844bc4ff592eec5aade18a8de 100644 (file)
@@ -41,7 +41,8 @@ class TestWriteBudgetMarkdown(unittest.TestCase):
                     person2 = {amount=421,paid=2020-01-01}
                     """,
                     summary="",
-                    assigned_to="person2@example.com"),
+                    assigned_to="person2@example.com",
+                    cf_is_in_nlnet_mou2="Yes"),
             MockBug(bug_id=2,
                     cf_budget_parent=None,
                     cf_budget="0",
@@ -49,7 +50,8 @@ class TestWriteBudgetMarkdown(unittest.TestCase):
                     cf_nlnet_milestone="milestone 2",
                     cf_payees_list="",
                     summary="",
-                    assigned_to="person2@example.com"),
+                    assigned_to="person2@example.com",
+                    cf_is_in_nlnet_mou2="Yes"),
         ], config)
         self.assertEqual([], budget_graph.get_errors())
         # pretty_print(budget_graph)
index c6dba2f8b8e7f1a441cbaf58461fb228e70114fe..0a8b5dc8f84b17e23810b353ba90d6603c2c00e5 100644 (file)
@@ -1,7 +1,7 @@
 import unittest
 from budget_sync.config import Config
 from budget_sync.test.mock_bug import MockBug
-from budget_sync.test.mock_path import MockPath, DIR
+from budget_sync.test.mock_path import MockFilesystem, MockPath, DIR
 from budget_sync.test.test_mock_path import make_filesystem_and_report_if_error
 from budget_sync.budget_graph import BudgetGraph
 from budget_sync.write_budget_markdown import (
@@ -20,6 +20,38 @@ class TestWriteBudgetMarkdown(unittest.TestCase):
         self.assertEqual(markdown_escape("abc * def_k < &k"),
                          r"abc \* def\_k &lt; &amp;k")
 
+    def format_files_dict(self, files):
+        assert isinstance(files, dict)
+        files_list: "list[str]" = []
+        for path, contents in files.items():
+            assert isinstance(path, str)
+            if contents is DIR:
+                files_list.append(f"    {path!r}: DIR,")
+                continue
+            assert isinstance(contents, bytes)
+            lines: "list[str]" = []
+            for line in contents.splitlines(keepends=True):
+                lines.append(f"        {line!r}")
+            if len(lines) == 0:
+                files_list.append(f"    {path!r}: b'',")
+            else:
+                lines_str = '\n'.join(lines)
+                files_list.append(f"    {path!r}: (\n{lines_str}\n    ),")
+        if len(files_list) == 0:
+            return "{}"
+        return "{\n" + "\n".join(files_list) + "\n}"
+
+    def assertFiles(self, expected_files, filesystem: MockFilesystem):
+        files = filesystem.files
+        self.assertIsInstance(expected_files, dict)
+        if files == expected_files:
+            return
+        files_str = self.format_files_dict(files)
+        expected_files_str = self.format_files_dict(expected_files)
+        self.assertEqual(
+            files, expected_files,
+            msg=f"\nfiles:\n{files_str}\nexpected:\n{expected_files_str}")
+
     def test(self):
         config = Config.from_str(
             """
@@ -45,16 +77,133 @@ class TestWriteBudgetMarkdown(unittest.TestCase):
         with make_filesystem_and_report_if_error(self) as filesystem:
             output_dir = MockPath("/output_dir/", filesystem=filesystem)
             write_budget_markdown(budget_graph, output_dir)
-            self.assertEqual({
-                "/": DIR,
-                "/output_dir": DIR,
-                '/output_dir/person1.mdwn': b'<!-- autogenerated by '
-                b'budget-sync -->\n\n# Person One (person1)\n\n\n\n#'
-                b' Status Tracking\n\n',
-                '/output_dir/person2.mdwn': b'<!-- autogenerated by '
-                b'budget-sync -->\n\n# Person Two (person2)\n\n\n\n#'
-                b' Status Tracking\n\n',
-            }, filesystem.files)
+            self.assertFiles({
+                '/': DIR,
+                '/output_dir': DIR,
+                '/output_dir/person1.mdwn': (
+                    b'<!-- autogenerated by budget-sync -->\n'
+                    b'\n'
+                    b'# Person One (person1)\n'
+                    b'\n'
+                    b'\n'
+                    b'\n'
+                    b'# Status Tracking\n'
+                    b'\n'
+                ),
+                '/output_dir/person2.mdwn': (
+                    b'<!-- autogenerated by budget-sync -->\n'
+                    b'\n'
+                    b'# Person Two (person2)\n'
+                    b'\n'
+                    b'\n'
+                    b'\n'
+                    b'# Status Tracking\n'
+                    b'\n'
+                ),
+            }, filesystem)
+
+    def test2(self):
+        config = Config.from_str(
+            """
+            bugzilla_url = "https://bugzilla.example.com/"
+            [milestones]
+            "milestone 1" = { canonical_bug_id = 1 }
+            [people."person1"]
+            email = "person1@example.com"
+            full_name = "Person One"
+            [people."person2"]
+            full_name = "Person Two"
+            """)
+        budget_graph = BudgetGraph([
+            MockBug(bug_id=1,
+                    cf_budget_parent=None,
+                    cf_budget="700",
+                    cf_total_budget="1000",
+                    cf_nlnet_milestone="milestone 1",
+                    cf_payees_list="",
+                    summary="",
+                    assigned_to="person1@example.com",
+                    cf_is_in_nlnet_mou2="Yes"),
+            MockBug(bug_id=2,
+                    cf_budget_parent=1,
+                    cf_budget="100",
+                    cf_total_budget="300",
+                    cf_nlnet_milestone="milestone 1",
+                    cf_payees_list="person2 = 100",
+                    summary="",
+                    assigned_to="person1@example.com",
+                    cf_is_in_nlnet_mou2="Yes"),
+            MockBug(bug_id=3,
+                    cf_budget_parent=2,
+                    cf_budget="100",
+                    cf_total_budget="200",
+                    cf_nlnet_milestone="milestone 1",
+                    cf_payees_list="person1 = 100",
+                    summary="",
+                    assigned_to="person1@example.com"),
+            MockBug(bug_id=4,
+                    cf_budget_parent=3,
+                    cf_budget="100",
+                    cf_total_budget="100",
+                    cf_nlnet_milestone="milestone 1",
+                    cf_payees_list="person2 = 100",
+                    summary="",
+                    assigned_to="person1@example.com"),
+        ], config)
+        self.assertEqual([], budget_graph.get_errors())
+        with make_filesystem_and_report_if_error(self) as filesystem:
+            output_dir = MockPath("/output_dir/", filesystem=filesystem)
+            write_budget_markdown(budget_graph, output_dir)
+            self.assertFiles({
+                '/': DIR,
+                '/output_dir': DIR,
+                '/output_dir/person1.mdwn': (
+                    b'<!-- autogenerated by budget-sync -->\n'
+                    b'\n'
+                    b'# Person One (person1)\n'
+                    b'\n'
+                    b'\n'
+                    b'\n'
+                    b'# Status Tracking\n'
+                    b'\n'
+                    b'\n'
+                    b'## Payment not yet submitted\n'
+                    b'\n'
+                    b'\n'
+                    b'### milestone 1\n'
+                    b'\n'
+                    b'* [Bug #3](https://bugzilla.example.com/show_bug.cgi?id=3):\n'
+                    b'  \n'
+                    b'    * &euro;100 which is the total amount\n'
+                    b'    * the closest parent task which is in the MoU is\n'
+                    b'      [Bug #2](https://bugzilla.example.com/show_bug.cgi?id=2)\n'
+                ),
+                '/output_dir/person2.mdwn': (
+                    b'<!-- autogenerated by budget-sync -->\n'
+                    b'\n'
+                    b'# Person Two (person2)\n'
+                    b'\n'
+                    b'\n'
+                    b'\n'
+                    b'# Status Tracking\n'
+                    b'\n'
+                    b'\n'
+                    b'## Payment not yet submitted\n'
+                    b'\n'
+                    b'\n'
+                    b'### milestone 1\n'
+                    b'\n'
+                    b'* [Bug #2](https://bugzilla.example.com/show_bug.cgi?id=2):\n'
+                    b'  \n'
+                    b'    * &euro;100 which is the total amount\n'
+                    b'    * this task is in the MoU\n'
+                    b'* [Bug #4](https://bugzilla.example.com/show_bug.cgi?id=4):\n'
+                    b'  \n'
+                    b'    * &euro;100 which is the total amount\n'
+                    b'    * the closest parent task which is in the MoU is\n'
+                    b'      [Bug #2](https://bugzilla.example.com/show_bug.cgi?id=2)\n'
+                ),
+            }, filesystem)
     # TODO: add more test cases
 
 
index f98bf2c6fe8553c7925aa62ece41c0eb44c6fc36..8c1d096262c691f1701b2e3ae7e61e073ba947dd 100644 (file)
@@ -97,6 +97,18 @@ class MarkdownWriter:
             else:
                 print(f"    * &euro;{payment.amount} which is the total amount",
                       file=self.buffer)
+            closest = node.closest_bug_in_mou
+            if closest is node:
+                print(f"    * this task is in the MoU",
+                      file=self.buffer)
+            elif closest is not None:
+                print(f"    * the closest parent task which is in the MoU is\n"
+                      f"      [Bug #{closest.bug.id}]({closest.bug_url})",
+                      file=self.buffer)
+            else:
+                print(f"    * neither this task nor any parent tasks are in "
+                      f"the MoU",
+                      file=self.buffer)
 
 
 def _markdown_for_person(person: Person,