Add issues and comment threads (Phase 3)

Issues: per-project numbers, statuses (open, accepted, rejected,
withdrawn, duplicate), feature links, edits, and a timeline of comments
and events. From an issue, collaborators create or update a feature
with the revision tied to the issue and an option to accept it.

Comments: general and line-anchored threads on features, one
discussion thread per issue, edit history, tombstone deletes, resolve,
and promoting a feature thread to an issue. Line threads attach to
top-level Markdown blocks and go outdated only when the text changes.

HTMX swaps threads in place with focus management; every action also
works as a plain form post. Markdown headings are re-levelled so user
text never skips heading levels on the page.

The example flow from concept.md (steps 1-7) is covered end to end in
tests/test_example_flow.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-10-27 09:18:45 -04:00
parent 99f810d043
commit 5811e72a0c
57 changed files with 2924 additions and 37 deletions

View file

@ -29,6 +29,8 @@ INSTALLED_APPS = [
"intent.orgs", "intent.orgs",
"intent.projects", "intent.projects",
"intent.features", "intent.features",
"intent.issues",
"intent.comments",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View file

@ -32,4 +32,6 @@ urlpatterns = [
path("<slug:org_slug>/", include("intent.orgs.urls")), path("<slug:org_slug>/", include("intent.orgs.urls")),
path("<slug:org_slug>/<slug:project_slug>/", include("intent.projects.urls")), path("<slug:org_slug>/<slug:project_slug>/", include("intent.projects.urls")),
path("<slug:org_slug>/<slug:project_slug>/features/", include("intent.features.urls")), path("<slug:org_slug>/<slug:project_slug>/features/", include("intent.features.urls")),
path("<slug:org_slug>/<slug:project_slug>/issues/", include("intent.issues.urls")),
path("<slug:org_slug>/<slug:project_slug>/", include("intent.comments.urls")),
] ]

View file

@ -22,16 +22,17 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Feature revision | Integer starting at 1, incremented by every commit to the feature: description edit, file add/replace/remove, parent change, status change. Each revision stores the full description text (not a diff); diffs are computed on display. | | Feature revision | Integer starting at 1, incremented by every commit to the feature: description edit, file add/replace/remove, parent change, status change. Each revision stores the full description text (not a diff); diffs are computed on display. |
| Commit note | Required, short, free text. Optionally linked to one or more issues. | | Commit note | Required, short, free text. Optionally linked to one or more issues. |
| Issue ↔ feature | Many-to-many. An issue promoted from a comment thread is linked to that thread's feature. Accepting an issue that creates a feature links both. | | Issue ↔ feature | Many-to-many. An issue promoted from a comment thread is linked to that thread's feature. Accepting an issue that creates a feature links both. |
| Issue statuses | `open`, `accepted`, `rejected`, `withdrawn` (author closes), `duplicate` (links to another issue). Collaborators can reopen. | | Issue statuses | `open`, `accepted`, `rejected`, `withdrawn` (author closes), `duplicate` (links to another issue). Collaborators can reopen any closed issue; authors can withdraw their own open issue and reopen it only if they withdrew it. Duplicate chains can't loop. Every status change, feature link and edit is an `IssueEvent` shown in the issue's timeline between comments; edits keep the previous title and text. Closed issues still take comments. |
| Issue → feature | From an issue, a collaborator follows "Create a feature from #N" or "Update INT-3 for #N". The feature form opens prefilled (title and description from the issue, or a change note naming it), ties the new revision to the issue, links the feature, and offers a checked "Mark #N accepted" box. Accept and Reject buttons also work on their own. |
| Feature statuses | `active`, `withdrawn`, `superseded` (requires `superseded_by` feature). | | Feature statuses | `active`, `withdrawn`, `superseded` (requires `superseded_by` feature). |
| Implementation status | `active`, `archived`. | | Implementation status | `active`, `archived`. |
| Feature × implementation status | `n/a`, `not_started`, `in_progress`, `shipped`, `retired`; stamped with the feature revision it applies to. Missing row displays as `not_started @ —`. If stamped revision < current revision, display "behind". | | Feature × implementation status | `n/a`, `not_started`, `in_progress`, `shipped`, `retired`; stamped with the feature revision it applies to. Missing row displays as `not_started @ —`. If stamped revision < current revision, display "behind". |
| Hierarchy | Single optional parent within the same project. Cycles rejected. Moving a feature records a revision. Statuses independent in V1. | | Hierarchy | Single optional parent within the same project. Cycles rejected. Moving a feature records a revision. Statuses independent in V1. |
| Files | Belong to a feature, typed `prototype` or `documentation`. Replacing a file creates a new file version and a feature revision; old versions stay (so old image-pin threads still resolve). Size cap 25 MB; allowed types: images, PDF, markdown/text, HTML, zip. | | Files | Belong to a feature, typed `prototype` or `documentation`. Replacing a file creates a new file version and a feature revision; old versions stay (so old image-pin threads still resolve). Size cap 25 MB; allowed types: images, PDF, markdown/text, HTML, zip. |
| Line-anchored threads | Anchor = feature revision + start/end source line. On later revisions the thread shows as "outdated" with a link to the revision it was made on; no attempt to re-map lines. | | Line-anchored threads | Anchor = feature revision + start/end source line of one top-level Markdown block (paragraph, list, heading, code block). Each block has its own `<details>` comment form, so it works without JS. A thread goes "outdated" only when the description text has changed since its revision (a status-only revision doesn't outdate it); outdated threads move to "Comments on earlier revisions", quote their original lines, and link to the revision. No attempt to re-map lines. A line comment posted against a stale revision is refused and the draft is shown back. |
| Image threads | Anchor = file version + x/y as fractions (0–1). | | Image threads | Anchor = file version + x/y as fractions (0–1). |
| Comments | Edits keep history (visible "edited" link). Author or project owner may delete; a deleted comment shows as a tombstone. Resolving a thread is allowed for the thread author or any collaborator. | | Comments | Edits keep history (visible "edited" link to a history page). Author or project owner may delete; a deleted comment shows as a tombstone and its text and history are removed. Delete asks for confirmation in a disclosure, so it works without JS. Resolving a feature thread is allowed for the thread author or any collaborator; resolved threads start collapsed. An issue has exactly one thread, its discussion, which isn't resolvable (the issue status says that). |
| Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. | | Thread → issue | Original commenter (while they can contribute) or any project collaborator. Thread gets `promoted_to` issue and is resolved; the issue is linked to the thread's feature, its body is prefilled with the quoted lines and comments, and its page links back to the thread. |
| Deleted users | Content remains, attributed to a "ghost" user. | | Deleted users | Content remains, attributed to a "ghost" user. |
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. | | Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and the first signed-in visitor claims the invite with whatever account they used. Claiming rotates the token, so the link (which appears in request logs) is dead after one use; the claimant then accepts or declines from their Invitations page. Unclaimed email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Sending is throttled (30 per sender per hour, 3 per recipient per org per hour). Expire after 14 days. The last org owner cannot be removed, demoted or leave. | | Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and the first signed-in visitor claims the invite with whatever account they used. Claiming rotates the token, so the link (which appears in request logs) is dead after one use; the claimant then accepts or declines from their Invitations page. Unclaimed email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Sending is throttled (30 per sender per hour, 3 per recipient per org per hour). Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
@ -51,7 +52,7 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. | | Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. |
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. | | Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. | | Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. |
| Markdown | `markdown-it-py` with source line mapping (`data-line` attributes on blocks) for line threads; output sanitized with `nh3`. Diffs via `difflib` rendered server-side. | | Markdown | `markdown-it-py` with source line mapping (`data-source-lines` attributes on blocks) for line threads; output sanitized with `nh3`. Descriptions render block by block so each block can carry its threads. Diffs via `difflib` rendered server-side. |
| Frontend | Django templates, HTMX for partial updates, Alpine for small local state (image pin placement, thread forms). CodeMirror 6 as a progressive enhancement on the markdown textarea, optional. No build step beyond copying vendored JS into static files. | | Frontend | Django templates, HTMX for partial updates, Alpine for small local state (image pin placement, thread forms). CodeMirror 6 as a progressive enhancement on the markdown textarea, optional. No build step beyond copying vendored JS into static files. |
| Email | `django-anymail` with the provider pluggable; console backend in development. **Provider TBD; launch blocker for magic links and email invites.** | | Email | `django-anymail` with the provider pluggable; console backend in development. **Provider TBD; launch blocker for magic links and email invites.** |
| Background work | None. Email is sent synchronously. | | Background work | None. Email is sent synchronously. |
@ -71,7 +72,8 @@ The app should look close to unstyled HTML. Guiding rule: if the stylesheet fail
- Keyboard: all actions reachable by tab; skip link to `<main>`; visible focus. Image pins get a list alternative (threads listed below the image with coordinates). Line threads have a per-line "comment" button, not hover-only. - Keyboard: all actions reachable by tab; skip link to `<main>`; visible focus. Image pins get a list alternative (threads listed below the image with coordinates). Line threads have a per-line "comment" button, not hover-only.
- Contrast and semantics checked with axe in the Playwright smoke tests. - Contrast and semantics checked with axe in the Playwright smoke tests.
- Until then, unit tests run a structural check (`tests/a11y.py`) on every page: `lang`, one `<h1>`, no skipped heading levels, unique ids, labelled form controls, `alt` on images. New pages get added to `tests/test_a11y.py`. - Until then, unit tests run a structural check (`tests/a11y.py`) on every page: `lang`, one `<h1>`, no skipped heading levels, unique ids, labelled form controls, `alt` on images. New pages get added to `tests/test_a11y.py`.
- Markdown headings render one level down (`#` becomes `<h2>`) so user text never adds a second `<h1>`. - Markdown headings are re-levelled so the shallowest heading in the text lands one below the surrounding page heading (`<h2>` in descriptions and issue bodies, `<h3>` in comments), keeping relative structure. User text never adds a second `<h1>`, and a comment starting at `##` doesn't skip a level.
- After an HTMX swap, focus moves to the new or edited comment, or to the thread (`data-autofocus`, `static/js/intent.js`).
## 3. Data model ## 3. Data model
@ -88,18 +90,20 @@ ProjectMembership project, user, role ∈ {owner, collaborator}
Feature project, number, parent (nullable, same project), title, status, superseded_by, Feature project, number, parent (nullable, same project), title, status, superseded_by,
current_revision (int), created_by current_revision (int), created_by
FeatureRevision feature, revision, description_md, note, author, parent_at_revision, FeatureRevision feature, revision, description_md, note, author, parent_at_revision,
status_at_revision, issues (M2M) status_at_revision (issues via Issue.revisions)
FeatureFile feature, kind ∈ {prototype, documentation}, name, current_version FeatureFile feature, kind ∈ {prototype, documentation}, name, current_version
FeatureFileVersion file, version, storage_key, content_type, size, created_in_revision FeatureFileVersion file, version, storage_key, content_type, size, created_in_revision
Implementation project, name, url, status ∈ {active, archived} Implementation project, name, url, status ∈ {active, archived}
FeatureImplStatus feature, implementation, status, feature_revision, updated_by (unique feature+impl) FeatureImplStatus feature, implementation, status, feature_revision, updated_by (unique feature+impl)
Issue project, number, title, body_md, author, status, duplicate_of, Issue project, number, title, body_md, author, status, duplicate_of, closed_at,
features (M2M), promoted_from_thread (nullable) features (M2M), revisions (M2M FeatureRevision)
CommentThread project, target: one of {feature, feature_line, feature_file, file_image_point, issue}, IssueEvent issue, actor, kind ∈ {status, linked, unlinked, revision, edited}, status,
feature, issue, revision, line_start, line_end, file_version, x, y, previous_status, duplicate_of, feature, revision, old_title, old_body
resolved_by, resolved_at, promoted_to (issue) CommentThread project, kind ∈ {feature, line, issue} (Phase 4 adds file, image point),
Comment thread, author, body_md, deleted_at feature, issue, revision, line_start, line_end, author,
CommentEdit comment, body_md, edited_at resolved_by, resolved_at, promoted_to (OneToOne issue)
Comment thread, author, body_md, edited_at, deleted_at, deleted_by
CommentEdit comment, body_md (text before the edit), created_at
``` ```
Notes: Notes:
@ -123,7 +127,9 @@ Resolved per request into a small `Access` object (`can_view`, `can_comment`, `c
| Accept/reject/reopen issue | | | | ✓ | ✓ | | Accept/reject/reopen issue | | | | ✓ | ✓ |
| Create/edit feature, files, status | | | | ✓ | ✓ | | Create/edit feature, files, status | | | | ✓ | ✓ |
| Link implementations, set statuses | | | | ✓ | ✓ | | Link implementations, set statuses | | | | ✓ | ✓ |
| Resolve threads, delete comments | own | own | own | ✓ | ✓ | | Resolve feature threads | | own | own | ✓ | ✓ |
| Edit comments, withdraw issues | | own | own | own | own |
| Delete comments | | own | own | own | ✓ |
| Manage project members, delete project | | | | | ✓ | | Manage project members, delete project | | | | | ✓ |
| Create project | | | if `can_create_projects` | | org owner ✓ | | Create project | | | if `can_create_projects` | | org owner ✓ |
| Manage org members, grants | | | | | org owner | | Manage org members, grants | | | | | org owner |
@ -172,6 +178,7 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
- Promote thread to issue. - Promote thread to issue.
- HTMX partials for posting comments and resolving threads, with form fallbacks. - HTMX partials for posting comments and resolving threads, with form fallbacks.
- **Exit:** the example flow in concept.md (steps 1–7) works end to end. - **Exit:** the example flow in concept.md (steps 1–7) works end to end.
- Done: `tests/test_example_flow.py` drives steps 1–7 (and the rejected alternative) through the views. HTMX reply, edit, resolve and the issue timeline were also checked once in headless Chromium; Playwright tests proper are Phase 5.
### Phase 4 — Files and implementations (medium) ### Phase 4 — Files and implementations (medium)

View file

@ -106,3 +106,48 @@ def visible_projects(user, org: Organization):
return projects.filter( return projects.filter(
Q(visibility__in=[Visibility.PUBLIC, Visibility.AUTHENTICATED]) | Q(memberships__user=user) Q(visibility__in=[Visibility.PUBLIC, Visibility.AUTHENTICATED]) | Q(memberships__user=user)
).distinct() ).distinct()
# Object-level rules for issues and comments. `access` is the user's ProjectAccess for the
# object's project. Authors keep rights over their own words only while they can still see
# (or, for edits, contribute to) the project.
def is_author(user, author_id) -> bool:
return _is_regular_user(user) and author_id is not None and author_id == user.pk
def can_edit_issue(user, access: ProjectAccess, issue) -> bool:
return access.can_edit or (is_author(user, issue.author_id) and access.can_contribute)
def can_withdraw_issue(user, access: ProjectAccess, issue) -> bool:
"""Authors close their own open issues, and may reopen ones they withdrew."""
return is_author(user, issue.author_id) and access.can_view
def can_resolve_thread(user, access: ProjectAccess, thread) -> bool:
from intent.comments.models import ThreadKind
if thread.kind == ThreadKind.ISSUE:
return False # An issue's status says whether its discussion is done.
return access.can_edit or (is_author(user, thread.author_id) and access.can_view)
def can_promote_thread(user, access: ProjectAccess, thread) -> bool:
from intent.comments.models import ThreadKind
if thread.kind == ThreadKind.ISSUE or thread.promoted_to_id:
return False
return access.can_edit or (is_author(user, thread.author_id) and access.can_contribute)
def can_edit_comment(user, access: ProjectAccess, comment) -> bool:
return not comment.is_deleted and is_author(user, comment.author_id) and access.can_contribute
def can_delete_comment(user, access: ProjectAccess, comment) -> bool:
"""Authors delete their own comments; project owners moderate."""
if comment.is_deleted:
return False
return access.can_admin or (is_author(user, comment.author_id) and access.can_view)

View file

29
intent/comments/admin.py Normal file
View file

@ -0,0 +1,29 @@
from django.contrib import admin
from .models import Comment, CommentThread
class CommentInline(admin.TabularInline):
model = Comment
extra = 0
can_delete = False
fields = ["created_at", "author", "body", "edited_at", "deleted_at"]
readonly_fields = fields
def has_add_permission(self, request, obj=None):
return False
@admin.register(CommentThread)
class CommentThreadAdmin(admin.ModelAdmin):
"""Read-only: moderation happens in the app, where it leaves a tombstone."""
list_display = ["__str__", "project", "author", "created_at", "resolved_at"]
list_filter = ["kind"]
inlines = [CommentInline]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

6
intent/comments/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CommentsConfig(AppConfig):
name = "intent.comments"
label = "comments"

23
intent/comments/forms.py Normal file
View file

@ -0,0 +1,23 @@
from django import forms
from .services import MAX_BODY
class CommentForm(forms.Form):
body = forms.CharField(max_length=MAX_BODY, widget=forms.Textarea)
class LineThreadForm(CommentForm):
revision = forms.IntegerField(min_value=1)
line_start = forms.IntegerField(min_value=1)
line_end = forms.IntegerField(min_value=1)
class PromoteForm(forms.Form):
title = forms.CharField(max_length=200)
body = forms.CharField(
label="Description",
required=False,
widget=forms.Textarea(attrs={"rows": 16, "cols": 80}),
help_text="Markdown. Starts as a quote of the thread; trim it to what matters.",
)

View file

@ -0,0 +1,66 @@
# Generated by Django 5.2.18 on 2026-10-27 12:49
import django.db.models.deletion
import intent.core.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('features', '0002_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('body', models.TextField(blank=True)),
('edited_at', models.DateTimeField(blank=True, null=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at', 'id'],
},
),
migrations.CreateModel(
name='CommentEdit',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('body', models.TextField()),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edits', to='comments.comment')),
],
options={
'ordering': ['created_at', 'id'],
},
),
migrations.CreateModel(
name='CommentThread',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('kind', models.CharField(choices=[('feature', 'Feature'), ('line', 'Lines of a feature description'), ('issue', 'Issue')], max_length=20)),
('revision', models.PositiveIntegerField(blank=True, null=True)),
('line_start', models.PositiveIntegerField(blank=True, null=True)),
('line_end', models.PositiveIntegerField(blank=True, null=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('feature', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='features.feature')),
],
options={
'ordering': ['created_at', 'id'],
},
),
]

View file

@ -0,0 +1,53 @@
# Generated by Django 5.2.18 on 2026-10-27 12:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('comments', '0001_initial'),
('issues', '0001_initial'),
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='commentthread',
name='issue',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='issues.issue'),
),
migrations.AddField(
model_name='commentthread',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='projects.project'),
),
migrations.AddField(
model_name='commentthread',
name='promoted_to',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='promoted_from', to='issues.issue'),
),
migrations.AddField(
model_name='commentthread',
name='resolved_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='comment',
name='thread',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='comments.commentthread'),
),
migrations.AddConstraint(
model_name='commentthread',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('feature__isnull', False), ('issue__isnull', True), ('kind', 'feature'), ('line_end__isnull', True), ('line_start__isnull', True), ('revision__isnull', True)), models.Q(('feature__isnull', False), ('issue__isnull', True), ('kind', 'line'), ('line_end__gte', models.F('line_start')), ('line_start__gte', 1), ('revision__isnull', False)), models.Q(('feature__isnull', True), ('issue__isnull', False), ('kind', 'issue'), ('line_end__isnull', True), ('line_start__isnull', True), ('revision__isnull', True)), _connector='OR'), name='thread_target'),
),
migrations.AddConstraint(
model_name='commentthread',
constraint=models.UniqueConstraint(condition=models.Q(('kind', 'issue')), fields=('issue',), name='thread_one_per_issue'),
),
]

View file

130
intent/comments/models.py Normal file
View file

@ -0,0 +1,130 @@
from django.conf import settings
from django.db import models
from django.db.models import F, Q
from intent.core.models import BaseModel
from intent.features.models import Feature
from intent.issues.models import Issue
from intent.projects.models import Project
class ThreadKind(models.TextChoices):
FEATURE = "feature", "Feature"
LINE = "line", "Lines of a feature description"
ISSUE = "issue", "Issue"
# Phase 4 adds whole-file and image-point threads.
def _target_is_consistent():
no_lines = Q(revision__isnull=True, line_start__isnull=True, line_end__isnull=True)
feature = Q(kind=ThreadKind.FEATURE, feature__isnull=False, issue__isnull=True) & no_lines
line = Q(
kind=ThreadKind.LINE,
feature__isnull=False,
issue__isnull=True,
revision__isnull=False,
line_start__gte=1,
line_end__gte=F("line_start"),
)
issue = Q(kind=ThreadKind.ISSUE, issue__isnull=False, feature__isnull=True) & no_lines
return feature | line | issue
class CommentThread(BaseModel):
"""A conversation attached to a feature, some lines of its description, or an issue.
An issue has exactly one thread: its discussion. Features can have many.
"""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="threads")
kind = models.CharField(max_length=20, choices=ThreadKind.choices)
feature = models.ForeignKey(
Feature, null=True, blank=True, on_delete=models.CASCADE, related_name="threads"
)
issue = models.ForeignKey(
Issue, null=True, blank=True, on_delete=models.CASCADE, related_name="threads"
)
# Line threads: the feature revision the lines refer to, and the 1-based inclusive range.
revision = models.PositiveIntegerField(null=True, blank=True)
line_start = models.PositiveIntegerField(null=True, blank=True)
line_end = models.PositiveIntegerField(null=True, blank=True)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
resolved_at = models.DateTimeField(null=True, blank=True)
resolved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
promoted_to = models.OneToOneField(
Issue,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="promoted_from",
)
class Meta:
constraints = [
models.CheckConstraint(condition=_target_is_consistent(), name="thread_target"),
models.UniqueConstraint(
fields=["issue"],
condition=Q(kind=ThreadKind.ISSUE),
name="thread_one_per_issue",
),
]
ordering = ["created_at", "id"]
def __str__(self):
target = self.issue.key if self.issue_id else self.feature.key
return f"{self.get_kind_display()} thread on {target}"
@property
def is_resolved(self) -> bool:
return self.resolved_at is not None
@property
def dom_id(self) -> str:
"""The id of the element that HTMX swaps when this thread changes."""
return "timeline" if self.kind == ThreadKind.ISSUE else f"thread-{self.pk}"
class Comment(BaseModel):
thread = models.ForeignKey(CommentThread, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
body = models.TextField(blank=True)
edited_at = models.DateTimeField(null=True, blank=True)
# Deleted comments stay as tombstones so the thread still reads in order.
deleted_at = models.DateTimeField(null=True, blank=True)
deleted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
class Meta:
ordering = ["created_at", "id"]
def __str__(self):
return f"Comment by {self.author} on {self.thread}"
@property
def is_deleted(self) -> bool:
return self.deleted_at is not None
class CommentEdit(BaseModel):
"""A comment's text before an edit. `created_at` is when it was replaced."""
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name="edits")
body = models.TextField()
class Meta:
ordering = ["created_at", "id"]

View file

@ -0,0 +1,84 @@
"""Prepare threads for templates: rendered comment text, per-object permissions, line state."""
from django.db.models import Prefetch
from django.urls import reverse
from intent.access import (
can_delete_comment,
can_edit_comment,
can_promote_thread,
can_resolve_thread,
)
from intent.markdown import render, render_blocks, source_lines
from .models import Comment, ThreadKind
def prepare_threads(threads, user, access) -> list:
"""Load threads with their comments, and attach what the templates need to each."""
threads = list(
threads.select_related(
"project__org", "feature__project", "issue", "author", "resolved_by", "promoted_to"
).prefetch_related(Prefetch("comments", queryset=Comment.objects.select_related("author")))
)
for thread in threads:
thread.can_resolve = can_resolve_thread(user, access, thread)
thread.can_promote = can_promote_thread(user, access, thread)
thread.comment_list = list(thread.comments.all())
thread.live_count = sum(1 for c in thread.comment_list if not c.is_deleted)
for comment in thread.comment_list:
comment.html = "" if comment.is_deleted else render(comment.body, heading_base=3)
comment.can_edit = can_edit_comment(user, access, comment)
comment.can_delete = can_delete_comment(user, access, comment)
return threads
def mark_line_threads(feature, threads) -> None:
"""Decide which line threads still point at the current text.
A thread is current if the description is unchanged since its revision (a status change
alone doesn't outdate it). Outdated threads keep a quote of the lines they were about.
"""
line_threads = [t for t in threads if t.kind == ThreadKind.LINE]
revisions = {t.revision for t in line_threads}
texts = dict(
feature.revisions.filter(revision__in=revisions).values_list("revision", "description")
)
for thread in line_threads:
old = texts.get(thread.revision, "")
thread.outdated = old != feature.description
thread.quoted = source_lines(old, thread.line_start, thread.line_end)
def feature_discussion(feature, user, access) -> dict:
"""Context for a feature page: description blocks with their line threads, and the rest."""
threads = prepare_threads(feature.threads.all(), user, access)
mark_line_threads(feature, threads)
blocks = render_blocks(feature.description)
by_range = {(b.start, b.end): [] for b in blocks}
outdated = []
for thread in threads:
if thread.kind != ThreadKind.LINE:
continue
key = (thread.line_start, thread.line_end)
if not thread.outdated and key in by_range:
by_range[key].append(thread)
else:
thread.outdated = True
outdated.append(thread)
return {
"blocks": [(block, by_range[(block.start, block.end)]) for block in blocks],
"general_threads": [t for t in threads if t.kind == ThreadKind.FEATURE],
"outdated_threads": outdated,
}
def thread_url(thread, anchor=None) -> str:
"""Where a thread lives: its feature's page, or its issue's page."""
project = thread.project
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug}
if thread.kind == ThreadKind.ISSUE:
url = reverse("issues:detail", kwargs={**kwargs, "number": thread.issue.number})
else:
url = reverse("features:detail", kwargs={**kwargs, "feature_key": thread.feature.key})
return f"{url}#{anchor or thread.dom_id}"

View file

@ -0,0 +1,98 @@
"""Comment thread rules. Same conventions as intent.orgs.services."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.utils import timezone
from intent.access import (
can_delete_comment,
can_edit_comment,
can_resolve_thread,
project_access,
)
from .models import Comment, CommentEdit, CommentThread, ThreadKind
MAX_BODY = 20_000
STALE_MESSAGE = (
"The description changed while you were writing, so those lines may have moved. "
"Your comment wasn't posted; choose the lines again."
)
def clean_body(body: str) -> str:
body = (body or "").strip()
if not body:
raise ValidationError("Write a comment first.")
if len(body) > MAX_BODY:
raise ValidationError(f"Keep comments under {MAX_BODY:,} characters.")
return body
def _require_contribute(by, project):
if not project_access(by, project).can_contribute:
raise PermissionDenied
def start_feature_thread(
feature, by, *, body, revision=None, line_start=None, line_end=None
) -> CommentThread:
"""Start a thread on a feature, or on lines of its current description."""
_require_contribute(by, feature.project)
body = clean_body(body)
fields = {"project": feature.project, "feature": feature, "author": by}
if line_start is None:
fields["kind"] = ThreadKind.FEATURE
else:
if revision != feature.current_revision:
raise ValidationError(STALE_MESSAGE)
line_count = len(feature.description.splitlines())
if not (1 <= line_start <= (line_end or 0) <= line_count):
raise ValidationError("Those lines aren't in the description.")
fields.update(
kind=ThreadKind.LINE, revision=revision, line_start=line_start, line_end=line_end
)
with transaction.atomic():
thread = CommentThread.objects.create(**fields)
Comment.objects.create(thread=thread, author=by, body=body)
return thread
def reply(thread, by, *, body) -> Comment:
_require_contribute(by, thread.project)
return Comment.objects.create(thread=thread, author=by, body=clean_body(body))
def edit_comment(comment, by, *, body) -> None:
if not can_edit_comment(by, project_access(by, comment.thread.project), comment):
raise PermissionDenied
body = clean_body(body)
if body == comment.body:
raise ValidationError("Nothing changed.")
with transaction.atomic():
CommentEdit.objects.create(comment=comment, body=comment.body)
comment.body = body
comment.edited_at = timezone.now()
comment.save(update_fields=["body", "edited_at", "updated_at"])
def delete_comment(comment, by) -> None:
"""Leave a tombstone: the text and its edit history are removed."""
if not can_delete_comment(by, project_access(by, comment.thread.project), comment):
raise PermissionDenied
with transaction.atomic():
comment.edits.all().delete()
comment.body = ""
comment.deleted_at = timezone.now()
comment.deleted_by = by
comment.save(update_fields=["body", "deleted_at", "deleted_by", "updated_at"])
def set_resolved(thread, by, resolved: bool) -> None:
if not can_resolve_thread(by, project_access(by, thread.project), thread):
raise PermissionDenied
if resolved == thread.is_resolved:
return
thread.resolved_at = timezone.now() if resolved else None
thread.resolved_by = by if resolved else None
thread.save(update_fields=["resolved_at", "resolved_by", "updated_at"])

14
intent/comments/urls.py Normal file
View file

@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = "comments"
urlpatterns = [
path("threads/<str:thread_id>/reply/", views.thread_reply, name="reply"),
path("threads/<str:thread_id>/resolve/", views.thread_resolve, name="resolve"),
path("threads/<str:thread_id>/promote/", views.thread_promote, name="promote"),
path("comments/<str:comment_id>/edit/", views.comment_edit, name="edit"),
path("comments/<str:comment_id>/delete/", views.comment_delete, name="delete"),
path("comments/<str:comment_id>/history/", views.comment_history, name="history"),
]

197
intent/comments/views.py Normal file
View file

@ -0,0 +1,197 @@
"""Thread and comment actions.
Every action is a plain form POST that redirects back to the thread. When HTMX sends it
(`HX-Request`), the response is instead the re-rendered thread, swapped in place.
"""
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.http import require_POST
from intent.access import can_promote_thread
from intent.features.views import get_feature
from intent.issues import services as issue_services
from intent.projects.decorators import project_view
from . import services
from .forms import CommentForm, LineThreadForm, PromoteForm
from .models import Comment, CommentThread, ThreadKind
from .presenters import mark_line_threads, prepare_threads, thread_url
def _is_htmx(request) -> bool:
return request.headers.get("HX-Request") == "true"
def _get_thread(project, thread_id) -> CommentThread:
return get_object_or_404(
CommentThread.objects.select_related("project__org", "feature__project", "issue"),
project=project,
pk=thread_id,
)
def _get_comment(project, comment_id) -> Comment:
return get_object_or_404(
Comment.objects.select_related(
"author", "thread__project__org", "thread__feature__project", "thread__issue"
),
thread__project=project,
pk=comment_id,
)
def render_thread(request, project, access, thread, *, focus=None, error=None, draft=""):
"""The HTMX fragment for a thread: an issue's timeline, or a single feature thread."""
context = {"project": project, "access": access, "focus": focus, "error": error}
if thread.kind == ThreadKind.ISSUE:
from intent.issues.views import timeline_context
context.update(timeline_context(thread.issue, request.user, access))
context["draft"] = draft
return render(request, "issues/_timeline.html", context)
(prepared,) = prepare_threads(CommentThread.objects.filter(pk=thread.pk), request.user, access)
if prepared.kind == ThreadKind.LINE:
mark_line_threads(prepared.feature, [prepared])
prepared.draft = draft
context["thread"] = prepared
return render(request, "comments/_thread.html", context)
def _done(request, project, access, thread, *, focus=None):
if _is_htmx(request):
return render_thread(request, project, access, thread, focus=focus)
return redirect(thread_url(thread, focus))
def _failed(request, project, access, thread, exc, draft="", back=None):
"""Show what went wrong without losing what the person wrote."""
error = " ".join(exc.messages)
if _is_htmx(request) and thread is not None:
return render_thread(request, project, access, thread, error=error, draft=draft)
back = back or thread_url(thread)
context = {"project": project, "access": access, "error": error, "draft": draft, "back": back}
return render(request, "comments/not_posted.html", context, status=400)
def _form_error(form) -> ValidationError:
return ValidationError([e for errors in form.errors.values() for e in errors])
@project_view(require="can_contribute")
@require_POST
def feature_thread_create(request, project, access, feature_key):
feature = get_feature(project, feature_key)
on_lines = "line_start" in request.POST
form = (LineThreadForm if on_lines else CommentForm)(request.POST)
draft = request.POST.get("body", "")
if not form.is_valid():
exc = _form_error(form)
else:
data = form.cleaned_data
lines = {k: data[k] for k in ("revision", "line_start", "line_end")} if on_lines else {}
try:
thread = services.start_feature_thread(
feature, request.user, body=data["body"], **lines
)
except ValidationError as error:
exc = error
else:
return redirect(thread_url(thread))
back = reverse(
"features:detail",
kwargs={
"org_slug": project.org.slug,
"project_slug": project.slug,
"feature_key": feature.key,
},
)
return _failed(request, project, access, None, exc, draft, back=back)
@project_view(require="can_contribute")
@require_POST
def thread_reply(request, project, access, thread_id):
thread = _get_thread(project, thread_id)
try:
comment = services.reply(thread, request.user, body=request.POST.get("body", ""))
except ValidationError as exc:
return _failed(request, project, access, thread, exc, request.POST.get("body", ""))
return _done(request, project, access, thread, focus=f"comment-{comment.pk}")
@project_view()
@require_POST
def thread_resolve(request, project, access, thread_id):
thread = _get_thread(project, thread_id)
services.set_resolved(thread, request.user, request.POST.get("resolved") == "1")
return _done(request, project, access, thread, focus=thread.dom_id)
@project_view(require="can_contribute")
def thread_promote(request, project, access, thread_id):
thread = _get_thread(project, thread_id)
if request.method == "POST":
form = PromoteForm(request.POST)
if form.is_valid():
try:
issue = issue_services.promote_thread(thread, request.user, **form.cleaned_data)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Opened {issue.key} from the thread.")
return redirect(
"issues:detail",
org_slug=project.org.slug,
project_slug=project.slug,
number=issue.number,
)
else:
# Check up front, so people without the right don't fill in the form for nothing.
if not can_promote_thread(request.user, access, thread):
raise PermissionDenied
title, body = issue_services.promotion_text(thread)
form = PromoteForm(initial={"title": title, "body": body})
context = {
"project": project,
"access": access,
"thread": thread,
"form": form,
"back": thread_url(thread),
}
return render(request, "comments/promote.html", context)
@project_view()
@require_POST
def comment_edit(request, project, access, comment_id):
comment = _get_comment(project, comment_id)
try:
services.edit_comment(comment, request.user, body=request.POST.get("body", ""))
except ValidationError as exc:
return _failed(request, project, access, comment.thread, exc, request.POST.get("body", ""))
return _done(request, project, access, comment.thread, focus=f"comment-{comment.pk}")
@project_view()
@require_POST
def comment_delete(request, project, access, comment_id):
comment = _get_comment(project, comment_id)
services.delete_comment(comment, request.user)
return _done(request, project, access, comment.thread, focus=f"comment-{comment.pk}")
@project_view()
def comment_history(request, project, access, comment_id):
comment = _get_comment(project, comment_id)
versions = list(comment.edits.all())
context = {
"project": project,
"access": access,
"comment": comment,
"versions": versions,
"back": thread_url(comment.thread, f"comment-{comment.pk}"),
}
return render(request, "comments/history.html", context)

View file

@ -31,8 +31,12 @@ class FeatureForm(forms.Form):
help_text="Why is this changing? Kept in the feature's history.", help_text="Why is this changing? Kept in the feature's history.",
) )
def __init__(self, *args, project, feature=None, exclude_ids=(), **kwargs): def __init__(self, *args, project, feature=None, exclude_ids=(), issue=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if issue is not None and issue.is_open:
self.fields["accept_issue"] = forms.BooleanField(
required=False, initial=True, label=f"Mark issue {issue.key} accepted"
)
others = project.features.select_related("project") others = project.features.select_related("project")
if feature: if feature:
others = others.exclude(pk=feature.pk) others = others.exclude(pk=feature.pk)

View file

@ -9,6 +9,8 @@ from intent.projects.models import Project
from .models import Feature, FeatureRevision, FeatureStatus from .models import Feature, FeatureRevision, FeatureStatus
# Imported late in functions: intent.issues depends on this app's models.
TRACKED_FIELDS = ["title", "description", "parent_id", "status", "superseded_by_id"] TRACKED_FIELDS = ["title", "description", "parent_id", "status", "superseded_by_id"]
@ -76,7 +78,20 @@ def _record(feature, by, note) -> FeatureRevision:
) )
def create_feature(project, by, *, title, description="", parent=None, note="") -> Feature: def _tie_to_issues(revision, by, issues, accept) -> None:
"""Link a revision to the issues it was made for, accepting any that are still open."""
from intent.issues import services as issue_services
from intent.issues.models import IssueStatus
for issue in issues:
issue_services.record_revision(issue, by, revision)
if accept and issue.is_open:
issue_services.set_status(issue, by, IssueStatus.ACCEPTED)
def create_feature(
project, by, *, title, description="", parent=None, note="", issues=(), accept_issues=False
) -> Feature:
_require_edit(by, project) _require_edit(by, project)
with transaction.atomic(): with transaction.atomic():
Project.objects.filter(pk=project.pk).update( Project.objects.filter(pk=project.pk).update(
@ -93,12 +108,18 @@ def create_feature(project, by, *, title, description="", parent=None, note="")
) )
_validate(feature) _validate(feature)
feature.save() feature.save()
_record(feature, by, note.strip() or "Created.") revision = _record(feature, by, note.strip() or "Created.")
_tie_to_issues(revision, by, issues, accept_issues)
return feature return feature
def update_feature(feature, by, *, note, **changes) -> FeatureRevision: def update_feature(
"""Apply changes to tracked fields and record a revision. `note` explains why.""" feature, by, *, note, issues=(), accept_issues=False, **changes
) -> FeatureRevision:
"""Apply changes to tracked fields and record a revision. `note` explains why.
`issues` are the issues this change was made for; `accept_issues` accepts the open ones.
"""
_require_edit(by, feature.project) _require_edit(by, feature.project)
note = note.strip() note = note.strip()
if not note: if not note:
@ -113,4 +134,6 @@ def update_feature(feature, by, *, note, **changes) -> FeatureRevision:
raise ValidationError("Nothing changed.") raise ValidationError("Nothing changed.")
feature.current_revision += 1 feature.current_revision += 1
feature.save() feature.save()
return _record(feature, by, note) revision = _record(feature, by, note)
_tie_to_issues(revision, by, issues, accept_issues)
return revision

View file

@ -1,5 +1,7 @@
from django.urls import path from django.urls import path
from intent.comments import views as comment_views
from . import views from . import views
app_name = "features" app_name = "features"
@ -12,4 +14,9 @@ urlpatterns = [
path("<str:feature_key>/history/", views.feature_history, name="history"), path("<str:feature_key>/history/", views.feature_history, name="history"),
path("<str:feature_key>/history/<int:revision>/", views.feature_revision, name="revision"), path("<str:feature_key>/history/<int:revision>/", views.feature_revision, name="revision"),
path("<str:feature_key>/compare/", views.feature_compare, name="compare"), path("<str:feature_key>/compare/", views.feature_compare, name="compare"),
path(
"<str:feature_key>/threads/",
comment_views.feature_thread_create,
name="thread_create",
),
] ]

View file

@ -1,9 +1,12 @@
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Prefetch
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from intent.comments.presenters import feature_discussion
from intent.markdown import render as render_markdown from intent.markdown import render as render_markdown
from intent.markdown import render_blocks
from intent.projects.decorators import project_view from intent.projects.decorators import project_view
from . import services from . import services
@ -23,6 +26,16 @@ def get_feature(project, feature_key) -> Feature:
) )
def _issue_from_query(request, project):
"""The issue a feature change is being made for, from `?issue=N`."""
from intent.issues.models import Issue
number = request.GET.get("issue", "")
if not number.isdigit():
return None
return get_object_or_404(Issue, project=project, number=int(number))
def _feature_redirect(feature, name="detail"): def _feature_redirect(feature, name="detail"):
return redirect( return redirect(
f"features:{name}", f"features:{name}",
@ -47,7 +60,10 @@ def feature_create(request, project, access):
initial = {} initial = {}
if parent_key := request.GET.get("parent"): if parent_key := request.GET.get("parent"):
initial["parent"] = get_feature(project, parent_key) initial["parent"] = get_feature(project, parent_key)
form = FeatureForm(request.POST or None, project=project, initial=initial) issue = _issue_from_query(request, project)
if issue:
initial.update(title=issue.title, description=issue.body, note=f"Created for {issue.key}.")
form = FeatureForm(request.POST or None, project=project, initial=initial, issue=issue)
preview = None preview = None
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
data = form.cleaned_data data = form.cleaned_data
@ -62,13 +78,21 @@ def feature_create(request, project, access):
description=data["description"], description=data["description"],
parent=data["parent"], parent=data["parent"],
note=data["note"], note=data["note"],
issues=[issue] if issue else [],
accept_issues=data.get("accept_issue", False),
) )
except ValidationError as exc: except ValidationError as exc:
form.add_error(None, exc) form.add_error(None, exc)
else: else:
messages.success(request, f"Created {feature.key}.") messages.success(request, f"Created {feature.key}.")
return _feature_redirect(feature) return _feature_redirect(feature)
context = {"project": project, "access": access, "form": form, "preview": preview} context = {
"project": project,
"access": access,
"form": form,
"preview": preview,
"issue": issue,
}
return render(request, "features/form.html", context) return render(request, "features/form.html", context)
@ -84,7 +108,8 @@ def feature_detail(request, project, access, feature_key):
"ancestors": _ancestors(feature), "ancestors": _ancestors(feature),
"children": feature.children.select_related("project"), "children": feature.children.select_related("project"),
"supersedes": feature.supersedes.select_related("project"), "supersedes": feature.supersedes.select_related("project"),
"description": render_markdown(feature.description), "issues": feature.issues.select_related("project"),
**feature_discussion(feature, request.user, access),
} }
return render(request, "features/detail.html", context) return render(request, "features/detail.html", context)
@ -100,6 +125,7 @@ def _ancestors(feature) -> list[Feature]:
@project_view(require="can_edit") @project_view(require="can_edit")
def feature_edit(request, project, access, feature_key): def feature_edit(request, project, access, feature_key):
feature = get_feature(project, feature_key) feature = get_feature(project, feature_key)
issue = _issue_from_query(request, project)
initial = { initial = {
"title": feature.title, "title": feature.title,
"description": feature.description, "description": feature.description,
@ -107,12 +133,15 @@ def feature_edit(request, project, access, feature_key):
"status": feature.status, "status": feature.status,
"superseded_by": feature.superseded_by, "superseded_by": feature.superseded_by,
} }
if issue:
initial["note"] = f"{issue.key}: {issue.title}"[:500]
form = FeatureForm( form = FeatureForm(
request.POST or None, request.POST or None,
project=project, project=project,
feature=feature, feature=feature,
exclude_ids=services.descendant_ids(feature), exclude_ids=services.descendant_ids(feature),
initial=initial, initial=initial,
issue=issue,
) )
preview = None preview = None
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
@ -120,8 +149,15 @@ def feature_edit(request, project, access, feature_key):
if "preview" in request.POST: if "preview" in request.POST:
preview = render_markdown(data["description"]) preview = render_markdown(data["description"])
else: else:
accept = data.pop("accept_issue", False)
try: try:
services.update_feature(feature, request.user, **data) services.update_feature(
feature,
request.user,
issues=[issue] if issue else [],
accept_issues=accept,
**data,
)
except ValidationError as exc: except ValidationError as exc:
form.add_error(None, exc) form.add_error(None, exc)
else: else:
@ -133,6 +169,7 @@ def feature_edit(request, project, access, feature_key):
"feature": feature, "feature": feature,
"form": form, "form": form,
"preview": preview, "preview": preview,
"issue": issue,
} }
return render(request, "features/form.html", context) return render(request, "features/form.html", context)
@ -156,7 +193,13 @@ def _changes(revision, previous) -> list[str]:
@project_view() @project_view()
def feature_history(request, project, access, feature_key): def feature_history(request, project, access, feature_key):
feature = get_feature(project, feature_key) feature = get_feature(project, feature_key)
revisions = list(feature.revisions.select_related("author")) from intent.issues.models import Issue
revisions = list(
feature.revisions.select_related("author").prefetch_related(
Prefetch("issues", queryset=Issue.objects.order_by("number"))
)
)
rows = [ rows = [
{"revision": rev, "changes": _changes(rev, revisions[i - 1] if i else None)} {"revision": rev, "changes": _changes(rev, revisions[i - 1] if i else None)}
for i, rev in enumerate(revisions) for i, rev in enumerate(revisions)
@ -180,7 +223,7 @@ def feature_revision(request, project, access, feature_key, revision):
"access": access, "access": access,
"feature": feature, "feature": feature,
"snapshot": snapshot, "snapshot": snapshot,
"description": render_markdown(snapshot.description), "blocks": render_blocks(snapshot.description),
} }
return render(request, "features/revision.html", context) return render(request, "features/revision.html", context)

View file

31
intent/issues/admin.py Normal file
View file

@ -0,0 +1,31 @@
from django.contrib import admin
from .models import Issue, IssueEvent
class IssueEventInline(admin.TabularInline):
model = IssueEvent
fk_name = "issue"
extra = 0
can_delete = False
fields = ["created_at", "actor", "kind", "status", "feature", "revision"]
readonly_fields = fields
def has_add_permission(self, request, obj=None):
return False
@admin.register(Issue)
class IssueAdmin(admin.ModelAdmin):
"""Read-only: changes go through the app so they're recorded in the timeline."""
list_display = ["__str__", "project", "status", "author", "created_at"]
list_filter = ["status"]
search_fields = ["title"]
inlines = [IssueEventInline]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

6
intent/issues/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class IssuesConfig(AppConfig):
name = "intent.issues"
label = "issues"

58
intent/issues/forms.py Normal file
View file

@ -0,0 +1,58 @@
from django import forms
from intent.features.forms import FeatureChoiceField
from .models import Issue
class FeatureMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj):
return f"{obj.key} {obj.title}"
class IssueForm(forms.Form):
title = forms.CharField(max_length=200)
body = forms.CharField(
label="Description",
required=False,
widget=forms.Textarea(attrs={"rows": 12, "cols": 80}),
help_text="Markdown. What's the problem, or what would you like to change?",
)
features = FeatureMultipleChoiceField(
queryset=None,
required=False,
label="Related features",
help_text="Optional. Hold Ctrl or ⌘ to choose more than one.",
)
def __init__(self, *args, project, include_features=True, **kwargs):
super().__init__(*args, **kwargs)
if include_features:
self.fields["features"].queryset = project.features.select_related("project")
else:
del self.fields["features"]
class DuplicateForm(forms.Form):
number = forms.IntegerField(min_value=1, label="Duplicate of issue number")
def __init__(self, *args, project, **kwargs):
super().__init__(*args, **kwargs)
self.project = project
def clean_number(self):
number = self.cleaned_data["number"]
original = Issue.objects.filter(project=self.project, number=number).first()
if original is None:
raise forms.ValidationError(f"There's no issue #{number} in this project.")
return original
class LinkFeatureForm(forms.Form):
feature = FeatureChoiceField(queryset=None, label="Feature")
def __init__(self, *args, issue, **kwargs):
super().__init__(*args, **kwargs)
self.fields["feature"].queryset = issue.project.features.select_related("project").exclude(
issues=issue
)

View file

@ -0,0 +1,70 @@
# Generated by Django 5.2.18 on 2026-10-27 12:49
import django.db.models.deletion
import intent.core.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('features', '0002_initial'),
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Issue',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('number', models.PositiveIntegerField()),
('title', models.CharField(max_length=200)),
('body', models.TextField(blank=True)),
('status', models.CharField(choices=[('open', 'Open'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('withdrawn', 'Withdrawn'), ('duplicate', 'Duplicate')], default='open', max_length=20)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('duplicate_of', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='duplicates', to='issues.issue')),
('features', models.ManyToManyField(blank=True, related_name='issues', to='features.feature')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issues', to='projects.project')),
('revisions', models.ManyToManyField(blank=True, related_name='issues', to='features.featurerevision')),
],
options={
'ordering': ['-number'],
},
),
migrations.CreateModel(
name='IssueEvent',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('kind', models.CharField(choices=[('status', 'Status changed'), ('linked', 'Feature linked'), ('unlinked', 'Feature unlinked'), ('revision', 'Feature revised'), ('edited', 'Edited')], max_length=20)),
('status', models.CharField(blank=True, choices=[('open', 'Open'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('withdrawn', 'Withdrawn'), ('duplicate', 'Duplicate')], max_length=20)),
('previous_status', models.CharField(blank=True, choices=[('open', 'Open'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('withdrawn', 'Withdrawn'), ('duplicate', 'Duplicate')], max_length=20)),
('old_title', models.CharField(blank=True, max_length=200)),
('old_body', models.TextField(blank=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('duplicate_of', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='issues.issue')),
('feature', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='features.feature')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='issues.issue')),
('revision', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='features.featurerevision')),
],
options={
'ordering': ['created_at', 'id'],
},
),
migrations.AddConstraint(
model_name='issue',
constraint=models.UniqueConstraint(fields=('project', 'number'), name='issue_unique_number'),
),
migrations.AddConstraint(
model_name='issue',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('status', 'duplicate'), ('duplicate_of__isnull', False)), models.Q(models.Q(('status', 'duplicate'), _negated=True), models.Q(('duplicate_of__isnull', False), _negated=True)), _connector='OR'), name='issue_duplicate_of'),
),
]

View file

100
intent/issues/models.py Normal file
View file

@ -0,0 +1,100 @@
from django.conf import settings
from django.db import models
from django.db.models import Q
from intent.core.models import BaseModel
from intent.features.models import Feature, FeatureRevision
from intent.projects.models import Project
class IssueStatus(models.TextChoices):
OPEN = "open", "Open"
ACCEPTED = "accepted", "Accepted"
REJECTED = "rejected", "Rejected"
WITHDRAWN = "withdrawn", "Withdrawn"
DUPLICATE = "duplicate", "Duplicate"
def _duplicate_needs_original():
is_duplicate = Q(status=IssueStatus.DUPLICATE)
has_original = Q(duplicate_of__isnull=False)
return (is_duplicate & has_original) | (~is_duplicate & ~has_original)
class Issue(BaseModel):
"""A problem or proposal. Discussed in its own comment thread, then decided."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="issues")
number = models.PositiveIntegerField()
title = models.CharField(max_length=200)
body = models.TextField(blank=True)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
status = models.CharField(max_length=20, choices=IssueStatus.choices, default=IssueStatus.OPEN)
# RESTRICT (not PROTECT) so deleting a whole project can still cascade.
duplicate_of = models.ForeignKey(
"self", null=True, blank=True, on_delete=models.RESTRICT, related_name="duplicates"
)
closed_at = models.DateTimeField(null=True, blank=True)
features = models.ManyToManyField(Feature, blank=True, related_name="issues")
# Feature revisions made because of this issue ("edits can be tied to an issue").
revisions = models.ManyToManyField(FeatureRevision, blank=True, related_name="issues")
class Meta:
constraints = [
models.UniqueConstraint(fields=["project", "number"], name="issue_unique_number"),
models.CheckConstraint(
condition=_duplicate_needs_original(), name="issue_duplicate_of"
),
]
ordering = ["-number"]
def __str__(self):
return f"{self.key} {self.title}"
@property
def key(self) -> str:
return f"#{self.number}"
@property
def is_open(self) -> bool:
return self.status == IssueStatus.OPEN
class EventKind(models.TextChoices):
STATUS = "status", "Status changed"
LINKED = "linked", "Feature linked"
UNLINKED = "unlinked", "Feature unlinked"
REVISION = "revision", "Feature revised"
EDITED = "edited", "Edited"
class IssueEvent(BaseModel):
"""Something that happened to an issue, shown in its timeline between comments."""
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="events")
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
kind = models.CharField(max_length=20, choices=EventKind.choices)
status = models.CharField(max_length=20, choices=IssueStatus.choices, blank=True)
previous_status = models.CharField(max_length=20, choices=IssueStatus.choices, blank=True)
duplicate_of = models.ForeignKey(
Issue, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
feature = models.ForeignKey(
Feature, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
revision = models.ForeignKey(
FeatureRevision, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
# For edits: the text before the change.
old_title = models.CharField(max_length=200, blank=True)
old_body = models.TextField(blank=True)
class Meta:
ordering = ["created_at", "id"]
def __str__(self):
return f"{self.issue.key} {self.get_kind_display()}"

193
intent/issues/services.py Normal file
View file

@ -0,0 +1,193 @@
"""Issue rules. Same conventions as intent.orgs.services."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import F
from django.utils import timezone
from intent.access import (
can_edit_issue,
can_promote_thread,
can_withdraw_issue,
project_access,
)
from intent.comments.models import CommentThread, ThreadKind
from intent.markdown import source_lines
from intent.projects.models import Project
from .models import EventKind, Issue, IssueEvent, IssueStatus
MAX_BODY = 50_000
def _clean_text(title, body):
title, body = title.strip(), body.strip()
if not title:
raise ValidationError("Give the issue a title.")
if len(body) > MAX_BODY:
raise ValidationError(f"Keep the description under {MAX_BODY:,} characters.")
return title, body
def _check_features(project, features):
for feature in features:
if feature.project_id != project.pk:
raise ValidationError("Linked features must be in the same project.")
def create_issue(project, by, *, title, body="", features=()) -> Issue:
if not project_access(by, project).can_contribute:
raise PermissionDenied
title, body = _clean_text(title, body)
_check_features(project, features)
with transaction.atomic():
Project.objects.filter(pk=project.pk).update(next_issue_number=F("next_issue_number") + 1)
project.refresh_from_db(fields=["next_issue_number"])
issue = Issue.objects.create(
project=project,
number=project.next_issue_number - 1,
title=title,
body=body,
author=by,
)
issue.features.set(features)
CommentThread.objects.create(project=project, kind=ThreadKind.ISSUE, issue=issue, author=by)
return issue
def update_issue(issue, by, *, title, body) -> None:
if not can_edit_issue(by, project_access(by, issue.project), issue):
raise PermissionDenied
title, body = _clean_text(title, body)
if (title, body) == (issue.title, issue.body):
raise ValidationError("Nothing changed.")
with transaction.atomic():
IssueEvent.objects.create(
issue=issue,
actor=by,
kind=EventKind.EDITED,
old_title=issue.title,
old_body=issue.body,
)
issue.title, issue.body = title, body
issue.save(update_fields=["title", "body", "updated_at"])
def _check_status_permission(issue, by, status) -> None:
access = project_access(by, issue.project)
if access.can_edit:
return
author = can_withdraw_issue(by, access, issue)
if status == IssueStatus.WITHDRAWN and author and issue.status == IssueStatus.OPEN:
return
if status == IssueStatus.OPEN and author and issue.status == IssueStatus.WITHDRAWN:
return
raise PermissionDenied
def _check_duplicate_of(issue, original) -> None:
if original is None:
raise ValidationError("Say which issue this duplicates.")
if original.project_id != issue.project_id:
raise ValidationError("The original must be in the same project.")
node = original
while node is not None:
if node.pk == issue.pk:
raise ValidationError("An issue can't be a duplicate of itself or of its duplicates.")
node = node.duplicate_of
def set_status(issue, by, status, *, duplicate_of=None) -> IssueEvent:
"""Accept, reject, withdraw, mark duplicate, or reopen."""
_check_status_permission(issue, by, status)
with transaction.atomic():
issue.refresh_from_db()
if status == IssueStatus.DUPLICATE:
_check_duplicate_of(issue, duplicate_of)
else:
duplicate_of = None
if status == issue.status and duplicate_of == issue.duplicate_of:
raise ValidationError(f"{issue.key} is already {issue.get_status_display().lower()}.")
previous = issue.status
issue.status = status
issue.duplicate_of = duplicate_of
issue.closed_at = None if status == IssueStatus.OPEN else timezone.now()
issue.save(update_fields=["status", "duplicate_of", "closed_at", "updated_at"])
return IssueEvent.objects.create(
issue=issue,
actor=by,
kind=EventKind.STATUS,
status=status,
previous_status=previous,
duplicate_of=duplicate_of,
)
def link_feature(issue, by, feature) -> None:
if not project_access(by, issue.project).can_edit:
raise PermissionDenied
_check_features(issue.project, [feature])
if issue.features.filter(pk=feature.pk).exists():
raise ValidationError(f"{feature.key} is already linked.")
with transaction.atomic():
issue.features.add(feature)
IssueEvent.objects.create(issue=issue, actor=by, kind=EventKind.LINKED, feature=feature)
def unlink_feature(issue, by, feature) -> None:
if not project_access(by, issue.project).can_edit:
raise PermissionDenied
if not issue.features.filter(pk=feature.pk).exists():
raise ValidationError(f"{feature.key} isn't linked.")
with transaction.atomic():
issue.features.remove(feature)
IssueEvent.objects.create(issue=issue, actor=by, kind=EventKind.UNLINKED, feature=feature)
def record_revision(issue, by, revision) -> None:
"""Tie a feature revision to the issue it was made for. Called by the feature services."""
feature = revision.feature
if issue.project_id != feature.project_id:
raise ValidationError("The issue must be in the same project as the feature.")
issue.revisions.add(revision)
issue.features.add(feature)
IssueEvent.objects.create(
issue=issue, actor=by, kind=EventKind.REVISION, feature=feature, revision=revision
)
def promotion_text(thread) -> tuple[str, str]:
"""A suggested title and body for an issue made from a thread: the thread, quoted."""
comments = [c for c in thread.comments.select_related("author") if not c.is_deleted]
first = comments[0].body if comments else ""
title = (first.strip().splitlines() or [""])[0][:120]
where = thread.feature.key
if thread.kind == ThreadKind.LINE:
where += f", lines {thread.line_start}–{thread.line_end} of revision {thread.revision}"
parts = [f"From a comment thread on {where}."]
if thread.kind == ThreadKind.LINE:
snapshot = thread.feature.revisions.filter(revision=thread.revision).first()
if snapshot:
quoted = source_lines(snapshot.description, thread.line_start, thread.line_end)
parts.append("\n".join(f"> {line}".rstrip() for line in quoted.splitlines()))
for c in comments:
name = c.author.username if c.author else "deleted user"
quoted = "\n".join(f"> {line}".rstrip() for line in c.body.splitlines())
parts.append(f"**{name}** wrote:\n\n{quoted}")
return title, "\n\n".join(parts)
def promote_thread(thread, by, *, title, body) -> Issue:
"""Turn a feature thread into an issue linked to the feature, and resolve the thread."""
if not can_promote_thread(by, project_access(by, thread.project), thread):
raise PermissionDenied
with transaction.atomic():
thread = CommentThread.objects.select_for_update().get(pk=thread.pk)
if thread.promoted_to_id:
raise ValidationError("This thread is already an issue.")
issue = create_issue(thread.project, by, title=title, body=body, features=[thread.feature])
thread.promoted_to = issue
if not thread.resolved_at:
thread.resolved_at, thread.resolved_by = timezone.now(), by
thread.save(update_fields=["promoted_to", "resolved_at", "resolved_by", "updated_at"])
return issue

14
intent/issues/urls.py Normal file
View file

@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = "issues"
urlpatterns = [
path("", views.issue_list, name="list"),
path("new/", views.issue_create, name="create"),
path("<int:number>/", views.issue_detail, name="detail"),
path("<int:number>/edit/", views.issue_edit, name="edit"),
path("<int:number>/status/", views.issue_status, name="status"),
path("<int:number>/features/", views.issue_features, name="features"),
]

210
intent/issues/views.py Normal file
View file

@ -0,0 +1,210 @@
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from intent.access import can_edit_issue, can_withdraw_issue
from intent.comments.models import CommentThread, ThreadKind
from intent.comments.presenters import prepare_threads, thread_url
from intent.features.views import get_feature
from intent.markdown import render as render_markdown
from intent.projects.decorators import project_view
from . import services
from .forms import DuplicateForm, IssueForm, LinkFeatureForm
from .models import Issue, IssueStatus
FILTERS = {
"open": ("Open", {"status": IssueStatus.OPEN}),
"closed": ("Closed", {"status__in": [s for s in IssueStatus.values if s != "open"]}),
"all": ("All", {}),
}
def get_issue(project, number) -> Issue:
return get_object_or_404(
Issue.objects.select_related("project__org", "author", "duplicate_of"),
project=project,
number=number,
)
def _issue_redirect(issue, anchor=""):
response = redirect(
"issues:detail",
org_slug=issue.project.org.slug,
project_slug=issue.project.slug,
number=issue.number,
)
if anchor:
response["Location"] += f"#{anchor}"
return response
def _error(request, exc: ValidationError):
for message in exc.messages:
messages.error(request, message)
def timeline_context(issue, user, access) -> dict:
"""Comments and events in time order, for the issue page and its HTMX fragment."""
(thread,) = prepare_threads(
CommentThread.objects.filter(issue=issue, kind=ThreadKind.ISSUE), user, access
)
events = issue.events.select_related("actor", "duplicate_of", "feature__project", "revision")
for event in events:
if event.old_body:
event.old_html = render_markdown(event.old_body, heading_base=3)
items = [("comment", c) for c in thread.comment_list] + [("event", e) for e in events]
items.sort(key=lambda item: (item[1].created_at, item[1].pk))
return {"issue": issue, "thread": thread, "timeline": items}
@project_view()
def issue_list(request, project, access):
current = request.GET.get("status", "open")
if current not in FILTERS:
current = "open"
issues = (
project.issues.filter(**FILTERS[current][1])
.select_related("author", "project")
.prefetch_related("features__project")
)
context = {
"project": project,
"access": access,
"issues": issues,
"filters": [(key, label) for key, (label, _) in FILTERS.items()],
"current": current,
}
return render(request, "issues/list.html", context)
@project_view(require="can_contribute")
def issue_create(request, project, access):
initial = {}
if feature_key := request.GET.get("feature"):
initial["features"] = [get_feature(project, feature_key)]
form = IssueForm(request.POST or None, project=project, initial=initial)
preview = None
if request.method == "POST" and form.is_valid():
data = form.cleaned_data
if "preview" in request.POST:
preview = render_markdown(data["body"], heading_base=3)
else:
try:
issue = services.create_issue(project, request.user, **data)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Opened {issue.key}.")
return _issue_redirect(issue)
context = {"project": project, "access": access, "form": form, "preview": preview}
return render(request, "issues/form.html", context)
@project_view()
def issue_detail(request, project, access, number):
issue = get_issue(project, number)
user = request.user
promoted_from = (
CommentThread.objects.filter(promoted_to=issue)
.select_related("project__org", "feature__project")
.first()
)
context = {
"project": project,
"access": access,
"body": render_markdown(issue.body),
"features": issue.features.select_related("project"),
"promoted_from": promoted_from,
"promoted_url": thread_url(promoted_from) if promoted_from else None,
"duplicates": issue.duplicates.all(),
"can_edit_issue": can_edit_issue(user, access, issue),
"can_withdraw": can_withdraw_issue(user, access, issue),
"duplicate_form": DuplicateForm(project=project),
"link_form": LinkFeatureForm(issue=issue) if access.can_edit else None,
**timeline_context(issue, user, access),
}
return render(request, "issues/detail.html", context)
@project_view()
def issue_edit(request, project, access, number):
issue = get_issue(project, number)
if not can_edit_issue(request.user, access, issue):
raise PermissionDenied
form = IssueForm(
request.POST or None,
project=project,
include_features=False,
initial={"title": issue.title, "body": issue.body},
)
preview = None
if request.method == "POST" and form.is_valid():
data = form.cleaned_data
if "preview" in request.POST:
preview = render_markdown(data["body"], heading_base=3)
else:
try:
services.update_issue(issue, request.user, **data)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Saved {issue.key}.")
return _issue_redirect(issue)
context = {
"project": project,
"access": access,
"issue": issue,
"form": form,
"preview": preview,
}
return render(request, "issues/form.html", context)
@project_view()
@require_POST
def issue_status(request, project, access, number):
issue = get_issue(project, number)
status = request.POST.get("status")
if status not in IssueStatus.values:
messages.error(request, "Choose a status.")
return _issue_redirect(issue)
duplicate_of = None
if status == IssueStatus.DUPLICATE:
form = DuplicateForm(request.POST, project=project)
if not form.is_valid():
for error in form.errors.get("number", []):
messages.error(request, error)
return _issue_redirect(issue, "decide")
duplicate_of = form.cleaned_data["number"]
try:
services.set_status(issue, request.user, status, duplicate_of=duplicate_of)
except ValidationError as exc:
_error(request, exc)
else:
messages.success(request, f"{issue.key} is now {issue.get_status_display().lower()}.")
return _issue_redirect(issue)
@project_view(require="can_edit")
@require_POST
def issue_features(request, project, access, number):
issue = get_issue(project, number)
if request.POST.get("action") == "unlink":
feature = get_object_or_404(project.features, pk=request.POST.get("feature"))
try:
services.unlink_feature(issue, request.user, feature)
except ValidationError as exc:
_error(request, exc)
return _issue_redirect(issue, "features")
form = LinkFeatureForm(request.POST, issue=issue)
if form.is_valid():
try:
services.link_feature(issue, request.user, form.cleaned_data["feature"])
except ValidationError as exc:
_error(request, exc)
else:
messages.error(request, "Choose a feature to link.")
return _issue_redirect(issue, "features")

View file

@ -1,10 +1,15 @@
"""Markdown to safe HTML for user-written text (feature descriptions, later issues and comments). """Markdown to safe HTML for user-written text: feature descriptions, issues and comments.
Block elements carry `data-source-lines="start-end"` (1-based, inclusive) so comment threads can Block elements carry `data-source-lines="start-end"` (1-based, inclusive) so comment threads can
anchor to lines of the source. Headings shift down one level so user text never adds a second anchor to lines of the source.
<h1> to the page.
Headings are re-levelled so the shallowest heading in the text becomes `heading_base` (h2 by
default), keeping their relative structure. User text then never adds a second <h1>, and a
comment that starts at `##` doesn't skip a level under the page's own headings.
""" """
from dataclasses import dataclass
import nh3 import nh3
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from markdown_it import MarkdownIt from markdown_it import MarkdownIt
@ -17,12 +22,14 @@ _ALLOWED_TAGS = nh3.ALLOWED_TAGS - {"img"}
def _annotate(state): def _annotate(state):
levels = [int(t.tag[1]) for t in state.tokens if t.type == "heading_open"]
shift = state.env.get("heading_base", 2) - min(levels, default=1)
for token in state.tokens: for token in state.tokens:
if token.nesting == 1 and token.map: if token.nesting == 1 and token.map:
start, end = token.map start, end = token.map
token.attrSet("data-source-lines", f"{start + 1}-{end}") token.attrSet("data-source-lines", f"{start + 1}-{end}")
if token.type in ("heading_open", "heading_close"): if token.type in ("heading_open", "heading_close"):
token.tag = f"h{min(int(token.tag[1]) + 1, 6)}" token.tag = f"h{max(1, min(int(token.tag[1]) + shift, 6))}"
def _build() -> MarkdownIt: def _build() -> MarkdownIt:
@ -34,6 +41,51 @@ def _build() -> MarkdownIt:
_md = _build() _md = _build()
def render(text: str) -> SafeString: def _clean(html: str) -> SafeString:
html = _md.render(text or "")
return mark_safe(nh3.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRIBUTES)) return mark_safe(nh3.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRIBUTES))
def render(text: str, *, heading_base: int = 2) -> SafeString:
return _clean(_md.render(text or "", {"heading_base": heading_base}))
@dataclass(frozen=True)
class Block:
"""One top-level block of rendered Markdown and the source lines it came from."""
start: int
end: int
html: SafeString
@property
def anchor(self) -> str:
return f"L{self.start}-{self.end}"
def render_blocks(text: str) -> list[Block]:
"""Render each top-level block separately, so each can carry its own comment threads."""
env = {"heading_base": 2}
tokens = _md.parse(text or "", env)
lines = (text or "").splitlines()
blocks, i = [], 0
while i < len(tokens):
j, depth = i, 0
while True:
depth += tokens[j].nesting
if depth <= 0:
break
j += 1
if tokens[i].map:
start, end = tokens[i].map
# Some blocks (loose lists) claim the blank line after them; don't count it.
while end > start + 1 and not lines[end - 1].strip():
end -= 1
html = _md.renderer.render(tokens[i : j + 1], _md.options, env)
blocks.append(Block(start + 1, end, _clean(html)))
i = j + 1
return blocks
def source_lines(text: str, start: int, end: int) -> str:
"""Lines start..end (1-based, inclusive) of the source."""
return "\n".join((text or "").splitlines()[start - 1 : end])

View file

@ -99,6 +99,59 @@ img {
outline-offset: 2px; outline-offset: 2px;
} }
/* Discussion. Threads and comments are indented by a rule, nothing more. */
.thread,
.timeline > li {
margin-block: 0.75rem;
}
.thread {
padding-inline-start: 0.75rem;
border-inline-start: 2px solid color-mix(in srgb, CanvasText 30%, Canvas);
}
.comment {
margin-block: 0.75rem;
}
.comment > header {
font-size: 0.9em;
}
.timeline {
padding: 0;
list-style: none;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: start;
gap: 0.5rem 1rem;
}
form.inline {
display: inline;
}
.line-comment > summary {
font-size: 0.85em;
}
.block:target,
.comment:target,
.thread:target {
outline: 2px dashed currentColor;
outline-offset: 4px;
}
pre.quote {
white-space: pre-wrap;
padding-inline-start: 0.75rem;
border-inline-start: 2px solid;
}
.skip-link { .skip-link {
position: absolute; position: absolute;
left: -100vw; left: -100vw;

1
static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

9
static/js/intent.js Normal file
View file

@ -0,0 +1,9 @@
// Keyboard and screen-reader focus after HTMX swaps a thread in place.
// The server marks the element to land on (a new or edited comment, or the thread itself)
// with data-autofocus; without this, focus would fall back to the top of the page.
document.addEventListener("htmx:afterSettle", function () {
var target = document.querySelector("[data-autofocus]");
if (!target) return;
target.removeAttribute("data-autofocus");
target.focus();
});

View file

@ -8,6 +8,9 @@
{# Blocking on purpose: applies the saved theme before first paint. #} {# Blocking on purpose: applies the saved theme before first paint. #}
<script src="{% static 'js/theme.js' %}"></script> <script src="{% static 'js/theme.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/intent.css' %}"> <link rel="stylesheet" href="{% static 'css/intent.css' %}">
{# Enhancements only: every form and link works without these. #}
<script src="{% static 'js/htmx.min.js' %}" defer></script>
<script src="{% static 'js/intent.js' %}" defer></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>

View file

@ -0,0 +1,48 @@
{% comment %}
One comment. Needs `c` (prepared by intent.comments.presenters), `thread`, `project`, `focus`.
Forms post normally; with HTMX they swap the whole thread (`thread.dom_id`) in place.
{% endcomment %}
{% with by=c.author.username|default:"deleted user" comment_id="comment-"|add:c.pk %}
<article id="{{ comment_id }}" class="comment" tabindex="-1"{% if focus == comment_id %} data-autofocus{% endif %}>
<header>
<strong>{{ by }}</strong>
<a href="#{{ comment_id }}"><time datetime="{{ c.created_at|date:'c' }}">{{ c.created_at|date:"j M Y, H:i" }}</time></a>
{% if c.edited_at and not c.is_deleted %}
· <a href="{% url 'comments:history' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk %}">edited<span class="visually-hidden"> (see earlier versions of {{ by }}’s comment)</span></a>
{% endif %}
</header>
{% if c.is_deleted %}
<p class="muted"><em>Comment deleted.</em></p>
{% else %}
<div class="prose">{{ c.html }}</div>
{% endif %}
{% if c.can_edit or c.can_delete %}
<div class="actions">
{% if c.can_edit %}
{% url 'comments:edit' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk as edit_url %}
<details>
<summary>Edit<span class="visually-hidden"> {{ by }}’s comment</span></summary>
<form method="post" action="{{ edit_url }}" hx-post="{{ edit_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
{% csrf_token %}
<label for="edit-{{ c.pk }}" class="visually-hidden">Comment text</label>
<textarea id="edit-{{ c.pk }}" name="body" rows="4" required>{{ c.body }}</textarea>
<button type="submit">Save edit</button>
</form>
</details>
{% endif %}
{% if c.can_delete %}
{% url 'comments:delete' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk as delete_url %}
{# A disclosure rather than a script confirm, so the second step works without JS. #}
<details>
<summary>Delete<span class="visually-hidden"> {{ by }}’s comment</span></summary>
<form method="post" action="{{ delete_url }}" hx-post="{{ delete_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
{% csrf_token %}
<p>The text and its edit history will be removed. This can't be undone.</p>
<button type="submit">Delete comment</button>
</form>
</details>
{% endif %}
</div>
{% endif %}
</article>
{% endwith %}

View file

@ -0,0 +1,61 @@
{% comment %}
A feature or line thread. Needs `thread` (prepared), `project`, `access`, `focus`, `error`.
Resolved threads start collapsed. This whole element is what HTMX swaps.
{% endcomment %}
<div id="{{ thread.dom_id }}" class="thread" tabindex="-1"{% if focus == thread.dom_id %} data-autofocus{% endif %}>
{% if error %}<p role="alert"><strong>Error:</strong> {{ error }}</p>{% endif %}
<details{% if not thread.is_resolved or error or focus %} open{% endif %}>
<summary>
Thread by {{ thread.author.username|default:"deleted user" }},
{{ thread.live_count }} comment{{ thread.live_count|pluralize }}{% if thread.promoted_to %}, became issue {{ thread.promoted_to.key }}{% elif thread.is_resolved %}, resolved{% endif %}
</summary>
{% if thread.kind == "line" and thread.outdated %}
<p class="muted">
On lines {{ thread.line_start }}–{{ thread.line_end }} of
<a href="{% url 'features:revision' org_slug=project.org.slug project_slug=project.slug feature_key=thread.feature.key revision=thread.revision %}#L{{ thread.line_start }}-{{ thread.line_end }}">revision {{ thread.revision }}</a>,
which have changed since:
</p>
<pre class="quote">{{ thread.quoted }}</pre>
{% endif %}
{% if thread.promoted_to %}
<p>Continued in issue <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=thread.promoted_to.number %}">{{ thread.promoted_to.key }} {{ thread.promoted_to.title }}</a>.</p>
{% endif %}
{% for c in thread.comment_list %}
{% include "comments/_comment.html" %}
{% endfor %}
{% if access.can_contribute %}
{% url 'comments:reply' org_slug=project.org.slug project_slug=project.slug thread_id=thread.pk as reply_url %}
<form method="post" action="{{ reply_url }}" hx-post="{{ reply_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
{% csrf_token %}
<label for="reply-{{ thread.pk }}">Reply</label>
<textarea id="reply-{{ thread.pk }}" name="body" rows="3" required>{{ thread.draft }}</textarea>
<button type="submit">Reply</button>
</form>
{% endif %}
{% if thread.can_resolve or thread.can_promote %}
<div class="actions">
{% if thread.can_resolve %}
{% url 'comments:resolve' org_slug=project.org.slug project_slug=project.slug thread_id=thread.pk as resolve_url %}
<form method="post" action="{{ resolve_url }}" hx-post="{{ resolve_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
{% csrf_token %}
{% if thread.is_resolved %}
<input type="hidden" name="resolved" value="0">
<button type="submit">Reopen thread</button>
{% else %}
<input type="hidden" name="resolved" value="1">
<button type="submit">Resolve thread</button>
{% endif %}
</form>
{% endif %}
{% if thread.can_promote %}
<a href="{% url 'comments:promote' org_slug=project.org.slug project_slug=project.slug thread_id=thread.pk %}">Turn into an issue</a>
{% endif %}
</div>
{% endif %}
</details>
</div>

View file

@ -0,0 +1,26 @@
{% extends "projects/base.html" %}
{% block page_title %}Comment history{% endblock %}
{% block project_content %}
<p><a href="{{ back }}">Back to the comment</a></p>
<h1>Edits to {{ comment.author.username|default:"deleted user" }}’s comment</h1>
{% if comment.is_deleted %}
<p>This comment was deleted, along with its history.</p>
{% else %}
<section aria-labelledby="current-heading">
<h2 id="current-heading">Current, edited <time datetime="{{ comment.edited_at|date:'c' }}">{{ comment.edited_at|date:"j M Y, H:i" }}</time></h2>
<pre class="quote">{{ comment.body }}</pre>
</section>
{% for version in versions reversed %}
<section aria-labelledby="version-{{ version.pk }}">
<h2 id="version-{{ version.pk }}">
{% if forloop.last %}Original{% else %}Earlier version{% endif %},
replaced <time datetime="{{ version.created_at|date:'c' }}">{{ version.created_at|date:"j M Y, H:i" }}</time>
</h2>
<pre class="quote">{{ version.body }}</pre>
</section>
{% endfor %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "projects/base.html" %}
{% block page_title %}Comment not posted{% endblock %}
{% block project_content %}
<h1>Your comment wasn't posted</h1>
<p role="alert"><strong>Error:</strong> {{ error }}</p>
{% if draft %}
<p>Here's what you wrote, so you can copy it:</p>
<label for="draft">Your comment</label>
<textarea id="draft" rows="8" readonly>{{ draft }}</textarea>
{% endif %}
<p><a href="{{ back }}">Go back</a></p>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "projects/base.html" %}
{% block page_title %}New issue from a thread{% endblock %}
{% block project_content %}
<p><a href="{{ back }}">Back to the thread</a></p>
<h1>New issue from a thread on <span class="muted">{{ thread.feature.key }}</span></h1>
<p>The issue will be linked to {{ thread.feature.key }} {{ thread.feature.title }}, and the thread will be resolved with a link to it.</p>
<form method="post">
{% csrf_token %}
{{ form }}
<p><button type="submit">Open issue</button></p>
</form>
{% endblock %}

View file

@ -37,9 +37,36 @@
<section aria-labelledby="description-heading"> <section aria-labelledby="description-heading">
<h2 id="description-heading" class="visually-hidden">Description</h2> <h2 id="description-heading" class="visually-hidden">Description</h2>
{% if blocks %}
{% url 'features:thread_create' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key as thread_create_url %}
<div class="prose"> <div class="prose">
{% if feature.description %}{{ description }}{% else %}<p class="muted">No description yet.</p>{% endif %} {% for block, threads in blocks %}
{# Each block of the description can carry its own threads, like lines in a code review. #}
<div class="block" id="{{ block.anchor }}">
{{ block.html }}
{% for thread in threads %}
{% include "comments/_thread.html" %}
{% endfor %}
{% if access.can_contribute %}
<details class="line-comment">
<summary>Comment<span class="visually-hidden"> on lines {{ block.start }}–{{ block.end }}</span></summary>
<form method="post" action="{{ thread_create_url }}">
{% csrf_token %}
<input type="hidden" name="revision" value="{{ feature.current_revision }}">
<input type="hidden" name="line_start" value="{{ block.start }}">
<input type="hidden" name="line_end" value="{{ block.end }}">
<label for="body-{{ block.anchor }}">Comment on lines {{ block.start }}–{{ block.end }}</label>
<textarea id="body-{{ block.anchor }}" name="body" rows="3" required></textarea>
<button type="submit">Comment</button>
</form>
</details>
{% endif %}
</div> </div>
{% endfor %}
</div>
{% else %}
<p class="muted">No description yet.</p>
{% endif %}
</section> </section>
{% if children %} {% if children %}
@ -66,4 +93,52 @@
</ul> </ul>
</section> </section>
{% endif %} {% endif %}
<section id="issues" aria-labelledby="issues-heading">
<h2 id="issues-heading">Issues</h2>
{% if issues %}
<ul>
{% for issue in issues %}
<li>
<a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }} {{ issue.title }}</a>
<span class="muted">({{ issue.get_status_display|lower }})</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No issues linked.</p>
{% endif %}
{% if access.can_contribute %}
<p><a href="{% url 'issues:create' org_slug=project.org.slug project_slug=project.slug %}?feature={{ feature.key }}">Open an issue about {{ feature.key }}</a></p>
{% endif %}
</section>
<section id="discussion" aria-labelledby="discussion-heading">
<h2 id="discussion-heading">Discussion</h2>
{% for thread in general_threads %}
{% include "comments/_thread.html" %}
{% empty %}
<p class="muted">No comments yet.</p>
{% endfor %}
{% if access.can_contribute %}
<form method="post" action="{% url 'features:thread_create' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">
{% csrf_token %}
<label for="new-thread">Start a thread about {{ feature.key }}</label>
<textarea id="new-thread" name="body" rows="4" required></textarea>
<p class="muted">Markdown. To comment on part of the description, use the Comment button below that part.</p>
<button type="submit">Comment</button>
</form>
{% elif not user.is_authenticated and project.open_contribution %}
<p><a href="{% url 'account_login' %}?next={{ request.path|urlencode }}">Sign in</a> to join the discussion.</p>
{% endif %}
</section>
{% if outdated_threads %}
<section aria-labelledby="outdated-heading">
<h2 id="outdated-heading">Comments on earlier revisions</h2>
{% for thread in outdated_threads %}
{% include "comments/_thread.html" %}
{% endfor %}
</section>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -10,6 +10,13 @@
<h1>New feature</h1> <h1>New feature</h1>
{% endif %} {% endif %}
{% if issue %}
<p role="note">
For issue <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }} {{ issue.title }}</a>.
The new revision will be linked to it.
</p>
{% endif %}
{% if preview %} {% if preview %}
<section aria-labelledby="preview-heading"> <section aria-labelledby="preview-heading">
<h2 id="preview-heading">Preview</h2> <h2 id="preview-heading">Preview</h2>

View file

@ -15,6 +15,7 @@
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">Author</th> <th scope="col">Author</th>
<th scope="col">Note</th> <th scope="col">Note</th>
<th scope="col">Issues</th>
<th scope="col">Changed</th> <th scope="col">Changed</th>
{% if rows|length > 1 %} {% if rows|length > 1 %}
<th scope="col">From</th> <th scope="col">From</th>
@ -36,6 +37,7 @@
<td><time datetime="{{ rev.created_at|date:'c' }}">{{ rev.created_at|date:"j M Y, H:i" }}</time></td> <td><time datetime="{{ rev.created_at|date:'c' }}">{{ rev.created_at|date:"j M Y, H:i" }}</time></td>
<td>{{ rev.author.username|default:"unknown" }}</td> <td>{{ rev.author.username|default:"unknown" }}</td>
<td>{{ rev.note }}</td> <td>{{ rev.note }}</td>
<td>{% for issue in rev.issues.all %}<a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }}</a>{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
<td>{{ row.changes|join:", " }}</td> <td>{{ row.changes|join:", " }}</td>
{% if rows|length > 1 %} {% if rows|length > 1 %}
<td> <td>

View file

@ -24,7 +24,11 @@
<section aria-labelledby="description-heading"> <section aria-labelledby="description-heading">
<h2 id="description-heading" class="visually-hidden">Description</h2> <h2 id="description-heading" class="visually-hidden">Description</h2>
<div class="prose"> <div class="prose">
{% if snapshot.description %}{{ description }}{% else %}<p class="muted">No description.</p>{% endif %} {% for block in blocks %}
<div class="block" id="{{ block.anchor }}">{{ block.html }}</div>
{% empty %}
<p class="muted">No description.</p>
{% endfor %}
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,33 @@
{% comment %}One timeline event. Needs `e`, `project`.{% endcomment %}
<p class="event muted">
<strong>{{ e.actor.username|default:"deleted user" }}</strong>
{% if e.kind == "status" %}
{% if e.status == "accepted" %}accepted this issue
{% elif e.status == "rejected" %}rejected this issue
{% elif e.status == "withdrawn" %}withdrew this issue
{% elif e.status == "duplicate" %}marked this a duplicate of
{% if e.duplicate_of %}<a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=e.duplicate_of.number %}">{{ e.duplicate_of.key }} {{ e.duplicate_of.title }}</a>{% else %}a deleted issue{% endif %}
{% else %}reopened this issue
{% endif %}
{% elif e.kind == "revision" and e.feature %}
{% if e.revision.revision == 1 %}created{% else %}updated{% endif %}
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=e.feature.key %}">{{ e.feature.key }} {{ e.feature.title }}</a>
{% if e.revision %}in <a href="{% url 'features:revision' org_slug=project.org.slug project_slug=project.slug feature_key=e.feature.key revision=e.revision.revision %}">revision {{ e.revision.revision }}</a>: {{ e.revision.note }}{% endif %}
{% elif e.kind == "linked" and e.feature %}
linked <a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=e.feature.key %}">{{ e.feature.key }} {{ e.feature.title }}</a>
{% elif e.kind == "unlinked" and e.feature %}
unlinked <a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=e.feature.key %}">{{ e.feature.key }} {{ e.feature.title }}</a>
{% elif e.kind == "edited" %}
edited this issue
{% else %}
{{ e.get_kind_display|lower }}
{% endif %}
· <time datetime="{{ e.created_at|date:'c' }}">{{ e.created_at|date:"j M Y, H:i" }}</time>
</p>
{% if e.kind == "edited" %}
<details>
<summary>Before the edit</summary>
<p><strong>{{ e.old_title }}</strong></p>
{% if e.old_body %}<div class="prose">{{ e.old_html }}</div>{% else %}<p class="muted">No description.</p>{% endif %}
</details>
{% endif %}

View file

@ -0,0 +1,35 @@
{% comment %}
An issue's comments and events, plus the comment form. Needs `issue`, `thread`, `timeline`,
`project`, `access`, `focus`, `error`, `draft`. This whole element is what HTMX swaps.
{% endcomment %}
<div id="timeline" tabindex="-1"{% if focus == "timeline" %} data-autofocus{% endif %}>
{% if timeline %}
<ol class="timeline">
{% for kind, item in timeline %}
<li>
{% if kind == "comment" %}
{% include "comments/_comment.html" with c=item %}
{% else %}
{% include "issues/_event.html" with e=item %}
{% endif %}
</li>
{% endfor %}
</ol>
{% else %}
<p class="muted">No comments yet.</p>
{% endif %}
{% if error %}<p role="alert"><strong>Error:</strong> {{ error }}</p>{% endif %}
{% if access.can_contribute %}
{% url 'comments:reply' org_slug=project.org.slug project_slug=project.slug thread_id=thread.pk as reply_url %}
<form method="post" action="{{ reply_url }}" hx-post="{{ reply_url }}" hx-target="#timeline" hx-swap="outerHTML">
{% csrf_token %}
<label for="new-comment">Add a comment</label>
<textarea id="new-comment" name="body" rows="5" required>{{ draft }}</textarea>
<p class="muted">Markdown.</p>
<button type="submit">Comment</button>
</form>
{% elif not user.is_authenticated and project.open_contribution %}
<p><a href="{% url 'account_login' %}?next={{ request.path|urlencode }}">Sign in</a> to join the discussion.</p>
{% endif %}
</div>

View file

@ -0,0 +1,114 @@
{% extends "projects/base.html" %}
{% block page_title %}{{ issue.key }} {{ issue.title }}{% endblock %}
{% block project_content %}
<h1><span class="muted">{{ issue.key }}</span> {{ issue.title }}</h1>
<p>
<strong>{{ issue.get_status_display }}</strong>{% if issue.duplicate_of %} duplicate of <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.duplicate_of.number %}">{{ issue.duplicate_of.key }} {{ issue.duplicate_of.title }}</a>{% endif %}.
Opened by {{ issue.author.username|default:"deleted user" }}
on <time datetime="{{ issue.created_at|date:'c' }}">{{ issue.created_at|date:"j M Y" }}</time>.
{% if promoted_url %}Started as <a href="{{ promoted_url }}">a comment thread on {{ promoted_from.feature.key }}</a>.{% endif %}
</p>
{% if can_edit_issue %}
<p><a href="{% url 'issues:edit' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">Edit issue</a></p>
{% endif %}
<section aria-labelledby="body-heading">
<h2 id="body-heading" class="visually-hidden">Description</h2>
<div class="prose">
{% if issue.body %}{{ body }}{% else %}<p class="muted">No description.</p>{% endif %}
</div>
</section>
<section id="features" aria-labelledby="features-heading">
<h2 id="features-heading">Features</h2>
{% if features %}
<ul>
{% for feature in features %}
<li>
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">{{ feature.key }} {{ feature.title }}</a>
{% if access.can_edit %}
<form method="post" action="{% url 'issues:features' org_slug=project.org.slug project_slug=project.slug number=issue.number %}" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="unlink">
<input type="hidden" name="feature" value="{{ feature.pk }}">
<button type="submit">Unlink <span class="visually-hidden">{{ feature.key }}</span></button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Not linked to any feature.</p>
{% endif %}
{% if access.can_edit %}
{% if link_form.fields.feature.queryset.exists %}
<form method="post" action="{% url 'issues:features' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">
{% csrf_token %}
<input type="hidden" name="action" value="link">
{{ link_form }}
<button type="submit">Link feature</button>
</form>
{% endif %}
<p>Change features for this issue. The revision is tied to {{ issue.key }}{% if issue.is_open %}, and you can accept the issue as you save{% endif %}.</p>
<ul>
<li><a href="{% url 'features:create' org_slug=project.org.slug project_slug=project.slug %}?issue={{ issue.number }}">Create a feature from {{ issue.key }}</a></li>
{% for feature in features %}
<li><a href="{% url 'features:edit' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}?issue={{ issue.number }}">Update {{ feature.key }} for {{ issue.key }}</a></li>
{% endfor %}
</ul>
{% endif %}
</section>
{% if duplicates %}
<section aria-labelledby="duplicates-heading">
<h2 id="duplicates-heading">Duplicates</h2>
<ul>
{% for dup in duplicates %}
<li><a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=dup.number %}">{{ dup.key }} {{ dup.title }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if access.can_edit or can_withdraw %}
{% url 'issues:status' org_slug=project.org.slug project_slug=project.slug number=issue.number as status_url %}
<section id="decide" aria-labelledby="decide-heading">
<h2 id="decide-heading">Decision</h2>
{% if issue.is_open %}
<form method="post" action="{{ status_url }}">
{% csrf_token %}
{% if access.can_edit %}
<button type="submit" name="status" value="accepted">Accept</button>
<button type="submit" name="status" value="rejected">Reject</button>
{% endif %}
{% if can_withdraw %}
<button type="submit" name="status" value="withdrawn">Withdraw</button>
{% endif %}
</form>
{% if access.can_edit %}
<form method="post" action="{{ status_url }}">
{% csrf_token %}
<input type="hidden" name="status" value="duplicate">
{{ duplicate_form }}
<button type="submit">Mark as duplicate</button>
</form>
{% endif %}
{% elif access.can_edit or issue.status == "withdrawn" %}
<form method="post" action="{{ status_url }}">
{% csrf_token %}
<button type="submit" name="status" value="open">Reopen</button>
</form>
{% else %}
<p class="muted">This issue is closed.</p>
{% endif %}
</section>
{% endif %}
<section aria-labelledby="discussion-heading">
<h2 id="discussion-heading">Discussion</h2>
{% include "issues/_timeline.html" %}
</section>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% extends "projects/base.html" %}
{% block page_title %}{% if issue %}Edit {{ issue.key }}{% else %}New issue{% endif %}{% endblock %}
{% block project_content %}
{% if issue %}
<h1>Edit <span class="muted">{{ issue.key }}</span> {{ issue.title }}</h1>
{% else %}
<h1>New issue</h1>
{% endif %}
{% if preview %}
<section aria-labelledby="preview-heading">
<h2 id="preview-heading">Preview</h2>
<div class="prose preview">{{ preview }}</div>
<p class="muted">Not saved yet.</p>
</section>
{% elif preview is not None %}
<p class="muted">Nothing to preview: the description is empty.</p>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form }}
<p>
{# Save comes first so pressing Enter in a text field saves rather than previews. #}
<button type="submit">{% if issue %}Save issue{% else %}Open issue{% endif %}</button>
<button type="submit" name="preview" value="1">Preview description</button>
</p>
</form>
{% endblock %}

View file

@ -0,0 +1,46 @@
{% extends "projects/base.html" %}
{% block page_title %}Issues{% endblock %}
{% block project_content %}
<h1>Issues</h1>
{% if access.can_contribute %}
<p><a href="{% url 'issues:create' org_slug=project.org.slug project_slug=project.slug %}">New issue</a></p>
{% endif %}
<nav aria-label="Filter issues">
<ul>
{% for key, label in filters %}
<li><a href="?status={{ key }}"{% if key == current %} aria-current="page"{% endif %}>{{ label }}</a></li>
{% endfor %}
</ul>
</nav>
{% if issues %}
<table>
<caption class="visually-hidden">{% for key, label in filters %}{% if key == current %}{{ label }}{% endif %}{% endfor %} issues</caption>
<thead>
<tr>
<th scope="col">Issue</th>
<th scope="col">Status</th>
<th scope="col">Features</th>
<th scope="col">Opened by</th>
<th scope="col">Updated</th>
</tr>
</thead>
<tbody>
{% for issue in issues %}
<tr>
<th scope="row"><a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }} {{ issue.title }}</a></th>
<td>{{ issue.get_status_display }}</td>
<td>{% for f in issue.features.all %}{{ f.key }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
<td>{{ issue.author.username|default:"deleted user" }}</td>
<td><time datetime="{{ issue.updated_at|date:'c' }}">{{ issue.updated_at|date:"j M Y" }}</time></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No {% if current != "all" %}{{ current }} {% endif %}issues.</p>
{% endif %}
{% endblock %}

View file

@ -17,6 +17,7 @@
<ul> <ul>
<li><a href="{% url 'projects:home' org_slug=project.org.slug project_slug=project.slug %}">Feature tree</a></li> <li><a href="{% url 'projects:home' org_slug=project.org.slug project_slug=project.slug %}">Feature tree</a></li>
<li><a href="{% url 'features:list' org_slug=project.org.slug project_slug=project.slug %}">All features</a></li> <li><a href="{% url 'features:list' org_slug=project.org.slug project_slug=project.slug %}">All features</a></li>
<li><a href="{% url 'issues:list' org_slug=project.org.slug project_slug=project.slug %}">Issues</a></li>
{% if access.can_edit %} {% if access.can_edit %}
<li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li> <li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li>
{% endif %} {% endif %}

View file

@ -64,3 +64,18 @@ def editor(make_user, org, project):
OrgMembership.objects.create(org=org, user=user) OrgMembership.objects.create(org=org, user=user)
ProjectMembership.objects.create(project=project, user=user) ProjectMembership.objects.create(project=project, user=user)
return user return user
@pytest.fixture
def open_project(project):
"""The project, made public and open to contributions from any signed-in user."""
project.visibility = Visibility.PUBLIC
project.open_contribution = True
project.save()
return project
@pytest.fixture
def outsider(make_user):
"""A signed-in user with no role in the org."""
return make_user("uma")

View file

@ -1,7 +1,9 @@
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from intent.comments.services import edit_comment, reply, start_feature_thread
from intent.features.services import create_feature, update_feature from intent.features.services import create_feature, update_feature
from intent.issues.services import create_issue, set_status, update_issue
from intent.orgs.services import invite from intent.orgs.services import invite
from .a11y import assert_accessible_html from .a11y import assert_accessible_html
@ -13,6 +15,20 @@ def populated(project, owner, make_user, org):
child = create_feature(project, owner, title="Sign in", parent=parent) child = create_feature(project, owner, title="Sign in", parent=parent)
update_feature(child, owner, note="Detail", description="Steps\n\n1. One\n2. Two\n") update_feature(child, owner, note="Detail", description="Steps\n\n1. One\n2. Two\n")
invite(org, owner, email="new@example.com") invite(org, owner, email="new@example.com")
# Discussion: a line thread that goes outdated, a current one, a general one, an issue.
old = start_feature_thread(child, owner, body="# Old\n\nOn steps", revision=2,
line_start=1, line_end=1) # fmt: skip
update_feature(child, owner, note="Reword", description="Steps\n\n1. One\n2. Two\n3. Three\n")
start_feature_thread(child, owner, body="On the list", revision=3, line_start=3, line_end=5)
general = start_feature_thread(child, owner, body="General")
reply(general, owner, body="## Reply heading")
edit_comment(general.comments.first(), owner, body="General, edited")
reply(old, owner, body="Still relevant?")
issue = create_issue(project, owner, title="Bug", body="# Heading\n\nText", features=[child])
reply(issue.threads.get(), owner, body="### Deep heading")
update_issue(issue, owner, title="Bug!", body="## New")
set_status(issue, owner, "rejected")
return child return child
@ -38,9 +54,26 @@ def _pages(org, project, feature):
reverse("features:history", kwargs=fk), reverse("features:history", kwargs=fk),
reverse("features:revision", kwargs={**fk, "revision": 1}), reverse("features:revision", kwargs={**fk, "revision": 1}),
reverse("features:compare", kwargs=fk), reverse("features:compare", kwargs=fk),
reverse("features:create", kwargs=pk) + "?issue=1",
reverse("features:edit", kwargs=fk) + "?issue=1",
reverse("issues:list", kwargs=pk),
reverse("issues:list", kwargs=pk) + "?status=closed",
reverse("issues:create", kwargs=pk),
reverse("issues:detail", kwargs={**pk, "number": 1}),
reverse("issues:edit", kwargs={**pk, "number": 1}),
reverse("comments:promote", kwargs={**pk, "thread_id": _thread_id(feature)}),
reverse("comments:history", kwargs={**pk, "comment_id": _edited_comment_id(feature)}),
] ]
def _thread_id(feature):
return feature.threads.filter(kind="feature").get().pk
def _edited_comment_id(feature):
return feature.threads.filter(kind="feature").get().comments.first().pk
def test_signed_in_pages(client, owner, org, project, populated): def test_signed_in_pages(client, owner, org, project, populated):
client.force_login(owner) client.force_login(owner)
for path in _pages(org, project, populated): for path in _pages(org, project, populated):
@ -52,6 +85,29 @@ def test_signed_in_pages(client, owner, org, project, populated):
raise AssertionError(f"{path}: {exc}") from None raise AssertionError(f"{path}: {exc}") from None
def test_outsider_pages_on_open_project(client, outsider, org, project, populated):
project.visibility, project.open_contribution = "public", True
project.save()
client.force_login(outsider)
pk = {"org_slug": org.slug, "project_slug": project.slug}
for path in [
reverse("features:detail", kwargs={**pk, "feature_key": populated.key}),
reverse("issues:detail", kwargs={**pk, "number": 1}),
]:
assert_accessible_html(client.get(path).content.decode())
def test_comment_not_posted_page(client, owner, project, populated):
client.force_login(owner)
url = reverse(
"features:thread_create",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": populated.key},
)
response = client.post(url, {"body": "x", "revision": 1, "line_start": 1, "line_end": 1})
assert response.status_code == 400
assert_accessible_html(response.content.decode())
@pytest.mark.parametrize( @pytest.mark.parametrize(
"html, problem", "html, problem",
[ [

View file

@ -75,3 +75,8 @@ def test_staff_cannot_grant_staff_without_superuser(client, make_user):
target = make_user("tina") target = make_user("tina")
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode() page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page
def test_admin_pages_for_issues_and_threads_render(admin_client, db):
for name in ["admin:issues_issue_changelist", "admin:comments_commentthread_changelist"]:
assert admin_client.get(reverse(name)).status_code == 200

261
tests/test_comments.py Normal file
View file

@ -0,0 +1,261 @@
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.urls import reverse
from intent.comments import services
from intent.comments.models import Comment, CommentThread, ThreadKind
from intent.features.services import create_feature, update_feature
from intent.issues.models import IssueStatus
from intent.issues.services import create_issue, promote_thread, set_status
DESCRIPTION = "Intro line\n\nSecond para\nwraps here.\n"
def furl(name, feature, **kwargs):
kwargs = {
"org_slug": "catbriar",
"project_slug": "intent",
"feature_key": feature.key,
**kwargs,
}
return reverse(f"features:{name}", kwargs=kwargs)
def curl(name, **kwargs):
return reverse(
f"comments:{name}", kwargs={"org_slug": "catbriar", "project_slug": "intent", **kwargs}
)
@pytest.fixture
def feature(open_project, owner):
return create_feature(open_project, owner, title="Sign in", description=DESCRIPTION)
@pytest.fixture
def thread(feature, outsider):
return services.start_feature_thread(feature, outsider, body="Why two paragraphs?")
def test_line_thread_must_match_current_revision_and_lines(feature, outsider, owner):
t = services.start_feature_thread(
feature, outsider, body="Hm", revision=1, line_start=3, line_end=4
)
assert t.kind == ThreadKind.LINE
with pytest.raises(ValidationError, match="changed"):
services.start_feature_thread(
feature, outsider, body="x", revision=2, line_start=1, line_end=1
)
with pytest.raises(ValidationError):
services.start_feature_thread(
feature, outsider, body="x", revision=1, line_start=3, line_end=9
)
with pytest.raises(ValidationError):
services.start_feature_thread(feature, outsider, body=" ")
def test_private_project_outsiders_cannot_comment(project, owner, make_user):
feature = create_feature(project, owner, title="Secret")
with pytest.raises(PermissionDenied):
services.start_feature_thread(feature, make_user(), body="hi")
def test_edit_keeps_history_and_only_author_edits(thread, outsider, owner):
comment = thread.comments.get()
with pytest.raises(PermissionDenied):
services.edit_comment(comment, owner, body="Rewritten")
services.edit_comment(comment, outsider, body="Why two?")
comment.refresh_from_db()
assert comment.edited_at and comment.edits.get().body == "Why two paragraphs?"
with pytest.raises(ValidationError):
services.edit_comment(comment, outsider, body="Why two?")
def test_delete_leaves_tombstone_and_drops_history(thread, outsider, make_user, owner, editor):
comment = thread.comments.get()
services.edit_comment(comment, outsider, body="Edited")
with pytest.raises(PermissionDenied):
services.delete_comment(comment, make_user())
with pytest.raises(PermissionDenied):
services.delete_comment(comment, editor) # Collaborators don't moderate; owners do.
services.delete_comment(comment, owner)
comment.refresh_from_db()
assert comment.is_deleted and comment.body == "" and not comment.edits.exists()
assert comment.deleted_by == owner
def test_resolve_by_thread_author_or_team(thread, outsider, editor, make_user):
with pytest.raises(PermissionDenied):
services.set_resolved(thread, make_user(), True)
services.set_resolved(thread, outsider, True)
assert thread.is_resolved
services.set_resolved(thread, editor, False)
assert not thread.is_resolved
def test_issue_threads_cannot_be_resolved(open_project, outsider, owner):
issue = create_issue(open_project, outsider, title="Bug")
with pytest.raises(PermissionDenied):
services.set_resolved(issue.threads.get(), owner, True)
def test_promote_by_author_or_team_only(thread, feature, outsider, make_user, editor):
services.reply(thread, editor, body="Good question")
with pytest.raises(PermissionDenied):
promote_thread(thread, make_user(), title="X", body="")
issue = promote_thread(thread, outsider, title="Two paragraphs", body="Quoted")
thread.refresh_from_db()
assert thread.promoted_to == issue and thread.is_resolved
assert list(issue.features.all()) == [feature]
assert issue.author == outsider
with pytest.raises(PermissionDenied):
promote_thread(thread, editor, title="Again", body="")
def test_promotion_text_quotes_lines_and_comments(feature, outsider, editor):
from intent.issues.services import promotion_text
t = services.start_feature_thread(
feature, outsider, body="Unclear wording", revision=1, line_start=3, line_end=4
)
services.reply(t, editor, body="Agreed")
title, body = promotion_text(t)
assert title == "Unclear wording"
assert "lines 3–4 of revision 1" in body
assert "> Second para\n> wraps here." in body
assert "**eddie** wrote:\n\n> Agreed" in body
# Views
def test_feature_page_shows_line_threads_under_their_block(client, feature, outsider):
services.start_feature_thread(
feature, outsider, body="About para two", revision=1, line_start=3, line_end=4
)
client.force_login(outsider)
page = client.get(furl("detail", feature)).content.decode()
block = page.index('id="L3-4"')
assert block < page.index("About para two") < page.index('id="discussion"')
assert 'id="body-L1-1"' in page # A comment form for each block.
def test_line_threads_go_outdated_only_when_text_changes(client, feature, outsider, owner):
services.start_feature_thread(
feature, outsider, body="About para two", revision=1, line_start=3, line_end=4
)
update_feature(feature, owner, note="Withdraw", status="withdrawn") # Text unchanged.
client.force_login(outsider)
page = client.get(furl("detail", feature)).content.decode()
assert "Comments on earlier revisions" not in page
update_feature(feature, owner, note="Reword", description="Intro\n\nNew text\n")
page = client.get(furl("detail", feature)).content.decode()
outdated = page.index("Comments on earlier revisions")
assert outdated < page.index("About para two")
assert "Second para\nwraps here." in page # The original lines are quoted.
assert furl("revision", feature, revision=1) + "#L3-4" in page
def test_create_thread_via_form(client, feature, outsider):
client.force_login(outsider)
response = client.post(furl("thread_create", feature), {"body": "General point"})
thread = CommentThread.objects.get()
assert response["Location"] == furl("detail", feature) + f"#thread-{thread.pk}"
assert thread.kind == ThreadKind.FEATURE
def test_stale_line_comment_shows_the_draft(client, feature, outsider, owner):
update_feature(feature, owner, note="Edit", description="Changed\n")
client.force_login(outsider)
response = client.post(
furl("thread_create", feature),
{"body": "My long thought", "revision": 1, "line_start": 1, "line_end": 1},
)
assert response.status_code == 400
assert b"My long thought" in response.content and b"changed" in response.content
assert not CommentThread.objects.exists()
def test_reply_without_js_redirects_to_the_comment(client, thread, outsider):
client.force_login(outsider)
response = client.post(curl("reply", thread_id=thread.pk), {"body": "More"})
comment = Comment.objects.get(body="More")
assert response["Location"].endswith(f"#comment-{comment.pk}")
def test_reply_with_htmx_returns_the_thread_fragment(client, thread, outsider):
client.force_login(outsider)
response = client.post(
curl("reply", thread_id=thread.pk), {"body": "More"}, headers={"HX-Request": "true"}
)
html = response.content.decode()
assert response.status_code == 200
assert html.lstrip().startswith(f'<div id="thread-{thread.pk}"')
assert "<html" not in html
comment = Comment.objects.get(body="More")
assert f'id="comment-{comment.pk}" class="comment" tabindex="-1" data-autofocus' in html
def test_htmx_empty_reply_keeps_thread_and_shows_error(client, thread, outsider):
client.force_login(outsider)
response = client.post(
curl("reply", thread_id=thread.pk), {"body": " "}, headers={"HX-Request": "true"}
)
assert response.status_code == 200 and b'role="alert"' in response.content
def test_resolve_edit_delete_via_views(client, thread, outsider, owner):
comment = thread.comments.get()
client.force_login(outsider)
client.post(curl("resolve", thread_id=thread.pk), {"resolved": "1"})
thread.refresh_from_db()
assert thread.is_resolved
client.post(curl("edit", comment_id=comment.pk), {"body": "Edited"})
history = client.get(curl("history", comment_id=comment.pk)).content.decode()
assert "Why two paragraphs?" in history and "Edited" in history
client.force_login(owner)
client.post(curl("delete", comment_id=comment.pk))
comment.refresh_from_db()
assert comment.is_deleted
def test_threads_from_another_project_are_404(client, thread, org, owner):
from intent.projects.services import create_project
create_project(
org, owner, slug="other", name="Other", feature_prefix="OTH", visibility="public",
open_contribution=True,
) # fmt: skip
client.force_login(owner)
other = reverse(
"comments:reply",
kwargs={"org_slug": "catbriar", "project_slug": "other", "thread_id": thread.pk},
)
assert client.post(other, {"body": "Sneaky"}).status_code == 404
def test_promote_page_prefills_and_creates_issue(client, thread, outsider):
client.force_login(outsider)
page = client.get(curl("promote", thread_id=thread.pk)).content.decode()
assert "Why two paragraphs?" in page
response = client.post(
curl("promote", thread_id=thread.pk), {"title": "Two paragraphs", "body": "See thread"}
)
thread.refresh_from_db()
issue = thread.promoted_to
assert response["Location"].endswith(f"/issues/{issue.number}/")
issue_page = client.get(response["Location"]).content.decode()
assert f"#thread-{thread.pk}" in issue_page # Links back to where it started.
def test_someone_elses_thread_cannot_be_promoted_by_outsider(client, thread, make_user):
client.force_login(make_user())
assert client.get(curl("promote", thread_id=thread.pk)).status_code == 403
def test_closed_issue_still_accepts_comments(client, open_project, outsider, editor):
issue = create_issue(open_project, outsider, title="Bug")
set_status(issue, editor, IssueStatus.REJECTED)
client.force_login(outsider)
client.post(curl("reply", thread_id=issue.threads.get().pk), {"body": "Why rejected?"})
assert Comment.objects.filter(body="Why rejected?").exists()

136
tests/test_example_flow.py Normal file
View file

@ -0,0 +1,136 @@
"""The example flow from docs/concept.md, steps 1–7, driven through the views like a browser."""
from django.urls import reverse
from intent.comments.models import CommentThread
from intent.features.models import Feature
from intent.issues.models import Issue, IssueStatus
P = {"org_slug": "catbriar", "project_slug": "intent"}
def issue_url(name, number, **kwargs):
return reverse(f"issues:{name}", kwargs={**P, "number": number, **kwargs})
def feature_url(name, key, **kwargs):
return reverse(f"features:{name}", kwargs={**P, "feature_key": key, **kwargs})
def reply_url(thread):
return reverse("comments:reply", kwargs={**P, "thread_id": thread.pk})
def test_example_flow(client, open_project, outsider, editor):
# 1. A general user creates a new issue in a project.
client.force_login(outsider)
client.post(
reverse("issues:create", kwargs=P),
{"title": "Remember me on shared computers", "body": "Sessions last too long."},
)
issue = Issue.objects.get()
# 2. Users, including team members, discuss in comments on the issue.
issue_thread = issue.threads.get()
client.post(reply_url(issue_thread), {"body": "Public library computers especially."})
client.force_login(editor)
client.post(reply_url(issue_thread), {"body": "Agreed, let's define this."})
# 3. A team member accepts the issue and creates a feature from it, linked to the issue.
create = reverse("features:create", kwargs=P) + f"?issue={issue.number}"
client.post(
create,
{
"title": "Session length",
"description": "Sessions end after 12 hours.\n\nShared computers end at close.\n",
"note": "",
"accept_issue": "on",
},
)
issue.refresh_from_db()
feature = Feature.objects.get()
assert issue.status == IssueStatus.ACCEPTED
assert list(issue.features.all()) == [feature]
assert feature.revisions.get().issues.get() == issue
# 4. The team member updates the feature's status manually.
client.post(
feature_url("edit", feature.key),
{
"title": feature.title,
"description": feature.description,
"status": "withdrawn",
"note": "Paused until the login rework.",
},
)
feature.refresh_from_db()
assert (feature.status, feature.current_revision) == ("withdrawn", 2)
client.post(
feature_url("edit", feature.key),
{
"title": feature.title,
"description": feature.description,
"status": "active",
"note": "Back on.",
},
)
# 5. Later, a user starts a comment thread on lines of the feature.
client.force_login(outsider)
client.post(
feature_url("thread_create", feature.key),
{"body": "What counts as close?", "revision": 3, "line_start": 3, "line_end": 3},
)
thread = CommentThread.objects.get(feature=feature)
# 6. After some discussion, the user promotes the thread to an issue.
client.force_login(editor)
client.post(reply_url(thread), {"body": "Browser close, I think."})
client.force_login(outsider)
promote = reverse("comments:promote", kwargs={**P, "thread_id": thread.pk})
client.post(promote, {"title": "Define 'close'", "body": "From the thread."})
follow_up = Issue.objects.get(number=2)
thread.refresh_from_db()
assert thread.promoted_to == follow_up and thread.is_resolved
assert list(follow_up.features.all()) == [feature]
# 7. More discussion, then a team member accepts the issue and updates the feature.
client.post(reply_url(follow_up.threads.get()), {"body": "Tab close too?"})
client.force_login(editor)
client.post(
feature_url("edit", feature.key) + f"?issue={follow_up.number}",
{
"title": feature.title,
"description": (
"Sessions end after 12 hours.\n\nShared computers end when the browser closes.\n"
),
"status": "active",
"note": "Define close as browser close.",
"accept_issue": "on",
},
)
follow_up.refresh_from_db()
feature.refresh_from_db()
assert follow_up.status == IssueStatus.ACCEPTED
assert feature.current_revision == 4
assert feature.revisions.get(revision=4).issues.get() == follow_up
# The line thread is now outdated, and the feature page shows both issues.
page = client.get(feature_url("detail", feature.key)).content.decode()
assert "Comments on earlier revisions" in page
assert "#1 Remember me on shared computers" in page and "#2 Define" in page
# 7, alternative: a third issue is rejected but stays linked, so the proposal is visible.
client.force_login(outsider)
client.post(
reverse("issues:create", kwargs=P),
{"title": "Never expire sessions", "features": [feature.pk]},
)
client.force_login(editor)
client.post(issue_url("status", 3), {"status": "rejected"})
rejected = Issue.objects.get(number=3)
assert rejected.status == IssueStatus.REJECTED
assert (
"#3 Never expire sessions"
in client.get(feature_url("detail", feature.key)).content.decode()
)

252
tests/test_issues.py Normal file
View file

@ -0,0 +1,252 @@
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import IntegrityError
from django.urls import reverse
from intent.comments.models import CommentThread, ThreadKind
from intent.features.services import create_feature, update_feature
from intent.issues import services
from intent.issues.models import EventKind, Issue, IssueStatus
def iurl(name, project, **kwargs):
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug, **kwargs}
return reverse(f"issues:{name}", kwargs=kwargs)
@pytest.fixture
def issue(open_project, outsider):
return services.create_issue(open_project, outsider, title="Sign in is slow", body="Why?")
def test_numbers_are_per_project_and_issue_gets_a_thread(open_project, outsider, issue):
second = services.create_issue(open_project, outsider, title="Another")
assert (issue.key, second.key) == ("#1", "#2")
assert CommentThread.objects.get(issue=issue).kind == ThreadKind.ISSUE
def test_outsiders_need_open_contribution(project, outsider):
with pytest.raises(PermissionDenied):
services.create_issue(project, outsider, title="Nope")
project.visibility = "public"
project.save()
with pytest.raises(PermissionDenied):
services.create_issue(project, outsider, title="Still no")
def test_title_required(open_project, outsider):
with pytest.raises(ValidationError):
services.create_issue(open_project, outsider, title=" ")
def test_only_team_decides(issue, outsider, editor):
with pytest.raises(PermissionDenied):
services.set_status(issue, outsider, IssueStatus.ACCEPTED)
services.set_status(issue, editor, IssueStatus.REJECTED)
issue.refresh_from_db()
assert issue.status == IssueStatus.REJECTED and issue.closed_at
services.set_status(issue, editor, IssueStatus.OPEN)
issue.refresh_from_db()
assert issue.is_open and issue.closed_at is None
assert [e.status for e in issue.events.all()] == ["rejected", "open"]
def test_author_withdraws_and_reopens_own_issue(issue, outsider, make_user):
with pytest.raises(PermissionDenied):
services.set_status(issue, make_user(), IssueStatus.WITHDRAWN)
services.set_status(issue, outsider, IssueStatus.WITHDRAWN)
services.set_status(issue, outsider, IssueStatus.OPEN)
services.set_status(issue, outsider, IssueStatus.WITHDRAWN)
issue.refresh_from_db()
assert issue.status == IssueStatus.WITHDRAWN
def test_author_cannot_reopen_a_rejected_issue(issue, outsider, editor):
services.set_status(issue, editor, IssueStatus.REJECTED)
with pytest.raises(PermissionDenied):
services.set_status(issue, outsider, IssueStatus.OPEN)
def test_same_status_is_refused(issue, editor):
with pytest.raises(ValidationError):
services.set_status(issue, editor, IssueStatus.OPEN)
def test_duplicates_need_an_original_and_no_cycles(open_project, outsider, editor, issue):
other = services.create_issue(open_project, outsider, title="Slow sign in")
with pytest.raises(ValidationError):
services.set_status(other, editor, IssueStatus.DUPLICATE)
with pytest.raises(ValidationError):
services.set_status(other, editor, IssueStatus.DUPLICATE, duplicate_of=other)
services.set_status(other, editor, IssueStatus.DUPLICATE, duplicate_of=issue)
with pytest.raises(ValidationError):
services.set_status(issue, editor, IssueStatus.DUPLICATE, duplicate_of=other)
# Reopening clears the link.
services.set_status(other, editor, IssueStatus.OPEN)
other.refresh_from_db()
assert other.duplicate_of is None
def test_database_enforces_duplicate_of(issue):
with pytest.raises(IntegrityError):
Issue.objects.filter(pk=issue.pk).update(status=IssueStatus.DUPLICATE)
def test_edit_keeps_old_text_in_timeline(issue, outsider, make_user):
with pytest.raises(PermissionDenied):
services.update_issue(issue, make_user(), title="Hijack", body="")
services.update_issue(issue, outsider, title="Sign in is very slow", body="Why?")
event = issue.events.get(kind=EventKind.EDITED)
assert event.old_title == "Sign in is slow"
with pytest.raises(ValidationError):
services.update_issue(issue, outsider, title="Sign in is very slow", body="Why?")
def test_link_and_unlink_features(issue, editor, outsider, open_project, owner):
feature = create_feature(open_project, owner, title="Sign in")
with pytest.raises(PermissionDenied):
services.link_feature(issue, outsider, feature)
services.link_feature(issue, editor, feature)
assert list(feature.issues.all()) == [issue]
with pytest.raises(ValidationError):
services.link_feature(issue, editor, feature)
services.unlink_feature(issue, editor, feature)
assert not feature.issues.exists()
def test_feature_revision_can_accept_an_issue(issue, editor, open_project, owner):
feature = create_feature(open_project, owner, title="Sign in")
revision = update_feature(
feature, editor, note="Cache sessions", title="Fast sign in", issues=[issue],
accept_issues=True,
) # fmt: skip
issue.refresh_from_db()
assert issue.status == IssueStatus.ACCEPTED
assert list(revision.issues.all()) == [issue]
assert list(issue.features.all()) == [feature]
assert issue.events.filter(kind=EventKind.REVISION, revision=revision).exists()
def test_cross_project_issue_cannot_be_tied_to_a_feature(issue, org, owner):
from intent.projects.services import create_project
other = create_project(
org, owner, slug="other", name="Other", feature_prefix="OTH", visibility="private",
open_contribution=False,
) # fmt: skip
with pytest.raises(ValidationError):
create_feature(other, owner, title="X", issues=[issue])
# Views
def test_issue_pages_for_outsider_on_open_project(client, open_project, outsider, issue):
client.force_login(outsider)
assert client.get(iurl("list", open_project)).status_code == 200
assert client.get(iurl("create", open_project)).status_code == 200
page = client.get(iurl("detail", open_project, number=1)).content.decode()
assert "Sign in is slow" in page
assert 'value="accepted"' not in page # No decision buttons for outsiders.
assert 'value="withdrawn"' in page
def test_private_issues_are_hidden(client, project, owner, outsider):
issue = services.create_issue(project, owner, title="Secret")
client.force_login(outsider)
assert client.get(iurl("detail", project, number=issue.number)).status_code == 404
def test_create_via_form_with_feature_prefill(client, open_project, outsider, owner):
feature = create_feature(open_project, owner, title="Sign in")
client.force_login(outsider)
page = client.get(iurl("create", open_project) + "?feature=INT-1").content.decode()
assert f'value="{feature.pk}" selected' in page
response = client.post(
iurl("create", open_project),
{"title": "Faster", "body": "Please", "features": [feature.pk]},
)
issue = Issue.objects.get(title="Faster")
assert response["Location"] == iurl("detail", open_project, number=issue.number)
assert list(issue.features.all()) == [feature]
def test_status_buttons(client, open_project, editor, issue):
client.force_login(editor)
client.post(iurl("status", open_project, number=1), {"status": "accepted"})
issue.refresh_from_db()
assert issue.status == IssueStatus.ACCEPTED
def test_duplicate_by_number(client, open_project, editor, outsider, issue):
other = services.create_issue(open_project, outsider, title="Again")
client.force_login(editor)
client.post(iurl("status", open_project, number=2), {"status": "duplicate", "number": 99})
other.refresh_from_db()
assert other.is_open
client.post(iurl("status", open_project, number=2), {"status": "duplicate", "number": 1})
other.refresh_from_db()
assert other.duplicate_of == issue
def test_outsider_cannot_post_status_or_links(client, open_project, outsider, issue, owner):
feature = create_feature(open_project, owner, title="Sign in")
client.force_login(outsider)
assert (
client.post(iurl("status", open_project, number=1), {"status": "accepted"}).status_code
== 403
)
assert (
client.post(
iurl("features", open_project, number=1), {"action": "link", "feature": feature.pk}
).status_code
== 403
)
def test_accept_and_create_feature_from_issue(client, open_project, editor, issue):
client.force_login(editor)
create = reverse("features:create", kwargs={"org_slug": "catbriar", "project_slug": "intent"})
page = client.get(f"{create}?issue=1").content.decode()
assert 'value="Sign in is slow"' in page and "Mark issue #1 accepted" in page
client.post(
f"{create}?issue=1",
{"title": "Fast sign in", "description": "Why?", "note": "", "accept_issue": "on"},
)
issue.refresh_from_db()
feature = issue.features.get()
assert feature.title == "Fast sign in"
assert issue.status == IssueStatus.ACCEPTED
assert feature.revisions.get().issues.get() == issue
def test_update_feature_for_issue_without_accepting(client, open_project, editor, issue, owner):
feature = create_feature(open_project, owner, title="Sign in")
client.force_login(editor)
edit = reverse(
"features:edit",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": "INT-1"},
)
page = client.get(f"{edit}?issue=1").content.decode()
assert "#1: Sign in is slow" in page
client.post(
f"{edit}?issue=1",
{"title": "Sign in", "description": "Faster.", "status": "active", "note": "Speed"},
)
issue.refresh_from_db()
assert issue.is_open and list(issue.features.all()) == [feature]
history = reverse(
"features:history",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": "INT-1"},
)
assert iurl("detail", open_project, number=1) in client.get(history).content.decode()
def test_list_filters(client, open_project, editor, outsider, issue):
services.create_issue(open_project, outsider, title="Closed one")
services.set_status(Issue.objects.get(number=2), editor, IssueStatus.REJECTED)
client.force_login(outsider)
open_page = client.get(iurl("list", open_project)).content.decode()
closed_page = client.get(iurl("list", open_project) + "?status=closed").content.decode()
assert "Sign in is slow" in open_page and "Closed one" not in open_page
assert "Closed one" in closed_page and "Sign in is slow" not in closed_page

View file

@ -39,3 +39,21 @@ def test_line_diff_hunks():
assert ("added", None, 10) in kinds assert ("added", None, 10) in kinds
assert kinds[0] == ("same", 7, 7) # three lines of context assert kinds[0] == ("same", 7, 7) # three lines of context
assert line_diff(old, old) == [] assert line_diff(old, old) == []
def test_blocks_split_top_level_markdown_with_line_ranges():
from intent.markdown import render_blocks, source_lines
text = "# Title\n\nPara one\ncontinues.\n\n- a\n- b\n\n```\ncode\n```\n"
blocks = render_blocks(text)
assert [(b.start, b.end) for b in blocks] == [(1, 1), (3, 4), (6, 7), (9, 11)]
assert blocks[0].anchor == "L1-1" and "<h2" in blocks[0].html
assert "<li" in blocks[2].html and "<li" not in blocks[1].html
assert source_lines(text, 3, 4) == "Para one\ncontinues."
def test_heading_base_is_adjustable():
assert "<h3" in render("# Deep", heading_base=3)
# The shallowest heading lands on the base level, keeping relative structure.
html = render("## Two\n\n### Three\n")
assert "<h2" in html and "<h3" in html and "<h4" not in html