[Concept,07/11] patman: Add series-todo workflow feature

Message ID 20260329150140.4095446-8-sjg@u-boot.org
State New
Headers
Series patman: Add workflow tracking for patch series |

Commit Message

Simon Glass March 29, 2026, 3:01 p.m. UTC
  From: Simon Glass <sjg@chromium.org>

Add functions to mark a series for attention after a given number of
days, clear the marker, and list which series are due. This is useful
for tracking when to follow up on sent patch series.

The todo list shows relative due dates (e.g. '3d overdue', 'in 7d',
'today') and a [todo] marker appears in series summary output when
a series is due.

Signed-off-by: Simon Glass <sjg@chromium.org>
---

 tools/patman/cser_helper.py  |  7 ++-
 tools/patman/cseries.py      |  1 -
 tools/patman/test_cseries.py | 97 ++++++++++++++++++++++++++++++++++++
 tools/patman/workflow.py     | 63 +++++++++++++++++++++++
 4 files changed, 166 insertions(+), 2 deletions(-)
  

Patch

diff --git a/tools/patman/cser_helper.py b/tools/patman/cser_helper.py
index 0b9f670ca25..7979deda6dc 100644
--- a/tools/patman/cser_helper.py
+++ b/tools/patman/cser_helper.py
@@ -28,6 +28,7 @@  from patman.database import Database, Pcommit, SerVer
 from patman import patchwork
 from patman.series import Series
 from patman import status
+from patman.workflow import Wtype
 
 
 # Tag to use for Change IDs
@@ -1552,7 +1553,11 @@  class CseriesHelper:
             if val in states:
                 state = val
         state_str, pad = self._build_col(state, base_str=name)
-        print(f"{state_str}{pad}  {stats.rjust(6)}  {desc}")
+        marker = ''
+        ts = self.db.workflow_get(Wtype.TODO, ser.idnum)
+        if ts and ts <= self.get_now().strftime('%Y-%m-%d %H:%M:%S'):
+            marker = ' [todo]'
+        print(f"{state_str}{pad}  {stats.rjust(6)}  {desc}{marker}")
 
     def _series_max_version(self, idnum):
         """Find the latest version of a series
diff --git a/tools/patman/cseries.py b/tools/patman/cseries.py
index 716d3c7aa88..8f159a8308f 100644
--- a/tools/patman/cseries.py
+++ b/tools/patman/cseries.py
@@ -7,7 +7,6 @@ 
 
 import asyncio
 from collections import OrderedDict, defaultdict
-
 import pygit2
 
 from u_boot_pylib import cros_subprocess
diff --git a/tools/patman/test_cseries.py b/tools/patman/test_cseries.py
index 798673e09cb..b4dce4cb853 100644
--- a/tools/patman/test_cseries.py
+++ b/tools/patman/test_cseries.py
@@ -4176,3 +4176,100 @@  Date:   .*
             'SELECT archived FROM workflow WHERE series_id = ?',
             (ser.idnum,))
         self.assertEqual(1, res.fetchone()[0])
+
+    def test_workflow_todo(self):
+        """Test setting and clearing a todo"""
+        cser = self.get_cser()
+        with terminal.capture():
+            cser.add('first', 'my description', allow_unmarked=True)
+
+        cser.fake_now = datetime(2025, 3, 1, 12, 0, 0)
+        ser = cser.get_series_by_name('first')
+
+        # Set a todo for 7 days
+        with terminal.capture() as (out, _):
+            wf.todo(cser,'first', 7)
+        self.assertIn('2025-03-08 12:00:00', out.getvalue())
+
+        # Check the DB entry
+        ts = cser.db.workflow_get('todo', ser.idnum)
+        self.assertEqual('2025-03-08 12:00:00', ts)
+
+        # Replacing the todo should work
+        with terminal.capture() as (out, _):
+            wf.todo(cser,'first', 14)
+        self.assertIn('2025-03-15 12:00:00', out.getvalue())
+        ts = cser.db.workflow_get('todo', ser.idnum)
+        self.assertEqual('2025-03-15 12:00:00', ts)
+
+        # Clear it
+        with terminal.capture() as (out, _):
+            wf.todo_clear(cser,'first')
+        self.assertIn('Todo cleared', out.getvalue())
+        self.assertIsNone(cser.db.workflow_get('todo', ser.idnum))
+
+    def test_workflow_todo_list(self):
+        """Test listing todos"""
+        cser = self.get_cser()
+        with terminal.capture():
+            cser.add('first', 'my description', allow_unmarked=True)
+            cser.add('second', 'board stuff', allow_unmarked=True)
+
+        cser.fake_now = datetime(2025, 3, 10, 12, 0, 0)
+
+        # Set todos: first is due, second is in the future
+        with terminal.capture():
+            wf.todo(cser,'first', 0)
+            wf.todo(cser,'second', 7)
+
+        # Default list shows only due entries
+        with terminal.capture() as (out, _):
+            wf.todo_list(cser,show_all=False)
+        lines = out.getvalue().splitlines()
+        self.assertEqual(3, len(lines))
+        self.assertIn('first', lines[2])
+        self.assertIn('today', lines[2])
+
+        # --all shows all entries
+        with terminal.capture() as (out, _):
+            wf.todo_list(cser,show_all=True)
+        lines = out.getvalue().splitlines()
+        self.assertEqual(4, len(lines))
+        self.assertIn('first', lines[2])
+        self.assertIn('today', lines[2])
+        self.assertIn('second', lines[3])
+        self.assertIn('in 7d', lines[3])
+
+        # No todos
+        with terminal.capture():
+            wf.todo_clear(cser,'first')
+            wf.todo_clear(cser,'second')
+        with terminal.capture() as (out, _):
+            wf.todo_list(cser,show_all=False)
+        self.assertIn('No todos due', out.getvalue())
+
+    def test_workflow_summary_marker(self):
+        """Test that [todo] shows in series summary"""
+        cser = self.get_cser()
+        with terminal.capture():
+            cser.add('first', 'my description', allow_unmarked=True)
+
+        cser.fake_now = datetime(2025, 3, 10, 12, 0, 0)
+
+        # Set a todo that is already due
+        with terminal.capture():
+            wf.todo(cser,'first', 0)
+
+        # Summary should show [todo]
+        with terminal.capture() as (out, _):
+            cser.summary(None)
+        self.assertIn('[todo]', out.getvalue())
+
+        # Set a todo in the future
+        with terminal.capture():
+            wf.todo(cser,'first', 14)
+
+        # Summary should NOT show [todo]
+        with terminal.capture() as (out, _):
+            cser.summary(None)
+        self.assertNotIn('[todo]', out.getvalue())
diff --git a/tools/patman/workflow.py b/tools/patman/workflow.py
index 37644a5de88..029d63cf6a4 100644
--- a/tools/patman/workflow.py
+++ b/tools/patman/workflow.py
@@ -5,9 +5,72 @@ 
 
 """Workflow types and operations for patman series management"""
 
+from datetime import datetime, timedelta
 import enum
 
 
 class Wtype(enum.StrEnum):
     """Types of workflow entry"""
     TODO = 'todo'
+
+
+def todo(cser, series, days):
+    """Mark a series as a todo item after a number of days
+
+    Args:
+        cser (CseriesHelper): Series helper with open database
+        series (str): Name of series to use, or None for current branch
+        days (int): Number of days from now to mark as due
+    """
+    ser = cser._parse_series(series)
+    cser.db.workflow_archive(Wtype.TODO, ser.idnum)
+    when = cser.get_now() + timedelta(days=days)
+    ts = when.strftime('%Y-%m-%d %H:%M:%S')
+    cser.db.workflow_add(Wtype.TODO, ser.idnum, ts)
+    cser.commit()
+    print(f"Series '{ser.name}' marked for todo on {ts}")
+
+
+def todo_clear(cser, series):
+    """Clear the todo marker for a series
+
+    Args:
+        cser (CseriesHelper): Series helper with open database
+        series (str): Name of series to use, or None for current branch
+    """
+    ser = cser._parse_series(series)
+    cser.db.workflow_archive(Wtype.TODO, ser.idnum)
+    cser.commit()
+    print(f"Todo cleared for series '{ser.name}'")
+
+
+def todo_list(cser, show_all):
+    """List series that are due (or scheduled) for attention
+
+    Args:
+        cser (CseriesHelper): Series helper with open database
+        show_all (bool): True to show all scheduled todos, not just
+            those that are due
+    """
+    now = cser.get_now().strftime('%Y-%m-%d %H:%M:%S')
+    before = None if show_all else now
+    entries = cser.db.workflow_get_by_type(Wtype.TODO, before=before)
+    if not entries:
+        if show_all:
+            print('No todos scheduled')
+        else:
+            print('No todos due')
+        return
+    print(f"{'Series':17}  {'Due':>14}  Description")
+    print(f"{'-' * 17}  {'-' * 14}  {'-' * 30}")
+    for _sid, name, desc, ts in entries:
+        when = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
+        delta = when - cser.get_now()
+        days = delta.days
+        if days < 0:
+            due = f'{-days}d overdue'
+        elif days == 0:
+            due = 'today'
+        else:
+            due = f'in {days}d'
+        print(f"{name:17}  {due:>14}  {desc}")