From 520647b463b2445871d71046e107a5929998b9a0 Mon Sep 17 00:00:00 2001
From: Joel Savitz <joel@underground.software>
Date: Thu, 22 May 2025 18:35:50 -0400
Subject: [PATCH 1/3] denis,orbit: give corrupt or missing patches an automatic
 zero

On the relevant deadline trigger, assign a zero score to a submission
when mailman receives none or corrupt patches.

Fixes #207

Signed-off-by: Joel Savitz <joel@underground.software>
---
 denis/final.py       |  8 ++++----
 denis/initial.py     |  8 ++++----
 denis/peer_review.py |  4 ++--
 denis/utilities.py   | 28 ++++++++++++++++++++++++++--
 4 files changed, 36 insertions(+), 12 deletions(-)

diff --git a/denis/final.py b/denis/final.py
index 3728d311..a31e2b74 100755
--- a/denis/final.py
+++ b/denis/final.py
@@ -5,12 +5,12 @@
 
 assignment = sys.argv[1]
 
-utilities.release_subs([sub for sub in
-                        utilities.user_to_sub(assignment, 'final').values()
-                        if sub])
+usernames_to_subs = utilities.user_to_sub(assignment, 'final')
+
+utilities.release_subs([sub.submission_id for sub in usernames_to_subs.values() if sub])
 
 print(f'final subs for {assignment} released')
 
 tags = utilities.update_tags(assignment, 'final')
 
-utilities.run_automated_checks(tags)
+utilities.run_automated_checks(tags, usernames_to_subs)
diff --git a/denis/initial.py b/denis/initial.py
index 71cf2d05..2047896f 100755
--- a/denis/initial.py
+++ b/denis/initial.py
@@ -13,8 +13,8 @@
 
 usernames_to_subs = utilities.user_to_sub(assignment, 'initial')
 
-students_who_submitted = [username for username, sub_id in
-                          usernames_to_subs.items() if sub_id is not None]
+students_who_submitted = [username for username, sub in
+                          usernames_to_subs.items() if sub is not None]
 
 # restrict future email access for students who didnt make an initial sub
 for student, sub_id in usernames_to_subs.items():
@@ -54,10 +54,10 @@
     print(e)
 
 
-utilities.release_subs([sub for sub in usernames_to_subs.values() if sub])
+utilities.release_subs([sub.submission_id for sub in usernames_to_subs.values() if sub])
 
 print(f'initial subs for {assignment} released')
 
 tags = utilities.update_tags(assignment, 'initial')
 
-utilities.run_automated_checks(tags)
+utilities.run_automated_checks(tags, usernames_to_subs)
diff --git a/denis/peer_review.py b/denis/peer_review.py
index 72bc418a..b3a4212a 100755
--- a/denis/peer_review.py
+++ b/denis/peer_review.py
@@ -7,8 +7,8 @@
 
 ids = []
 
-ids += [sub for sub in utilities.user_to_sub(assignment, 'review1').values() if sub]
-ids += [sub for sub in utilities.user_to_sub(assignment, 'review2').values() if sub]
+ids += [sub.submission_id for sub in utilities.user_to_sub(assignment, 'review1').values() if sub]
+ids += [sub.submission_id for sub in utilities.user_to_sub(assignment, 'review2').values() if sub]
 
 utilities.release_subs(ids)
 
diff --git a/denis/utilities.py b/denis/utilities.py
index 09e34f74..4d6e9b8b 100644
--- a/denis/utilities.py
+++ b/denis/utilities.py
@@ -23,7 +23,7 @@ def user_to_sub(assignment, component):
         sub = (relevant_gradeables
                .where(grd_tbl.user == username)
                .first())
-        submission_ids[user.username] = sub.submission_id if sub else None
+        submission_ids[user.username] = sub if sub else None
 
     return submission_ids
 
@@ -80,7 +80,29 @@ def update_tags(assignment, component):
     return updated_tags
 
 
-def run_automated_checks(tags):
+def check_corrupt_or_missing(repo, tag, username_to_subs):
+    [assignment, component, user] = tag.split('_')
+
+    gradable = username_to_subs[user]
+
+    msg = 'corruption and existence check'
+    msg += '\n'
+    msg += '------------------------------'
+    msg += '\n\n'
+    if not gradable or gradable.status[-1] == '!':
+        repo.git.execute(['git', 'notes', '--ref=grade', 'add', tag, '-m', '0'])
+        if not gradable:
+            msg += 'automatic 0 due to missing submission!'
+        else:
+            msg += 'automatic 0 due to corrupt submission!'
+    else:
+        msg += 'Patchset applies.'
+
+    msg += '\n\n'
+    return msg
+
+
+def run_automated_checks(tags, username_to_subs):
     with tempfile.TemporaryDirectory() as repo_path:
         repo = git.Repo.clone_from(PULL_URL, repo_path)
 
@@ -90,6 +112,8 @@ def run_automated_checks(tags):
 
         for tag in tags:
             msg = 'Automated tests by denis'
+            msg += '\n\n'
+            msg += check_corrupt_or_missing(repo, tag, username_to_subs)
             repo.git.execute(['git', 'notes', '--ref=denis', 'add', tag, '-m', msg])
 
         remote.push('refs/notes/*:refs/notes/*')

From 0da9847b23f4be4bf4ae19b45d77ba695e521879 Mon Sep 17 00:00:00 2001
From: Joel Savitz <joel@underground.software>
Date: Fri, 23 May 2025 00:30:13 -0400
Subject: [PATCH 2/3] denis: verify DCO in each patch in patchset

compare DCO in each patch and cover letter to one created by combining
the known username and hostname.

Fixes #110

Signed-off-by: Joel Savitz <joel@underground.software>
---
 container-compose.yml |  1 +
 denis/utilities.py    | 33 +++++++++++++++++++++++++++++++++
 2 files changed, 34 insertions(+)

diff --git a/container-compose.yml b/container-compose.yml
index 77890ceb..2f269382 100644
--- a/container-compose.yml
+++ b/container-compose.yml
@@ -185,6 +185,7 @@ services:
         - orbit_source=./orbit
     environment:
       TZ: ${SINGULARITY_TIMEZONE}
+      SINGULARITY_HOSTNAME: ${SINGULARITY_HOSTNAME}
     volumes:
       - type: volume
         source: denis-db
diff --git a/denis/utilities.py b/denis/utilities.py
index 4d6e9b8b..b26bd883 100644
--- a/denis/utilities.py
+++ b/denis/utilities.py
@@ -1,3 +1,4 @@
+import os
 import git
 import subprocess
 import tempfile
@@ -102,6 +103,34 @@ def check_corrupt_or_missing(repo, tag, username_to_subs):
     return msg
 
 
+def check_signed_off_by(repo, tag):
+    [_, _, user] = tag.split('_')
+    hostname = os.getenv("SINGULARITY_HOSTNAME")
+
+    msg = 'signed off by check'
+    msg += '\n'
+    msg += '-------------------'
+    msg += '\n\n'
+
+    commits = repo.git.execute(['git', 'rev-list', '--reverse', tag]).split('\n')
+    expected_dco = f'Signed-off-by: {user} <{user}@{hostname}>'
+    nr_flawless = 0
+    for i, commit in enumerate(commits):
+        patch = repo.git.execute(['git', 'show', commit])
+        if expected_dco in patch:
+            nr_flawless += 1
+        elif ('Signed-off-by:' in patch) or ('signed-off-by:' in patch):
+            msg += f'patch {i}: double check Signed-off-by\n'
+        else:
+            msg += f'patch {i}: no Signed-off-by line found\n'
+
+    if len(commits) == nr_flawless:
+        msg += 'All signed off by lines present as expected.\n'
+
+    msg += '\n'
+    return msg
+
+
 def run_automated_checks(tags, username_to_subs):
     with tempfile.TemporaryDirectory() as repo_path:
         repo = git.Repo.clone_from(PULL_URL, repo_path)
@@ -114,6 +143,10 @@ def run_automated_checks(tags, username_to_subs):
             msg = 'Automated tests by denis'
             msg += '\n\n'
             msg += check_corrupt_or_missing(repo, tag, username_to_subs)
+            if msg[-3] != '!':
+                msg += '\n\n'
+                msg += check_signed_off_by(repo, tag)
+
             repo.git.execute(['git', 'notes', '--ref=denis', 'add', tag, '-m', msg])
 
         remote.push('refs/notes/*:refs/notes/*')

From 265cd3d113c1ed922e370c102ff313de38cf6ba4 Mon Sep 17 00:00:00 2001
From: Joel Savitz <joel@underground.software>
Date: Fri, 30 May 2025 12:40:51 -0400
Subject: [PATCH 3/3] denis: check subject tag format

Check RFC/lack thereof, PATH, vN consistency, and correct indexes

Fixes: #262

Signed-off-by: Joel Savitz <joel@underground.software>
---
 denis/utilities.py | 36 ++++++++++++++++++++++++++++++++++++
 1 file changed, 36 insertions(+)

diff --git a/denis/utilities.py b/denis/utilities.py
index b26bd883..2d777f7a 100644
--- a/denis/utilities.py
+++ b/denis/utilities.py
@@ -131,6 +131,41 @@ def check_signed_off_by(repo, tag):
     return msg
 
 
+def check_subject_tag(repo, tag):
+    [assignment, component, user] = tag.split('_')
+
+    msg = 'subject tag check'
+    msg += '\n'
+    msg += '-----------------'
+    msg += '\n\n'
+
+    commits = repo.git.execute(['git', 'rev-list', '--reverse', tag]).split('\n')
+
+    sub_tbl = mailman.db.Submission
+    relevant_submissions = (sub_tbl.select()
+                            .where(sub_tbl.recipient == assignment)
+                            .where(sub_tbl.user == user)
+                            .order_by(sub_tbl.timestamp.desc()))
+    expected_revision_number = relevant_submissions.count()
+
+    nr_commits = len(commits)
+
+    nr_flawless = 0
+    for i, commit in enumerate(commits):
+        # from 0/n .. n/n
+        expected_tag = f'[{"RFC " if component == "initial" else ""}PATCH v{expected_revision_number} {i}/{nr_commits - 1}]'
+        patch = repo.git.execute(['git', 'show', commit])
+        if expected_tag in patch:
+            nr_flawless += 1
+        else:
+            msg += f'patch {i}: subject tag not detected (expected "{expected_tag}")\n'
+
+    if nr_commits == nr_flawless:
+        msg += 'Found all expected correct subject tags\n'
+
+    return msg
+
+
 def run_automated_checks(tags, username_to_subs):
     with tempfile.TemporaryDirectory() as repo_path:
         repo = git.Repo.clone_from(PULL_URL, repo_path)
@@ -146,6 +181,7 @@ def run_automated_checks(tags, username_to_subs):
             if msg[-3] != '!':
                 msg += '\n\n'
                 msg += check_signed_off_by(repo, tag)
+                msg += check_subject_tag(repo, tag)
 
             repo.git.execute(['git', 'notes', '--ref=denis', 'add', tag, '-m', msg])
 
