@@ -19,7 +19,7 @@ from u_boot_pylib import tout
from patman.series import Series
# Schema version (version 0 means there is no database yet)
-LATEST = 5
+LATEST = 6
# Information about a series/version record
SerVer = namedtuple(
@@ -198,6 +198,21 @@ class Database: # pylint:disable=R0904
self.cur.execute('ALTER TABLE upstream ADD COLUMN no_tags BIT')
self.cur.execute('ALTER TABLE ser_ver ADD COLUMN desc')
+ def _migrate_to_v6(self):
+ """Add workflow table for tracking todos and other workflow items
+
+ Fields:
+ id: Auto-increment primary key
+ type: Workflow-entry type, e.g. 'todo' or 'sent'
+ series_id: Foreign key referencing series.id
+ timestamp: Due/event time as 'YYYY-MM-DD HH:MM:SS'
+ archived: 0 for active entries, 1 for archived (soft-delete)
+ """
+ self.cur.execute(
+ 'CREATE TABLE workflow (id INTEGER PRIMARY KEY AUTOINCREMENT,'
+ 'type, series_id INTEGER, timestamp, archived BIT,'
+ 'FOREIGN KEY (series_id) REFERENCES series (id))')
+
def migrate_to(self, dest_version):
"""Migrate the database to the selected version
@@ -226,6 +241,8 @@ class Database: # pylint:disable=R0904
self._migrate_to_v4()
elif version == 5:
self._migrate_to_v5()
+ elif version == 6:
+ self._migrate_to_v6()
# Save the new version if we have a schema_version table
if version > 1:
@@ -1041,3 +1058,75 @@ class Database: # pylint:disable=R0904
if not recs:
return None
return recs[0]
+
+ # workflow functions
+
+ def workflow_add(self, wtype, series_id, timestamp):
+ """Add a workflow entry
+
+ Args:
+ wtype (str): Workflow type, e.g. 'todo'
+ series_id (int): ID of the series
+ timestamp (str): Timestamp string, e.g. '2025-01-15 10:30:00'
+ """
+ self.execute(
+ 'INSERT INTO workflow (type, series_id, timestamp, archived) '
+ 'VALUES (?, ?, ?, 0)', (wtype, series_id, timestamp))
+
+ def workflow_archive(self, wtype, series_id):
+ """Archive active workflow entries for a given type and series
+
+ Args:
+ wtype (str): Workflow type, e.g. 'todo'
+ series_id (int): ID of the series
+ """
+ self.execute(
+ 'UPDATE workflow SET archived = 1 '
+ 'WHERE type = ? AND series_id = ? AND archived = 0',
+ (wtype, series_id))
+
+ def workflow_get(self, wtype, series_id):
+ """Get the active workflow entry for a given type and series
+
+ Args:
+ wtype (str): Workflow type, e.g. 'todo'
+ series_id (int): ID of the series
+
+ Return:
+ str or None: Timestamp string if found, else None
+ """
+ res = self.execute(
+ 'SELECT timestamp FROM workflow '
+ 'WHERE type = ? AND series_id = ? AND archived = 0',
+ (wtype, series_id))
+ rec = res.fetchone()
+ if rec:
+ return rec[0]
+ return None
+
+ def workflow_get_by_type(self, wtype, before=None):
+ """Get active workflow entries for a given type, joined with series
+
+ Args:
+ wtype (str): Workflow type, e.g. 'todo'
+ before (str or None): If set, only return entries where
+ timestamp <= this value
+
+ Return:
+ list of tuple:
+ int: series ID
+ str: series name
+ str: series description
+ str: timestamp
+ """
+ query = ('SELECT s.id, s.name, s.desc, w.timestamp '
+ 'FROM workflow w '
+ 'JOIN series s ON w.series_id = s.id '
+ 'WHERE w.type = ? AND w.archived = 0 AND s.archived = 0')
+ params = [wtype]
+ if before is not None:
+ query += ' AND w.timestamp <= ?'
+ params.append(before)
+ query += ' ORDER BY w.timestamp'
+ res = self.execute(query, params)
+ return res.fetchall()
@@ -26,6 +26,7 @@ from patman import database
from patman import patchstream
from patman.patchwork import Patchwork
from patman.test_common import TestCommon
+from patman import workflow as wf
HASH_RE = r'[0-9a-f]+'
#pylint: disable=protected-access
@@ -3556,7 +3557,7 @@ Date: .*
self.assertEqual(f'Update database to v{version}',
out.getvalue().strip())
self.assertEqual(version, db.get_schema_version())
- self.assertEqual(5, database.LATEST)
+ self.assertEqual(6, database.LATEST)
def test_migrate_future_version(self):
"""Test that a database newer than patman is rejected"""
@@ -4130,3 +4131,48 @@ Date: .*
self.run_args('series', '-s', 'first', 'version-change',
'--new-version', '3', pwork=True)
method.assert_called_once_with('first', None, 3, dry_run=False)
+
+ def test_workflow_db_methods(self):
+ """Test workflow database methods"""
+ cser = self.get_cser()
+ with terminal.capture():
+ cser.add('first', 'my description', allow_unmarked=True)
+
+ ser = cser.get_series_by_name('first')
+
+ # Initially there is no workflow entry
+ self.assertIsNone(cser.db.workflow_get('todo', ser.idnum))
+
+ # Add a todo entry
+ cser.db.workflow_add('todo', ser.idnum, '2025-03-15 10:00:00')
+ cser.commit()
+
+ # Should be able to read it back
+ ts = cser.db.workflow_get('todo', ser.idnum)
+ self.assertEqual('2025-03-15 10:00:00', ts)
+
+ # Get by type should return it
+ entries = cser.db.workflow_get_by_type('todo')
+ self.assertEqual(1, len(entries))
+ entry = entries[0]
+ self.assertEqual(ser.idnum, entry[0])
+ self.assertEqual('first', entry[1])
+ self.assertEqual('my description', entry[2])
+ self.assertEqual('2025-03-15 10:00:00', entry[3])
+
+ # Get by type with before filter
+ entries = cser.db.workflow_get_by_type(
+ 'todo', before='2025-03-14 00:00:00')
+ self.assertEqual(0, len(entries))
+ entries = cser.db.workflow_get_by_type(
+ 'todo', before='2025-03-16 00:00:00')
+ self.assertEqual(1, len(entries))
+
+ # Archive it - should no longer be active, but still in the table
+ cser.db.workflow_archive('todo', ser.idnum)
+ cser.commit()
+ self.assertIsNone(cser.db.workflow_get('todo', ser.idnum))
+ res = cser.db.execute(
+ 'SELECT archived FROM workflow WHERE series_id = ?',
+ (ser.idnum,))
+ self.assertEqual(1, res.fetchone()[0])
new file mode 100644
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-2.0+
+#
+# Copyright 2025 Google LLC
+#
+
+"""Workflow types and operations for patman series management"""
+
+import enum
+
+
+class Wtype(enum.StrEnum):
+ """Types of workflow entry"""
+ TODO = 'todo'