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.projects",
"intent.features",
"intent.issues",
"intent.comments",
]
MIDDLEWARE = [

View file

@ -32,4 +32,6 @@ urlpatterns = [
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>/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. |
| 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 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). |
| 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". |
| 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. |
| 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). |
| 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. |
| Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. |
| 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 (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. |
| 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. |
@ -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. |
| 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. |
| 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. |
| 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. |
@ -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.
- 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`.
- 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
@ -88,18 +90,20 @@ ProjectMembership project, user, role ∈ {owner, collaborator}
Feature project, number, parent (nullable, same project), title, status, superseded_by,
current_revision (int), created_by
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
FeatureFileVersion file, version, storage_key, content_type, size, created_in_revision
Implementation project, name, url, status ∈ {active, archived}
FeatureImplStatus feature, implementation, status, feature_revision, updated_by (unique feature+impl)
Issue project, number, title, body_md, author, status, duplicate_of,
features (M2M), promoted_from_thread (nullable)
CommentThread project, target: one of {feature, feature_line, feature_file, file_image_point, issue},
feature, issue, revision, line_start, line_end, file_version, x, y,
resolved_by, resolved_at, promoted_to (issue)
Comment thread, author, body_md, deleted_at
CommentEdit comment, body_md, edited_at
Issue project, number, title, body_md, author, status, duplicate_of, closed_at,
features (M2M), revisions (M2M FeatureRevision)
IssueEvent issue, actor, kind ∈ {status, linked, unlinked, revision, edited}, status,
previous_status, duplicate_of, feature, revision, old_title, old_body
CommentThread project, kind ∈ {feature, line, issue} (Phase 4 adds file, image point),
feature, issue, revision, line_start, line_end, author,
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:
@ -123,7 +127,9 @@ Resolved per request into a small `Access` object (`can_view`, `can_comment`, `c
| Accept/reject/reopen issue | | | | ✓ | ✓ |
| Create/edit feature, files, status | | | | ✓ | ✓ |
| 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 | | | | | ✓ |
| Create project | | | if `can_create_projects` | | 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.
- 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.
- 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)

View file

@ -106,3 +106,48 @@ def visible_projects(user, org: Organization):
return projects.filter(
Q(visibility__in=[Visibility.PUBLIC, Visibility.AUTHENTICATED]) | Q(memberships__user=user)
).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.",
)
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)
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")
if feature:
others = others.exclude(pk=feature.pk)

View file

@ -9,6 +9,8 @@ from intent.projects.models import Project
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"]
@ -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)
with transaction.atomic():
Project.objects.filter(pk=project.pk).update(
@ -93,12 +108,18 @@ def create_feature(project, by, *, title, description="", parent=None, note="")
)
_validate(feature)
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
def update_feature(feature, by, *, note, **changes) -> FeatureRevision:
"""Apply changes to tracked fields and record a revision. `note` explains why."""
def update_feature(
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)
note = note.strip()
if not note:
@ -113,4 +134,6 @@ def update_feature(feature, by, *, note, **changes) -> FeatureRevision:
raise ValidationError("Nothing changed.")
feature.current_revision += 1
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 intent.comments import views as comment_views
from . import views
app_name = "features"
@ -12,4 +14,9 @@ urlpatterns = [
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>/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.core.exceptions import ValidationError
from django.db.models import Prefetch
from django.http import Http404
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_blocks
from intent.projects.decorators import project_view
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"):
return redirect(
f"features:{name}",
@ -47,7 +60,10 @@ def feature_create(request, project, access):
initial = {}
if parent_key := request.GET.get("parent"):
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
if request.method == "POST" and form.is_valid():
data = form.cleaned_data
@ -62,13 +78,21 @@ def feature_create(request, project, access):
description=data["description"],
parent=data["parent"],
note=data["note"],
issues=[issue] if issue else [],
accept_issues=data.get("accept_issue", False),
)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Created {feature.key}.")
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)
@ -84,7 +108,8 @@ def feature_detail(request, project, access, feature_key):
"ancestors": _ancestors(feature),
"children": feature.children.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)
@ -100,6 +125,7 @@ def _ancestors(feature) -> list[Feature]:
@project_view(require="can_edit")
def feature_edit(request, project, access, feature_key):
feature = get_feature(project, feature_key)
issue = _issue_from_query(request, project)
initial = {
"title": feature.title,
"description": feature.description,
@ -107,12 +133,15 @@ def feature_edit(request, project, access, feature_key):
"status": feature.status,
"superseded_by": feature.superseded_by,
}
if issue:
initial["note"] = f"{issue.key}: {issue.title}"[:500]
form = FeatureForm(
request.POST or None,
project=project,
feature=feature,
exclude_ids=services.descendant_ids(feature),
initial=initial,
issue=issue,
)
preview = None
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:
preview = render_markdown(data["description"])
else:
accept = data.pop("accept_issue", False)
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:
form.add_error(None, exc)
else:
@ -133,6 +169,7 @@ def feature_edit(request, project, access, feature_key):
"feature": feature,
"form": form,
"preview": preview,
"issue": issue,
}
return render(request, "features/form.html", context)
@ -156,7 +193,13 @@ def _changes(revision, previous) -> list[str]:
@project_view()
def feature_history(request, project, access, 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 = [
{"revision": rev, "changes": _changes(rev, revisions[i - 1] if i else None)}
for i, rev in enumerate(revisions)
@ -180,7 +223,7 @@ def feature_revision(request, project, access, feature_key, revision):
"access": access,
"feature": feature,
"snapshot": snapshot,
"description": render_markdown(snapshot.description),
"blocks": render_blocks(snapshot.description),
}
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
anchor to lines of the source. Headings shift down one level so user text never adds a second
<h1> to the page.
anchor to lines of the source.
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
from django.utils.safestring import SafeString, mark_safe
from markdown_it import MarkdownIt
@ -17,12 +22,14 @@ _ALLOWED_TAGS = nh3.ALLOWED_TAGS - {"img"}
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:
if token.nesting == 1 and token.map:
start, end = token.map
token.attrSet("data-source-lines", f"{start + 1}-{end}")
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:
@ -34,6 +41,51 @@ def _build() -> MarkdownIt:
_md = _build()
def render(text: str) -> SafeString:
html = _md.render(text or "")
def _clean(html: str) -> SafeString:
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;
}
/* 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 {
position: absolute;
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. #}
<script src="{% static 'js/theme.js' %}"></script>
<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 %}
</head>
<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">
<h2 id="description-heading" class="visually-hidden">Description</h2>
<div class="prose">
{% if feature.description %}{{ description }}{% else %}<p class="muted">No description yet.</p>{% endif %}
</div>
{% 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">
{% 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>
{% endfor %}
</div>
{% else %}
<p class="muted">No description yet.</p>
{% endif %}
</section>
{% if children %}
@ -66,4 +93,52 @@
</ul>
</section>
{% 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 %}

View file

@ -10,6 +10,13 @@
<h1>New feature</h1>
{% 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 %}
<section aria-labelledby="preview-heading">
<h2 id="preview-heading">Preview</h2>

View file

@ -15,6 +15,7 @@
<th scope="col">Date</th>
<th scope="col">Author</th>
<th scope="col">Note</th>
<th scope="col">Issues</th>
<th scope="col">Changed</th>
{% if rows|length > 1 %}
<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>{{ rev.author.username|default:"unknown" }}</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>
{% if rows|length > 1 %}
<td>

View file

@ -24,7 +24,11 @@
<section aria-labelledby="description-heading">
<h2 id="description-heading" class="visually-hidden">Description</h2>
<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>
</section>
{% 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>
<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 'issues:list' org_slug=project.org.slug project_slug=project.slug %}">Issues</a></li>
{% if access.can_edit %}
<li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li>
{% endif %}

View file

@ -64,3 +64,18 @@ def editor(make_user, org, project):
OrgMembership.objects.create(org=org, user=user)
ProjectMembership.objects.create(project=project, user=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
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.issues.services import create_issue, set_status, update_issue
from intent.orgs.services import invite
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)
update_feature(child, owner, note="Detail", description="Steps\n\n1. One\n2. Two\n")
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
@ -38,9 +54,26 @@ def _pages(org, project, feature):
reverse("features:history", kwargs=fk),
reverse("features:revision", kwargs={**fk, "revision": 1}),
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):
client.force_login(owner)
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
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(
"html, problem",
[

View file

@ -75,3 +75,8 @@ def test_staff_cannot_grant_staff_without_superuser(client, make_user):
target = make_user("tina")
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
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 kinds[0] == ("same", 7, 7) # three lines of context
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