[PATCH RFC v2 18/25] review: match a stray posting by message-id

Christian Brauner <[email protected]>
Newsgroups org.kernel.linux.tools
Message-ID <[email protected]>
Three places ask whether another series already owns a posting: [l]'s
confirmation preview, the absorb it then performs, and the conflicts [o]
reports.  They disagreed, and the preview warned about the safe cases
while staying silent on the destructive one.

Give them one rule.  find_stray_revision() matches by message-id first
and fingerprint second.  A fingerprint hashes only the patches present,
so a revision recorded from a partial fetch never matches the one
computed with the whole series in hand.

Both lookups exclude the target change_id in SQL and walk every remaining
row rather than testing one.  A posting can sit in the catalog under the
link target as well as under the stray, since auto-discovery records it
and the maintainer then links it, so taking the first row back would
answer with the target itself whenever it sorts first.  [l] would then
duplicate the series instead of absorbing it.

An all-archived owner is not a match at all.  It is invisible in the
tracking list, so absorbing it deletes a series the maintainer cannot see
and reporting it sends them after one they cannot reach.  The same walk
keeps such an owner from hiding a live one behind it.

Act on the absorb result rather than assuming it.  Absorb still declines
a revision with neither a series nor a catalog row, and that revision
would otherwise go unrecorded while the TUI reported both a link and an
absorbed duplicate.

Signed-off-by: Christian Brauner (Amutable) <[email protected]>
---
 src/b4/review/tracking.py          | 111 +++++++++++++++++++++++++++++++------
 src/b4/review_tui/_tracking_app.py |  10 +++-
 2 files changed, 102 insertions(+), 19 deletions(-)

diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 958b5180..6662cd9e 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -1804,6 +1804,32 @@ def get_revisions_with_tracked(
     return merge_tracked_revisions(change_id, get_revisions(conn, change_id), rows)
 
 
+def _find_revisions_by(
+    conn: sqlite3.Connection,
+    column: str,
+    value: Optional[str],
+    exclude_change_id: Optional[str] = None,
+) -> List[dict[str, Any]]:
+    """Every catalog row whose *column* equals *value*, ordered by change_id.
+
+    Plural on purpose: nothing constrains a message-id or a fingerprint to
+    one change_id, so a caller looking for *another* series' copy has to be
+    able to walk past the first row.  Ordered, or the answer is rowid order
+    and flips the first time a row is rewritten.
+
+    *column* is a literal supplied by this module, never caller input.
+    """
+    if not value:
+        return []
+    sql = _REVISION_SELECT + f' WHERE r.{column} = ?'
+    params: List[Any] = [value]
+    if exclude_change_id is not None:
+        sql += ' AND r.change_id != ?'
+        params.append(exclude_change_id)
+    sql += ' ORDER BY r.change_id'
+    return [dict(zip(_REVISION_COLS, row)) for row in conn.execute(sql, params)]
+
+
 def find_revision_by_fingerprint(
     conn: sqlite3.Connection, fingerprint: Optional[str]
 ) -> Optional[dict[str, Any]]:
@@ -1813,15 +1839,43 @@ def find_revision_by_fingerprint(
     under a different change_id) so it can be absorbed rather than duplicated.
     An empty or None fingerprint never matches.
     """
-    if not fingerprint:
-        return None
-    row = conn.execute(
-        _REVISION_SELECT + ' WHERE r.fingerprint = ? ORDER BY r.change_id LIMIT 1',
-        (fingerprint,),
-    ).fetchone()
-    if row is None:
-        return None
-    return dict(zip(_REVISION_COLS, row))
+    rows = _find_revisions_by(conn, 'fingerprint', fingerprint)
+    return rows[0] if rows else None
+
+
+def find_stray_revision(
+    conn: sqlite3.Connection,
+    change_id: str,
+    message_id: Optional[str],
+    fingerprint: Optional[str],
+) -> Optional[dict[str, Any]]:
+    """Return the revision another series already owns this posting under.
+
+    The single rule behind [l]'s confirmation preview, the absorb it then
+    performs, and the conflicts [o] reports -- they disagreed once and the
+    preview warned about the safe cases while staying silent on the
+    destructive one.
+
+    Message-id first, fingerprint second: a fingerprint hashes only the
+    patches present, so a revision recorded from a partial fetch does not
+    match the one computed with the whole series in hand.  An all-archived
+    owner is not a match at all -- it is invisible in the tracking list, so
+    absorbing it deletes a series the maintainer cannot see and reporting
+    it sends them after one they cannot reach.
+
+    Both lookups exclude *change_id* in SQL and walk every remaining row
+    rather than testing one.  A posting can sit in the catalog under the
+    link target as well as under the stray -- auto-discovery records it,
+    then the maintainer links it -- and taking the first row back would
+    answer with the target itself whenever it sorts first, reporting no
+    stray and letting [l] duplicate the series instead of absorbing it.
+    The same walk keeps an archived owner from hiding a live one behind it.
+    """
+    for column, value in (('message_id', message_id), ('fingerprint', fingerprint)):
+        for stray in _find_revisions_by(conn, column, value, change_id):
+            if not _is_archived_only(conn, str(stray['change_id'])):
+                return stray
+    return None
 
 
 def find_existing_change_id(
@@ -2251,8 +2305,10 @@ def record_linked_revision(
       returns ``status='collision'`` without mutating anything, so the caller
       can confirm before overwriting.
     - If the posting is already tracked as its own stray series (matched by
-      fingerprint under a different change_id), that series is absorbed
-      (``absorbed=True``) rather than duplicated.
+      message-id, then fingerprint, under a different change_id), that series
+      is absorbed (``absorbed=True``) rather than duplicated.  A stray whose
+      series rows are all archived is left alone and the revision is simply
+      recorded.
     - Otherwise the revision and its patches are recorded with
       ``source='manual'``.
 
@@ -2283,17 +2339,20 @@ def record_linked_revision(
         return result
 
     fingerprint = lser.fingerprint
-    stray = find_revision_by_fingerprint(conn, fingerprint)
-    if stray is not None and stray['change_id'] != change_id:
-        absorb_series_as_revision(
+    stray = find_stray_revision(conn, change_id, ref_msg.msgid, fingerprint)
+    if stray is not None:
+        # Acted on, not assumed: absorb still declines a revision with
+        # neither a series nor a catalog row, and the revision the
+        # maintainer asked to link would then go unrecorded while the TUI
+        # reported a link and an absorbed duplicate.
+        result['absorbed'] = absorb_series_as_revision(
             conn,
             change_id,
             stray['change_id'],
             revision,
             stray_revision=stray.get('revision'),
         )
-        result['absorbed'] = True
-    else:
+    if not result['absorbed']:
         message_id = ref_msg.msgid
         add_revision(
             conn,
@@ -4162,6 +4221,26 @@ def update_revision_message_counts(
     }
 
 
+def _is_archived_only(conn: sqlite3.Connection, change_id: str) -> bool:
+    """Whether *change_id* has series rows and every one of them is archived.
+
+    The v11 migration gives every series row a catalog entry, archived ones
+    included, so a catalog hit under another change_id no longer proves
+    anything is still tracked there.  A change_id with no series row at all
+    is a different matter -- that is a catalogued posting, and colliding
+    with it still matters -- so only the all-archived case is exempt.
+    """
+    row = conn.execute(
+        'SELECT COUNT(*),'
+        " SUM(COALESCE(status, 'new') != 'archived') FROM series"
+        ' WHERE change_id = ?',
+        (change_id,),
+    ).fetchone()
+    if row is None or not row[0]:
+        return False
+    return not (row[1] or 0)
+
+
 def mark_all_messages_seen(
     conn: sqlite3.Connection, change_id: str, revision: int
 ) -> None:
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index bcad86f3..f0ad512a 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -4006,8 +4006,12 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             known = {
                 r['revision'] for r in b4.review.tracking.get_revisions(conn, change_id)
             }
-            stray = b4.review.tracking.find_revision_by_fingerprint(
-                conn, lser.fingerprint
+            ref_msg = b4.review.tracking._series_ref_message(lser)
+            stray = b4.review.tracking.find_stray_revision(
+                conn,
+                change_id,
+                ref_msg.msgid if ref_msg is not None else None,
+                lser.fingerprint,
             )
             conn.close()
         except Exception as ex:
@@ -4019,7 +4023,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         warning = ''
         if collision:
             warning = f'v{revision} is already tracked — linking replaces it.'
-        elif stray is not None and stray['change_id'] != change_id:
+        elif stray is not None:
             warning = 'Already tracked as a separate series — it will be absorbed.'
 
         num_patches = sum(1 for p in lser.patches[1:] if p is not None)

-- 
2.53.0
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.