diff --git a/.editorconfig b/.editorconfig index b1eff40a..e3256545 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,6 +12,6 @@ trim_trailing_whitespace = true indent_style = space indent_size = 4 -[*.html] +[*.{html,inc}] indent_style = space indent_size = 1 diff --git a/Makefile b/Makefile index c0343821..284c7a1b 100644 --- a/Makefile +++ b/Makefile @@ -16,3 +16,10 @@ lint-fix-unsafe: npx @biomejs/biome check --fix --unsafe fix: format lint-fix-unsafe + +init-dev: + dropdb --if-exists pgcommitfest + createdb pgcommitfest + ./manage.py migrate + ./manage.py loaddata auth_data.json + ./manage.py loaddata commitfest_data.json diff --git a/media/commitfest/css/commitfest.css b/media/commitfest/css/commitfest.css index 72cae0e9..985491fb 100644 --- a/media/commitfest/css/commitfest.css +++ b/media/commitfest/css/commitfest.css @@ -91,3 +91,23 @@ div.form-group div.controls input.threadpick-input { .search-bar { display: inline-block; } + +.workflow table.table { + width: auto; +} + +#keyvalue-table { + display: none; +} + +#keyvalue-table.collapse.in { + display: table; +} + +#history-table { + display: none; +} + +#history-table.collapse.in { + display: table; +} diff --git a/pgcommitfest/commitfest/fixtures/auth_data.json b/pgcommitfest/commitfest/fixtures/auth_data.json index 88d8c708..9a6e2f03 100644 --- a/pgcommitfest/commitfest/fixtures/auth_data.json +++ b/pgcommitfest/commitfest/fixtures/auth_data.json @@ -88,5 +88,41 @@ "groups": [], "user_permissions": [] } +}, +{ + "model": "auth.user", + "pk": 6, + "fields": { + "password": "", + "last_login": null, + "is_superuser": false, + "username": "prolific-author", + "first_name": "Prolific", + "last_name": "Author", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2025-01-01T00:00:00", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 7, + "fields": { + "password": "", + "last_login": null, + "is_superuser": false, + "username": "prolific-reviewer", + "first_name": "Prolific", + "last_name": "Reviewer", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2025-01-01T00:00:00", + "groups": [], + "user_permissions": [] + } } ] diff --git a/pgcommitfest/commitfest/fixtures/commitfest_data.json b/pgcommitfest/commitfest/fixtures/commitfest_data.json index 6e5b32ff..9317624a 100644 --- a/pgcommitfest/commitfest/fixtures/commitfest_data.json +++ b/pgcommitfest/commitfest/fixtures/commitfest_data.json @@ -60,6 +60,16 @@ "enddate": "2025-05-31" } }, +{ + "model": "commitfest.commitfest", + "pk": 5, + "fields": { + "name": "Drafts v18", + "status": 5, + "startdate": "2024-09-01", + "enddate": "2025-08-31" + } +}, { "model": "commitfest.topic", "pk": 1, @@ -237,6 +247,25 @@ ] } }, +{ + "model": "commitfest.patch", + "pk": 8, + "fields": { + "name": "Test DGJ Multi-Author and Reviewer", + "topic": 3, + "wikilink": "", + "gitlink": "", + "targetversion": 1, + "committer": 4, + "created": "2025-02-01T00:00", + "modified": "2025-02-01T00:00", + "lastmail": null, + "authors": [6,3], + "reviewers": [7,1], + "subscribers": [], + "mailthread_set": [] + } +}, { "model": "commitfest.patchoncommitfest", "pk": 1, @@ -325,6 +354,17 @@ "status": 1 } }, +{ + "model": "commitfest.patchoncommitfest", + "pk": 9, + "fields": { + "patch": 8, + "commitfest": 5, + "enterdate": "2025-02-01T00:00:00", + "leavedate": null, + "status": 1 + } +}, { "model": "commitfest.patchhistory", "pk": 1, diff --git a/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py b/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py new file mode 100644 index 00000000..5291d190 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0011_add_status_related_constraints.py @@ -0,0 +1,42 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0010_add_failing_since_column"), + ] + operations = [ + migrations.RunSQL(""" +CREATE UNIQUE INDEX cf_enforce_maxoneopen_idx +ON commitfest_commitfest (status) +WHERE status not in (4); +"""), + migrations.RunSQL(""" +CREATE UNIQUE INDEX poc_enforce_maxoneoutcome_idx +ON commitfest_patchoncommitfest (patch_id) +WHERE status not in (5); +"""), + migrations.RunSQL(""" +ALTER TABLE commitfest_patchoncommitfest +ADD CONSTRAINT status_and_leavedate_correlation +CHECK ((status IN (4,5,6,7,8)) = (leavedate IS NOT NULL)); +"""), + migrations.RunSQL(""" +COMMENT ON COLUMN commitfest_patchoncommitfest.leavedate IS +$$A leave date is recorded in two situations, both of which +means this particular patch-cf combination became inactive +on the corresponding date. For status 5 the patch was moved +to some other cf. For 4,6,7, and 8, this was the final cf. +$$ +"""), + migrations.RunSQL(""" +COMMENT ON TABLE commitfest_patchoncommitfest IS +$$This is a re-entrant table: patches may become associated +with a given cf multiple times, resetting the entrydate and clearing +the leavedate each time. Non-final statuses never have a leavedate +while final statuses always do. The final status of 5 (moved) is +special in that all but one of the rows a patch has in this table +must have it as the status. +$$ +"""), + ] diff --git a/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py b/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py new file mode 100644 index 00000000..90759466 --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0012_add_parked_cf_status.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0011_add_status_related_constraints"), + ] + operations = [ + migrations.AlterField( + model_name="commitfest", + name="status", + field=models.IntegerField( + choices=[ + (1, "Future"), + (2, "Open"), + (3, "In Progress"), + (4, "Closed"), + (5, "Parked"), + ], + default=1, + ), + ) + ] diff --git a/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py b/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py new file mode 100644 index 00000000..75ed886f --- /dev/null +++ b/pgcommitfest/commitfest/migrations/0013_no_patches_in_future_cfs.py @@ -0,0 +1,57 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("commitfest", "0012_add_parked_cf_status"), + ] + operations = [ + migrations.RunSQL(""" +CREATE FUNCTION assert_poc_not_future_for_poc() +RETURNS TRIGGER AS $$ +DECLARE + cfstatus int; +BEGIN + SELECT status INTO cfstatus + FROM commitfest_commitfest + WHERE id = NEW.commitfest_id; + + IF cfstatus = 1 THEN + RAISE EXCEPTION 'Patches cannot exist on future commitfests'; + END IF; + + RETURN NEW; +END; +$$ +LANGUAGE plpgsql; + +CREATE FUNCTION assert_poc_not_future_for_cf() +RETURNS trigger AS $$ +BEGIN + -- Trigger checks that we only get called when status is 1 + PERFORM 1 + FROM commitfest_patchoncommitfest + WHERE commitfest_id = NEW.id + LIMIT 1; + + IF FOUND THEN + RAISE EXCEPTION 'Cannot change commitfest status to 1, patches exists.'; + END IF; + RETURN NEW; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER assert_poc_commitfest_is_not_future +BEFORE INSERT OR UPDATE ON commitfest_patchoncommitfest +FOR EACH ROW +EXECUTE FUNCTION assert_poc_not_future_for_poc(); + +CREATE TRIGGER assert_poc_commitfest_is_not_future +-- Newly inserted cfs can't have patches +BEFORE UPDATE ON commitfest_commitfest +FOR EACH ROW +WHEN (NEW.status = 1) +EXECUTE FUNCTION assert_poc_not_future_for_cf(); +"""), + ] diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index fcd9edb9..8548f61c 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import User from django.db import models +from django.db.models import Q from django.shortcuts import get_object_or_404 from datetime import datetime @@ -38,17 +39,20 @@ class CommitFest(models.Model): STATUS_OPEN = 2 STATUS_INPROGRESS = 3 STATUS_CLOSED = 4 + STATUS_PARKED = 5 _STATUS_CHOICES = ( (STATUS_FUTURE, "Future"), (STATUS_OPEN, "Open"), - (STATUS_INPROGRESS, "In Progress"), + (STATUS_INPROGRESS, "Ongoing"), (STATUS_CLOSED, "Closed"), + (STATUS_PARKED, "Drafts"), ) _STATUS_LABELS = ( (STATUS_FUTURE, "default"), (STATUS_OPEN, "info"), (STATUS_INPROGRESS, "success"), (STATUS_CLOSED, "danger"), + (STATUS_PARKED, "default"), ) name = models.CharField(max_length=100, blank=False, null=False, unique=True) status = models.IntegerField( @@ -63,6 +67,8 @@ def statusstring(self): @property def periodstring(self): + # Current Workflow intent is to have all Committfest be time-bounded + # but the information is just contextual so we still permit null if self.startdate and self.enddate: return "{0} - {1}".format(self.startdate, self.enddate) return "" @@ -71,10 +77,22 @@ def periodstring(self): def title(self): return "Commitfest %s" % self.name + @property + def isclosed(self): + return self.status == self.STATUS_CLOSED + @property def isopen(self): return self.status == self.STATUS_OPEN + @property + def isinprogress(self): + return self.status == self.STATUS_INPROGRESS + + @property + def isparked(self): + return self.status == self.STATUS_PARKED + def __str__(self): return self.name @@ -158,12 +176,18 @@ class Patch(models.Model, DiffableModel): "reviewers": "reviewers_string", } + # XXX probably should just encourage using PoC.commitfest since most places + # dealing with the Patch need the PoC anyway. def current_commitfest(self): - return self.commitfests.order_by("-startdate").first() + return self.current_patch_on_commitfest().commitfest def current_patch_on_commitfest(self): - cf = self.current_commitfest() - return get_object_or_404(PatchOnCommitFest, patch=self, commitfest=cf) + # The unique partial index poc_enforce_maxoneoutcome_idx stores the PoC + # No caching here (inside the instance) since the caller should just need + # the PoC once per request. + return get_object_or_404( + PatchOnCommitFest, Q(patch=self) & ~Q(status=PatchOnCommitFest.STATUS_NEXT) + ) # Some accessors @property @@ -229,13 +253,13 @@ class PatchOnCommitFest(models.Model): STATUS_RETURNED = 7 STATUS_WITHDRAWN = 8 _STATUS_CHOICES = ( - (STATUS_REVIEW, "Needs review"), + (STATUS_REVIEW, "Needs Review"), (STATUS_AUTHOR, "Waiting on Author"), (STATUS_COMMITTER, "Ready for Committer"), (STATUS_COMMITTED, "Committed"), - (STATUS_NEXT, "Moved to next CF"), + (STATUS_NEXT, "Moved"), (STATUS_REJECTED, "Rejected"), - (STATUS_RETURNED, "Returned with feedback"), + (STATUS_RETURNED, "Returned with Feedback"), (STATUS_WITHDRAWN, "Withdrawn"), ) _STATUS_LABELS = ( @@ -273,6 +297,14 @@ def is_closed(self): def is_open(self): return not self.is_closed + @property + def is_committed(self): + return self.status == self.STATUS_COMMITTED + + @property + def is_committer(self): + return self.status == self.STATUS_COMMITTER + @property def statusstring(self): return [v for k, v in self._STATUS_CHOICES if k == self.status][0] @@ -528,3 +560,253 @@ class CfbotTask(models.Model): status = models.TextField(choices=STATUS_CHOICES, null=False) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) + + +# Workflow provides access to the elements required to support +# the workflow this application is built for. These elements exist +# independent of what the user is presently seeing on their page. +class Workflow(models.Model): + def get_poc_for_patchid_or_404(patchid): + return get_object_or_404( + Patch.objects.select_related(), pk=patchid + ).current_patch_on_commitfest() + + # At most a single Open CommitFest is allowed and this function returns it. + def open_cf(): + cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_OPEN)) + return cfs[0] if len(cfs) == 1 else None + + # At most a single In Progress CommitFest is allowed and this function returns it. + def inprogress_cf(): + cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_INPROGRESS)) + return cfs[0] if len(cfs) == 1 else None + + # At most a single Parked CommitFest is allowed and this function returns it. + def parked_cf(): + cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_PARKED)) + return cfs[0] if len(cfs) == 1 else None + + # Returns whether the user is a committer in general and for this patch + # since we retrieve all committers in order to answer these questions + # provide that list as a third return value. Passing None for both user + # and patch still returns the list of committers. + def isCommitter(user, patch): + all_committers = Committer.objects.filter(active=True).order_by( + "user__last_name", "user__first_name" + ) + if not user and not patch: + return False, False, all_committers + + committer = [c for c in all_committers if c.user == user] + if len(committer) == 1: + is_committer = True + is_this_committer = committer[0] == patch.committer + else: + is_committer = is_this_committer = False + return is_committer, is_this_committer, all_committers + + def getCommitfest(cfid): + if cfid is None or cfid == "": + return None + try: + int_cfid = int(cfid) + cfs = list(CommitFest.objects.filter(id=int_cfid)) + if len(cfs) == 1: + return cfs[0] + else: + return None + except ValueError: + return None + + # Implements a re-entrant Commitfest POC creation procedure. + # Returns the new POC object. + # Creates history and notifies as a side-effect. + def createNewPOC(patch, commitfest, initial_status, by_user): + poc, created = PatchOnCommitFest.objects.update_or_create( + patch=patch, + commitfest=commitfest, + defaults=dict( + enterdate=datetime.now(), + status=initial_status, + leavedate=None, + ), + ) + poc.patch.set_modified() + poc.patch.save() + poc.save() + + PatchHistory( + patch=poc.patch, + by=by_user, + what="{} in {}".format(poc.statusstring, commitfest.name), + ).save_and_notify() + + return poc + + # The rule surrounding patches is they may only be in one active + # commitfest at a time. The transition function takes a patch + # open in one commitfest and associates it, with the same status, + # in a new commitfest; then makes it inactive in the original. + # Returns the new POC object. + # Creates history and notifies as a side-effect. + def transitionPatch(poc, target_cf, by_user): + Workflow.userCanTransitionPatch(poc, target_cf, by_user) + + existing_status = poc.status + + # History looks cleaner if we've left the existing + # commitfest entry before joining the new one. Plus, + # not allowed to change non-current commitfest status + # and once the new POC is created it becomes current. + + Workflow.updatePOCStatus(poc, PatchOnCommitFest.STATUS_NEXT, by_user) + + new_poc = Workflow.createNewPOC(poc.patch, target_cf, existing_status, by_user) + + return new_poc + + def userCanTransitionPatch(poc, target_cf, user): + # Policies not allowed to be broken by anyone. + + # Prevent changes to non-current commitfest for the patch + # Meaning, change status to Moved before/during transitioning + if poc.commitfest != poc.patch.current_commitfest(): + raise Exception("PoC commitfest is not patch's current commitfest.") + + # The UI should be preventing people from trying to perform no-op requests + if poc.commitfest.id == target_cf.id: + raise Exception("Cannot transition to the same commitfest.") + + # This one is arguable but facilitates treating non-open status as final + # A determined staff member can always change the status first. + if poc.is_closed: + raise Exception("Cannot transition a closed patch.") + + # We trust privileged users to make informed choices + if user.is_staff: + return + + if target_cf.isclosed: + raise Exception("Cannot transition to a closed commitfest.") + + if target_cf.isinprogress: + raise Exception("Cannot transition to an in-progress commitfest.") + + # Prevent users from moving closed patches, or moving open ones to + # non-open commitfests. The else clause should be a can't happen. + if poc.is_open and target_cf.isopen: + pass + else: + # Default deny policy basis + raise Exception("Transition not permitted.") + + def userCanChangePOCStatus(poc, new_status, user): + # Policies not allowed to be broken by anyone. + + # Prevent changes to non-current commitfest for the patch + # Meaning, change status to Moved before/during transitioning + if poc.commitfest != poc.patch.current_commitfest(): + raise Exception("PoC commitfest is not patch's current commitfest.") + + # The UI should be preventing people from trying to perform no-op requests + if poc.status == new_status: + raise Exception("Cannot change to the same status.") + + # We want commits to happen from, usually, In Progress commitfests, + # or Open ones for exempt patches. We accept Future ones too just because + # they do represent a proper, if non-current, Commitfest. + if ( + poc.commitfest.id == CommitFest.STATUS_PARKED + and new_status == PatchOnCommitFest.STATUS_COMMITTED + ): + raise Exception("Cannot change status to committed in a parked commitfest.") + + # We trust privileged users to make informed choices + if user.is_staff: + return + + is_committer = Workflow.isCommitter(user, poc.patch) + + # XXX Not sure if we want to tighten this up to is_this_committer + # with only the is_staff exemption + if new_status == PatchOnCommitFest.STATUS_COMMITTED and not is_committer: + raise Exception("Only a committer can set status to committed.") + + if new_status == PatchOnCommitFest.STATUS_REJECTED and not is_committer: + raise Exception("Only a committer can set status to rejected.") + + if new_status == PatchOnCommitFest.STATUS_RETURNED and not is_committer: + raise Exception("Only a committer can set status to returned.") + + if ( + new_status == PatchOnCommitFest.STATUS_WITHDRAWN + and user not in poc.patch.authors.all() + ): + raise Exception("Only the author can set status to withdrawn.") + + # Prevent users from modifying closed patches + # The else clause should be considered a can't happen + if poc.is_open: + pass + else: + raise Exception("Cannot change status of closed patch.") + + # Update the status of a PoC + # Returns True if the status was changed, False for a same-status no-op. + # Creates history and notifies as a side-effect. + def updatePOCStatus(poc, new_status, by_user): + # XXX Workflow disallows this no-op but not quite ready to enforce it. + if poc.status == new_status: + return False + + Workflow.userCanChangePOCStatus(poc, new_status, by_user) + + poc.status = new_status + poc.leavedate = datetime.now() if not poc.is_open else None + poc.patch.set_modified() + poc.patch.save() + poc.save() + PatchHistory( + patch=poc.patch, + by=by_user, + what="{} in {}".format( + poc.statusstring, + poc.commitfest.name, + ), + ).save_and_notify() + + return True + + # Sets the value of the committer for the patch + # Returns True if the committer was changed, False for a same-committer no-op. + # Creates history and notifies as a side-effect. + def setCommitter(poc, committer, by_user): + if poc.patch.committer == committer: + return False + + prevcommitter = poc.patch.committer + poc.patch.committer = committer + poc.patch.set_modified() + poc.patch.save() + poc.save() + + if committer is None: + msg = "Removed {} as committer in {}".format( + prevcommitter.fullname, poc.commitfest.name + ) + elif prevcommitter is None: + msg = "Set {} as committer in {}".format( + poc.patch.committer.fullname, poc.commitfest.name + ) + else: + msg = "Changed to {} as committer in {}".format( + poc.patch.committer.fullname, poc.commitfest.name + ) + + PatchHistory( + patch=poc.patch, + by=by_user, + what=msg, + ).save_and_notify(prevcommitter=prevcommitter) + + return True diff --git a/pgcommitfest/commitfest/templates/base.html b/pgcommitfest/commitfest/templates/base.html index c70a7f77..b606d666 100644 --- a/pgcommitfest/commitfest/templates/base.html +++ b/pgcommitfest/commitfest/templates/base.html @@ -30,6 +30,9 @@ <a href="/account/login/?next={{request.path}}">Log in</a> {%endif%} </li> + <li class="pull-right active"> + <a href="/workflow">Workflow</a> + </li> {%if header_activity%} <li class="pull-right active"><a href="{{header_activity_link}}">{{header_activity}}</a></li>{%endif%} </ul> diff --git a/pgcommitfest/commitfest/templates/patch.html b/pgcommitfest/commitfest/templates/patch.html index c2e73460..7c2d78d1 100644 --- a/pgcommitfest/commitfest/templates/patch.html +++ b/pgcommitfest/commitfest/templates/patch.html @@ -1,205 +1,83 @@ {%extends "base.html"%} {%load commitfest%} {%block contents%} - {%include "patch_commands.inc"%} - <table class="table table-bordered"> - <tbody> - <tr> - <th>ID</th> - <td><a href="/patch/{{patch.id}}">{{patch.id}}</a></td> - </tr> - <tr> - <th>Title</th> - <td>{{patch.name}}</td> - </tr> - <tr> - <th>CI (CFBot)</th> - <td> - {%if not cfbot_branch %} - <span class="label label-default">Not processed</span></a> - {%elif cfbot_branch.needs_rebase_since %} - <a href="{{cfbot_branch.apply_url}}"> - <span class="label label-warning" title="View git apply logs">Needs rebase!</span></a> - Needs rebase {% cfsince cfbot_branch.needs_rebase_since %}. {%if cfbot_branch.failing_since and cfbot_branch.failing_since != cfbot_branch.needs_rebase_since %}Failing {% cfsince cfbot_branch.failing_since %}. {%endif%}<br>Additional links previous successfully applied patch (outdated):<br> - <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View previous successfully applied patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a> - <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}"> - <span class="label label-default">Summary</span></a> - {%else%} - <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View last patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a> - <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}"> - <span class="label label-default">Summary</span></a> - {%for c in cfbot_tasks %} - {%if c.status == 'COMPLETED'%} - <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_success.svg"/></a> - {%elif c.status == 'CREATED' or c.status == 'SCHEDULED' %} - <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/waiting_to_start.svg"/></a> - {%elif c.status == 'EXECUTING' %} - <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/running.svg"/></a> - {%else %} - <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_failure.svg"/></a> - {%endif%} - {%endfor%} - {%endif%} - {%if cfbot_branch %} - <button class="btn btn-default" title="This adds the following to your clipboard (needs to be run in an existing git repo): - git remote add commitfest https://github.com/postgresql-cfbot/postgresql.git - git fetch commitfest cf/{{patch.id}} - git checkout commitfest/cf/{{patch.id}}" onclick="addGitCheckoutToClipboard({{patch.id}})">Copy git checkout commands</button> - {%endif%} - </a> - </td> - </tr> - <tr> - <th>Stats (from CFBot)</th> - <td> - {%if cfbot_branch and cfbot_branch.commit_id %} - {%if cfbot_branch.version %} - Patch version: {{ cfbot_branch.version }}, - {%endif%} - Patch count: {{ cfbot_branch.patch_count }}, - First patch: <span class="additions">+{{ cfbot_branch.first_additions }}</span><span class="deletions">−{{ cfbot_branch.first_deletions }}</span>, - All patches: <span class="additions">+{{ cfbot_branch.all_additions }}</span><span class="deletions">−{{ cfbot_branch.all_deletions }}</span> - {%else%} - Unknown - {%endif%} - </tr> - <tr> - <th>Topic</th> - <td>{{patch.topic}}</td> - </tr> - <tr> - <th>Created</th> - <td>{{patch.created}}</td> - </tr> - <tr> - <th style="white-space: nowrap;">Last modified</th> - <td>{{patch.modified}} ({% cfwhen patch.modified %})</td> - </tr> - <tr> - <th style="white-space: nowrap;">Latest email</th> - <td>{%if patch.lastmail%}{{patch.lastmail}} ({% cfwhen patch.lastmail %}){%endif%}</td> - </tr> - <tr> - <th>Status</th> - <td>{%for c in patch_commitfests %} - <div style="margin-bottom: 3px;"><a href="/{{c.commitfest.id}}/">{{c.commitfest}}</a>: <span class="label label-{{c.status|patchstatuslabel}}">{{c.statusstring}}</span></div> - {%endfor%} - </td> - </tr> - <tr> - <th>Target version</th> - <td>{%if patch.targetversion%}<span class="label label-default">{{patch.targetversion}}</span>{%endif%}</td> - </tr> - <tr> - <th>Authors</th> - <td>{{patch.authors_string}}</td> - </tr> - <tr> - <th>Reviewers</th> - <td>{{patch.reviewers_string}}<a href="reviewer/{{is_reviewer|yesno:"remove,become"}}/" class="btn btn-default pull-right">{{is_reviewer|yesno:"Remove from reviewers,Become reviewer"}}</a></td> - </tr> - <tr> - <th>Committer</th> - <td>{%if patch.committer%}{{patch.committer.fullname}}{%endif%} - {%if is_committer%}<a href="committer/{{is_this_committer|yesno:"remove,become"}}/" class="btn btn-default pull-right">{{is_this_committer|yesno:"Unclaim as committer,Claim as committer"}}</a>{%endif%} - </td> - </tr> - <tr> - <th>Links</th> - {%if patch.wikilink%} - <a href="{{patch.wikilink}}">Wiki</a>{%endif%}{%if patch.gitlink%} - <a href="{{patch.gitlink}}">Git</a> - {%endif%}</td> - </tr> - <tr> - <th>Emails</th> - <td> - {%if user.is_authenticated%} - <div style="float:right"><button class="btn btn-default" onclick="attachThread({{cf.id}},{{patch.id}})">Attach thread</button></div> - {%else%} - <div style="float:right"><button class="btn btn-default" onclick="location.href='/account/login/?next=/{{cf.id}}/{{patch.id}}/%3Fattachthreadnow'">Attach thread</button></div> - {%endif%} - <dl> - {%for t in patch.mailthread_set.all%} - <dt><a href="https://www.postgresql.org/message-id/flat/{{t.messageid}}">{{t.subject}}</a> <button type="button" class="close close-nofloat" title="Detach this thread" onclick="detachThread({{cf.id}},{{patch.id}},'{{t.messageid}}')">×</button></dt> - <dd> - First at <a href="https://www.postgresql.org/message-id/{{t.messageid}}">{{t.firstmessage}}</a> by {{t.firstauthor|hidemail}}<br/> - Latest at <a href="https://www.postgresql.org/message-id/{{t.latestmsgid}}">{{t.latestmessage}}</a> by {{t.latestauthor|hidemail}}<br/> - {%for ta in t.mailthreadattachment_set.all%} - {%if forloop.first%} - Latest attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} <button type="button" class="btn btn-default btn-xs" data-toggle="collapse" data-target="#att{{t.pk}}" title="Show all attachments"><i class="glyphicon glyphicon-plus"></i></button> - <div id="att{{t.pk}}" class="collapse"> - {%endif%} - Attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} (Patch: {{ta.ispatch|yesno:"Yes,No,Pending check"}})<br/> - {%if forloop.last%}</div>{%endif%} - {%endfor%} - <div> - {%for a in t.mailthreadannotation_set.all%} - {%if forloop.first%} - <h4>Annotations</h4> - <table class="table table-bordered table-striped table-condensed small"> - <thead> - <tr> - <th>When</th> - <th>Who</th> - <th>Mail</th> - <th>Annotation</th> - </tr> - </thead> - <tbody> - {%endif%} - <tr> - <td>{{a.date}}</td> - <td style="white-space: nowrap">{{a.user_string}}</td> - <td style="white-space: nowrap">From {{a.mailauthor}}<br/>at <a href="https://www.postgresql.org/message-id/{{a.msgid}}">{{a.maildate}}</a></td> - <td width="99%">{{a.annotationtext}} <button type="button" class="close" title="Delete this annotation" onclick="deleteAnnotation({{a.id}})">×</button></td> - </tr> - {%if forloop.last%} - </body> - </table> - {%endif%} - {%endfor%} - {%if user.is_authenticated%}<button class="btn btn-xs btn-default" onclick="addAnnotation({{t.id}})">Add annotation</button>{%endif%} - </div> - </dd> + {%if user.is_staff %} + {%include "patch_administrative.inc"%} + {%endif%} + + {%if not user.is_authenticated %} + <div> + Read-Only mode. <a href="/account/login/?next={{request.path}}">Log in</a> to interact with this patch. + </div> + {%endif%} + + {%include "patch_workflow.inc"%} + + <div class="workflow"> + <table class="table table-bordered"> + <tbody> + {%include "patch_tr_email.inc"%} + {%include "patch_tr_cfbot.inc"%} + <tr> + <th>Links</th> + <td> + {%if patch.wikilink%} + <a href="{{patch.wikilink}}">Wiki</a> + {%endif%} + {%if patch.gitlink%} + <a href="{{patch.gitlink}}">Git</a> + {%endif%} + {%if user.is_authenticated %} + <a class="btn btn-default pull-right" href="edit/">Edit</a> + {%endif%} + </td> + </tr> + <tr> + <th>Status</th> + <td>{%for c in patch_commitfests %} + <div style="margin-bottom: 3px;"> + <a href="/{{c.commitfest.id}}/">{{c.commitfest}}</a>: + <span class="label label-{{c.status|patchstatuslabel}}">{{c.statusstring}}</span> + <span>({{c.enterdate}} to {%if c.leavedate%}{{c.leavedate}}{%else%}present{%endif%})</span> + </div> {%endfor%} - </dl> - </td> - </tr> - <tr> - <th>History</th> - <td> - <div style="max-height: 200px; overflow-y: scroll;"> - <table class="table table-bordered table-striped table-condensed"> - <thead> - <tr> - <th>When</th> - <th>Who</th> - <th>What</th> - </tr> - </thead> - <tbody> - {%for h in patch.history %} - <tr> - <td style="white-space: nowrap;">{{h.date}}</td> - <td style="white-space: nowrap;">{{h.by_string}}</td> - <td width="99%">{{h.what}}</td> - </tr> - {%endfor%} - </tbody> - </table> - </div> - {%if user.is_authenticated%} - <a href="{{is_subscribed|yesno:"unsubscribe,subscribe"}}/" class="btn btn-default">{{is_subscribed|yesno:"Unsubscribe from patch update emails,Subscribe to patch update emails"}}</a> - {%endif%} - </td> - </tr> - </tbody> - </table> + </td> + </tr> + </tbody> + </table> + </div> + + <div> + <button class="btn btn-default" data-toggle="collapse" data-target="#history-table">Toggle History Table</button> + {%if user.is_authenticated %} + <button class="btn btn-default" data-toggle="collapse" data-target="#keyvalue-table">Toggle Key-Value Table</button> + {%endif%} + </div> - <div class="dropup" > - {%include "patch_commands.inc"%} + <div id="history-table" class="table table-bordered"> + <h3>History</h3> + <table class="table table-bordered table-striped table-condensed"> + <thead> + <tr> + <th>When</th> + <th>Who</th> + <th>What</th> + </tr> + </thead> + <tbody> + {%for h in patch.history %} + <tr> + <td style="white-space: nowrap;">{{h.date}}</td> + <td style="white-space: nowrap;">{{h.by_string}}</td> + <td width="99%">{{h.what}}</td> + </tr> + {%endfor%} + </tbody> + </table> </div> + {%if user.is_authenticated %} + {%include "patch_table_keyvalue.inc"%} + {%endif%} {%comment%}commit dialog{%endcomment%} <div class="modal fade" id="commitModal" role="dialog"> diff --git a/pgcommitfest/commitfest/templates/patch_commands.inc b/pgcommitfest/commitfest/templates/patch_administrative.inc similarity index 53% rename from pgcommitfest/commitfest/templates/patch_commands.inc rename to pgcommitfest/commitfest/templates/patch_administrative.inc index 8f09b18c..ca3ff0a0 100644 --- a/pgcommitfest/commitfest/templates/patch_commands.inc +++ b/pgcommitfest/commitfest/templates/patch_administrative.inc @@ -12,17 +12,39 @@ <div class="btn-group"> <a class="btn btn-default dropdown-toggle" data-toggle="dropdown" href="#">Change Status <span class="caret"></span></a> <ul class="dropdown-menu" role="menu"> - <li role="presentation" class="dropdown-header">Open statuses</li> - <li role="presentation"><a href="status/review/">Needs review</a></li> - <li role="presentation"><a href="status/author/">Waiting on Author</a></li> - <li role="presentation"><a href="status/committer/">Ready for Committer</a></li> + <li role="presentation" class="dropdown-header">Assign to:</li> + <li role="presentation"><a href="status/review/">Reviewer</a></li> + <li role="presentation"><a href="status/author/">Author</a></li> + <li role="presentation"><a href="status/committer/">Committer</a></li> <li role="presentation" class="divider"></li> - <li role="presentation" class="dropdown-header">Closed statuses</li> - <li role="presentation"><a href="close/reject/" onclick="return verify_reject()">Rejected</a></li> - <li role="presentation"><a href="close/withdrawn/" onclick="return verify_withdrawn()">Withdrawn</a></li> - <li role="presentation"><a href="close/feedback/" onclick="return verify_returned()">Returned with feedback</a></li> - <li role="presentation"><a href="close/next/?cfid={{cf.id}}" onclick="return verify_next()">Move to next CF</a></li> - <li role="presentation"><a href="close/committed/" onclick="return flagCommitted({%if patch.committer%}'{{patch.committer}}'{%elif is_committer%}'{{user.username}}'{%else%}null{%endif%})">Committed</a></li> + <li role="presentation" class="dropdown-header">Resolve:</li> + <li role="presentation"><a href="close/reject/" onclick="return verify_reject()">Reject</a></li> + <li role="presentation"><a href="close/withdrawn/" onclick="return verify_withdrawn()">Withdraw</a></li> + <li role="presentation"><a href="close/feedback/" onclick="return verify_returned()">Return</a></li> + <li role="presentation"><a href="close/committed/" onclick="return flagCommitted({%if patch.committer%}'{{patch.committer}}'{%elif is_committer%}'{{user.username}}'{%else%}null{%endif%})">Commit</a></li> + <li role="presentation" class="divider"></li> + <li role="presentation" class="dropdown-header">Move To:</li> + {%if not cf.isopen and workflow.open %} + <li role="presentation"> + <a + href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.open.id}}"> + {{workflow.open.name}}</a> + </li> + {%endif%} + {%if not cf.isinprogress and workflow.progress %} + <li role="presentation"> + <a + href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.progress.id}}"> + {{workflow.progress.name}}</a> + </li> + {%endif%} + {%if not cf.isparked and workflow.parked %} + <li role="presentation"> + <a + href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.parked.id}}"> + {{workflow.parked.name}}</a> + </li> + {%endif%} </ul> </div> diff --git a/pgcommitfest/commitfest/templates/patch_table_keyvalue.inc b/pgcommitfest/commitfest/templates/patch_table_keyvalue.inc new file mode 100644 index 00000000..27acdab7 --- /dev/null +++ b/pgcommitfest/commitfest/templates/patch_table_keyvalue.inc @@ -0,0 +1,195 @@ +{%load commitfest%} +<table id="keyvalue-table" class="table table-bordered"> + <tbody> + <tr> + <th>ID</th> + <td><a href="/patch/{{patch.id}}">{{patch.id}}</a></td> + </tr> + <tr> + <th>Title</th> + <td>{{patch.name}}</td> + </tr> + <tr> + <th>CI (CFBot)</th> + <td> + {%if not cfbot_branch %} + <span class="label label-default">Not processed</span></a> + {%elif cfbot_branch.needs_rebase_since %} + <a href="{{cfbot_branch.apply_url}}"> + <span class="label label-warning" title="View git apply logs">Needs rebase!</span></a> + Needs rebase {% cfsince cfbot_branch.needs_rebase_since %}. {%if cfbot_branch.failing_since and cfbot_branch.failing_since != cfbot_branch.needs_rebase_since %}Failing {% cfsince cfbot_branch.failing_since %}. {%endif%}<br>Additional links previous successfully applied patch (outdated):<br> + <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View previous successfully applied patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a> + <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}"> + <span class="label label-default">Summary</span></a> + {%else%} + <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View last patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a> + <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}"> + <span class="label label-default">Summary</span></a> + {%for c in cfbot_tasks %} + {%if c.status == 'COMPLETED'%} + <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_success.svg"/></a> + {%elif c.status == 'CREATED' or c.status == 'SCHEDULED' %} + <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/waiting_to_start.svg"/></a> + {%elif c.status == 'EXECUTING' %} + <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/running.svg"/></a> + {%else %} + <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_failure.svg"/></a> + {%endif%} + {%endfor%} + {%endif%} + {%if cfbot_branch %} + <button class="btn btn-default" title="This adds the following to your clipboard (needs to be run in an existing git repo): + git remote add commitfest https://github.com/postgresql-cfbot/postgresql.git + git fetch commitfest cf/{{patch.id}} + git checkout commitfest/cf/{{patch.id}}" onclick="addGitCheckoutToClipboard({{patch.id}})">Copy git checkout commands</button> + {%endif%} + </a> + </td> + </tr> + <tr> + <th>Stats (from CFBot)</th> + <td> + {%if cfbot_branch and cfbot_branch.commit_id %} + {%if cfbot_branch.version %} + Patch version: {{ cfbot_branch.version }}, + {%endif%} + Patch count: {{ cfbot_branch.patch_count }}, + First patch: <span class="additions">+{{ cfbot_branch.first_additions }}</span><span class="deletions">−{{ cfbot_branch.first_deletions }}</span>, + All patches: <span class="additions">+{{ cfbot_branch.all_additions }}</span><span class="deletions">−{{ cfbot_branch.all_deletions }}</span> + {%else%} + Unknown + {%endif%} + </tr> + <tr> + <th>Topic</th> + <td>{{patch.topic}}</td> + </tr> + <tr> + <th>Created</th> + <td>{{patch.created}}</td> + </tr> + <tr> + <th style="white-space: nowrap;">Last modified</th> + <td>{{patch.modified}} ({% cfwhen patch.modified %})</td> + </tr> + <tr> + <th style="white-space: nowrap;">Latest email</th> + <td>{%if patch.lastmail%}{{patch.lastmail}} ({% cfwhen patch.lastmail %}){%endif%}</td> + </tr> + <tr> + <th>Status</th> + <td>{%for c in patch_commitfests %} + <div style="margin-bottom: 3px;"><a href="/{{c.commitfest.id}}/">{{c.commitfest}}</a>: <span class="label label-{{c.status|patchstatuslabel}}">{{c.statusstring}}</span></div> + {%endfor%} + </td> + </tr> + <tr> + <th>Target version</th> + <td>{%if patch.targetversion%}<span class="label label-default">{{patch.targetversion}}</span>{%endif%}</td> + </tr> + <tr> + <th>Authors</th> + <td>{{patch.authors_string}}</td> + </tr> + <tr> + <th>Reviewers</th> + <td>{{patch.reviewers_string}}<a href="reviewer/{{is_reviewer|yesno:"remove,become"}}/" class="btn btn-default pull-right">{{is_reviewer|yesno:"Remove from reviewers,Become reviewer"}}</a></td> + </tr> + <tr> + <th>Committer</th> + <td>{%if patch.committer%}{{patch.committer.fullname}}{%endif%} + {%if is_committer%}<a href="committer/{{is_this_committer|yesno:"remove,become"}}/" class="btn btn-default pull-right">{{is_this_committer|yesno:"Unclaim as committer,Claim as committer"}}</a>{%endif%} + </td> + </tr> + <tr> + <th>Links</th> + {%if patch.wikilink%} + <a href="{{patch.wikilink}}">Wiki</a>{%endif%}{%if patch.gitlink%} + <a href="{{patch.gitlink}}">Git</a> + {%endif%}</td> + </tr> + <tr> + <th>Emails</th> + <td> + {%if user.is_authenticated%} + <div style="float:right"><button class="btn btn-default" onclick="attachThread({{cf.id}},{{patch.id}})">Attach thread</button></div> + {%else%} + <div style="float:right"><button class="btn btn-default" onclick="location.href='/account/login/?next=/{{cf.id}}/{{patch.id}}/%3Fattachthreadnow'">Attach thread</button></div> + {%endif%} + <dl> + {%for t in patch.mailthread_set.all%} + <dt><a href="https://www.postgresql.org/message-id/flat/{{t.messageid}}">{{t.subject}}</a> <button type="button" class="close close-nofloat" title="Detach this thread" onclick="detachThread({{cf.id}},{{patch.id}},'{{t.messageid}}')">×</button></dt> + <dd> + First at <a href="https://www.postgresql.org/message-id/{{t.messageid}}">{{t.firstmessage}}</a> by {{t.firstauthor|hidemail}}<br/> + Latest at <a href="https://www.postgresql.org/message-id/{{t.latestmsgid}}">{{t.latestmessage}}</a> by {{t.latestauthor|hidemail}}<br/> + {%for ta in t.mailthreadattachment_set.all%} + {%if forloop.first%} + Latest attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} <button type="button" class="btn btn-default btn-xs" data-toggle="collapse" data-target="#att{{t.pk}}" title="Show all attachments"><i class="glyphicon glyphicon-plus"></i></button> + <div id="att{{t.pk}}" class="collapse"> + {%endif%} + Attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} (Patch: {{ta.ispatch|yesno:"Yes,No,Pending check"}})<br/> + {%if forloop.last%}</div>{%endif%} + {%endfor%} + <div> + {%for a in t.mailthreadannotation_set.all%} + {%if forloop.first%} + <h4>Annotations</h4> + <table class="table table-bordered table-striped table-condensed small"> + <thead> + <tr> + <th>When</th> + <th>Who</th> + <th>Mail</th> + <th>Annotation</th> + </tr> + </thead> + <tbody> + {%endif%} + <tr> + <td>{{a.date}}</td> + <td style="white-space: nowrap">{{a.user_string}}</td> + <td style="white-space: nowrap">From {{a.mailauthor}}<br/>at <a href="https://www.postgresql.org/message-id/{{a.msgid}}">{{a.maildate}}</a></td> + <td width="99%">{{a.annotationtext}} <button type="button" class="close" title="Delete this annotation" onclick="deleteAnnotation({{a.id}})">×</button></td> + </tr> + {%if forloop.last%} + </body> + </table> + {%endif%} + {%endfor%} + {%if user.is_authenticated%}<button class="btn btn-xs btn-default" onclick="addAnnotation({{t.id}})">Add annotation</button>{%endif%} + </div> + </dd> + {%endfor%} + </dl> + </td> + </tr> + <tr> + <th>History</th> + <td> + <div style="max-height: 200px; overflow-y: scroll;"> + <table class="table table-bordered table-striped table-condensed"> + <thead> + <tr> + <th>When</th> + <th>Who</th> + <th>What</th> + </tr> + </thead> + <tbody> + {%for h in patch.history %} + <tr> + <td style="white-space: nowrap;">{{h.date}}</td> + <td style="white-space: nowrap;">{{h.by_string}}</td> + <td width="99%">{{h.what}}</td> + </tr> + {%endfor%} + </tbody> + </table> + </div> + {%if user.is_authenticated%} + <a href="{{is_subscribed|yesno:"unsubscribe,subscribe"}}/" class="btn btn-default">{{is_subscribed|yesno:"Unsubscribe from patch update emails,Subscribe to patch update emails"}}</a> + {%endif%} + </td> + </tr> +</tbody> +</table> diff --git a/pgcommitfest/commitfest/templates/patch_tr_cfbot.inc b/pgcommitfest/commitfest/templates/patch_tr_cfbot.inc new file mode 100644 index 00000000..af43a350 --- /dev/null +++ b/pgcommitfest/commitfest/templates/patch_tr_cfbot.inc @@ -0,0 +1,53 @@ +{%load commitfest%} +<tr> + <th>CFBot Status</th> + <td> + {%if not cfbot_branch %} + <span class="label label-default">Not processed</span></a> + {%elif cfbot_branch.needs_rebase_since %} + <a href="{{cfbot_branch.apply_url}}"> + <span class="label label-warning" title="View git apply logs">Needs rebase!</span></a> + Needs rebase {% cfsince cfbot_branch.needs_rebase_since %}. {%if cfbot_branch.failing_since and cfbot_branch.failing_since != cfbot_branch.needs_rebase_since %}Failing {% cfsince cfbot_branch.failing_since %}. {%endif%}<br>Additional links previous successfully applied patch (outdated):<br> + <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View previous successfully applied patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a> + <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}"> + <span class="label label-default">Summary</span></a> + {%else%} + <a href="https://github.com/postgresql-cfbot/postgresql/compare/cf/{{patch.id}}~1...cf/{{patch.id}}" title="View last patch set on GitHub"><img class="github-logo" src="/media/commitfest/github-mark.svg"/></a> + <a href="https://cirrus-ci.com/github/postgresql-cfbot/postgresql/cf%2F{{patch.id}}"> + <span class="label label-default">Summary</span></a> + {%for c in cfbot_tasks %} + {%if c.status == 'COMPLETED'%} + <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_success.svg"/></a> + {%elif c.status == 'CREATED' or c.status == 'SCHEDULED' %} + <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/waiting_to_start.svg"/></a> + {%elif c.status == 'EXECUTING' %} + <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/running.svg"/></a> + {%else %} + <a href="https://cirrus-ci.com/task/{{c.task_id}}" title="{{c.task_name}}: {{c.status}}"><img src="/media/commitfest/new_failure.svg"/></a> + {%endif%} + {%endfor%} + {%endif%} + {%if cfbot_branch %} + <button class="btn btn-default" title="This adds the following to your clipboard (needs to be run in an existing git repo): + git remote add commitfest https://github.com/postgresql-cfbot/postgresql.git + git fetch commitfest cf/{{patch.id}} + git checkout commitfest/cf/{{patch.id}}" onclick="addGitCheckoutToClipboard({{patch.id}})">Copy git checkout commands</button> + {%endif%} + </a> +</td> +</tr> +<tr> + <th>CFBot Calculations</th> + <td> + {%if cfbot_branch and cfbot_branch.commit_id %} + {%if cfbot_branch.version %} + Patch version: {{ cfbot_branch.version }}, + {%endif%} + Patch count: {{ cfbot_branch.patch_count }}, + First patch: <span class="additions">+{{ cfbot_branch.first_additions }}</span><span class="deletions">−{{ cfbot_branch.first_deletions }}</span>, + All patches: <span class="additions">+{{ cfbot_branch.all_additions }}</span><span class="deletions">−{{ cfbot_branch.all_deletions }}</span> + {%else%} + Unknown + {%endif%} + </td> +</tr> diff --git a/pgcommitfest/commitfest/templates/patch_tr_email.inc b/pgcommitfest/commitfest/templates/patch_tr_email.inc new file mode 100644 index 00000000..b310b35a --- /dev/null +++ b/pgcommitfest/commitfest/templates/patch_tr_email.inc @@ -0,0 +1,56 @@ +{%load commitfest%} +<tr> + <th>Emails</th> + <td> + <dl> + {%for t in patch.mailthread_set.all%} + <dt style="white-space: nowrap;"><a href="https://www.postgresql.org/message-id/flat/{{t.messageid}}">{{t.subject}}</a> <button type="button" class="close close-nofloat" title="Detach this thread" onclick="detachThread({{cf.id}},{{patch.id}},'{{t.messageid}}')">×</button></dt> + <dd> + First at <a href="https://www.postgresql.org/message-id/{{t.messageid}}">{{t.firstmessage}}</a> by {{t.firstauthor|hidemail}}<br/> + Latest at <a href="https://www.postgresql.org/message-id/{{t.latestmsgid}}">{{t.latestmessage}}</a> by {{t.latestauthor|hidemail}}<br/> + {%for ta in t.mailthreadattachment_set.all%} + {%if forloop.first%} + Latest attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} <button type="button" class="btn btn-default btn-xs" data-toggle="collapse" data-target="#att{{t.pk}}" title="Show all attachments"><i class="glyphicon glyphicon-plus"></i></button> + <div id="att{{t.pk}}" class="collapse"> + {%endif%} + Attachment (<a href="https://www.postgresql.org/message-id/attachment/{{ta.attachmentid}}/{{ta.filename}}">{{ta.filename}}</a>) at <a href="https://www.postgresql.org/message-id/{{ta.messageid}}">{{ta.date}}</a> from {{ta.author|hidemail}} (Patch: {{ta.ispatch|yesno:"Yes,No,Pending check"}})<br/> + {%if forloop.last%}</div>{%endif%} + {%endfor%} + <div> + {%for a in t.mailthreadannotation_set.all%} + {%if forloop.first%} + <h4>Annotations</h4> + <table class="table table-bordered table-striped table-condensed small"> + <thead> + <tr> + <th>When</th> + <th>Who</th> + <th>Mail</th> + <th>Annotation</th> + </tr> + </thead> + <tbody> + {%endif%} + <tr> + <td>{{a.date}}</td> + <td style="white-space: nowrap">{{a.user_string}}</td> + <td style="white-space: nowrap">From {{a.mailauthor}}<br/>at <a href="https://www.postgresql.org/message-id/{{a.msgid}}">{{a.maildate}}</a></td> + <td width="99%">{{a.annotationtext}} <button type="button" class="close" title="Delete this annotation" onclick="deleteAnnotation({{a.id}})">×</button></td> + </tr> + {%if forloop.last%} + </body> + </table> + {%endif%} + {%endfor%} + {%if user.is_authenticated%}<button class="btn btn-xs btn-default" onclick="addAnnotation({{t.id}})">Add annotation</button>{%endif%} + {%if user.is_authenticated%} + <div><button class="btn btn-default" onclick="attachThread({{cf.id}},{{patch.id}})">Attach thread</button></div> + {%else%} + <div><button class="btn btn-default" onclick="location.href='/account/login/?next=/{{cf.id}}/{{patch.id}}/%3Fattachthreadnow'">Attach New Thread</button></div> + {%endif%} + </div> + </dd> + {%endfor%} + </dl> + </td> +</tr> diff --git a/pgcommitfest/commitfest/templates/patch_workflow.inc b/pgcommitfest/commitfest/templates/patch_workflow.inc new file mode 100644 index 00000000..3fd91521 --- /dev/null +++ b/pgcommitfest/commitfest/templates/patch_workflow.inc @@ -0,0 +1,151 @@ +{%load commitfest%} +<div class="workflow"> + <table class="table table-bordered"> + {%if user.is_authenticated %} + <thead> + <tr> + <th>Assign To</th> + <th>Annotate</th> + <th>Resolve</th> + <th>Move To</th> + </tr> + </thead> + <tbody> + <tr> + <!-- Change --> + <td> + {%if poc.status == poc.STATUS_AUTHOR %} + <a class="btn btn-default" href="status/review/">Reviewer</a> + {%endif%} + {%if poc.status == poc.STATUS_REVIEW or poc.status == poc.STATUS_COMMITTER %} + <a class="btn btn-default" href="status/author/">Author</a> + {%endif%} + {%if poc.status == poc.STATUS_REVIEW %} + <a class="btn btn-default" href="status/committer/">Committer</a> + {%endif%} + </td> + <!-- Annotate --> + <td> + <a class="btn btn-default" href="comment/">Comment</a> + <a class="btn btn-default" href="review/">Review</a> + </td> + <!-- Resolve --> + <td> + {%if is_committer or is_author %} + {%if is_committer and poc.is_open %} + {%if not poc.isparked and poc.is_committer %} + <a class="btn btn-default" href="close/committed/" onclick="return flagCommitted({%if patch.committer%}'{{patch.committer}}'{%elif is_committer%}'{{user.username}}'{%else%}null{%endif%})">Commit</a> + {%endif%} + <a class="btn btn-default" href="close/reject/" onclick="return verify_reject()">Reject</a> + {%endif%} + {%if is_author %} + <a class="btn btn-default" href="close/withdrawn/" onclick="return verify_withdrawn()">Withdraw</a> + {%endif%} + {%if is_committer and not poc.is_open %} + <a class="btn btn-default" href="status/author/"> + {{poc.is_committed|yesno:"Revert,Re-Open"}} + </a> + {%endif%} + {%else%} + <span>No Actions Available</span> + {%endif%} + </td> + <!-- Move --> + <td> + {%if poc.is_open %} + {%if not cf.isopen and workflow.open %} + {%if is_author or is_committer %} + <a class="btn btn-default" + href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.open.id}}"> + {{workflow.open.name}}</a> + {%endif%} + {%endif%} + + {%if not cf.isinprogress and workflow.progress %} + {%if is_committer %} + <a class="btn btn-default" + href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.progress.id}}"> + {{workflow.progress.name}}</a> + {%endif%} + {%endif%} + + {%if not cf.isparked and workflow.parked %} + {%if is_author %} + <a class="btn btn-default" + href="transition/?fromcfid={{poc.commitfest.id}}&tocfid={{workflow.parked.id}}"> + {{workflow.parked.name}}</a> + {%endif%} + {%endif%} + {%else%} + <span>Cannot move closed patches.</span> + {%endif%} + </td> + </tr> + </tbody> + {%endif%} + <thead> + <tr> + <th>Author(s)</th> + <th>Reviewer(s)</th> + <th>Committer</th> + <th>Status</th> + </tr> + </thead> + <tbody> + <tr> + <!-- Change --> + <td> + {%for author in authors %} + <div>{{author.first_name}} {{author.last_name}}</div> + {%endfor%} + {%if is_author or is_committer %} + <a class="btn btn-default " href="edit/">Edit Authors</a> + {%endif%} + </td> + <!-- Annotate --> + <td> + {%for reviewer in reviewers %} + <div>{{reviewer.first_name}} {{reviewer.last_name}}</div> + {%endfor%} + {%if user.is_authenticated %} + <div><a href="reviewer/{{is_reviewer|yesno:"remove,become"}}/" class="btn btn-default">{{is_reviewer|yesno:"Remove Self,Become Reviewer"}}</a></div> + {%endif%} + </td> + <!-- Resolve --> + <td> + {%if patch.committer%}<div>{{patch.committer.fullname}}</div>{%endif%} + {%if is_committer%}<div><a href="committer/{{is_this_committer|yesno:"remove,become"}}/" class="btn btn-default">{{is_this_committer|yesno:"Unclaim,Claim for Self"}}</a></div>{%endif%} + </td> + <!-- Move --> + <td> + <div> + <span class="label label-{{poc.commitfest.status|patchstatuslabel}}">{{poc.commitfest.statusstring}}</span> + <span class="label label-{{poc.status|patchstatuslabel}}">{{poc.statusstring}}</span> + <span> + Version: + </span> + <span class="label label-default">{%if patch.targetversion%}{{patch.targetversion}}{%else%}N/A{%endif%}</span> + {%if user.is_authenticated %} + <a href="edit/" class="glyphicon glyphicon-edit"></a> + {%endif%} + {%if user.is_authenticated %} + <span class="pull-right">Updates: <a href="{{is_subscribed|yesno:"unsubscribe,subscribe"}}/" class="btn btn-default">{{is_subscribed|yesno:"Unsubscribe,Subscribe"}}</a></span> + {%endif%} + </div> + <div> + <span> + Topic: + </span> + <span>{{ patch.topic }}</span> + {%if user.is_authenticated %} + <a href="edit/" class="glyphicon glyphicon-edit"></a> + {%endif%} + </div> + <div> + <span>Last Modified:</span> <span>{{patch.modified}} ({% cfwhen patch.modified %})</span> + </div> + </td> + </tr> + </tbody> + </table> +</div> diff --git a/pgcommitfest/commitfest/templates/workflow.html b/pgcommitfest/commitfest/templates/workflow.html new file mode 100644 index 00000000..eff223f3 --- /dev/null +++ b/pgcommitfest/commitfest/templates/workflow.html @@ -0,0 +1,244 @@ +{%extends "base.html"%} +{%block contents%} + <h2>Key Concepts</h2> + <h3>The Commitfest Workflow</h3> + <p> + The commitfest workflow is a model for patch writing management. The workflow + is designed to manage new feature patches. Bug fixes are typically handled + without the overhead of creating patches in the Commitfest application. + Thus the worflow broadly separates patches into "drafts" and + "potentially committable". The former go onto the "Drafts v19" commitfest + while the later are initially placed into the open commitfest, which is + eventually converted into the ongoing commitfest. + Second, they can be awaiting changes, + awaiting guidance, awaiting review, or waiting to be committed (status). + The workflow uses commitfests and patch status in combination to communicate + the overall state of the patch within these two categories. + </p> + <p> + Commitfests are just bins filled with patches. Bins have a defined lifespan + after which they are "Closed" and a new bin for the same category is created. + Each bin serves a role in the workflow and there are 3 active categories of bins. + <ul> + <li>Drafts - annual, drafts for new features</li> + <li>Open - scheduled, proposed new features</li> + <li>Ongoing - scheduled, review and commit new features</li> + </ul> + There are three active categories of patch status covering the four statuses. + <ul> + <li>Author - the author needs to make changes</li> + <li>Reviewer - the patch needs guidance or a review</li> + <li>Committer - the patch is ready to be committed</li> + </ul> + And there are three preferred categories of patch status for when a patch has + been resolved, either by being committed or not. + <ul> + <li>Committed - a committer has applied the patches to the git repo</li> + <li>Withdrawn - the author has withdrawn the patch from consideration</li> + <li>Rejected - a committer has decided that the patch should not be applied</li> + </ul> + (See notes below for all available statuses and their intended usage.) + </p> + <p> + The drafts bin is where patches waiting on significant work go. A reviewer + patch status category here mainly means awaiting guidance, though that will + often lead to a final review, allowing the patch to be moved to the open bin + with the committer patch status category already set. + </p> + <p> + The open and ongoing bins are the ones that strictly comprise a commitfest. + The open bin always exists and new features ready for review and commit go + here. At scheduled points, the open bin becomes an ongoing bin and a new + open bin is created (possibly by reclassifying an existing future bin). + </p> + <p> + The ongoing bin only exists during an active commitfest period, during which + no new patches should be added and the community reviewers and committers are + encouraged to focus on reviews and commits. At the end of the scheduled period + the bin is closed. This is only bin that is not present at all times. + </p> + <p> + The workflow is designed with the release cycle in mind, in particular + the start of the annual feature freeze period. At this time the drafts + bin is closed and a new one is created. If the feature freeze is for + version 18 then the new drafts bin is called "Drafts v19". + </p> + <h3>Patches on Commitfests (PoC)</h3> + <h4>Overview</h4> + <p> + PoCs are the central concept of the commitfest workflow. When you are viewing + a Patch you are always implicitly acting within its primary PoC. The key + property of a PoC is its state, the possible values of which are: active, + inactive, moved, and abandoned. Each is described in detail below. + </p> + <h4>Active</h4> + <p> + A Patch is active if, in its primary PoC, it is in one of the following states: + <ul> + <li>Waiting on Author (Author)</li> + <li>Review Needed (Reviewer)</li> + <li>Ready for Committer (Committer)</li> + </ul> + These correspond to the three people-related fields of the patch and indicate + whose effort is presently needed. Of course, a patch may be in a state for + which no person is assigned; in which case the patch is advertising itself as + needing that kind of attention. + </p> + <h4>Inactive</h4> + <p> + A Patch is inactive if, in its primary PoC, it is in one of the following states: + <ul> + <li>Committed</li> + <li>Withdrawn</li> + <li>Rejected</li> + <li>Returned With Feedback*</li> + </ul> + </p> + <p> + A Committed patch is one whose files have been committed to the repository. + A Withdrawn patch is one where the author(s) have decided to no longer pursue + working on the patch and have proactively communicated that intent through + updating the PoC to this state. + </p> + <p> + A Withdrawn patch is the desired outcome if a patch is not going to be committed. + Only an author can withdraw a patch. If the patch simply needs work it should + updated to author and placed into whichever commitfest bin is appropriate. + </p> + <p> + A Rejected patch has the same effect as Withdrawn but communicates that the + community, not the author, made the status change. This should only be used + when it is when the author is unable or unwilling to withdraw the patch or park + it for rework. + </p> + <p> + *Returned With Feedback complements rejected in that the implication of rejected + is that the feature the patch introduces is unwanted while, here, the implementation + is simply not acceptable. The workflow takes a different approach and considers + both to be less desirable than withdraw. Considering the distinction between + author and committer making the decision to be the key difference the workflow + leaves reject as the fallback non-commit option and makes returned a deprecated + administrator-only option. + </p> + <h3>Special PoC Situations</h3> + <h4>Moved</h4> + <p> + Moved PoCs are a side-effect of having commitfests, and volume. Many active + patches cannot be gotten to within a commitfest. In order to keep them visible + they must be moved to another commitfest, creating a new PoC with the same + state. The PoC they were moved from is updated to "Moved" to indicate this + flow-of-time action. + </p> + <h4>Is Abandoned</h4> + <p> + This check returns true if the PoC state is in the set of active states + but the commitfest is closed. Conceptually, this is the same as + withdrawn, but through inaction. + </p> + <h3>Reverted Patches</h3> + <p> + Not every patch that is committed stays that way. The Workflow doesn't have + any statuses for this, though the user interface does provide an action that + a committer can invoke. Upon doing so the PoC status is updated to + author. The history flow of commited followed by author indictes a probable + revert. + </p> + <h3>Patches</h3> + <h4>Overview</h4> + <p> + Patches are the basic unit of work in the workflow. They are moved around + between various commitfest bins during their lifetime to reflect how they + interact with the focus of the community during each time period. + This movement, combined with the fact that a patch may only have a single + status, means that only one of the patch's PoCs can have a state that isn't + "Moved". While a patch's status is derived from its primary PoC, everything + else is attached to the patch itself. + </p> + <h4>Authors, Reviewers, and Committers</h4> + <p> + Especially in open source projects, attribution for work is important. Git + commit messages include author and reviewer attributions (among others) and + inherently identify the committer. To aid the committer in properly recording + attributions in the commit message record authors and reviewers on the patch. + </p> + <p> + Additionally, the commitfest application uses this information to provide + user-specific reporting and notifications. + </p> + <h4>Threads</h4> + <p> + The actual content of a patch does not existing within the commitfest application + (more-or-less); instead, the patch file submission and review process is all done + on the -hackers mailing list. Threads are the means by which the commitfest + is made aware of these files and can incorporate information pertaining to them + into its reporting. + </p> + <h3>Commitfests</h3> + <h4>Overview</h4> + <p> + Commitfests are just a collection of patches. The workflow described above + explains the purpose of these collections and defines which patches belong in + in which collection. One key constraint the described workflow imposes is that + among the statuses listed below at most one commitfest can be in each of them + at any given time (except for "Closed"). This allows for implementing movement + of a patch to be keyed to commitfest status type without the need for further + disambiguation. + </p> + <h4>Ongoing</h4> + <p> + An Active (see Workflow above) period where no new features should be added + and the goal is to get as many review"patches committed as possible. + </p> + <h4>Open</h4> + <p> + Patches ready for final review and commit, according to the author, are placed + in the current open commitfest. Upon the scheduled start date it is manually + updated to be an ongoing commitfest, at which point no new patches should be + added. + </p> + <h4>Future</h4> + <p> + The PostgreSQL project works on a schedule release cycle. At the beginning + of each cycle the planned commitfest periods are decided and communicated to + the community via the creation of future commitfests. While classified as + future these commitfests are not permitted to associated with any patches. + Their classification is changed to open as each prior ongoing commitfest + closes. + </p> + <h4>Drafts</h4> + <p> + The commitfest setup as drafts is used to hold patches that are not intended + to be formally reviewed and committed. Another term is "work-in-progress" (WIP). + Within the Workflow, at the start of the v18 feature freeze, the existing + "Draft v18" commitfest is closed and a new "Draft v19" commitfest is created. + This allows for a fresh start coinciding with the project release cycle. + And while commits cannot accumulate within a drafts commitfest, withdrawn and + rejected patches would and so having a truly never-closing commitfest is not + ideal. Similarly, given the volume of patches, getting rid of abandonment + is counter-productive. This workflow provides a middle-ground between + every-other-month and never patch moving requirements. + </p> + <h4>Closed</h4> + <p> + Drafts and ongoing commitfests are closed (open ones + always go through ongoing) when the period they cover has passed. + Closing a commitfest does not impact its related PoCs; though no new PoCs can + be created for a closed commitfest. + </p> + <h3>History</h3> + <p> + Textual event log for a patch. + </p> + <h3>CFBot</h3> + <p> + The CFBot is continuous integration (CI) infrastructure that uses threads + to retrieve patch files, builds and tests them, and posts the results to + the Patch. Only active PoCs are tested. + </p> + <h3>Administrative Actions</h3> + <p> + Protections put in place to guide the described workflow can be bypassed + by those with the appropriate permissions. Exercising these elevated + permissions is called an administrative action, or administratively performed. + </p> +{%endblock%} diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py index 40616ff8..3799a496 100644 --- a/pgcommitfest/commitfest/views.py +++ b/pgcommitfest/commitfest/views.py @@ -42,6 +42,7 @@ Patch, PatchHistory, PatchOnCommitFest, + Workflow, ) @@ -66,6 +67,16 @@ def home(request): ) +def workflow(request): + return render( + request, + "workflow.html", + { + "title": "Workflow Overview", + }, + ) + + @login_required def me(request): cfs = list(CommitFest.objects.filter(status=CommitFest.STATUS_INPROGRESS)) @@ -658,33 +669,28 @@ def patch(request, patchid): patch_commitfests = ( PatchOnCommitFest.objects.select_related("commitfest") .filter(patch=patch) - .order_by("-commitfest__startdate") + .order_by("-enterdate") .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: - committer = [c for c in committers if c.user == request.user] - if len(committer) > 0: - is_committer = True - is_this_committer = committer[0] == patch.committer - else: - is_committer = is_this_committer = False - + is_committer, is_this_committer, all_committers = Workflow.isCommitter( + request.user, patch + ) + is_author = request.user in patch.authors.all() is_reviewer = request.user in patch.reviewers.all() is_subscribed = patch.subscribers.filter(id=request.user.id).exists() else: - is_committer = False - is_this_committer = False + is_committer, is_this_committer, all_committers = Workflow.isCommitter( + None, None + ) + is_author = False is_reviewer = False is_subscribed = False @@ -692,6 +698,7 @@ def patch(request, patchid): request, "patch.html", { + "poc": patch_commitfests[0], "cf": cf, "patch": patch, "patch_commitfests": patch_commitfests, @@ -699,15 +706,23 @@ def patch(request, patchid): "cfbot_tasks": cfbot_tasks, "is_committer": is_committer, "is_this_committer": is_this_committer, + "is_author": is_author, "is_reviewer": is_reviewer, "is_subscribed": is_subscribed, - "committers": committers, + "authors": patch.authors.all(), + "reviewers": patch.reviewers.all(), + "committers": all_committers, "attachnow": "attachthreadnow" in request.GET, "title": patch.name, "breadcrumbs": [ {"title": cf.title, "href": "/%s/" % cf.pk}, ], "userprofile": getattr(request.user, "userprofile", UserProfile()), + "workflow": { + "open": Workflow.open_cf(), + "progress": Workflow.inprogress_cf(), + "parked": Workflow.parked_cf(), + }, }, ) @@ -964,14 +979,7 @@ def comment(request, patchid, what): @login_required @transaction.atomic 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, - ) - + # Active status only since those can be freely swapped between. if status == "review": newstatus = PatchOnCommitFest.STATUS_REVIEW elif status == "author": @@ -981,16 +989,13 @@ def status(request, patchid, status): else: raise Exception("Can't happen") - if newstatus != poc.status: - # Only save it if something actually changed - poc.status = newstatus - poc.patch.set_modified() - poc.patch.save() - poc.save() + poc = Workflow.get_poc_for_patchid_or_404(patchid) - PatchHistory( - patch=poc.patch, by=request.user, what="New status: %s" % poc.statusstring - ).save_and_notify() + try: + if Workflow.updatePOCStatus(poc, newstatus, request.user): + messages.info(request, "Updated status to %s" % poc.statusstring) + except Exception as e: + messages.error(request, "Failed to update status of patch: {}".format(e)) return HttpResponseRedirect("/patch/%s/" % (poc.patch.id)) @@ -998,149 +1003,76 @@ def status(request, patchid, status): @login_required @transaction.atomic def close(request, patchid, status): - patch = get_object_or_404(Patch.objects.select_related(), pk=patchid) - cf = patch.current_commitfest() + poc = Workflow.get_poc_for_patchid_or_404(patchid) + committer = None + + # Inactive statuses only, except moved (next), which is handled by /transition + if status == "reject": + newstatus = PatchOnCommitFest.STATUS_REJECTED + elif status == "withdrawn": + newstatus = PatchOnCommitFest.STATUS_WITHDRAWN + elif status == "feedback": + newstatus = PatchOnCommitFest.STATUS_RETURNED + elif status == "committed": + committer = get_object_or_404(Committer, user__username=request.GET["c"]) + newstatus = PatchOnCommitFest.STATUS_COMMITTED + else: + raise Exception("Can't happen") try: - request_cfid = int(request.GET.get("cfid", "")) - except ValueError: - # int() failed, ignore - request_cfid = None + if committer: + Workflow.setCommitter(poc, committer, request.user) + messages.info(request, "Committed by %s" % committer.user) + if Workflow.updatePOCStatus(poc, newstatus, request.user): + messages.info(request, "Closed. Updated status to %s" % poc.statusstring) + except Exception as e: + messages.error(request, "Failed to close patch: {}".format(e)) + + return HttpResponseRedirect("/patch/%s/" % poc.patch.id) + + +@login_required +@transaction.atomic +def transition(request, patchid): + from_cf = Workflow.getCommitfest(request.GET.get("fromcfid", 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. + if from_cf is None: 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.", + "Unknown from commitfest id {}".format(request.GET.get("fromcfid", None)), ) - return HttpResponseRedirect("/%s/%s/" % (cf.id, patch.id)) + return HttpResponseRedirect("/patch/%s/" % (patchid)) - poc = get_object_or_404( - PatchOnCommitFest.objects.select_related(), - commitfest__id=cf.id, - patch__id=patchid, - ) + cur_poc = Workflow.get_poc_for_patchid_or_404(patchid) - poc.leavedate = datetime.now() - - # We know the status can't be one of the ones below, since we - # have checked that we're not closed yet. Therefor, we don't - # need to check if the individual status has changed. - if status == "reject": - poc.status = PatchOnCommitFest.STATUS_REJECTED - elif status == "withdrawn": - poc.status = PatchOnCommitFest.STATUS_WITHDRAWN - elif status == "feedback": - poc.status = PatchOnCommitFest.STATUS_RETURNED - elif status == "next": - # Only some patch statuses can actually be moved. - if poc.status in ( - PatchOnCommitFest.STATUS_COMMITTED, - PatchOnCommitFest.STATUS_NEXT, - PatchOnCommitFest.STATUS_RETURNED, - PatchOnCommitFest.STATUS_REJECTED, - ): - # Can't be moved! - messages.error( - request, - "A patch in status {0} cannot be moved to next commitfest.".format( - poc.statusstring - ), - ) - return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id)) - elif poc.status in ( - PatchOnCommitFest.STATUS_REVIEW, - PatchOnCommitFest.STATUS_AUTHOR, - PatchOnCommitFest.STATUS_COMMITTER, - ): - # This one can be moved - pass - else: - messages.error(request, "Invalid existing patch status") - - oldstatus = poc.status - - poc.status = PatchOnCommitFest.STATUS_NEXT - # Figure out the commitfest to actually put it on - newcf = CommitFest.objects.filter(status=CommitFest.STATUS_OPEN) - if len(newcf) == 0: - # Ok, there is no open CF at all. Let's see if there is a - # future one. - newcf = CommitFest.objects.filter(status=CommitFest.STATUS_FUTURE) - if len(newcf) == 0: - messages.error(request, "No open and no future commitfest exists!") - return HttpResponseRedirect( - "/%s/%s/" % (poc.commitfest.id, poc.patch.id) - ) - elif len(newcf) != 1: - messages.error( - request, "No open and multiple future commitfests exist!" - ) - return HttpResponseRedirect( - "/%s/%s/" % (poc.commitfest.id, poc.patch.id) - ) - elif len(newcf) != 1: - messages.error(request, "Multiple open commitfests exists!") - return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id)) - elif newcf[0] == poc.commitfest: - # The current open CF is the same one that we are already on. - # In this case, try to see if there is a future CF we can - # move it to. - newcf = CommitFest.objects.filter(status=CommitFest.STATUS_FUTURE) - if len(newcf) == 0: - messages.error( - request, - "Cannot move patch to the same commitfest, and no future commitfests exist!", - ) - return HttpResponseRedirect( - "/%s/%s/" % (poc.commitfest.id, poc.patch.id) - ) - elif len(newcf) != 1: - messages.error( - request, - "Cannot move patch to the same commitfest, and multiple future commitfests exist!", - ) - return HttpResponseRedirect( - "/%s/%s/" % (poc.commitfest.id, poc.patch.id) - ) - # Create a mapping to the new commitfest that we are bouncing - # this patch to. - newpoc = PatchOnCommitFest( - patch=poc.patch, - commitfest=newcf[0], - status=oldstatus, - enterdate=datetime.now(), + if from_cf != cur_poc.commitfest: + messages.error( + request, + "Transition aborted, Redirect performed. Commitfest for patch already changed.", ) - newpoc.save() - elif status == "committed": - committer = get_object_or_404(Committer, user__username=request.GET["c"]) - if committer != poc.patch.committer: - # Committer changed! - prevcommitter = poc.patch.committer - poc.patch.committer = committer - PatchHistory( - patch=poc.patch, - by=request.user, - what="Changed committer to %s" % committer, - ).save_and_notify(prevcommitter=prevcommitter) - poc.status = PatchOnCommitFest.STATUS_COMMITTED - else: - raise Exception("Can't happen") + return HttpResponseRedirect("/patch/%s/" % (cur_poc.patch.id)) + + target_cf = Workflow.getCommitfest(request.GET.get("tocfid", None)) - poc.patch.set_modified() - poc.patch.save() - poc.save() + if target_cf is None: + messages.error( + request, + "Unknown destination commitfest id {}".format( + request.GET.get("tocfid", None) + ), + ) + return HttpResponseRedirect("/patch/%s/" % (cur_poc.patch.id)) - PatchHistory( - patch=poc.patch, - by=request.user, - what="Closed in commitfest %s with status: %s" - % (poc.commitfest, poc.statusstring), - ).save_and_notify() + try: + new_poc = Workflow.transitionPatch(cur_poc, target_cf, request.user) + messages.info( + request, "Transitioned patch to commitfest %s" % new_poc.commitfest.name + ) + except Exception as e: + messages.error(request, "Failed to transition patch: {}".format(e)) + return HttpResponseRedirect("/patch/%s/" % (cur_poc.patch.id)) - return HttpResponseRedirect("/%s/%s/" % (poc.commitfest.id, poc.patch.id)) + return HttpResponseRedirect("/patch/%s/" % (new_poc.patch.id)) @login_required @@ -1172,33 +1104,28 @@ def reviewer(request, patchid, status): @login_required @transaction.atomic def committer(request, patchid, status): - patch = get_object_or_404(Patch, pk=patchid) + poc = Workflow.get_poc_for_patchid_or_404(patchid) - committer = list(Committer.objects.filter(user=request.user, active=True)) - if len(committer) == 0: + find_me = list(Committer.objects.filter(user=request.user, active=True)) + if len(find_me) == 0: return HttpResponseForbidden("Only committers can do that!") - committer = committer[0] - - is_committer = committer == patch.committer + me = find_me[0] + + # At this point it doesn't matter who the existing committer, if any, is. + # If someone else is one we are permitted to become the new one, then + # remove ourselves. So removing them is acceptable, and desireable for admin. + # So we just need to know whether we are leaving the patch with ourselves + # as the commiter, or None. + if status == "become": + new_committer = me + elif status == "remove": + new_committer = None + + # We could ignore a no-op become...but we expect the application to + # prevent such things on this particular interface. + if not Workflow.setCommitter(poc, new_committer, request.user): + raise Exception("Not expecting a no-op: toggling committer") - prevcommitter = patch.committer - if status == "become" and not is_committer: - patch.committer = committer - patch.set_modified() - PatchHistory( - patch=patch, - by=request.user, - what="Added %s as committer" % request.user.username, - ).save_and_notify(prevcommitter=prevcommitter) - elif status == "remove" and is_committer: - patch.committer = None - patch.set_modified() - PatchHistory( - patch=patch, - by=request.user, - what="Removed %s from committers" % request.user.username, - ).save_and_notify(prevcommitter=prevcommitter) - patch.save() return HttpResponseRedirect("../../") diff --git a/pgcommitfest/urls.py b/pgcommitfest/urls.py index a67f55fc..b81f2c11 100644 --- a/pgcommitfest/urls.py +++ b/pgcommitfest/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ re_path(r"^$", views.home), + re_path(r"^workflow/$", views.workflow), re_path(r"^me/$", views.me), re_path(r"^archive/$", views.archive), re_path(r"^activity(?P<rss>\.rss)?/", views.activity), @@ -26,9 +27,8 @@ re_path(r"^patch/(\d+)/edit/$", views.patchform), re_path(r"^(\d+)/new/$", views.newpatch), 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+)/close/(reject|withdrawn|feedback|committed)/$", views.close), + re_path(r"^patch/(\d+)/transition/$", views.transition), 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),