Repository: spdk/spdk-ci
Pull request #59: infra: bring back mergable_changes service (text version)
Author: login: karlatec
Created: 2025-05-07T11:45:00Z | Merged: 2025-05-13T12:07:55Z

Bring back "mergable changes" which was available in previous CI, under Intel umbrella. This script queries Gerrit for +2CR/+1 Verified changes, makes additional checks like:
- is there a merge conflict flag for the change
- is there a -1CR vote beside +2CR votes
- are any parents of the change (if it's a series) not mergable and presents the information organized in tables.

Text version with ASCII tables for now, but with
plans to bring back HTML version in nearby future.


Diff:
diff --git a/infra/docker-compose.yaml b/infra/docker-compose.yaml
index 3b6a78be..42e01b98 100644
--- a/infra/docker-compose.yaml
+++ b/infra/docker-compose.yaml
@@ -31,9 +31,28 @@ services:
     networks:
     - gerrit
 
+  mergable_changes:
+    image: mergable_changes:latest
+    build:
+      context: ./mergable_changes
+      dockerfile: Dockerfile
+    container_name: mergable_changes
+    restart: always
+    environment:
+    - OUTPUT_FILE=${OUTPUT_FILE}
+    depends_on:
+    - gerrit
+    volumes:
+    - mergable_changes_logs:/var/log
+    - mergable_changes_out:/output
+    networks:
+    - gerrit
+
 volumes:
   gerrit_data:
   forwarder_logs:
+  mergable_changes_logs:
+  mergable_changes_out:
 
 networks:
   gerrit:
diff --git a/infra/mergable_changes/Dockerfile b/infra/mergable_changes/Dockerfile
new file mode 100644
index 00000000..0392f522
--- /dev/null
+++ b/infra/mergable_changes/Dockerfile
@@ -0,0 +1,9 @@
+FROM python:3.13-alpine
+
+RUN pip install \
+    requests \
+    pygerrit2 \
+    prettytable
+COPY mergable_changes.py /app/mergable_changes.py
+
+CMD [ "python", "/app/mergable_changes.py"]
diff --git a/infra/mergable_changes/mergable_changes.py b/infra/mergable_changes/mergable_changes.py
new file mode 100755
index 00000000..f89e1370
--- /dev/null
+++ b/infra/mergable_changes/mergable_changes.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+
+import os
+import time
+import logging
+import datetime
+from typing import Dict
+from prettytable import PrettyTable
+from requests import RequestException
+from dataclasses import dataclass, field
+from pygerrit2 import GerritRestAPI
+
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+OUTPUT_FILE = os.getenv("OUTPUT_FILE", "mergable_changes.txt")
+GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://review.spdk.io")
+GERRIT_CHANGE_URL = os.path.join(GERRIT_BASE_URL, "c")
+
+
+@dataclass
+class GerritChange:
+    number: int
+    project: str
+    subject: str
+    owner: str
+    revisions: dict
+    blocked_by: str
+    reviewed_by: str
+    has_minus_one: bool = False
+    has_merge_conflict: bool = False
+    needs_plus_two: bool = False
+    ready: bool = False
+    age: datetime.timedelta = field(init=False)
+    hours: int = field(init=False)
+    url: str = field(init=False)
+
+    def __post_init__(self):
+        first_revision = next(iter(self.revisions))
+        created = datetime.datetime.strptime(first_revision['created'], '%Y-%m-%d %H:%M:%S.%f000')
+        created = created.replace(tzinfo=datetime.timezone.utc)
+        self.age = datetime.datetime.now(datetime.timezone.utc) - created
+        self.hours = self.age.seconds // 3600
+        self.url = os.path.join(GERRIT_CHANGE_URL, self.project, '+', str(self.number))
+
+    @classmethod
+    def from_json(cls, change_json: Dict):
+        ready = True
+        is_mergeable = change_json['mergeable']
+        is_submittable = change_json['submittable']
+        has_merge_conflict = not is_mergeable
+        code_reviews = change_json['labels']['Code-Review']['all']
+        plus_two_crs = sum(1 for review in code_reviews if review['value'] == 2)
+        minus_one_crs = sum(1 for review in code_reviews if review['value'] < 0)
+        has_minus_one = minus_one_crs > 0
+        needs_plus_two = not has_minus_one and not has_merge_conflict and plus_two_crs == 1
+        reviewed_by = str(next((review['name'] for review in code_reviews if review['value'] == 2), None))
+
+        if not is_submittable:
+            ready = False
+
+        return cls(
+            number=change_json['_number'],
+            project=change_json['project'],
+            subject=change_json['subject'],
+            owner=change_json['owner']['name'],
+            revisions=change_json['revisions'].values(),
+            blocked_by="",
+            reviewed_by=reviewed_by,
+            has_minus_one=has_minus_one,
+            has_merge_conflict=has_merge_conflict,
+            needs_plus_two=needs_plus_two,
+            ready=ready
+        )
+
+    @classmethod
+    def blocking_change(cls, change_json: Dict):
+        return cls(
+            number=change_json['_number'],
+            project=change_json['project'],
+            subject=change_json['subject'],
+            owner=change_json['owner']['name'],
+            revisions=change_json['revisions'].values(),
+            blocked_by="",
+            reviewed_by="",
+            has_minus_one=False,
+            has_merge_conflict=False,
+            needs_plus_two=False,
+            ready=False
+        )
+
+
+    def check_parents_ready(self, gerrit, all_changes):
+        if self.ready:
+            try:
+                query = "".join([
+                    "/changes/", str(self.number), "/submitted_together", "?o=DETAILED_ACCOUNTS" 
+                ])
+                change_series = gerrit.get(query)
+            except RequestException:
+                # GET operation on /submitted_together can fail with 403 if there are patches which
+                # the caller cannot read, e.g. private changes. Bail out if this happens.
+                # TODO: Check if using NON_VISIBLE_CHANGES option could help.
+                # https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together
+                pass
+
+            for change in reversed(change_series):
+                # submitted_together contains a list data for changes submitted together including the
+                # one currently inspected - skip it.
+                if self.number == change['_number']:
+                    continue
+
+                parent_change = get_change_by_number(all_changes, change['_number'])
+                if not parent_change:
+                    # Looks like there's no +2'd parent changes so create a "blocking" one
+                    parent_change = GerritChange.blocking_change(change)
+
+                if parent_change.ready is False:
+                    self.ready = False
+                    self.needs_plus_two = False
+                    self.blocked_by = parent_change
+                    break
+
+def get_gerrit_changes(gerrit, all_changes):
+    query = "".join([
+        "/changes/", "?q=project:spdk/spdk status:open label:Code-Review=2 label:Verified=1",
+        "&o=CURRENT_REVISION", "&o=DETAILED_LABELS", "&o=DETAILED_ACCOUNTS", "&o=SUBMITTABLE"
+    ])
+    changes_json = gerrit.get(query)
+    for change_json in changes_json:
+        change = GerritChange.from_json(change_json)
+        all_changes.append(change)
+
+def get_change_by_number(all_changes, number):
+    for change in all_changes:
+        if change.number == number:
+            return change
+
+def get_ready_changes(all_changes):
+    return [c for c in all_changes if c.ready]
+
+def get_needs_plus_two_changes(all_changes):
+    return [c for c in all_changes if c.needs_plus_two]
+
+def get_minus_one_changes(all_changes):
+    return [c for c in all_changes if c.has_minus_one]
+
+def get_merge_conflict_changes(all_changes):
+    return [c for c in all_changes if c.has_merge_conflict]
+
+def get_blocked_by_changes(all_changes):
+    return [c for c in all_changes if c.blocked_by]
+
+def write_text_summary(all_changes):
+    def write_and_log(line, fh):
+        fh.write(line + "\n")
+        logging.debug(line)
+
+    sections = [
+        ("Changes ready for merge", get_ready_changes(all_changes)),
+        ("Changes needing another +2 CR vote", get_needs_plus_two_changes(all_changes)),
+        ("Changes with a -1 CR vote", get_minus_one_changes(all_changes)),
+        ("Changes with a merge conflict", get_merge_conflict_changes(all_changes)),
+        ("Changes blocked by parents in series", get_blocked_by_changes(all_changes))
+    ]
+
+    with open(OUTPUT_FILE, "w") as fh:
+        fh.write(f"Generated at {datetime.datetime.now(datetime.timezone.utc)}\n")
+        fh.write("Contents are re-generated every 5 minutes.\n\n\n")
+        for section_name, changes in sections:
+            write_and_log(f"{section_name}", fh)
+            write_and_log("-" * len(section_name), fh)
+
+            if changes:
+                table = PrettyTable()
+                table.align = "l"
+                field_names = ["Number", "Subject", "Owner", "URL", "Age"]
+                field_names.append("Reviewed by") if "another +2 CR" in section_name else None
+                field_names.append("Blocked by") if "blocked" in section_name else None
+                    
+                table.field_names = field_names
+                for change in changes:
+                    row_values = [change.number, change.subject, change.owner, change.url, f"{change.age.days:} days {change.hours} hours"]
+                    row_values.append(change.reviewed_by) if "another +2 CR" in section_name else None
+                    row_values.append(change.blocked_by.url) if "blocked" in section_name else None
+                    table.add_row(row_values)
+                write_and_log(table.get_string() + "\n", fh)
+            else:
+                write_and_log("No changes in this category.\n", fh)
+
+def main():
+    logging.basicConfig(
+        level=getattr(logging, LOG_LEVEL, logging.INFO),
+        format="%(asctime)s - %(levelname)s - %(message)s",
+        handlers=[
+            logging.StreamHandler(),
+            logging.FileHandler("/var/log/mergable_changes.log", mode="a")
+        ]
+    )
+
+    while True:
+        all_changes = []
+        gerrit = GerritRestAPI(url=GERRIT_BASE_URL)
+        get_gerrit_changes(gerrit, all_changes)
+        for change in all_changes:
+            change.check_parents_ready(gerrit, all_changes)
+        all_changes.sort(key=lambda c: c.age, reverse=True)
+        write_text_summary(all_changes)
+        time.sleep(300)
+
+if __name__ == '__main__':
+    main()


Reviews:
user: login: tomzawadzki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:18:41Z
user: login: karlatec
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T09:21:00Z
user: login: karlatec
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T09:22:03Z
user: login: karlatec
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T09:22:40Z
user: login: tomzawadzki
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:17:52Z
user: login: mikeBashStuff
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:43:38Z

Review comments:
diff_hunk: @@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import time
+import logging
+import datetime
+from typing import Dict
+from prettytable import PrettyTable
+from requests import RequestException
+from dataclasses import dataclass, field
+from pygerrit2 import GerritRestAPI, HTTPBasicAuth
+
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+OUTPUT_FILE = os.getenv("OUTPUT_FILE", "mergable_changes.txt")
+GERRIT_USER_NAME = os.getenv("GERRIT_USER_NAME")
+GERRIT_PASSWORD = os.getenv("GERRIT_PASSWORD")
+GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://review.spdk.io")
+GERRIT_CHANGE_URL = os.path.join(GERRIT_BASE_URL, "c")
+
+
+@dataclass
+class GerritChange:
+    number: int
+    project: str
+    subject: str
+    owner: str
+    revisions: dict
+    blocked_by: str
+    reviewed_by: str
+    has_minus_one: bool = False
+    has_merge_conflict: bool = False
+    needs_plus_two: bool = False
+    ready: bool = False
+    age: datetime.timedelta = field(init=False)
+    hours: int = field(init=False)
+    url: str = field(init=False)
+
+    def __post_init__(self):
+        first_revision = next(iter(self.revisions))
+        created = datetime.datetime.strptime(first_revision['created'], '%Y-%m-%d %H:%M:%S.%f000')
+        created = created.replace(tzinfo=datetime.timezone.utc)
+        self.age = datetime.datetime.now(datetime.timezone.utc) - created
+        self.hours = self.age.seconds // 3600
+        self.url = os.path.join(GERRIT_CHANGE_URL, self.project, '+', str(self.number))
+
+    @classmethod
+    def from_json(cls, change_json: Dict):
+        ready = True
+        is_mergeable = change_json['mergeable']
+        has_merge_conflict = not is_mergeable
+        code_reviews = change_json['labels']['Code-Review']['all']
+        plus_two_crs = sum(1 for review in code_reviews if review['value'] == 2)
+        minus_one_crs = sum(1 for review in code_reviews if review['value'] < 0)
+        has_minus_one = minus_one_crs > 0
+        needs_plus_two = not has_minus_one and not has_merge_conflict and plus_two_crs == 1
+        reviewed_by = str(next((review['name'] for review in code_reviews if review['value'] == 2), None))
+
+        if plus_two_crs < 2 or minus_one_crs > 0 or has_merge_conflict:
+            ready = False
+
+        return cls(
+            number=change_json['_number'],
+            project=change_json['project'],
+            subject=change_json['subject'],
+            owner=change_json['owner']['name'],
+            revisions=change_json['revisions'].values(),
+            blocked_by="",
+            reviewed_by=reviewed_by,
+            has_minus_one=has_minus_one,
+            has_merge_conflict=has_merge_conflict,
+            needs_plus_two=needs_plus_two,
+            ready=ready
+        )
+
+    def check_parents_ready(self, gerrit, all_changes):
+        if self.ready:
+            try:
+                query = "".join([
+                    "/changes/", str(self.number), "/submitted_together", "?o=DETAILED_ACCOUNTS" 
+                ])
+                change_series = gerrit.get(query)
+            except RequestException:
+                # GET operation on /submitted_together can fail with 403 if there are patches which
+                # the caller cannot read, e.g. private changes. Bail out if this happens.
+                # TODO: Check if using NON_VISIBLE_CHANGES option could help.
+                # https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together
+                pass
+
+            for change in change_series:
+                # submitted_together contains a list data for changes submitted together including the
+                # one currently inspected - skip it.
+                if self.number == change['_number']:
+                    continue
+
+                parent_change = get_change_by_number(all_changes, change['_number'])
+                if not parent_change:
+                    # Looks like there's no parent changes for current change.
+                    continue
+
+                if parent_change.ready is False:
+                    self.ready = False
+                    self.needs_plus_two = False
+                    self.blocked_by = parent_change
+                    break
+
+def get_gerrit_changes(gerrit, all_changes):
+    query = "".join([
+        "/changes/", "?q=project:spdk/spdk status:open label:Code-Review=2 label:Verified=1",
+        "&o=CURRENT_REVISION", "&o=DETAILED_LABELS", "&o=DETAILED_ACCOUNTS"
+    ])
+    changes_json = gerrit.get(query)
+    for change_json in changes_json:
+        change = GerritChange.from_json(change_json)
+        all_changes.append(change)
+
+def get_change_by_number(all_changes, number):
+    for change in all_changes:
+        if change.number == number:
+            return change
+
+def get_ready_changes(all_changes):
+    return [c for c in all_changes if c.ready]
+
+def get_needs_plus_two_changes(all_changes):
+    return [c for c in all_changes if c.needs_plus_two]
+
+def get_minus_one_changes(all_changes):
+    return [c for c in all_changes if c.has_minus_one]
+
+def get_merge_conflict_changes(all_changes):
+    return [c for c in all_changes if c.has_merge_conflict]
+
+def get_blocked_by_changes(all_changes):
+    return [c for c in all_changes if c.blocked_by]
+
+def write_text_summary(all_changes):
+    def write_and_log(line, fh):
+        fh.write(line + "\n")
+        logging.debug(line)
+
+    sections = [
+        ("Changes ready for merge", get_ready_changes(all_changes)),
+        ("Changes needing another +2 CR vote", get_needs_plus_two_changes(all_changes)),
+        ("Changes with a -1 CR vote", get_minus_one_changes(all_changes)),
+        ("Changes with a merge conflict", get_merge_conflict_changes(all_changes)),
+        ("Changes blocked by parents in series", get_blocked_by_changes(all_changes))
+    ]
+
+    with open("mergable_changes.txt", "w") as fh:
+        fh.write(f"Generated at {datetime.datetime.now(datetime.timezone.utc)}\n")
+        fh.write("Contents are re-generated every 5 minutes.\n\n\n")
+        for section_name, changes in sections:
+            write_and_log(f"{section_name}", fh)
+            write_and_log("-" * len(section_name), fh)
+
+            if changes:
+                table = PrettyTable()
+                table.align = "l"
+                field_names = ["Number", "Subject", "Owner", "URL", "Age"]
+                field_names.append("Reviewed by") if "another +2 CR" in section_name else None
+                field_names.append("Blocked by") if "blocked" in section_name else None
+                    
+                table.field_names = field_names
+                for change in changes:
+                    row_values = [change.number, change.subject, change.owner, change.url, f"{change.age.days:} days {change.hours} hours"]
+                    row_values.append(change.reviewed_by) if "another +2 CR" in section_name else None
+                    row_values.append(change.blocked_by.url) if "blocked" in section_name else None
+                    table.add_row(row_values)
+                write_and_log(table.get_string() + "\n", fh)
+            else:
+                write_and_log("No changes in this category.\n", fh)
+
+def main():
+    logging.basicConfig(
+        level=getattr(logging, LOG_LEVEL, logging.INFO),
+        format="%(asctime)s - %(levelname)s - %(message)s",
+        handlers=[
+            logging.StreamHandler(),
+            logging.FileHandler("/var/log/mergable_changes.log", mode="a")
+        ]
+    )
+
+    if not GERRIT_USER_NAME or not GERRIT_PASSWORD:
+        logging.error("Error: GERRIT_USER_NAME or GERRIT_PASSWORD environment variable is not set.")
+        sys.exit(1)
+
+    while True:
+        all_changes = []
+        auth = HTTPBasicAuth(GERRIT_USER_NAME, GERRIT_PASSWORD)
path: infra/mergable_changes/mergable_changes.py
user: login: tomzawadzki
body: Do we need to authenticate at all ? I think the unauthenticated endpoint should already have all the information we need.
created_at: 2025-05-09T10:48:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import time
+import logging
+import datetime
+from typing import Dict
+from prettytable import PrettyTable
+from requests import RequestException
+from dataclasses import dataclass, field
+from pygerrit2 import GerritRestAPI, HTTPBasicAuth
+
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+OUTPUT_FILE = os.getenv("OUTPUT_FILE", "mergable_changes.txt")
+GERRIT_USER_NAME = os.getenv("GERRIT_USER_NAME")
+GERRIT_PASSWORD = os.getenv("GERRIT_PASSWORD")
+GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://review.spdk.io")
+GERRIT_CHANGE_URL = os.path.join(GERRIT_BASE_URL, "c")
+
+
+@dataclass
+class GerritChange:
+    number: int
+    project: str
+    subject: str
+    owner: str
+    revisions: dict
+    blocked_by: str
+    reviewed_by: str
+    has_minus_one: bool = False
+    has_merge_conflict: bool = False
+    needs_plus_two: bool = False
+    ready: bool = False
+    age: datetime.timedelta = field(init=False)
+    hours: int = field(init=False)
+    url: str = field(init=False)
+
+    def __post_init__(self):
+        first_revision = next(iter(self.revisions))
+        created = datetime.datetime.strptime(first_revision['created'], '%Y-%m-%d %H:%M:%S.%f000')
+        created = created.replace(tzinfo=datetime.timezone.utc)
+        self.age = datetime.datetime.now(datetime.timezone.utc) - created
+        self.hours = self.age.seconds // 3600
+        self.url = os.path.join(GERRIT_CHANGE_URL, self.project, '+', str(self.number))
+
+    @classmethod
+    def from_json(cls, change_json: Dict):
+        ready = True
+        is_mergeable = change_json['mergeable']
+        has_merge_conflict = not is_mergeable
+        code_reviews = change_json['labels']['Code-Review']['all']
+        plus_two_crs = sum(1 for review in code_reviews if review['value'] == 2)
+        minus_one_crs = sum(1 for review in code_reviews if review['value'] < 0)
+        has_minus_one = minus_one_crs > 0
+        needs_plus_two = not has_minus_one and not has_merge_conflict and plus_two_crs == 1
+        reviewed_by = str(next((review['name'] for review in code_reviews if review['value'] == 2), None))
+
+        if plus_two_crs < 2 or minus_one_crs > 0 or has_merge_conflict:
+            ready = False
+
+        return cls(
+            number=change_json['_number'],
+            project=change_json['project'],
+            subject=change_json['subject'],
+            owner=change_json['owner']['name'],
+            revisions=change_json['revisions'].values(),
+            blocked_by="",
+            reviewed_by=reviewed_by,
+            has_minus_one=has_minus_one,
+            has_merge_conflict=has_merge_conflict,
+            needs_plus_two=needs_plus_two,
+            ready=ready
+        )
+
+    def check_parents_ready(self, gerrit, all_changes):
+        if self.ready:
+            try:
+                query = "".join([
+                    "/changes/", str(self.number), "/submitted_together", "?o=DETAILED_ACCOUNTS" 
+                ])
+                change_series = gerrit.get(query)
+            except RequestException:
+                # GET operation on /submitted_together can fail with 403 if there are patches which
+                # the caller cannot read, e.g. private changes. Bail out if this happens.
+                # TODO: Check if using NON_VISIBLE_CHANGES option could help.
+                # https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together
+                pass
+
+            for change in change_series:
+                # submitted_together contains a list data for changes submitted together including the
+                # one currently inspected - skip it.
+                if self.number == change['_number']:
+                    continue
+
+                parent_change = get_change_by_number(all_changes, change['_number'])
+                if not parent_change:
+                    # Looks like there's no parent changes for current change.
+                    continue
+
+                if parent_change.ready is False:
+                    self.ready = False
+                    self.needs_plus_two = False
+                    self.blocked_by = parent_change
+                    break
+
+def get_gerrit_changes(gerrit, all_changes):
+    query = "".join([
+        "/changes/", "?q=project:spdk/spdk status:open label:Code-Review=2 label:Verified=1",
+        "&o=CURRENT_REVISION", "&o=DETAILED_LABELS", "&o=DETAILED_ACCOUNTS"
path: infra/mergable_changes/mergable_changes.py
user: login: tomzawadzki
body: You can query for SUBMITTABLE to immediately get information if a change satisfies submit requirements (current change, not parents).
https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submittable
created_at: 2025-05-09T10:56:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import time
+import logging
+import datetime
+from typing import Dict
+from prettytable import PrettyTable
+from requests import RequestException
+from dataclasses import dataclass, field
+from pygerrit2 import GerritRestAPI, HTTPBasicAuth
+
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+OUTPUT_FILE = os.getenv("OUTPUT_FILE", "mergable_changes.txt")
+GERRIT_USER_NAME = os.getenv("GERRIT_USER_NAME")
+GERRIT_PASSWORD = os.getenv("GERRIT_PASSWORD")
+GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://review.spdk.io")
+GERRIT_CHANGE_URL = os.path.join(GERRIT_BASE_URL, "c")
+
+
+@dataclass
+class GerritChange:
+    number: int
+    project: str
+    subject: str
+    owner: str
+    revisions: dict
+    blocked_by: str
+    reviewed_by: str
+    has_minus_one: bool = False
+    has_merge_conflict: bool = False
+    needs_plus_two: bool = False
+    ready: bool = False
+    age: datetime.timedelta = field(init=False)
+    hours: int = field(init=False)
+    url: str = field(init=False)
+
+    def __post_init__(self):
+        first_revision = next(iter(self.revisions))
+        created = datetime.datetime.strptime(first_revision['created'], '%Y-%m-%d %H:%M:%S.%f000')
+        created = created.replace(tzinfo=datetime.timezone.utc)
+        self.age = datetime.datetime.now(datetime.timezone.utc) - created
+        self.hours = self.age.seconds // 3600
+        self.url = os.path.join(GERRIT_CHANGE_URL, self.project, '+', str(self.number))
+
+    @classmethod
+    def from_json(cls, change_json: Dict):
+        ready = True
+        is_mergeable = change_json['mergeable']
+        has_merge_conflict = not is_mergeable
+        code_reviews = change_json['labels']['Code-Review']['all']
+        plus_two_crs = sum(1 for review in code_reviews if review['value'] == 2)
+        minus_one_crs = sum(1 for review in code_reviews if review['value'] < 0)
+        has_minus_one = minus_one_crs > 0
+        needs_plus_two = not has_minus_one and not has_merge_conflict and plus_two_crs == 1
+        reviewed_by = str(next((review['name'] for review in code_reviews if review['value'] == 2), None))
+
+        if plus_two_crs < 2 or minus_one_crs > 0 or has_merge_conflict:
path: infra/mergable_changes/mergable_changes.py
user: login: tomzawadzki
body: You can check `submittable` field instead, which has all the conditions listed here.
created_at: 2025-05-09T10:59:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import time
+import logging
+import datetime
+from typing import Dict
+from prettytable import PrettyTable
+from requests import RequestException
+from dataclasses import dataclass, field
+from pygerrit2 import GerritRestAPI, HTTPBasicAuth
+
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+OUTPUT_FILE = os.getenv("OUTPUT_FILE", "mergable_changes.txt")
+GERRIT_USER_NAME = os.getenv("GERRIT_USER_NAME")
+GERRIT_PASSWORD = os.getenv("GERRIT_PASSWORD")
+GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://review.spdk.io")
+GERRIT_CHANGE_URL = os.path.join(GERRIT_BASE_URL, "c")
+
+
+@dataclass
+class GerritChange:
+    number: int
+    project: str
+    subject: str
+    owner: str
+    revisions: dict
+    blocked_by: str
+    reviewed_by: str
+    has_minus_one: bool = False
+    has_merge_conflict: bool = False
+    needs_plus_two: bool = False
+    ready: bool = False
+    age: datetime.timedelta = field(init=False)
+    hours: int = field(init=False)
+    url: str = field(init=False)
+
+    def __post_init__(self):
+        first_revision = next(iter(self.revisions))
+        created = datetime.datetime.strptime(first_revision['created'], '%Y-%m-%d %H:%M:%S.%f000')
+        created = created.replace(tzinfo=datetime.timezone.utc)
+        self.age = datetime.datetime.now(datetime.timezone.utc) - created
+        self.hours = self.age.seconds // 3600
+        self.url = os.path.join(GERRIT_CHANGE_URL, self.project, '+', str(self.number))
+
+    @classmethod
+    def from_json(cls, change_json: Dict):
+        ready = True
+        is_mergeable = change_json['mergeable']
+        has_merge_conflict = not is_mergeable
+        code_reviews = change_json['labels']['Code-Review']['all']
+        plus_two_crs = sum(1 for review in code_reviews if review['value'] == 2)
+        minus_one_crs = sum(1 for review in code_reviews if review['value'] < 0)
+        has_minus_one = minus_one_crs > 0
+        needs_plus_two = not has_minus_one and not has_merge_conflict and plus_two_crs == 1
+        reviewed_by = str(next((review['name'] for review in code_reviews if review['value'] == 2), None))
+
+        if plus_two_crs < 2 or minus_one_crs > 0 or has_merge_conflict:
+            ready = False
+
+        return cls(
+            number=change_json['_number'],
+            project=change_json['project'],
+            subject=change_json['subject'],
+            owner=change_json['owner']['name'],
+            revisions=change_json['revisions'].values(),
+            blocked_by="",
+            reviewed_by=reviewed_by,
+            has_minus_one=has_minus_one,
+            has_merge_conflict=has_merge_conflict,
+            needs_plus_two=needs_plus_two,
+            ready=ready
+        )
+
+    def check_parents_ready(self, gerrit, all_changes):
+        if self.ready:
+            try:
+                query = "".join([
+                    "/changes/", str(self.number), "/submitted_together", "?o=DETAILED_ACCOUNTS" 
+                ])
+                change_series = gerrit.get(query)
+            except RequestException:
+                # GET operation on /submitted_together can fail with 403 if there are patches which
+                # the caller cannot read, e.g. private changes. Bail out if this happens.
+                # TODO: Check if using NON_VISIBLE_CHANGES option could help.
+                # https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together
+                pass
+
+            for change in change_series:
+                # submitted_together contains a list data for changes submitted together including the
+                # one currently inspected - skip it.
+                if self.number == change['_number']:
+                    continue
+
+                parent_change = get_change_by_number(all_changes, change['_number'])
+                if not parent_change:
+                    # Looks like there's no parent changes for current change.
+                    continue
+
+                if parent_change.ready is False:
+                    self.ready = False
+                    self.needs_plus_two = False
+                    self.blocked_by = parent_change
+                    break
+
+def get_gerrit_changes(gerrit, all_changes):
+    query = "".join([
+        "/changes/", "?q=project:spdk/spdk status:open label:Code-Review=2 label:Verified=1",
+        "&o=CURRENT_REVISION", "&o=DETAILED_LABELS", "&o=DETAILED_ACCOUNTS"
path: infra/mergable_changes/mergable_changes.py
user: login: karlatec
body: Done.
created_at: 2025-05-13T09:21:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import time
+import logging
+import datetime
+from typing import Dict
+from prettytable import PrettyTable
+from requests import RequestException
+from dataclasses import dataclass, field
+from pygerrit2 import GerritRestAPI, HTTPBasicAuth
+
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+OUTPUT_FILE = os.getenv("OUTPUT_FILE", "mergable_changes.txt")
+GERRIT_USER_NAME = os.getenv("GERRIT_USER_NAME")
+GERRIT_PASSWORD = os.getenv("GERRIT_PASSWORD")
+GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://review.spdk.io")
+GERRIT_CHANGE_URL = os.path.join(GERRIT_BASE_URL, "c")
+
+
+@dataclass
+class GerritChange:
+    number: int
+    project: str
+    subject: str
+    owner: str
+    revisions: dict
+    blocked_by: str
+    reviewed_by: str
+    has_minus_one: bool = False
+    has_merge_conflict: bool = False
+    needs_plus_two: bool = False
+    ready: bool = False
+    age: datetime.timedelta = field(init=False)
+    hours: int = field(init=False)
+    url: str = field(init=False)
+
+    def __post_init__(self):
+        first_revision = next(iter(self.revisions))
+        created = datetime.datetime.strptime(first_revision['created'], '%Y-%m-%d %H:%M:%S.%f000')
+        created = created.replace(tzinfo=datetime.timezone.utc)
+        self.age = datetime.datetime.now(datetime.timezone.utc) - created
+        self.hours = self.age.seconds // 3600
+        self.url = os.path.join(GERRIT_CHANGE_URL, self.project, '+', str(self.number))
+
+    @classmethod
+    def from_json(cls, change_json: Dict):
+        ready = True
+        is_mergeable = change_json['mergeable']
+        has_merge_conflict = not is_mergeable
+        code_reviews = change_json['labels']['Code-Review']['all']
+        plus_two_crs = sum(1 for review in code_reviews if review['value'] == 2)
+        minus_one_crs = sum(1 for review in code_reviews if review['value'] < 0)
+        has_minus_one = minus_one_crs > 0
+        needs_plus_two = not has_minus_one and not has_merge_conflict and plus_two_crs == 1
+        reviewed_by = str(next((review['name'] for review in code_reviews if review['value'] == 2), None))
+
+        if plus_two_crs < 2 or minus_one_crs > 0 or has_merge_conflict:
path: infra/mergable_changes/mergable_changes.py
user: login: karlatec
body: Done.  Although it doesn't change much as we still need to check -1's, single +2's, etc later to match changes to appropriate "buckets". At least the condition looks better ;)
created_at: 2025-05-13T09:22:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import time
+import logging
+import datetime
+from typing import Dict
+from prettytable import PrettyTable
+from requests import RequestException
+from dataclasses import dataclass, field
+from pygerrit2 import GerritRestAPI, HTTPBasicAuth
+
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
+OUTPUT_FILE = os.getenv("OUTPUT_FILE", "mergable_changes.txt")
+GERRIT_USER_NAME = os.getenv("GERRIT_USER_NAME")
+GERRIT_PASSWORD = os.getenv("GERRIT_PASSWORD")
+GERRIT_BASE_URL = os.getenv("GERRIT_BASE_URL", "https://review.spdk.io")
+GERRIT_CHANGE_URL = os.path.join(GERRIT_BASE_URL, "c")
+
+
+@dataclass
+class GerritChange:
+    number: int
+    project: str
+    subject: str
+    owner: str
+    revisions: dict
+    blocked_by: str
+    reviewed_by: str
+    has_minus_one: bool = False
+    has_merge_conflict: bool = False
+    needs_plus_two: bool = False
+    ready: bool = False
+    age: datetime.timedelta = field(init=False)
+    hours: int = field(init=False)
+    url: str = field(init=False)
+
+    def __post_init__(self):
+        first_revision = next(iter(self.revisions))
+        created = datetime.datetime.strptime(first_revision['created'], '%Y-%m-%d %H:%M:%S.%f000')
+        created = created.replace(tzinfo=datetime.timezone.utc)
+        self.age = datetime.datetime.now(datetime.timezone.utc) - created
+        self.hours = self.age.seconds // 3600
+        self.url = os.path.join(GERRIT_CHANGE_URL, self.project, '+', str(self.number))
+
+    @classmethod
+    def from_json(cls, change_json: Dict):
+        ready = True
+        is_mergeable = change_json['mergeable']
+        has_merge_conflict = not is_mergeable
+        code_reviews = change_json['labels']['Code-Review']['all']
+        plus_two_crs = sum(1 for review in code_reviews if review['value'] == 2)
+        minus_one_crs = sum(1 for review in code_reviews if review['value'] < 0)
+        has_minus_one = minus_one_crs > 0
+        needs_plus_two = not has_minus_one and not has_merge_conflict and plus_two_crs == 1
+        reviewed_by = str(next((review['name'] for review in code_reviews if review['value'] == 2), None))
+
+        if plus_two_crs < 2 or minus_one_crs > 0 or has_merge_conflict:
+            ready = False
+
+        return cls(
+            number=change_json['_number'],
+            project=change_json['project'],
+            subject=change_json['subject'],
+            owner=change_json['owner']['name'],
+            revisions=change_json['revisions'].values(),
+            blocked_by="",
+            reviewed_by=reviewed_by,
+            has_minus_one=has_minus_one,
+            has_merge_conflict=has_merge_conflict,
+            needs_plus_two=needs_plus_two,
+            ready=ready
+        )
+
+    def check_parents_ready(self, gerrit, all_changes):
+        if self.ready:
+            try:
+                query = "".join([
+                    "/changes/", str(self.number), "/submitted_together", "?o=DETAILED_ACCOUNTS" 
+                ])
+                change_series = gerrit.get(query)
+            except RequestException:
+                # GET operation on /submitted_together can fail with 403 if there are patches which
+                # the caller cannot read, e.g. private changes. Bail out if this happens.
+                # TODO: Check if using NON_VISIBLE_CHANGES option could help.
+                # https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together
+                pass
+
+            for change in change_series:
+                # submitted_together contains a list data for changes submitted together including the
+                # one currently inspected - skip it.
+                if self.number == change['_number']:
+                    continue
+
+                parent_change = get_change_by_number(all_changes, change['_number'])
+                if not parent_change:
+                    # Looks like there's no parent changes for current change.
+                    continue
+
+                if parent_change.ready is False:
+                    self.ready = False
+                    self.needs_plus_two = False
+                    self.blocked_by = parent_change
+                    break
+
+def get_gerrit_changes(gerrit, all_changes):
+    query = "".join([
+        "/changes/", "?q=project:spdk/spdk status:open label:Code-Review=2 label:Verified=1",
+        "&o=CURRENT_REVISION", "&o=DETAILED_LABELS", "&o=DETAILED_ACCOUNTS"
+    ])
+    changes_json = gerrit.get(query)
+    for change_json in changes_json:
+        change = GerritChange.from_json(change_json)
+        all_changes.append(change)
+
+def get_change_by_number(all_changes, number):
+    for change in all_changes:
+        if change.number == number:
+            return change
+
+def get_ready_changes(all_changes):
+    return [c for c in all_changes if c.ready]
+
+def get_needs_plus_two_changes(all_changes):
+    return [c for c in all_changes if c.needs_plus_two]
+
+def get_minus_one_changes(all_changes):
+    return [c for c in all_changes if c.has_minus_one]
+
+def get_merge_conflict_changes(all_changes):
+    return [c for c in all_changes if c.has_merge_conflict]
+
+def get_blocked_by_changes(all_changes):
+    return [c for c in all_changes if c.blocked_by]
+
+def write_text_summary(all_changes):
+    def write_and_log(line, fh):
+        fh.write(line + "\n")
+        logging.debug(line)
+
+    sections = [
+        ("Changes ready for merge", get_ready_changes(all_changes)),
+        ("Changes needing another +2 CR vote", get_needs_plus_two_changes(all_changes)),
+        ("Changes with a -1 CR vote", get_minus_one_changes(all_changes)),
+        ("Changes with a merge conflict", get_merge_conflict_changes(all_changes)),
+        ("Changes blocked by parents in series", get_blocked_by_changes(all_changes))
+    ]
+
+    with open("mergable_changes.txt", "w") as fh:
+        fh.write(f"Generated at {datetime.datetime.now(datetime.timezone.utc)}\n")
+        fh.write("Contents are re-generated every 5 minutes.\n\n\n")
+        for section_name, changes in sections:
+            write_and_log(f"{section_name}", fh)
+            write_and_log("-" * len(section_name), fh)
+
+            if changes:
+                table = PrettyTable()
+                table.align = "l"
+                field_names = ["Number", "Subject", "Owner", "URL", "Age"]
+                field_names.append("Reviewed by") if "another +2 CR" in section_name else None
+                field_names.append("Blocked by") if "blocked" in section_name else None
+                    
+                table.field_names = field_names
+                for change in changes:
+                    row_values = [change.number, change.subject, change.owner, change.url, f"{change.age.days:} days {change.hours} hours"]
+                    row_values.append(change.reviewed_by) if "another +2 CR" in section_name else None
+                    row_values.append(change.blocked_by.url) if "blocked" in section_name else None
+                    table.add_row(row_values)
+                write_and_log(table.get_string() + "\n", fh)
+            else:
+                write_and_log("No changes in this category.\n", fh)
+
+def main():
+    logging.basicConfig(
+        level=getattr(logging, LOG_LEVEL, logging.INFO),
+        format="%(asctime)s - %(levelname)s - %(message)s",
+        handlers=[
+            logging.StreamHandler(),
+            logging.FileHandler("/var/log/mergable_changes.log", mode="a")
+        ]
+    )
+
+    if not GERRIT_USER_NAME or not GERRIT_PASSWORD:
+        logging.error("Error: GERRIT_USER_NAME or GERRIT_PASSWORD environment variable is not set.")
+        sys.exit(1)
+
+    while True:
+        all_changes = []
+        auth = HTTPBasicAuth(GERRIT_USER_NAME, GERRIT_PASSWORD)
path: infra/mergable_changes/mergable_changes.py
user: login: karlatec
body: Well, yeah. That'd be the same as our approach not to touch private / not-visible changes as in other parts of scripts and workflows in this repository. Done.
created_at: 2025-05-13T09:22:39Z
author_association: CONTRIBUTOR

PR comments:
user: login: karlatec
created_at: 2025-05-09T09:44:45Z
body: Example output:
```
Generated at 2025-05-09 09:43:05.183208+00:00                                                                                                                                                                                                              
Contents are re-generated every 5 minutes.                                                                                                                                                                                                                 
                                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                           
Changes ready for merge                                                                                                                                                                                                                                    
-----------------------
+--------+-------------------------------------------------------------+--------------------+--------------------------------------------+------------------+
| Number | Subject                                                     | Owner              | URL                                        | Age              |
+--------+-------------------------------------------------------------+--------------------+--------------------------------------------+------------------+
| 25742  | nvme/rdma: Fix finding rdma SRQ with multiple devices       | Alex Michon        | https://review.spdk.io/c/spdk/spdk/+/25742 | 39 days 2 hours  |
| 25788  | bdev/aio: fix potential stdin fd close                      | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25788 | 22 days 13 hours |
| 25789  | bdev/aio: move bdev_aio_close up                            | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25789 | 22 days 13 hours |
| 25787  | raid1: forward UNMAP/FLUSH to base bdev if supported        | Yalong Wang        | https://review.spdk.io/c/spdk/spdk/+/25787 | 18 days 0 hours  |
| 25725  | lib/blob: Skip cluster release when blob in special state.  | Yalong Wang        | https://review.spdk.io/c/spdk/spdk/+/25725 | 12 days 6 hours  |
| 25754  | ci: publish spdk python to pypi                             | Boris Glimcher     | https://review.spdk.io/c/spdk/spdk/+/25754 | 11 days 10 hours |
| 25737  | autotest: remove lcov tracefiles                            | Karol Latecki      | https://review.spdk.io/c/spdk/spdk/+/25737 | 11 days 1 hours  |
| 25783  | nvmf/tcp: simplify nvmf_tcp_request_get_buffers_abort       | Krzysztof Goreczny | https://review.spdk.io/c/spdk/spdk/+/25783 | 10 days 19 hours |
| 25784  | nvmf: set the data_from_pool when it actually happens       | Krzysztof Goreczny | https://review.spdk.io/c/spdk/spdk/+/25784 | 10 days 19 hours |
| 25830  | MAINTAINERS: add myself as raid module maintainer           | Artur Paszkiewicz  | https://review.spdk.io/c/spdk/spdk/+/25830 | 10 days 2 hours  |
| 25827  | nvme: fix reservation report missing cdw11.eds              | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25827 | 10 days 2 hours  |
| 25793  | util/fd: add util to set/clear fd nonblock                  | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25793 | 9 days 2 hours   |
| 25843  | include/spdk_internal: rename sock.h to sock_module.h       | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25843 | 9 days 2 hours   |
| 25820  | bdev/nvme: adjust elapsed var type                          | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25820 | 9 days 1 hours   |
| 25806  | sock: add util to create sock fd                            | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25806 | 8 days 13 hours  |
| 25807  | sock: add util to connect                                   | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25807 | 8 days 13 hours  |
| 25813  | sock: add util to getaddrinfo                               | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25813 | 8 days 13 hours  |
| 25453  | bdev/uring: handle async out of resource from uring         | Ed Reed            | https://review.spdk.io/c/spdk/spdk/+/25453 | 7 days 11 hours  |
| 25652  | crypto: add public helper function for crypto opts creation | Diwakar Sharma     | https://review.spdk.io/c/spdk/spdk/+/25652 | 3 days 23 hours  |
| 25857  | app/examples/test: fix Transport ID usage description       | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25857 | 3 days 2 hours   |
| 25855  | nvme/pcie: add pcie addr sanity check to enum cb            | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25855 | 1 days 11 hours  |
| 25851  | nvme: add spdk_nvme_trid_entry_parse util function          | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25851 | 1 days 11 hours  |
| 25858  | nvme: add spdk_nvme_transport_id_usage util function        | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25858 | 1 days 11 hours  |
| 25852  | nvme: add spdk_nvme_build_name util function                | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25852 | 1 days 11 hours  |
| 25854  | nvme: add pci vendor|device_id to spdk_nvme_build_name()    | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25854 | 1 days 11 hours  |
| 25828  | test/nvme: add NVMeoF support to reserve app                | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25828 | 0 days 21 hours  |
| 25795  | sock: add connect timeout opt                               | Jacek Kalwas       | https://review.spdk.io/c/spdk/spdk/+/25795 | 0 days 17 hours  |
+--------+-------------------------------------------------------------+--------------------+--------------------------------------------+------------------+


Changes needing another +2 CR vote
----------------------------------
+--------+----------------------------------------------------------+------------------+--------------------------------------------+------------------+------------------+
| Number | Subject                                                  | Owner            | URL                                        | Age              | Reviewed by      |
+--------+----------------------------------------------------------+------------------+--------------------------------------------+------------------+------------------+
| 25655  | qat_setup.sh: Rename driver from vfio_pci to vfio-pci    | Yankun Li        | https://review.spdk.io/c/spdk/spdk/+/25655 | 14 days 21 hours | Jim Harris       |
| 25761  | python: use new pyproject.toml                           | Boris Glimcher   | https://review.spdk.io/c/spdk/spdk/+/25761 | 14 days 16 hours | Konrad Sztyber   |
| 25738  | autorun_post: remove aggregate coverage trace file       | Karol Latecki    | https://review.spdk.io/c/spdk/spdk/+/25738 | 11 days 1 hours  | Jim Harris       |
| 25832  | nvmf: Fix counting oustanding IOs for passthru subsystem | Pawel Baldysiak  | https://review.spdk.io/c/spdk/spdk/+/25832 | 7 days 11 hours  | Jim Harris       |
| 25875  | MAINTAINERS: add myself to reduce compression            | Yankun Li        | https://review.spdk.io/c/spdk/spdk/+/25875 | 1 days 1 hours   | Changpeng Liu    |
| 25883  | nvmf/rdma: Ensure cpl.cid is valid when aborting cmds    | Michael Haeuptle | https://review.spdk.io/c/spdk/spdk/+/25883 | 0 days 12 hours  | Aleksey Marchuk  |
| 25815  | json: fail on invalid object_end call                    | Anton Einax      | https://review.spdk.io/c/spdk/spdk/+/25815 | 0 days 9 hours   | Shuhei Matsumoto |
| 25651  | fio nvme plugin: add option to enable interrupts         | Eric Gouriou     | https://review.spdk.io/c/spdk/spdk/+/25651 | 0 days 3 hours   | Changpeng Liu    |
+--------+----------------------------------------------------------+------------------+--------------------------------------------+------------------+------------------+

Changes with a -1 CR vote
-------------------------
+--------+----------------------------------------+--------------+--------------------------------------------+------------------+
| Number | Subject                                | Owner        | URL                                        | Age              |
+--------+----------------------------------------+--------------+--------------------------------------------+------------------+
| 25755  | bdev: retry w/o nomem threshold check  | Jacek Kalwas | https://review.spdk.io/c/spdk/spdk/+/25755 | 34 days 12 hours |
| 25419  | bdev/nvme: bdev nvme delete public api | Saravanan D  | https://review.spdk.io/c/spdk/spdk/+/25419 | 14 days 15 hours |
+--------+----------------------------------------+--------------+--------------------------------------------+------------------+

Changes with a merge conflict
-----------------------------
No changes in this category.

Changes blocked by parents in series
------------------------------------
No changes in this category.

```
author_association: CONTRIBUTOR
user: login: tomzawadzki
created_at: 2025-05-09T11:16:36Z
body: > Text version with ASCII tables for now, but with
> plans to bring back HTML version in nearby future.

Just to double check, the idea is to present the current text file instead of just making folks download it ? (via nginx config)
author_association: CONTRIBUTOR
user: login: karlatec
created_at: 2025-05-09T12:23:31Z
body: Exactly that. I plan to make the file available at `review.spdk.io/mergable_changes.txt`

author_association: CONTRIBUTOR
user: login: karlatec
created_at: 2025-05-13T09:23:16Z
body: Last force-push: I messed up most recent commit and it failed linter tests, so I just amended it.
author_association: CONTRIBUTOR