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>
2026-10-27 09:18:45 -04:00
{% 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 %}.
Harden for launch: limits, CSP, moderation, admin 2FA (Phase 5)
- Rate limits in the shared cache for comments, issues, uploads and
invites; a limited HTMX reply keeps the draft.
- Content-Security-Policy (self only, no inline script or style, bucket
origin for images and frames) and Permissions-Policy on every page.
htmx configured not to inject styles or eval.
- Moderation: deactivating a user ends their sessions and keeps their
content; deleted accounts show as "ghost"; reserved usernames; the
admin won't delete an org's last owner.
- Admin requires a TOTP code (django-otp), with add_admin_totp to enrol.
- Email provider chosen by environment through django-anymail.
- Playwright browser tests: axe WCAG 2.1 AA on every page in light and
dark, no CSP violations, keyboard walkthrough, theme toggle. Explicit
Canvas/CanvasText on :root fixes dark-mode contrast detection.
- bin/start creates the data dir if missing; docs/operations.md runbook.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:51:06 -04:00
Opened by {{ issue.author.username|default:"ghost" }}
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>
2026-10-27 09:18:45 -04:00
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 %}