[Concept,10/11] patman: Add 'workflow list' command to show workflow history

Message ID 20260329150140.4095446-11-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 a 'list' subcommand (aliases: 'ls', 'wl') to the workflow command
that shows all workflow entries ordered by timestamp. Use -a/--all to
include archived entries, which are marked with '*' in an extra column.

Timestamps are shown in a friendly format: time only for today,
day and time for the past week, 'Nd ago' up to two weeks, and
'Nw ago' beyond that. Future dates use the same pattern.

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

 tools/patman/cmdline.py      |  7 ++++
 tools/patman/control.py      |  2 +
 tools/patman/database.py     | 23 ++++++++++++
 tools/patman/test_cseries.py | 72 ++++++++++++++++++++++++++++++++++++
 tools/patman/workflow.py     | 59 +++++++++++++++++++++++++++++
 5 files changed, 163 insertions(+)
  

Patch

diff --git a/tools/patman/cmdline.py b/tools/patman/cmdline.py
index 3628c12459c..26626f5b441 100644
--- a/tools/patman/cmdline.py
+++ b/tools/patman/cmdline.py
@@ -37,6 +37,7 @@  ALIASES = {
     'progress': ['p', 'pr', 'prog'],
     'rm-version': ['rmv'],
     'todo-list': ['tl'],
+    'workflow-list': ['wl'],
     'unarchive': ['unar'],
     }
 
@@ -501,6 +502,12 @@  def add_workflow_subparser(subparsers):
                                            aliases=ALIASES['todo-list'])
     tlist.add_argument('--all', action='store_true', dest='show_all',
                        help='Show all scheduled todos, not just due ones')
+
+    wlist = workflow_subparsers.add_parser('list',
+                                           aliases=[*ALIASES['workflow-list'],
+                                                    'ls'])
+    wlist.add_argument('-a', '--all', action='store_true', dest='show_all',
+                       help='Include archived entries')
     return workflow
 
 
diff --git a/tools/patman/control.py b/tools/patman/control.py
index fe012f1a21b..0c3a3097967 100644
--- a/tools/patman/control.py
+++ b/tools/patman/control.py
@@ -365,6 +365,8 @@  def do_workflow(args, test_db=None):
                 workflow.todo(cser, args.series, args.days)
         elif args.subcmd == 'todo-list':
             workflow.todo_list(cser, args.show_all)
+        elif args.subcmd in ['list', 'wl', 'ls']:
+            workflow.list_entries(cser, args.show_all)
         else:
             raise ValueError(f"Unknown workflow subcommand '{args.subcmd}'")
     finally:
diff --git a/tools/patman/database.py b/tools/patman/database.py
index edb7d116c33..3133a320694 100644
--- a/tools/patman/database.py
+++ b/tools/patman/database.py
@@ -1130,3 +1130,26 @@  class Database:  # pylint:disable=R0904
         query += ' ORDER BY w.timestamp'
         res = self.execute(query, params)
         return res.fetchall()
+
+    def workflow_list(self, include_archived=False):
+        """Get workflow entries joined with series info
+
+        Args:
+            include_archived (bool): True to include archived entries
+
+        Return:
+            list of tuple:
+                str: workflow type
+                str: series name
+                str: series description
+                str: timestamp
+                int: archived flag (0 or 1)
+        """
+        query = ('SELECT w.type, s.name, s.desc, w.timestamp, w.archived '
+                 'FROM workflow w '
+                 'JOIN series s ON w.series_id = s.id')
+        if not include_archived:
+            query += ' WHERE w.archived = 0'
+        query += ' ORDER BY w.timestamp'
+        res = self.execute(query)
+        return res.fetchall()
diff --git a/tools/patman/test_cseries.py b/tools/patman/test_cseries.py
index 01f4f4a133d..4eea5922c84 100644
--- a/tools/patman/test_cseries.py
+++ b/tools/patman/test_cseries.py
@@ -4323,3 +4323,75 @@  Date:   .*
 
         ts = cser.db.workflow_get('todo', ser.idnum)
         self.assertEqual('2025-03-12 12:00:00', ts)
+
+    def test_workflow_list(self):
+        """Test listing all workflow entries"""
+        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)
+
+        # Record a send (creates SENT + TODO)
+        ser = cser.get_series_by_name('first')
+        wf.sent(cser, ser.idnum)
+
+        # Default list shows only active entries
+        with terminal.capture() as (out, _):
+            wf.list_entries(cser, show_all=False)
+        lines = out.getvalue().splitlines()
+        self.assertEqual(4, len(lines))
+        self.assertIn('sent', lines[2])
+        self.assertIn('todo', lines[3])
+
+        # Archive the todo
+        wf.todo_clear(cser, 'first')
+
+        # Without --all, only SENT is active; no 'A' column
+        with terminal.capture() as (out, _):
+            wf.list_entries(cser, show_all=False)
+        lines = out.getvalue().splitlines()
+        self.assertEqual(3, len(lines))
+        self.assertIn('sent', lines[2])
+        self.assertNotIn('A', lines[0])
+
+        # With --all, archived entries appear with '*' marker
+        with terminal.capture() as (out, _):
+            wf.list_entries(cser, show_all=True)
+        lines = out.getvalue().splitlines()
+        self.assertGreater(len(lines), 3)
+        self.assertIn('  A  ', lines[0])
+        has_archived = any('*' in line for line in lines[2:])
+        self.assertTrue(has_archived)
+
+    def test_friendly_time(self):
+        """Test friendly timestamp formatting"""
+        now = datetime(2025, 3, 10, 15, 0, 0)  # Monday
+
+        # Same day
+        when = datetime(2025, 3, 10, 9, 30, 0)
+        self.assertEqual('09:30', wf.friendly_time(now, when))
+
+        # Earlier this week (3 days ago = Friday)
+        when = datetime(2025, 3, 7, 14, 20, 0)
+        self.assertEqual('Fri 14:20', wf.friendly_time(now, when))
+
+        # 10 days ago
+        when = datetime(2025, 2, 28, 10, 0, 0)
+        self.assertEqual('10d ago', wf.friendly_time(now, when))
+
+        # 3 weeks ago
+        when = datetime(2025, 2, 17, 10, 0, 0)
+        self.assertEqual('3w ago', wf.friendly_time(now, when))
+
+        # Future within a week (3 days from now = Thursday)
+        when = datetime(2025, 3, 13, 16, 0, 0)
+        self.assertEqual('Thu 16:00', wf.friendly_time(now, when))
+
+        # Future 10 days
+        when = datetime(2025, 3, 20, 10, 0, 0)
+        self.assertEqual('in 10d', wf.friendly_time(now, when))
+
+        # Future 3 weeks
+        when = datetime(2025, 3, 31, 10, 0, 0)
+        self.assertEqual('in 3w', wf.friendly_time(now, when))
diff --git a/tools/patman/workflow.py b/tools/patman/workflow.py
index 42e38db56f6..d123e3068a2 100644
--- a/tools/patman/workflow.py
+++ b/tools/patman/workflow.py
@@ -15,6 +15,34 @@  class Wtype(enum.StrEnum):
     TODO = 'todo'
 
 
+def friendly_time(now, when):
+    """Format a timestamp in a human-friendly way
+
+    Args:
+        now (datetime): Current time
+        when (datetime): Timestamp to format
+
+    Return:
+        str: Friendly string, e.g. 'Tue 15:34', '3d ago', '2w ago'
+    """
+    delta = now - when
+    days = delta.days
+    if days < 0:
+        days = -days
+        if days >= 14:
+            return f'in {days // 7}w'
+        if days >= 7:
+            return f'in {days}d'
+        return when.strftime('%a %H:%M')
+    if days == 0:
+        return when.strftime('%H:%M')
+    if days < 7:
+        return when.strftime('%a %H:%M')
+    if days < 14:
+        return f'{days}d ago'
+    return f'{days // 7}w ago'
+
+
 def sent(cser, series_id):
     """Record that a series was sent and create a follow-up todo
 
@@ -92,3 +120,34 @@  def todo_list(cser, show_all):
         else:
             due = f'in {days}d'
         print(f"{name:17}  {due:>14}  {desc}")
+
+
+def list_entries(cser, show_all):
+    """List all workflow entries
+
+    Args:
+        cser (CseriesHelper): Series helper with open database
+        show_all (bool): True to include archived entries
+    """
+    entries = cser.db.workflow_list(include_archived=show_all)
+    if not entries:
+        print('No workflow entries')
+        return
+    hdr = f"{'Type':6}  {'Series':17}  {'When':>10}"
+    div = f"{'-' * 6}  {'-' * 17}  {'-' * 10}"
+    if show_all:
+        hdr += '  A'
+        div += '  -'
+    hdr += '  Description'
+    div += '  ' + '-' * 30
+    print(hdr)
+    print(div)
+    now = cser.get_now()
+    for wtype, name, desc, ts, archived in entries:
+        when = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
+        friendly = friendly_time(now, when)
+        line = f"{wtype:6}  {name:17}  {friendly:>10}"
+        if show_all:
+            line += f"  {'*' if archived else ' '}"
+        line += f"  {desc}"
+        print(line)