diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b1eff40 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# top-most EditorConfig file +root = true + +# basic rules for all files +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.{py,js}] +indent_style = space +indent_size = 4 + +[*.html] +indent_style = space +indent_size = 1 diff --git a/README.md b/README.md index a379f53..f90bb60 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,14 @@ A commitfest is a collection of patches and reviews for a project and is part of ## The Application -This is a Django 3.2 application backed by PostgreSQL and running on Python 3.x. +This is a Django 4.2 application backed by PostgreSQL and running on Python 3.x. ## Getting Started ### Ubuntu instructions +#### Install Dependencies / Configure Environment + First, prepare your development environment by installing pip, virtualenv, and postgresql-server-dev-X.Y. ```bash @@ -45,12 +47,24 @@ be provided. ./manage.py migrate ``` -You'll need either a database dump of the actual server's data or else to create a superuser: +#### Load data +For a quick start, you can load some dummy data into the database. Here's how you do that: + +``` +./manage.py loaddata auth_data.json +./manage.py loaddata commitfest_data.json +``` + +If you do this, the admin username and password are `admin` and `admin`. + +On the other hand, if you'd like to start from scratch instead, you can run the following command to create +a super user: ```bash ./manage.py createsuperuser ``` +#### Start application Finally, you're ready to start the application: ```bash @@ -69,3 +83,11 @@ codestyle. ln -s ../../tools/githook/pre-commit .git/hooks/ ``` + +If you'd like to regenerate the database dump files, you can run the following commands: +``` +./manage.py dumpdata auth --format=json --indent=4 --exclude=auth.permission > pgcommitfest/commitfest/fixtures/auth_data.json +./manage.py dumpdata commitfest --format=json --indent=4 > pgcommitfest/commitfest/fixtures/commitfest_data.json +``` + +If you want to reload data from dump file, you can run `drop owned by postgres;` in the `pgcommitfest` database first. diff --git a/media/commitfest/css/commitfest.css b/media/commitfest/css/commitfest.css index 07bfa10..9189b9a 100644 --- a/media/commitfest/css/commitfest.css +++ b/media/commitfest/css/commitfest.css @@ -65,3 +65,11 @@ div.form-group div.controls input.threadpick-input { #annotateMessageBody.loading * { display: none; } + +.cfbot-summary img { + margin-top: -3px; +} + +.github-logo { + height: 20px; +} diff --git a/media/commitfest/github-mark.svg b/media/commitfest/github-mark.svg new file mode 100644 index 0000000..37fa923 --- /dev/null +++ b/media/commitfest/github-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/commitfest/js/commitfest.js b/media/commitfest/js/commitfest.js index 2f1edda..57b34cd 100644 --- a/media/commitfest/js/commitfest.js +++ b/media/commitfest/js/commitfest.js @@ -222,7 +222,11 @@ function flagCommitted(committer) { function sortpatches(sortby) { - $('#id_sortkey').val(sortby); + if ($('#id_sortkey').val() == sortby) { + $('#id_sortkey').val(0); + } else { + $('#id_sortkey').val(sortby); + } $('#filterform').submit(); return false; @@ -306,3 +310,10 @@ function searchUserListChanged() { $('#doSelectUserButton').addClass('disabled'); } } + +function addGitCheckoutToClipboard(patchId) { + navigator.clipboard.writeText(`git remote add commitfest https://github.com/postgresql-cfbot/postgresql.git +git fetch commitfest cf/${patchId} +git checkout commitfest/cf/${patchId} +`); +} diff --git a/media/commitfest/needs_rebase_success.svg b/media/commitfest/needs_rebase_success.svg new file mode 100644 index 0000000..7f4113f --- /dev/null +++ b/media/commitfest/needs_rebase_success.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/media/commitfest/new_failure.svg b/media/commitfest/new_failure.svg new file mode 100644 index 0000000..ff3012d --- /dev/null +++ b/media/commitfest/new_failure.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/media/commitfest/new_success.svg b/media/commitfest/new_success.svg new file mode 100644 index 0000000..a0d9b7c --- /dev/null +++ b/media/commitfest/new_success.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/media/commitfest/old_failure.svg b/media/commitfest/old_failure.svg new file mode 100644 index 0000000..9d91d6c --- /dev/null +++ b/media/commitfest/old_failure.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/media/commitfest/old_success.svg b/media/commitfest/old_success.svg new file mode 100644 index 0000000..2de4117 --- /dev/null +++ b/media/commitfest/old_success.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/media/commitfest/running.svg b/media/commitfest/running.svg new file mode 100644 index 0000000..a137d41 --- /dev/null +++ b/media/commitfest/running.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/commitfest/waiting_to_start.svg b/media/commitfest/waiting_to_start.svg new file mode 100644 index 0000000..efd371d --- /dev/null +++ b/media/commitfest/waiting_to_start.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/pgcommitfest/commitfest/ajax.py b/pgcommitfest/commitfest/ajax.py index eaf7cdc..76c260c 100644 --- a/pgcommitfest/commitfest/ajax.py +++ b/pgcommitfest/commitfest/ajax.py @@ -9,6 +9,7 @@ import requests import json import textwrap +import re from pgcommitfest.auth import user_search from .models import CommitFest, Patch, MailThread, MailThreadAttachment @@ -23,7 +24,26 @@ class Http503(Exception): pass +def mockArchivesAPI(path): + with open(settings.MOCK_ARCHIVE_DATA, 'r', encoding='utf-8') as file: + data = json.load(file) + for message in data: + message['atts'] = [] + + message_pattern = re.compile(r"^/message-id\.json/(?P[^/]+)$") + + message_match = message_pattern.match(path) + if message_match: + message_id = message_match.group("message_id") + return [message for message in data if message['msgid'] == message_id] + else: + return data + + def _archivesAPI(suburl, params=None): + if getattr(settings, 'MOCK_ARCHIVES', False) and getattr(settings, 'MOCK_ARCHIVE_DATA'): + return mockArchivesAPI(suburl) + try: resp = requests.get( "http{0}://{1}:{2}{3}".format(settings.ARCHIVES_PORT == 443 and 's' or '', diff --git a/pgcommitfest/commitfest/fixtures/archive_data.json b/pgcommitfest/commitfest/fixtures/archive_data.json new file mode 100644 index 0000000..680ea08 --- /dev/null +++ b/pgcommitfest/commitfest/fixtures/archive_data.json @@ -0,0 +1,602 @@ +[ + { + "msgid": "example@message-0", + "date": "2025-01-20T14:20:10", + "from": "test@test.com", + "subj": "Re: Sample rate added to pg_stat_statements" + }, + { + "msgid": "example@message-1", + "date": "2025-01-20T14:01:53", + "from": "test@test.com", + "subj": "Re: [PATCH] Add get_bytes() and set_bytes() functions" + }, + { + "msgid": "example@message-2", + "date": "2025-01-20T13:49:45", + "from": "test@test.com", + "subj": "pg_stat_statements: improve loading and saving routines for the dump\n file" + }, + { + "msgid": "example@message-3", + "date": "2025-01-20T13:26:55", + "from": "test@test.com", + "subj": "Re: per backend I/O statistics" + }, + { + "msgid": "example@message-4", + "date": "2025-01-20T12:44:40", + "from": "test@test.com", + "subj": "Re: create subscription with (origin = none, copy_data = on)" + }, + { + "msgid": "example@message-5", + "date": "2025-01-20T11:10:40", + "from": "test@test.com", + "subj": "Re: per backend I/O statistics" + }, + { + "msgid": "example@message-6", + "date": "2025-01-20T08:21:35", + "from": "test@test.com", + "subj": "Re: Statistics Import and Export" + }, + { + "msgid": "example@message-7", + "date": "2025-01-20T08:03:54", + "from": "test@test.com", + "subj": "Re: Introduce XID age and inactive timeout based replication slot invalidation" + }, + { + "msgid": "example@message-8", + "date": "2025-01-20T06:53:39", + "from": "test@test.com", + "subj": "RE: Conflict detection for update_deleted in logical replication" + }, + { + "msgid": "example@message-9", + "date": "2025-01-20T06:49:41", + "from": "test@test.com", + "subj": "Re: Adding a '--two-phase' option to 'pg_createsubscriber' utility." + }, + { + "msgid": "example@message-10", + "date": "2025-01-20T06:34:41", + "from": "test@test.com", + "subj": "Re: per backend I/O statistics" + }, + { + "msgid": "example@message-11", + "date": "2025-01-20T05:56:21", + "from": "test@test.com", + "subj": "Re: [PATCH] immediately kill psql process if server is not running." + }, + { + "msgid": "example@message-12", + "date": "2025-01-20T05:33:23", + "from": "test@test.com", + "subj": "Re: connection establishment versus parallel workers" + }, + { + "msgid": "example@message-13", + "date": "2025-01-20T05:32:07", + "from": "test@test.com", + "subj": "Re: Pgoutput not capturing the generated columns" + }, + { + "msgid": "example@message-14", + "date": "2025-01-20T04:10:41", + "from": "test@test.com", + "subj": "Re: Pgoutput not capturing the generated columns" + }, + { + "msgid": "example@message-15", + "date": "2025-01-20T04:01:27", + "from": "test@test.com", + "subj": "int64 support in List API" + }, + { + "msgid": "example@message-16", + "date": "2025-01-19T23:55:17", + "from": "test@test.com", + "subj": "Re: Add RESPECT/IGNORE NULLS and FROM FIRST/LAST options" + }, + { + "msgid": "example@message-17", + "date": "2025-01-19T23:47:14", + "from": "test@test.com", + "subj": "Re: attndims, typndims still not enforced, but make the value within a sane threshold" + }, + { + "msgid": "example@message-18", + "date": "2025-01-19T15:50:49", + "from": "test@test.com", + "subj": "Re: Parallel heap vacuum" + }, + { + "msgid": "example@message-19", + "date": "2025-01-19T14:56:49", + "from": "test@test.com", + "subj": "Re: [RFC] Lock-free XLog Reservation from WAL" + }, + { + "msgid": "example@message-20", + "date": "2025-01-19T12:16:49", + "from": "test@test.com", + "subj": "Re: Pgoutput not capturing the generated columns" + }, + { + "msgid": "example@message-21", + "date": "2025-01-19T09:33:55", + "from": "test@test.com", + "subj": "Re: Add XMLNamespaces to XMLElement" + }, + { + "msgid": "example@message-22", + "date": "2025-01-19T00:11:32", + "from": "test@test.com", + "subj": "Get rid of WALBufMappingLock" + }, + { + "msgid": "example@message-23", + "date": "2025-01-18T23:42:50", + "from": "test@test.com", + "subj": "Re: improve DEBUG1 logging of parallel workers for CREATE INDEX?" + }, + { + "msgid": "example@message-24", + "date": "2025-01-18T20:37:54", + "from": "test@test.com", + "subj": "Re: Adding comments to help understand psql hidden queries" + }, + { + "msgid": "example@message-25", + "date": "2025-01-18T19:44:00", + "from": "test@test.com", + "subj": "Re: Coccinelle for PostgreSQL development [1/N]: coccicheck.py" + }, + { + "msgid": "example@message-26", + "date": "2025-01-18T17:32:10", + "from": "test@test.com", + "subj": "Re: Replace current implementations in crypt() and gen_salt() to\n OpenSSL" + }, + { + "msgid": "example@message-27", + "date": "2025-01-18T17:00:04", + "from": "test@test.com", + "subj": "Re: Statistics Import and Export" + }, + { + "msgid": "example@message-28", + "date": "2025-01-18T16:51:08", + "from": "test@test.com", + "subj": "Re: Confine vacuum skip logic to lazy_scan_skip" + }, + { + "msgid": "example@message-29", + "date": "2025-01-18T14:18:00", + "from": "test@test.com", + "subj": "Re: Revisiting {CREATE INDEX, REINDEX} CONCURRENTLY improvements" + }, + { + "msgid": "example@message-30", + "date": "2025-01-18T12:59:35", + "from": "test@test.com", + "subj": "Re: Issues with ON CONFLICT UPDATE and REINDEX CONCURRENTLY" + }, + { + "msgid": "example@message-31", + "date": "2025-01-18T07:14:02", + "from": "test@test.com", + "subj": "Re: Old BufferDesc refcount in PrintBufferDescs and PrintPinnedBufs" + }, + { + "msgid": "example@message-32", + "date": "2025-01-18T06:42:15", + "from": "test@test.com", + "subj": "Re: Collation & ctype method table, and extension hooks" + }, + { + "msgid": "example@message-33", + "date": "2025-01-18T05:01:27", + "from": "test@test.com", + "subj": "Re: create subscription with (origin = none, copy_data = on)" + }, + { + "msgid": "example@message-34", + "date": "2025-01-18T03:45:13", + "from": "test@test.com", + "subj": "RE: Conflict detection for update_deleted in logical replication" + }, + { + "msgid": "example@message-35", + "date": "2025-01-18T02:02:03", + "from": "test@test.com", + "subj": "Re: Old BufferDesc refcount in PrintBufferDescs and PrintPinnedBufs" + }, + { + "msgid": "example@message-36", + "date": "2025-01-18T01:23:19", + "from": "test@test.com", + "subj": "rename es_epq_active to es_epqstate" + }, + { + "msgid": "example@message-37", + "date": "2025-01-18T01:11:41", + "from": "test@test.com", + "subj": "Re: pg_trgm comparison bug on cross-architecture replication due to\n different char implementation" + }, + { + "msgid": "example@message-38", + "date": "2025-01-18T00:34:43", + "from": "test@test.com", + "subj": "Re: Add CASEFOLD() function." + }, + { + "msgid": "example@message-39", + "date": "2025-01-18T00:27:43", + "from": "test@test.com", + "subj": "Re: [PATCH] Add roman support for to_number function" + }, + { + "msgid": "example@message-40", + "date": "2025-01-17T22:11:56", + "from": "test@test.com", + "subj": "Old BufferDesc refcount in PrintBufferDescs and PrintPinnedBufs" + }, + { + "msgid": "example@message-41", + "date": "2025-01-17T20:44:01", + "from": "test@test.com", + "subj": "Re: Bug in detaching a partition with a foreign key." + }, + { + "msgid": "example@message-42", + "date": "2025-01-17T19:02:15", + "from": "test@test.com", + "subj": "Re: [PoC] Federated Authn/z with OAUTHBEARER" + }, + { + "msgid": "example@message-43", + "date": "2025-01-17T16:43:29", + "from": "test@test.com", + "subj": "Re: Add RESPECT/IGNORE NULLS and FROM FIRST/LAST options" + }, + { + "msgid": "example@message-44", + "date": "2025-01-17T16:01:53", + "from": "test@test.com", + "subj": "Re: Accept recovery conflict interrupt on blocked writing" + }, + { + "msgid": "example@message-45", + "date": "2025-01-17T15:45:46", + "from": "test@test.com", + "subj": "Re: Set AUTOCOMMIT to on in script output by pg_dump" + }, + { + "msgid": "example@message-46", + "date": "2025-01-17T15:42:13", + "from": "test@test.com", + "subj": "Re: POC: track vacuum/analyze cumulative time per relation" + }, + { + "msgid": "example@message-47", + "date": "2025-01-17T15:40:54", + "from": "test@test.com", + "subj": "Re: pure parsers and reentrant scanners" + }, + { + "msgid": "example@message-48", + "date": "2025-01-17T14:20:12", + "from": "test@test.com", + "subj": "Re: Statistics Import and Export" + }, + { + "msgid": "example@message-49", + "date": "2025-01-17T12:50:15", + "from": "test@test.com", + "subj": "Re: NOT ENFORCED constraint feature" + }, + { + "msgid": "example@message-50", + "date": "2025-01-17T12:03:09", + "from": "test@test.com", + "subj": "Re: Bypassing cursors in postgres_fdw to enable parallel plans" + }, + { + "msgid": "example@message-51", + "date": "2025-01-17T10:23:48", + "from": "test@test.com", + "subj": "Re: per backend I/O statistics" + }, + { + "msgid": "example@message-52", + "date": "2025-01-17T09:29:50", + "from": "test@test.com", + "subj": "Re: Add “FOR UPDATE NOWAIT” lock details to the log." + }, + { + "msgid": "example@message-53", + "date": "2025-01-17T08:30:04", + "from": "test@test.com", + "subj": "create subscription with (origin = none, copy_data = on)" + }, + { + "msgid": "example@message-54", + "date": "2025-01-17T07:18:20", + "from": "test@test.com", + "subj": "Re: Re: proposal: schema variables" + }, + { + "msgid": "example@message-55", + "date": "2025-01-17T07:15:34", + "from": "test@test.com", + "subj": "Re: SQLFunctionCache and generic plans" + }, + { + "msgid": "example@message-56", + "date": "2025-01-17T05:05:41", + "from": "test@test.com", + "subj": "Re: Some ExecSeqScan optimizations" + }, + { + "msgid": "example@message-57", + "date": "2025-01-17T05:00:49", + "from": "test@test.com", + "subj": "Remove XLogRecGetFullXid() in xlogreader.c?" + }, + { + "msgid": "example@message-58", + "date": "2025-01-17T04:22:07", + "from": "test@test.com", + "subj": "Re: Adding a '--two-phase' option to 'pg_createsubscriber' utility." + }, + { + "msgid": "example@message-59", + "date": "2025-01-17T03:18:45", + "from": "test@test.com", + "subj": "Automatic update of time column" + }, + { + "msgid": "example@message-60", + "date": "2025-01-17T01:06:14", + "from": "test@test.com", + "subj": "Re: Parallel heap vacuum" + }, + { + "msgid": "example@message-61", + "date": "2025-01-17T01:05:53", + "from": "test@test.com", + "subj": "Timeline issue if StartupXLOG() is interrupted right before\n end-of-recovery record is done" + }, + { + "msgid": "example@message-62", + "date": "2025-01-16T22:50:14", + "from": "test@test.com", + "subj": "Re: Trigger more frequent autovacuums of heavy insert tables" + }, + { + "msgid": "example@message-63", + "date": "2025-01-16T22:41:06", + "from": "test@test.com", + "subj": "Re: Document NULL" + }, + { + "msgid": "example@message-64", + "date": "2025-01-16T21:43:49", + "from": "test@test.com", + "subj": "Re: Trigger more frequent autovacuums of heavy insert tables" + }, + { + "msgid": "example@message-65", + "date": "2025-01-16T20:52:54", + "from": "test@test.com", + "subj": "Re: An improvement of ProcessTwoPhaseBuffer logic" + }, + { + "msgid": "example@message-66", + "date": "2025-01-16T19:38:21", + "from": "test@test.com", + "subj": "Re: Document How Commit Handles Aborted Transactions" + }, + { + "msgid": "example@message-67", + "date": "2025-01-16T18:42:32", + "from": "test@test.com", + "subj": "Re: Non-text mode for pg_dumpall" + }, + { + "msgid": "example@message-68", + "date": "2025-01-16T15:59:31", + "from": "test@test.com", + "subj": "Re: per backend WAL statistics" + }, + { + "msgid": "example@message-69", + "date": "2025-01-16T14:14:25", + "from": "test@test.com", + "subj": "Re: [PATCH] Add sortsupport for range types and btree_gist" + }, + { + "msgid": "example@message-70", + "date": "2025-01-16T13:53:31", + "from": "test@test.com", + "subj": "Bug in detaching a partition with a foreign key." + }, + { + "msgid": "example@message-71", + "date": "2025-01-16T13:52:46", + "from": "test@test.com", + "subj": "Increase NUM_XLOGINSERT_LOCKS" + }, + { + "msgid": "example@message-72", + "date": "2025-01-16T13:32:09", + "from": "test@test.com", + "subj": "Re: POC: make mxidoff 64 bits" + }, + { + "msgid": "example@message-73", + "date": "2025-01-16T13:24:41", + "from": "test@test.com", + "subj": "Re: Accept recovery conflict interrupt on blocked writing" + }, + { + "msgid": "example@message-74", + "date": "2025-01-16T11:16:06", + "from": "test@test.com", + "subj": "Re: Adding a '--two-phase' option to 'pg_createsubscriber' utility." + }, + { + "msgid": "example@message-75", + "date": "2025-01-16T10:54:53", + "from": "test@test.com", + "subj": "Re: Change GUC hashtable to use simplehash?" + }, + { + "msgid": "example@message-76", + "date": "2025-01-16T10:54:22", + "from": "test@test.com", + "subj": "Re: Psql meta-command conninfo+" + }, + { + "msgid": "example@message-77", + "date": "2025-01-16T08:47:08", + "from": "test@test.com", + "subj": "Re: Pgoutput not capturing the generated columns" + }, + { + "msgid": "example@message-78", + "date": "2025-01-16T08:44:18", + "from": "test@test.com", + "subj": "Re: Non-text mode for pg_dumpall" + }, + { + "msgid": "example@message-79", + "date": "2025-01-16T08:40:51", + "from": "test@test.com", + "subj": "Re: Show WAL write and fsync stats in pg_stat_io" + }, + { + "msgid": "example@message-80", + "date": "2025-01-16T07:50:09", + "from": "test@test.com", + "subj": "Re: An improvement of ProcessTwoPhaseBuffer logic" + }, + { + "msgid": "example@message-81", + "date": "2025-01-16T07:21:13", + "from": "test@test.com", + "subj": "Re: XMLDocument (SQL/XML X030)" + }, + { + "msgid": "example@message-82", + "date": "2025-01-16T07:05:16", + "from": "test@test.com", + "subj": "Re: Introduce XID age and inactive timeout based replication slot invalidation" + }, + { + "msgid": "example@message-83", + "date": "2025-01-16T07:04:23", + "from": "test@test.com", + "subj": "Re: Log a warning in pg_createsubscriber for max_slot_wal_keep_size" + }, + { + "msgid": "example@message-84", + "date": "2025-01-16T05:38:19", + "from": "test@test.com", + "subj": "Re: TOAST versus toast" + }, + { + "msgid": "example@message-85", + "date": "2025-01-16T05:17:39", + "from": "test@test.com", + "subj": "Re: Log a warning in pg_createsubscriber for max_slot_wal_keep_size" + }, + { + "msgid": "example@message-86", + "date": "2025-01-16T05:13:08", + "from": "test@test.com", + "subj": "Re: Make pg_stat_io view count IOs as bytes instead of blocks" + }, + { + "msgid": "example@message-87", + "date": "2025-01-16T04:14:31", + "from": "test@test.com", + "subj": "Re: Make pg_stat_io view count IOs as bytes instead of blocks" + }, + { + "msgid": "example@message-88", + "date": "2025-01-16T03:57:49", + "from": "test@test.com", + "subj": "TOAST versus toast" + }, + { + "msgid": "example@message-89", + "date": "2025-01-16T02:19:49", + "from": "test@test.com", + "subj": "Limit length of queryies in pg_stat_statement extension" + }, + { + "msgid": "example@message-90", + "date": "2025-01-16T01:45:15", + "from": "test@test.com", + "subj": "Re: Confine vacuum skip logic to lazy_scan_skip" + }, + { + "msgid": "example@message-91", + "date": "2025-01-16T01:15:31", + "from": "test@test.com", + "subj": "Re: Change GUC hashtable to use simplehash?" + }, + { + "msgid": "example@message-92", + "date": "2025-01-16T01:12:51", + "from": "test@test.com", + "subj": "Fix misuse use of pg_b64_encode function (contrib/postgres_fdw/connection.c)" + }, + { + "msgid": "example@message-93", + "date": "2025-01-16T01:00:51", + "from": "test@test.com", + "subj": "Re: An improvement of ProcessTwoPhaseBuffer logic" + }, + { + "msgid": "example@message-94", + "date": "2025-01-16T00:42:49", + "from": "test@test.com", + "subj": "Re: Infinite loop in XLogPageRead() on standby" + }, + { + "msgid": "example@message-95", + "date": "2025-01-15T23:47:51", + "from": "test@test.com", + "subj": "Re: convert libpgport's pqsignal() to a void function" + }, + { + "msgid": "example@message-96", + "date": "2025-01-15T22:20:58", + "from": "test@test.com", + "subj": "Re: Use Python \"Limited API\" in PL/Python" + }, + { + "msgid": "example@message-97", + "date": "2025-01-15T20:56:04", + "from": "test@test.com", + "subj": "Re: Statistics Import and Export" + }, + { + "msgid": "example@message-98", + "date": "2025-01-15T20:55:52", + "from": "test@test.com", + "subj": "Re: Eagerly scan all-visible pages to amortize aggressive vacuum" + }, + { + "msgid": "example@message-99", + "date": "2025-01-15T20:35:41", + "from": "test@test.com", + "subj": "Re: Add XMLNamespaces to XMLElement" + } +] diff --git a/pgcommitfest/commitfest/fixtures/auth_data.json b/pgcommitfest/commitfest/fixtures/auth_data.json new file mode 100644 index 0000000..bfaf3bf --- /dev/null +++ b/pgcommitfest/commitfest/fixtures/auth_data.json @@ -0,0 +1,20 @@ +[ +{ + "model": "auth.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$600000$49rgHaLmmFQUm7c663LCrU$i68PFeI493lPmgNx/RHnWNuw4ZRzzvJWNqU4os5VnF4=", + "last_login": "2025-01-26T10:43:07.735", + "is_superuser": true, + "username": "admin", + "first_name": "", + "last_name": "", + "email": "test@test.com", + "is_staff": true, + "is_active": true, + "date_joined": "2025-01-20T15:47:04.132", + "groups": [], + "user_permissions": [] + } +} +] diff --git a/pgcommitfest/commitfest/fixtures/commitfest_data.json b/pgcommitfest/commitfest/fixtures/commitfest_data.json new file mode 100644 index 0000000..4befdfa --- /dev/null +++ b/pgcommitfest/commitfest/fixtures/commitfest_data.json @@ -0,0 +1,373 @@ +[ +{ + "model": "commitfest.commitfest", + "pk": 1, + "fields": { + "name": "Sample Old Commitfest", + "status": 4, + "startdate": "2024-05-01", + "enddate": "2024-05-31" + } +}, +{ + "model": "commitfest.commitfest", + "pk": 2, + "fields": { + "name": "Sample In Progress Commitfest", + "status": 3, + "startdate": "2025-01-01", + "enddate": "2025-02-28" + } +}, +{ + "model": "commitfest.commitfest", + "pk": 3, + "fields": { + "name": "Sample Open Commitfest", + "status": 2, + "startdate": "2025-03-01", + "enddate": "2025-03-31" + } +}, +{ + "model": "commitfest.commitfest", + "pk": 4, + "fields": { + "name": "Sample Future Commitfest", + "status": 1, + "startdate": "2025-05-01", + "enddate": "2025-05-31" + } +}, +{ + "model": "commitfest.topic", + "pk": 1, + "fields": { + "topic": "Bugs" + } +}, +{ + "model": "commitfest.topic", + "pk": 2, + "fields": { + "topic": "Performance" + } +}, +{ + "model": "commitfest.topic", + "pk": 3, + "fields": { + "topic": "Miscellaneous" + } +}, +{ + "model": "commitfest.targetversion", + "pk": 1, + "fields": { + "version": "18" + } +}, +{ + "model": "commitfest.patch", + "pk": 1, + "fields": { + "name": "Conflict detection for update_deleted in logical replication", + "topic": 1, + "wikilink": "", + "gitlink": "", + "targetversion": null, + "committer": null, + "created": "2025-01-26T10:48:31.579", + "modified": "2025-01-26T10:53:20.498", + "lastmail": "2025-01-20T06:53:39", + "authors": [ + 1 + ], + "reviewers": [], + "subscribers": [], + "mailthread_set": [ + 1 + ] + } +}, +{ + "model": "commitfest.patch", + "pk": 2, + "fields": { + "name": "Sample rate added to pg_stat_statements", + "topic": 3, + "wikilink": "", + "gitlink": "", + "targetversion": null, + "committer": null, + "created": "2025-01-26T10:51:17.305", + "modified": "2025-01-26T10:51:19.631", + "lastmail": "2025-01-20T14:20:10", + "authors": [], + "reviewers": [], + "subscribers": [], + "mailthread_set": [ + 2 + ] + } +}, +{ + "model": "commitfest.patch", + "pk": 3, + "fields": { + "name": "Per Backend I/O statistics", + "topic": 3, + "wikilink": "", + "gitlink": "", + "targetversion": null, + "committer": null, + "created": "2025-01-26T11:02:07.467", + "modified": "2025-01-26T11:02:10.911", + "lastmail": "2025-01-20T13:26:55", + "authors": [], + "reviewers": [], + "subscribers": [], + "mailthread_set": [ + 3 + ] + } +}, +{ + "model": "commitfest.patchoncommitfest", + "pk": 1, + "fields": { + "patch": 1, + "commitfest": 2, + "enterdate": "2025-01-26T10:48:31.579", + "leavedate": null, + "status": 3 + } +}, +{ + "model": "commitfest.patchoncommitfest", + "pk": 2, + "fields": { + "patch": 2, + "commitfest": 2, + "enterdate": "2025-01-26T10:51:17.305", + "leavedate": null, + "status": 1 + } +}, +{ + "model": "commitfest.patchoncommitfest", + "pk": 3, + "fields": { + "patch": 1, + "commitfest": 1, + "enterdate": "2024-04-01T10:52:24", + "leavedate": "2024-06-05T10:52:34", + "status": 5 + } +}, +{ + "model": "commitfest.patchoncommitfest", + "pk": 4, + "fields": { + "patch": 3, + "commitfest": 3, + "enterdate": "2025-01-26T11:02:07.467", + "leavedate": null, + "status": 1 + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 1, + "fields": { + "patch": 1, + "date": "2025-01-26T10:48:31.580", + "by": 1, + "by_cfbot": false, + "what": "Created patch record" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 2, + "fields": { + "patch": 1, + "date": "2025-01-26T10:48:31.582", + "by": 1, + "by_cfbot": false, + "what": "Attached mail thread example@message-8" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 3, + "fields": { + "patch": 1, + "date": "2025-01-26T10:48:54.115", + "by": 1, + "by_cfbot": false, + "what": "Changed authors to (admin)" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 4, + "fields": { + "patch": 2, + "date": "2025-01-26T10:51:17.306", + "by": 1, + "by_cfbot": false, + "what": "Created patch record" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 5, + "fields": { + "patch": 2, + "date": "2025-01-26T10:51:17.307", + "by": 1, + "by_cfbot": false, + "what": "Attached mail thread example@message-0" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 6, + "fields": { + "patch": 1, + "date": "2025-01-26T10:53:20.498", + "by": 1, + "by_cfbot": false, + "what": "New status: Ready for Committer" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 7, + "fields": { + "patch": 3, + "date": "2025-01-26T11:02:07.468", + "by": 1, + "by_cfbot": false, + "what": "Created patch record" + } +}, +{ + "model": "commitfest.patchhistory", + "pk": 8, + "fields": { + "patch": 3, + "date": "2025-01-26T11:02:07.469", + "by": 1, + "by_cfbot": false, + "what": "Attached mail thread example@message-3" + } +}, +{ + "model": "commitfest.mailthread", + "pk": 1, + "fields": { + "messageid": "example@message-8", + "subject": "RE: Conflict detection for update_deleted in logical replication", + "firstmessage": "2025-01-20T06:53:39", + "firstauthor": "test@test.com", + "latestmessage": "2025-01-20T06:53:39", + "latestauthor": "test@test.com", + "latestsubject": "RE: Conflict detection for update_deleted in logical replication", + "latestmsgid": "example@message-8" + } +}, +{ + "model": "commitfest.mailthread", + "pk": 2, + "fields": { + "messageid": "example@message-0", + "subject": "Re: Sample rate added to pg_stat_statements", + "firstmessage": "2025-01-20T14:20:10", + "firstauthor": "test@test.com", + "latestmessage": "2025-01-20T14:20:10", + "latestauthor": "test@test.com", + "latestsubject": "Re: Sample rate added to pg_stat_statements", + "latestmsgid": "example@message-0" + } +}, +{ + "model": "commitfest.mailthread", + "pk": 3, + "fields": { + "messageid": "example@message-3", + "subject": "Re: per backend I/O statistics", + "firstmessage": "2025-01-20T13:26:55", + "firstauthor": "test@test.com", + "latestmessage": "2025-01-20T13:26:55", + "latestauthor": "test@test.com", + "latestsubject": "Re: per backend I/O statistics", + "latestmsgid": "example@message-3" + } +}, +{ + "model": "commitfest.patchstatus", + "pk": 1, + "fields": { + "statusstring": "Needs review", + "sortkey": 10 + } +}, +{ + "model": "commitfest.patchstatus", + "pk": 2, + "fields": { + "statusstring": "Waiting on Author", + "sortkey": 15 + } +}, +{ + "model": "commitfest.patchstatus", + "pk": 3, + "fields": { + "statusstring": "Ready for Committer", + "sortkey": 20 + } +}, +{ + "model": "commitfest.patchstatus", + "pk": 4, + "fields": { + "statusstring": "Committed", + "sortkey": 25 + } +}, +{ + "model": "commitfest.patchstatus", + "pk": 5, + "fields": { + "statusstring": "Moved to next CF", + "sortkey": 30 + } +}, +{ + "model": "commitfest.patchstatus", + "pk": 6, + "fields": { + "statusstring": "Rejected", + "sortkey": 50 + } +}, +{ + "model": "commitfest.patchstatus", + "pk": 7, + "fields": { + "statusstring": "Returned with Feedback", + "sortkey": 50 + } +}, +{ + "model": "commitfest.patchstatus", + "pk": 8, + "fields": { + "statusstring": "Withdrawn", + "sortkey": 50 + } +} +] diff --git a/pgcommitfest/commitfest/forms.py b/pgcommitfest/commitfest/forms.py index 61d9046..ec0c62a 100644 --- a/pgcommitfest/commitfest/forms.py +++ b/pgcommitfest/commitfest/forms.py @@ -44,7 +44,7 @@ class PatchForm(forms.ModelForm): class Meta: model = Patch - exclude = ('commitfests', 'mailthreads', 'modified', 'lastmail', 'subscribers', ) + exclude = ('commitfests', 'mailthread_set', 'modified', 'lastmail', 'subscribers', ) def __init__(self, *args, **kwargs): super(PatchForm, self).__init__(*args, **kwargs) diff --git a/pgcommitfest/commitfest/migrations/0006_cfbot_integration.py b/pgcommitfest/commitfest/migrations/0006_cfbot_integration.py new file mode 100644 index 0000000..ac84969 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0006_cfbot_integration.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.17 on 2024-12-21 14:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('commitfest', '0005_history_dateindex'), + ] + + operations = [ + migrations.CreateModel( + name='CfbotBranch', + fields=[ + ('patch', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='cfbot_branch', serialize=False, to='commitfest.patch')), + ('branch_id', models.IntegerField()), + ('branch_name', models.TextField()), + ('commit_id', models.TextField(blank=True, null=True)), + ('apply_url', models.TextField()), + ('status', models.TextField(choices=[('testing', 'Testing'), ('finished', 'Finished'), ('failed', 'Failed'), ('timeout', 'Timeout')])), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='CfbotTask', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('task_id', models.TextField(unique=True)), + ('task_name', models.TextField()), + ('branch_id', models.IntegerField()), + ('position', models.IntegerField()), + ('status', models.TextField(choices=[('CREATED', 'Created'), ('NEEDS_APPROVAL', 'Needs Approval'), ('TRIGGERED', 'Triggered'), ('EXECUTING', 'Executing'), ('FAILED', 'Failed'), ('COMPLETED', 'Completed'), ('SCHEDULED', 'Scheduled'), ('ABORTED', 'Aborted'), ('ERRORED', 'Errored')])), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('patch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cfbot_tasks', to='commitfest.patch')), + ], + ), + migrations.RunSQL( + """ + CREATE TYPE cfbotbranch_status AS ENUM ( + 'testing', + 'finished', + 'failed', + 'timeout' + ); + """ + ), + migrations.RunSQL( + """ + CREATE TYPE cfbottask_status AS ENUM ( + 'CREATED', + 'NEEDS_APPROVAL', + 'TRIGGERED', + 'EXECUTING', + 'FAILED', + 'COMPLETED', + 'SCHEDULED', + 'ABORTED', + 'ERRORED' + ); + """ + ), + migrations.RunSQL( + """ + ALTER TABLE commitfest_cfbotbranch + ALTER COLUMN status TYPE cfbotbranch_status + USING status::cfbotbranch_status; + """ + ), + migrations.RunSQL( + """ + ALTER TABLE commitfest_cfbottask + ALTER COLUMN status TYPE cfbottask_status + USING status::cfbottask_status; + """ + ), + ] diff --git a/pgcommitfest/commitfest/migrations/0007_needs_rebase_emails.py b/pgcommitfest/commitfest/migrations/0007_needs_rebase_emails.py new file mode 100644 index 0000000..42740aa --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0007_needs_rebase_emails.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.17 on 2024-12-25 11:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("commitfest", "0006_cfbot_integration"), + ] + + operations = [ + migrations.AddField( + model_name="cfbotbranch", + name="needs_rebase_since", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="patchhistory", + name="by_cfbot", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="patchhistory", + name="by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddConstraint( + model_name="patchhistory", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("by_cfbot", True), ("by__isnull", True)), + models.Q(("by_cfbot", False), ("by__isnull", False)), + _connector="OR", + ), + name="check_by", + ), + ), + ] diff --git a/pgcommitfest/commitfest/migrations/0008_move_mail_thread_many_to_many.py b/pgcommitfest/commitfest/migrations/0008_move_mail_thread_many_to_many.py new file mode 100644 index 0000000..72d9d42 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0008_move_mail_thread_many_to_many.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.17 on 2025-01-25 11:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('commitfest', '0007_needs_rebase_emails'), + ] + + operations = [ + migrations.RunSQL( + migrations.RunSQL.noop, + reverse_sql=migrations.RunSQL.noop, + state_operations=[ + migrations.RemoveField( + model_name='mailthread', + name='patches', + ), + migrations.AddField( + model_name='patch', + name='mailthread_set', + field=models.ManyToManyField(db_table='commitfest_mailthread_patches', related_name='patches', to='commitfest.mailthread'), + ), + ] + ) + ] diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index 28722f0..4380446 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -109,6 +109,8 @@ class Patch(models.Model, DiffableModel): # Users to be notified when something happens subscribers = models.ManyToManyField(User, related_name='patch_subscriber', blank=True) + mailthread_set = models.ManyToManyField("MailThread", related_name="patches", blank=False, db_table="commitfest_mailthread_patches") + # Datestamps for tracking activity created = models.DateTimeField(blank=False, null=False, auto_now_add=True) modified = models.DateTimeField(blank=False, null=False) @@ -122,6 +124,9 @@ class Patch(models.Model, DiffableModel): 'reviewers': 'reviewers_string', } + def current_commitfest(self): + return self.commitfests.order_by('-startdate').first() + # Some accessors @property def authors_string(self): @@ -224,11 +229,15 @@ class Meta: class PatchHistory(models.Model): patch = models.ForeignKey(Patch, blank=False, null=False, on_delete=models.CASCADE) date = models.DateTimeField(blank=False, null=False, auto_now_add=True, db_index=True) - by = models.ForeignKey(User, blank=False, null=False, on_delete=models.CASCADE) + by = models.ForeignKey(User, blank=True, null=True, on_delete=models.CASCADE) + by_cfbot = models.BooleanField(null=False, blank=False, default=False) what = models.CharField(max_length=500, null=False, blank=False) @property def by_string(self): + if (self.by_cfbot): + return "CFbot" + return "%s %s (%s)" % (self.by.first_name, self.by.last_name, self.by.username) def __str__(self): @@ -236,34 +245,45 @@ def __str__(self): class Meta: ordering = ('-date', ) + constraints = [ + models.CheckConstraint( + check=( + models.Q(by_cfbot=True) & models.Q(by__isnull=True) + ) | ( + models.Q(by_cfbot=False) & models.Q(by__isnull=False) + ), + name='check_by', + ), + ] def save_and_notify(self, prevcommitter=None, - prevreviewers=None, prevauthors=None): + prevreviewers=None, prevauthors=None, authors_only=False): # Save this model, and then trigger notifications if there are any. There are # many different things that can trigger notifications, so try them all. self.save() recipients = [] - recipients.extend(self.patch.subscribers.all()) - - # Current or previous committer wants all notifications - try: - if self.patch.committer and self.patch.committer.user.userprofile.notify_all_committer: - recipients.append(self.patch.committer.user) - except UserProfile.DoesNotExist: - pass - - try: - if prevcommitter and prevcommitter.user.userprofile.notify_all_committer: - recipients.append(prevcommitter.user) - except UserProfile.DoesNotExist: - pass - - # Current or previous reviewers wants all notifications - recipients.extend(self.patch.reviewers.filter(userprofile__notify_all_reviewer=True)) - if prevreviewers: - # prevreviewers is a list - recipients.extend(User.objects.filter(id__in=[p.id for p in prevreviewers], userprofile__notify_all_reviewer=True)) + if not authors_only: + recipients.extend(self.patch.subscribers.all()) + + # Current or previous committer wants all notifications + try: + if self.patch.committer and self.patch.committer.user.userprofile.notify_all_committer: + recipients.append(self.patch.committer.user) + except UserProfile.DoesNotExist: + pass + + try: + if prevcommitter and prevcommitter.user.userprofile.notify_all_committer: + recipients.append(prevcommitter.user) + except UserProfile.DoesNotExist: + pass + + # Current or previous reviewers wants all notifications + recipients.extend(self.patch.reviewers.filter(userprofile__notify_all_reviewer=True)) + if prevreviewers: + # prevreviewers is a list + recipients.extend(User.objects.filter(id__in=[p.id for p in prevreviewers], userprofile__notify_all_reviewer=True)) # Current or previous authors wants all notifications recipients.extend(self.patch.authors.filter(userprofile__notify_all_author=True)) @@ -284,7 +304,6 @@ class MailThread(models.Model): # so we can keep track of when there was last a change on the # thread in question. messageid = models.CharField(max_length=1000, null=False, blank=False, unique=True) - patches = models.ManyToManyField(Patch, blank=False) subject = models.CharField(max_length=500, null=False, blank=False) firstmessage = models.DateTimeField(null=False, blank=False) firstauthor = models.CharField(max_length=500, null=False, blank=False) @@ -341,3 +360,55 @@ class PatchStatus(models.Model): class PendingNotification(models.Model): history = models.ForeignKey(PatchHistory, blank=False, null=False, on_delete=models.CASCADE) user = models.ForeignKey(User, blank=False, null=False, on_delete=models.CASCADE) + + +class CfbotBranch(models.Model): + STATUS_CHOICES = [ + ('testing', 'Testing'), + ('finished', 'Finished'), + ('failed', 'Failed'), + ('timeout', 'Timeout'), + ] + + patch = models.OneToOneField(Patch, on_delete=models.CASCADE, related_name="cfbot_branch", primary_key=True) + branch_id = models.IntegerField(null=False) + branch_name = models.TextField(null=False) + commit_id = models.TextField(null=True, blank=True) + apply_url = models.TextField(null=False) + # Actually a postgres enum column + status = models.TextField(choices=STATUS_CHOICES, null=False) + needs_rebase_since = models.DateTimeField(null=True, blank=True) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + +class CfbotTask(models.Model): + STATUS_CHOICES = [ + ('CREATED', 'Created'), + ('NEEDS_APPROVAL', 'Needs Approval'), + ('TRIGGERED', 'Triggered'), + ('EXECUTING', 'Executing'), + ('FAILED', 'Failed'), + ('COMPLETED', 'Completed'), + ('SCHEDULED', 'Scheduled'), + ('ABORTED', 'Aborted'), + ('ERRORED', 'Errored'), + ] + + # This id is only used by Django. Using text type for primary keys, has + # historically caused problems. + id = models.BigAutoField(primary_key=True) + # This is the id used by the external CI system. Currently with CirrusCI + # this is an integer, and thus we could probably store it as such. But + # given that we might need to change CI providers at some point, and that + # CI provider might use e.g. UUIDs, we prefer to consider the format of the + # ID opaque and store it as text. + task_id = models.TextField(unique=True) + task_name = models.TextField(null=False) + patch = models.ForeignKey(Patch, on_delete=models.CASCADE, related_name="cfbot_tasks") + branch_id = models.IntegerField(null=False) + position = models.IntegerField(null=False) + # Actually a postgres enum column + status = models.TextField(choices=STATUS_CHOICES, null=False) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) diff --git a/pgcommitfest/commitfest/templates/commitfest.html b/pgcommitfest/commitfest/templates/commitfest.html index 63f793a..2504721 100644 --- a/pgcommitfest/commitfest/templates/commitfest.html +++ b/pgcommitfest/commitfest/templates/commitfest.html @@ -60,15 +60,17 @@

{{p.is_open|yesno:"Active patches,Closed patches"}}

- + + + - - - + + + {%if user.is_staff%} {%endif%} @@ -79,13 +81,38 @@

{{p.is_open|yesno:"Active patches,Closed patches"}}

{%if grouping%} {%ifchanged p.topic%} - -{%endifchanged%} + +{%endifchanged%} {%endif%} - + + + diff --git a/pgcommitfest/commitfest/templates/mail/patch_notify.txt b/pgcommitfest/commitfest/templates/mail/patch_notify.txt index 1ab838c..5f3d744 100644 --- a/pgcommitfest/commitfest/templates/mail/patch_notify.txt +++ b/pgcommitfest/commitfest/templates/mail/patch_notify.txt @@ -5,7 +5,7 @@ have received updates in the PostgreSQL commitfest app: {{p.patch.name}} https://commitfest.postgresql.org/{{p.patch.patchoncommitfest_set.all.0.commitfest.id}}/{{p.patch.id}}/ {%for h in p.entries%} -* {{h.what}} ({{h.by}}){%endfor%} +* {{h.what}} by {{h.by_string()}}{%endfor%} {%endfor%} diff --git a/pgcommitfest/commitfest/templates/patch.html b/pgcommitfest/commitfest/templates/patch.html index 33670cf..69d72bc 100644 --- a/pgcommitfest/commitfest/templates/patch.html +++ b/pgcommitfest/commitfest/templates/patch.html @@ -12,6 +12,43 @@ + + + + @@ -31,7 +68,7 @@ @@ -55,21 +92,11 @@ - - - - - - {%for h in patch.history %} + {%for h in patch.history %} @@ -156,7 +183,9 @@

Annotations

{%if p.is_open%}Patch{%if sortkey == 0%}
{%endif%}{%endif%}
Patch{%if sortkey == 5%}
{%endif%}
ID{%if sortkey == 4%}
{%endif%}
Status VerCI status Author Reviewers Committer{%if p.is_open%}Num cfs{%if sortkey == 3%}
{%endif%}{%else%}Num cfs{%endif%}
{%if p.is_open%}Latest activity{%if sortkey == 1%}
{%endif%}{%else%}Latest activity{%endif%}
{%if p.is_open%}Latest mail{%if sortkey == 2%}
{%endif%}{%else%}Latest mail{%endif%}
Num cfs{%if sortkey == 3%}
{%endif%}
Latest activity{%if sortkey == 1%}
{%endif%}
Latest mail{%if sortkey == 2%}
{%endif%}
Select
{{p.topic}}
{{p.topic}}
{{p.name}}{{p.name}}{{p.id}} {{p.status|patchstatusstring}} {%if p.targetversion%}{{p.targetversion}}{%endif%} + {%if not p.cfbot_results %} + Not processed + {%elif p.cfbot_results.needs_rebase %} + + Needs rebase! + + {%else%} + + + {%if p.cfbot_results.failed > 0 %} + + {%elif p.cfbot_results.completed < p.cfbot_results.total %} + + {%else%} + + {%endif%} + + {{p.cfbot_results.completed}}/{{p.cfbot_results.total}} + + + {%endif%} + {{p.author_names|default:''}} {{p.reviewer_names|default:''}} {{p.committer|default:''}}Title {{patch.name}}
CI (CFBot) + {%if not cfbot_branch %} + Not processed + {%elif not cfbot_branch.commit_id %} + + Needs rebase! + Additional links previous successfully applied patch (outdated): + + + Summary + {%else%} + + + Summary + {%for c in cfbot_tasks %} + {%if c.status == 'COMPLETED'%} + + {%elif c.status == 'CREATED' %} + + {%elif c.status == 'EXECUTING' %} + + {%else %} + + {%endif%} + {%endfor%} + {%endif%} + {%if cfbot_branch %} + + {%endif%} + +
Topic {{patch.topic}}
Status {%for c in patch_commitfests %} -
{{c.commitfest}}: {{c.statusstring}}
+
{{c.commitfest}}: {{c.statusstring}}
{%endfor%}
LinksCFbot results (CirrusCI) - CFbot GitHub{%if patch.wikilink%} + {%if patch.wikilink%} Wiki{%endif%}{%if patch.gitlink%} Git {%endif%}
Checkout latest CFbot patchset - Go to your local checkout of the PostgreSQL repository and run: -
git remote add commitfest https://github.com/postgresql-cfbot/postgresql.git
-git fetch commitfest cf/{{patch.id}}
-git checkout commitfest/cf/{{patch.id}}
-
Emails @@ -138,7 +165,7 @@

Annotations

{{h.date}} {{h.by_string}}
+
{%include "patch_commands.inc"%} +
{%comment%}commit dialog{%endcomment%} @@ -37,4 +37,4 @@ {%endif%} - \ No newline at end of file + diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py index 2c21cd3..4b52864 100644 --- a/pgcommitfest/commitfest/views.py +++ b/pgcommitfest/commitfest/views.py @@ -14,12 +14,13 @@ from email.mime.text import MIMEText from email.utils import formatdate, make_msgid import json +import hmac import urllib from pgcommitfest.mailqueue.util import send_mail, send_simple_mail from pgcommitfest.userprofile.util import UserWrapper -from .models import CommitFest, Patch, PatchOnCommitFest, PatchHistory, Committer +from .models import CommitFest, Patch, PatchOnCommitFest, PatchHistory, Committer, CfbotBranch, CfbotTask from .models import MailThread from .forms import PatchForm, NewPatchForm, CommentForm, CommitFestFilterForm from .forms import BulkEmailForm @@ -186,6 +187,10 @@ def commitfest(request, cfid): orderby_str = 'lastmail, created' elif sortkey == 3: orderby_str = 'num_cfs DESC, modified, created' + elif sortkey == 4: + orderby_str = 'p.id' + elif sortkey == 5: + orderby_str = 'p.name, created' else: orderby_str = 'p.id' sortkey = 0 @@ -213,7 +218,24 @@ def commitfest(request, cfid): (poc.status=ANY(%(openstatuses)s)) AS is_open, (SELECT string_agg(first_name || ' ' || last_name || ' (' || username || ')', ', ') FROM auth_user INNER JOIN commitfest_patch_authors cpa ON cpa.user_id=auth_user.id WHERE cpa.patch_id=p.id) AS author_names, (SELECT string_agg(first_name || ' ' || last_name || ' (' || username || ')', ', ') FROM auth_user INNER JOIN commitfest_patch_reviewers cpr ON cpr.user_id=auth_user.id WHERE cpr.patch_id=p.id) AS reviewer_names, -(SELECT count(1) FROM commitfest_patchoncommitfest pcf WHERE pcf.patch_id=p.id) AS num_cfs +(SELECT count(1) FROM commitfest_patchoncommitfest pcf WHERE pcf.patch_id=p.id) AS num_cfs, +( + SELECT row_to_json(t) as cfbot_results + from ( + SELECT + count(*) FILTER (WHERE task.status = 'COMPLETED') as completed, + count(*) FILTER (WHERE task.status in ('CREATED', 'SCHEDULED', 'EXECUTING')) running, + count(*) FILTER (WHERE task.status in ('ABORTED', 'ERRORED', 'FAILED')) failed, + count(*) total, + string_agg(task.task_name, ', ') FILTER (WHERE task.status in ('ABORTED', 'ERRORED', 'FAILED')) as failed_task_names, + branch.commit_id IS NULL as needs_rebase, + branch.apply_url + FROM commitfest_cfbotbranch branch + LEFT JOIN commitfest_cfbottask task ON task.branch_id = branch.branch_id + WHERE branch.patch_id=p.id + GROUP BY branch.patch_id + ) t +) FROM commitfest_patch p INNER JOIN commitfest_patchoncommitfest poc ON poc.patch_id=p.id INNER JOIN commitfest_topic t ON t.id=p.topic_id @@ -298,19 +320,23 @@ def global_search(request): }) -def patch_redirect(request, patchid): - last_commitfest = PatchOnCommitFest.objects.select_related('commitfest').filter(patch_id=patchid).order_by('-commitfest__startdate').first() - if not last_commitfest: - raise Http404("Patch not found") - return HttpResponseRedirect(f'/{last_commitfest.commitfest_id}/{patchid}/') +def patch_legacy_redirect(request, cfid, patchid): + # Previously we would include the commitfest id in the URL. This is no + # longer the case. + return HttpResponseRedirect(f'/patch/{patchid}/') -def patch(request, cfid, patchid): - cf = get_object_or_404(CommitFest, pk=cfid) - patch = get_object_or_404(Patch.objects.select_related(), pk=patchid, commitfests=cf) - patch_commitfests = PatchOnCommitFest.objects.select_related('commitfest').filter(patch=patch).order_by('-commitfest__startdate') +def patch(request, patchid): + patch = get_object_or_404(Patch.objects.select_related(), pk=patchid) + + patch_commitfests = PatchOnCommitFest.objects.select_related('commitfest').filter(patch=patch).order_by('-commitfest__startdate').all() + cf = patch_commitfests[0].commitfest + committers = Committer.objects.filter(active=True).order_by('user__last_name', 'user__first_name') + cfbot_branch = getattr(patch, 'cfbot_branch', None) + cfbot_tasks = patch.cfbot_tasks.order_by('position') if cfbot_branch else [] + # XXX: this creates a session, so find a smarter way. Probably handle # it in the callback and just ask the user then? if request.user.is_authenticated: @@ -333,6 +359,8 @@ def patch(request, cfid, patchid): 'cf': cf, 'patch': patch, 'patch_commitfests': patch_commitfests, + 'cfbot_branch': cfbot_branch, + 'cfbot_tasks': cfbot_tasks, 'is_committer': is_committer, 'is_this_committer': is_this_committer, 'is_reviewer': is_reviewer, @@ -346,9 +374,9 @@ def patch(request, cfid, patchid): @login_required @transaction.atomic -def patchform(request, cfid, patchid): - cf = get_object_or_404(CommitFest, pk=cfid) - patch = get_object_or_404(Patch, pk=patchid, commitfests=cf) +def patchform(request, patchid): + patch = get_object_or_404(Patch, pk=patchid) + cf = patch.current_commitfest() prevreviewers = list(patch.reviewers.all()) prevauthors = list(patch.authors.all()) @@ -404,7 +432,7 @@ def newpatch(request, cfid): # Now add the thread try: doAttachThread(cf, patch, form.cleaned_data['threadmsgid'], request.user) - return HttpResponseRedirect("/%s/%s/edit/" % (cf.id, patch.id)) + return HttpResponseRedirect("/patch/%s/edit/" % (patch.id,)) except Http404: # Thread not found! # This is a horrible breakage of API layers @@ -438,21 +466,12 @@ def _review_status_string(reviewstatus): @login_required @transaction.atomic -def comment(request, cfid, patchid, what): - cf = get_object_or_404(CommitFest, pk=cfid) +def comment(request, patchid, what): patch = get_object_or_404(Patch, pk=patchid) + cf = patch.current_commitfest() poc = get_object_or_404(PatchOnCommitFest, patch=patch, commitfest=cf) is_review = (what == 'review') - if poc.is_closed: - # We allow modification of patches in closed CFs *only* if it's the - # last CF that the patch is part of. If it's part of another CF, that - # is later than this one, tell the user to go there instead. - lastcf = PatchOnCommitFest.objects.filter(patch=patch).order_by('-commitfest__startdate')[0] - if poc != lastcf: - messages.add_message(request, messages.INFO, "The status of this patch cannot be changed in this commitfest. You must modify it in the one where it's open!") - return HttpResponseRedirect('..') - if request.method == 'POST': try: form = CommentForm(patch, poc, is_review, data=request.POST) @@ -535,17 +554,10 @@ def comment(request, cfid, patchid, what): @login_required @transaction.atomic -def status(request, cfid, patchid, status): - poc = get_object_or_404(PatchOnCommitFest.objects.select_related(), commitfest__id=cfid, patch__id=patchid) - - if poc.is_closed: - # We allow modification of patches in closed CFs *only* if it's the - # last CF that the patch is part of. If it's part of another CF, that - # is later than this one, tell the user to go there instead. - lastcf = PatchOnCommitFest.objects.filter(patch__id=patchid).order_by('-commitfest__startdate')[0] - if poc != lastcf: - messages.add_message(request, messages.INFO, "The status of this patch cannot be changed in this commitfest. You must modify it in the one where it's open!") - return HttpResponseRedirect('/%s/%s/' % (poc.commitfest.id, poc.patch.id)) +def status(request, patchid, status): + patch = get_object_or_404(Patch.objects.select_related(), pk=patchid) + cf = patch.current_commitfest() + poc = get_object_or_404(PatchOnCommitFest.objects.select_related(), commitfest__id=cf.id, patch__id=patchid) if status == 'review': newstatus = PatchOnCommitFest.STATUS_REVIEW @@ -565,22 +577,29 @@ def status(request, cfid, patchid, status): PatchHistory(patch=poc.patch, by=request.user, what='New status: %s' % poc.statusstring).save_and_notify() - return HttpResponseRedirect('/%s/%s/' % (poc.commitfest.id, poc.patch.id)) + return HttpResponseRedirect('/patch/%s/' % (poc.patch.id)) @login_required @transaction.atomic -def close(request, cfid, patchid, status): - poc = get_object_or_404(PatchOnCommitFest.objects.select_related(), commitfest__id=cfid, patch__id=patchid) - - if poc.is_closed: - # We allow modification of patches in closed CFs *only* if it's the - # last CF that the patch is part of. If it's part of another CF, that - # is later than this one, tell the user to go there instead. - lastcf = PatchOnCommitFest.objects.filter(patch__id=patchid).order_by('-commitfest__startdate')[0] - if poc != lastcf: - messages.add_message(request, messages.INFO, "The status of this patch cannot be changed in this commitfest. You must modify it in the one where it's open!") - return HttpResponseRedirect('/%s/%s/' % (poc.commitfest.id, poc.patch.id)) +def close(request, patchid, status): + patch = get_object_or_404(Patch.objects.select_related(), pk=patchid) + cf = patch.current_commitfest() + + try: + request_cfid = int(request.GET.get('cfid', '')) + except ValueError: + # int() failed, ignore + request_cfid = None + + if request_cfid is not None and request_cfid != cf.id: + # The cfid parameter is only added to the /next/ link. That's the only + # close operation where two people pressing the button at the same time + # can have unintended effects. + messages.error(request, "The patch was moved to a new commitfest by someone else. Please double check if you still want to retry this operation.") + return HttpResponseRedirect('/%s/%s/' % (cf.id, patch.id)) + + poc = get_object_or_404(PatchOnCommitFest.objects.select_related(), commitfest__id=cf.id, patch__id=patchid) poc.leavedate = datetime.now() @@ -668,8 +687,7 @@ def close(request, cfid, patchid, status): @login_required @transaction.atomic -def reviewer(request, cfid, patchid, status): - get_object_or_404(CommitFest, pk=cfid) +def reviewer(request, patchid, status): patch = get_object_or_404(Patch, pk=patchid) is_reviewer = request.user in patch.reviewers.all() @@ -688,7 +706,6 @@ def reviewer(request, cfid, patchid, status): @login_required @transaction.atomic def committer(request, cfid, patchid, status): - get_object_or_404(CommitFest, pk=cfid) patch = get_object_or_404(Patch, pk=patchid) committer = list(Committer.objects.filter(user=request.user, active=True)) @@ -713,8 +730,7 @@ def committer(request, cfid, patchid, status): @login_required @transaction.atomic -def subscribe(request, cfid, patchid, sub): - get_object_or_404(CommitFest, pk=cfid) +def subscribe(request, patchid, sub): patch = get_object_or_404(Patch, pk=patchid) if sub == 'un': @@ -727,6 +743,12 @@ def subscribe(request, cfid, patchid, sub): return HttpResponseRedirect("../") +def send_patch_email(request, patchid): + patch = get_object_or_404(Patch, pk=patchid) + cf = patch.current_commitfest() + return send_email(request, cf.id) + + @login_required @transaction.atomic def send_email(request, cfid): @@ -788,6 +810,137 @@ def _user_and_mail(u): }) +@transaction.atomic +def cfbot_ingest(message): + """Ingest a single message status update message receive from cfbot. It + should be a Python dictionary, decoded from JSON already.""" + + cursor = connection.cursor() + + branch_status = message["branch_status"] + patch_id = branch_status["submission_id"] + branch_id = branch_status["branch_id"] + + try: + patch = Patch.objects.get(pk=patch_id) + except Patch.DoesNotExist: + # If the patch doesn't exist, there's nothing to do. This should never + # happen in production, but on the test system it's possible because + # not it doesn't contain the newest patches that the CFBot knows about. + return + + # Every message should have a branch_status, which we will INSERT + # or UPDATE. We do this first, because cfbot_task refers to it. + # Due to the way messages are sent/queued by cfbot it's possible that it + # sends the messages out-of-order. To handle this we we only update in two + # cases: + # 1. The created time of the branch is newer than the one in our database: + # This is a newer branch + # 2. If it's the same branch that we already have, but the modified time is + # newer: This is a status update for the current branch that we received + # in-order. + cursor.execute("""INSERT INTO commitfest_cfbotbranch (patch_id, branch_id, + branch_name, commit_id, + apply_url, status, + created, modified) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (patch_id) DO UPDATE + SET status = EXCLUDED.status, + modified = EXCLUDED.modified, + branch_id = EXCLUDED.branch_id, + branch_name = EXCLUDED.branch_name, + commit_id = EXCLUDED.commit_id, + apply_url = EXCLUDED.apply_url, + created = EXCLUDED.created + WHERE commitfest_cfbotbranch.created < EXCLUDED.created + OR (commitfest_cfbotbranch.branch_id = EXCLUDED.branch_id + AND commitfest_cfbotbranch.modified < EXCLUDED.modified) + """, + ( + patch_id, + branch_id, + branch_status["branch_name"], + branch_status["commit_id"], + branch_status["apply_url"], + branch_status["status"], + branch_status["created"], + branch_status["modified"]) + ) + + # Now we check what we have in our database. If that contains a different + # branch_id than what we just tried to insert, then apparently this is a + # status update for an old branch and we don't care about any of the + # contents of this message. + branch_in_db = CfbotBranch.objects.get(pk=patch_id) + if branch_in_db.branch_id != branch_id: + return + + # Most messages have a task_status. It might be missing in rare cases, like + # when cfbot decides that a whole branch has timed out. We INSERT or + # UPDATE. + if "task_status" in message: + task_status = message["task_status"] + cursor.execute("""INSERT INTO commitfest_cfbottask (task_id, task_name, patch_id, branch_id, + position, status, + created, modified) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (task_id) DO UPDATE + SET status = EXCLUDED.status, + modified = EXCLUDED.modified + WHERE commitfest_cfbottask.modified < EXCLUDED.modified""", + ( + task_status["task_id"], + task_status["task_name"], + patch_id, + branch_id, + task_status["position"], + task_status["status"], + task_status["created"], + task_status["modified"]) + ) + + # Remove any old tasks that are not related to this branch. These should + # only be left over when we just updated the branch_id. Knowing if we just + # updated the branch_id was is not trivial though, because INSERT ON + # CONFLICT does not allow us to easily return the old value of the row. + # So instead we always delete all tasks that are not related to this + # branch. This is fine, because doing so is very cheap in the no-op case + # because we have an index on patch_id and there's only a handful of tasks + # per patch. + cursor.execute("DELETE FROM commitfest_cfbottask WHERE patch_id=%s AND branch_id != %s", (patch_id, branch_id)) + + # We change the needs_rebase field using a separate UPDATE because this way + # we can find out what the previous state of the field was (sadly INSERT ON + # CONFLICT does not allow us to return that). We need to know the previous + # state so we can skip sending notifications if the needs_rebase status did + # not change. + needs_rebase = branch_status['commit_id'] is None + if bool(branch_in_db.needs_rebase_since) is not needs_rebase: + if needs_rebase: + branch_in_db.needs_rebase_since = datetime.now() + else: + branch_in_db.needs_rebase_since = None + branch_in_db.save() + + if needs_rebase: + PatchHistory(patch=patch, by=None, by_cfbot=True, what='Patch needs rebase').save_and_notify(authors_only=True) + else: + PatchHistory(patch=patch, by=None, by_cfbot=True, what='Patch does not need rebase anymore').save_and_notify(authors_only=True) + + +@csrf_exempt +def cfbot_notify(request): + if request.method != 'POST': + return HttpResponseForbidden("Invalid method") + + j = json.loads(request.body) + if not hmac.compare_digest(j['shared_secret'], settings.CFBOT_SECRET): + return HttpResponseForbidden("Invalid API key") + + cfbot_ingest(j) + return HttpResponse(status=200) + + @csrf_exempt def thread_notify(request): if request.method != 'POST': diff --git a/pgcommitfest/local_settings_example.py b/pgcommitfest/local_settings_example.py index d3648cc..31da1f0 100644 --- a/pgcommitfest/local_settings_example.py +++ b/pgcommitfest/local_settings_example.py @@ -1,3 +1,5 @@ +import os + # Enable more debugging information DEBUG = True # Prevent logging to try to send emails to postgresql.org admins. @@ -22,3 +24,7 @@ # It's not great, because it won't redirect to the page you were trying to # access, but it's better than a HTTP 500 error. PGAUTH_REDIRECT = '/admin/login/' + +MOCK_ARCHIVES = True +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MOCK_ARCHIVE_DATA = os.path.join(BASE_DIR, 'commitfest', 'fixtures', 'archive_data.json') diff --git a/pgcommitfest/urls.py b/pgcommitfest/urls.py index 53fce1c..733fcc5 100644 --- a/pgcommitfest/urls.py +++ b/pgcommitfest/urls.py @@ -19,23 +19,31 @@ re_path(r'^(\d+)/$', views.commitfest), re_path(r'^(open|inprogress|current)/(.*)$', views.redir), re_path(r'^(?P\d+)/activity(?P\.rss)?/$', views.activity), - re_path(r'^patch/(\d+)/$', views.patch_redirect), - re_path(r'^(\d+)/(\d+)/$', views.patch), - re_path(r'^(\d+)/(\d+)/edit/$', views.patchform), + re_path(r'^(\d+)/(\d+)/$', views.patch_legacy_redirect), + re_path(r'^patch/(\d+)/$', views.patch), + re_path(r'^patch/(\d+)/edit/$', views.patchform), re_path(r'^(\d+)/new/$', views.newpatch), - re_path(r'^(\d+)/(\d+)/status/(review|author|committer)/$', views.status), - re_path(r'^(\d+)/(\d+)/close/(reject|withdrawn|feedback|committed|next)/$', views.close), - re_path(r'^(\d+)/(\d+)/reviewer/(become|remove)/$', views.reviewer), - re_path(r'^(\d+)/(\d+)/committer/(become|remove)/$', views.committer), - re_path(r'^(\d+)/(\d+)/(un)?subscribe/$', views.subscribe), - re_path(r'^(\d+)/(\d+)/(comment|review)/', views.comment), + re_path(r'^patch/(\d+)/status/(review|author|committer)/$', views.status), + re_path(r'^patch/(\d+)/close/(reject|withdrawn|feedback|committed|next)/$', views.close), + re_path(r'^patch/(\d+)/reviewer/(become|remove)/$', views.reviewer), + re_path(r'^patch/(\d+)/committer/(become|remove)/$', views.committer), + re_path(r'^patch/(\d+)/(un)?subscribe/$', views.subscribe), + re_path(r'^patch/(\d+)/(comment|review)/', views.comment), re_path(r'^(\d+)/send_email/$', views.send_email), - re_path(r'^(\d+)/\d+/send_email/$', views.send_email), + re_path(r'^patch/(\d+)/send_email/$', views.send_patch_email), re_path(r'^(\d+)/reports/authorstats/$', reports.authorstats), re_path(r'^search/$', views.global_search), re_path(r'^ajax/(\w+)/$', ajax.main), re_path(r'^lookups/user/$', lookups.userlookup), re_path(r'^thread_notify/$', views.thread_notify), + re_path(r'^cfbot_notify/$', views.cfbot_notify), + + # Legacy email POST route. This can be safely removed in a few days from + # the first time this is deployed. It's only puprose is not breaking + # submissions from a previous page lood, during the deploy of the new + # /patch/(\d+) routes. It would be a shame if someone lost their well + # written email because of this. + re_path(r'^\d+/(\d+)/send_email/$', views.send_patch_email), # Auth system integration re_path(r'^(?:account/)?login/?$', pgcommitfest.auth.login),