FossilRepo

Add ticket comment thread, RSS feed, CSV export, vdiff links Ticket comments: - Full comment thread on ticket detail page - All comments from ticketchng rendered with markdown/HTML - User links and timestamps on each comment RSS feed: - /fossil/timeline/rss/ serves RSS 2.0 XML feed - Last 30 checkins with links to checkin detail - RSS icon on timeline page CSV export: - /fossil/tickets/export/ downloads all tickets as CSV - "Export CSV" link on ticket list page Link fixes: - /vdiff?from=X&to=Y → compare view - /forumpost/HASH → forum thread (was catching /forum prefix first)

lmata 2026-04-07 00:42 trunk
Commit 63b6ac26ee935d04a6a6702820ee143ead66aaa3ec4625315e2cd935b684fbde
--- fossil/reader.py
+++ fossil/reader.py
@@ -910,10 +910,35 @@
910910
resolution=row["resolution"] or "",
911911
body=body,
912912
)
913913
except sqlite3.OperationalError:
914914
return None
915
+
916
+ def get_ticket_comments(self, uuid: str) -> list[dict]:
917
+ """Get all comments/changes for a ticket."""
918
+ comments = []
919
+ try:
920
+ row = self.conn.execute("SELECT tkt_id FROM ticket WHERE tkt_uuid LIKE ?", (uuid + "%",)).fetchone()
921
+ if not row:
922
+ return []
923
+ rows = self.conn.execute(
924
+ "SELECT tkt_mtime, login, username, icomment, mimetype FROM ticketchng WHERE tkt_id=? ORDER BY tkt_mtime ASC",
925
+ (row["tkt_id"],),
926
+ ).fetchall()
927
+ for r in rows:
928
+ if r["icomment"]:
929
+ comments.append(
930
+ {
931
+ "timestamp": _julian_to_datetime(r["tkt_mtime"]) if r["tkt_mtime"] else None,
932
+ "user": r["username"] or r["login"] or "",
933
+ "comment": r["icomment"],
934
+ "mimetype": r["mimetype"] or "text/plain",
935
+ }
936
+ )
937
+ except sqlite3.OperationalError:
938
+ pass
939
+ return comments
915940
916941
# --- Wiki ---
917942
918943
def get_wiki_pages(self) -> list[WikiPage]:
919944
pages = []
920945
--- fossil/reader.py
+++ fossil/reader.py
@@ -910,10 +910,35 @@
910 resolution=row["resolution"] or "",
911 body=body,
912 )
913 except sqlite3.OperationalError:
914 return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915
916 # --- Wiki ---
917
918 def get_wiki_pages(self) -> list[WikiPage]:
919 pages = []
920
--- fossil/reader.py
+++ fossil/reader.py
@@ -910,10 +910,35 @@
910 resolution=row["resolution"] or "",
911 body=body,
912 )
913 except sqlite3.OperationalError:
914 return None
915
916 def get_ticket_comments(self, uuid: str) -> list[dict]:
917 """Get all comments/changes for a ticket."""
918 comments = []
919 try:
920 row = self.conn.execute("SELECT tkt_id FROM ticket WHERE tkt_uuid LIKE ?", (uuid + "%",)).fetchone()
921 if not row:
922 return []
923 rows = self.conn.execute(
924 "SELECT tkt_mtime, login, username, icomment, mimetype FROM ticketchng WHERE tkt_id=? ORDER BY tkt_mtime ASC",
925 (row["tkt_id"],),
926 ).fetchall()
927 for r in rows:
928 if r["icomment"]:
929 comments.append(
930 {
931 "timestamp": _julian_to_datetime(r["tkt_mtime"]) if r["tkt_mtime"] else None,
932 "user": r["username"] or r["login"] or "",
933 "comment": r["icomment"],
934 "mimetype": r["mimetype"] or "text/plain",
935 }
936 )
937 except sqlite3.OperationalError:
938 pass
939 return comments
940
941 # --- Wiki ---
942
943 def get_wiki_pages(self) -> list[WikiPage]:
944 pages = []
945
--- fossil/urls.py
+++ fossil/urls.py
@@ -27,8 +27,10 @@
2727
path("stats/", views.repo_stats, name="stats"),
2828
path("compare/", views.compare_checkins, name="compare"),
2929
path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
3030
path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
3131
path("code/history/<path:filepath>", views.file_history, name="file_history"),
32
+ path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
33
+ path("tickets/export/", views.tickets_csv, name="tickets_csv"),
3234
path("docs/", views.fossil_docs, name="docs"),
3335
path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
3436
]
3537
--- fossil/urls.py
+++ fossil/urls.py
@@ -27,8 +27,10 @@
27 path("stats/", views.repo_stats, name="stats"),
28 path("compare/", views.compare_checkins, name="compare"),
29 path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
30 path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
31 path("code/history/<path:filepath>", views.file_history, name="file_history"),
 
 
32 path("docs/", views.fossil_docs, name="docs"),
33 path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
34 ]
35
--- fossil/urls.py
+++ fossil/urls.py
@@ -27,8 +27,10 @@
27 path("stats/", views.repo_stats, name="stats"),
28 path("compare/", views.compare_checkins, name="compare"),
29 path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
30 path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
31 path("code/history/<path:filepath>", views.file_history, name="file_history"),
32 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
33 path("tickets/export/", views.tickets_csv, name="tickets_csv"),
34 path("docs/", views.fossil_docs, name="docs"),
35 path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
36 ]
37
+87 -2
--- fossil/views.py
+++ fossil/views.py
@@ -193,15 +193,23 @@
193193
return f'href="{base}/wiki/page/{m.group(1)}"'
194194
# /tktview/HASH or /tktview?name=HASH -> ticket detail
195195
m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
196196
if m:
197197
return f'href="{base}/tickets/{m.group(1)}/"'
198
+ # /vdiff?from=X&to=Y -> compare view
199
+ m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
200
+ if m:
201
+ return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
198202
# /timeline -> timeline
199203
if url.startswith("/timeline"):
200204
return f'href="{base}/timeline/"'
201
- # /forum -> forum
202
- if url.startswith("/forumpost") or url.startswith("/forum"):
205
+ # /forumpost/HASH -> forum thread
206
+ m = re.match(r"/forumpost/([0-9a-f]+)", url)
207
+ if m:
208
+ return f'href="{base}/forum/{m.group(1)}/"'
209
+ # /forum -> forum list
210
+ if url.startswith("/forum"):
203211
return f'href="{base}/forum/"'
204212
# /www/file.wiki or /www/subdir/file -> doc page viewer
205213
m = re.match(r"/(www/.+)", url)
206214
if m:
207215
return f'href="{base}/docs/{m.group(1)}"'
@@ -616,24 +624,35 @@
616624
P.PROJECT_VIEW.check(request.user)
617625
project, fossil_repo, reader = _get_repo_and_reader(slug)
618626
619627
with reader:
620628
ticket = reader.get_ticket_detail(ticket_uuid)
629
+ comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
621630
622631
if not ticket:
623632
raise Http404("Ticket not found")
624633
625634
body_html = mark_safe(_render_fossil_content(ticket.body, project_slug=slug)) if ticket.body else ""
635
+ rendered_comments = []
636
+ for c in comments:
637
+ rendered_comments.append(
638
+ {
639
+ "user": c["user"],
640
+ "timestamp": c["timestamp"],
641
+ "html": mark_safe(_render_fossil_content(c["comment"], project_slug=slug)),
642
+ }
643
+ )
626644
627645
return render(
628646
request,
629647
"fossil/ticket_detail.html",
630648
{
631649
"project": project,
632650
"fossil_repo": fossil_repo,
633651
"ticket": ticket,
634652
"body_html": body_html,
653
+ "comments": rendered_comments,
635654
"active_tab": "tickets",
636655
},
637656
)
638657
639658
@@ -993,10 +1012,76 @@
9931012
"results": results,
9941013
"active_tab": "code",
9951014
},
9961015
)
9971016
1017
+
1018
+# --- RSS Feed ---
1019
+
1020
+
1021
+@login_required
1022
+def timeline_rss(request, slug):
1023
+ """RSS feed of recent timeline entries."""
1024
+ P.PROJECT_VIEW.check(request.user)
1025
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
1026
+
1027
+ with reader:
1028
+ entries = reader.get_timeline(limit=30, event_type="ci")
1029
+
1030
+ from django.http import HttpResponse as DjHttpResponse
1031
+ from django.utils.html import escape
1032
+
1033
+ items = []
1034
+ for e in entries:
1035
+ link = request.build_absolute_uri(f"/projects/{slug}/fossil/checkin/{e.uuid}/")
1036
+ items.append(
1037
+ f"<item><title>{escape(e.comment)}</title><link>{link}</link>"
1038
+ f"<author>{escape(e.user)}</author>"
1039
+ f"<pubDate>{e.timestamp.strftime('%a, %d %b %Y %H:%M:%S +0000')}</pubDate>"
1040
+ f"<guid>{e.uuid}</guid></item>"
1041
+ )
1042
+
1043
+ tl_link = request.build_absolute_uri(f"/projects/{slug}/fossil/timeline/")
1044
+ rss = (
1045
+ '<?xml version="1.0" encoding="UTF-8"?>'
1046
+ '<rss version="2.0"><channel>'
1047
+ f"<title>{escape(project.name)} — Timeline</title>"
1048
+ f"<link>{tl_link}</link>"
1049
+ f"<description>Recent checkins for {escape(project.name)}</description>"
1050
+ f"{''.join(items)}"
1051
+ "</channel></rss>"
1052
+ )
1053
+ return DjHttpResponse(rss, content_type="application/rss+xml")
1054
+
1055
+
1056
+# --- CSV Export ---
1057
+
1058
+
1059
+@login_required
1060
+def tickets_csv(request, slug):
1061
+ """Export all tickets as CSV."""
1062
+ P.PROJECT_VIEW.check(request.user)
1063
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
1064
+
1065
+ with reader:
1066
+ tickets = reader.get_tickets(limit=5000)
1067
+
1068
+ import csv
1069
+ import io
1070
+
1071
+ from django.http import HttpResponse as DjHttpResponse
1072
+
1073
+ output = io.StringIO()
1074
+ writer = csv.writer(output)
1075
+ writer.writerow(["UUID", "Title", "Status", "Type", "Priority", "Severity", "Created"])
1076
+ for t in tickets:
1077
+ writer.writerow([t.uuid, t.title, t.status, t.type, t.priority, t.severity, t.created.isoformat() if t.created else ""])
1078
+
1079
+ response = DjHttpResponse(output.getvalue(), content_type="text/csv")
1080
+ response["Content-Disposition"] = f'attachment; filename="{slug}-tickets.csv"'
1081
+ return response
1082
+
9981083
9991084
# --- File History ---
10001085
10011086
10021087
@login_required
10031088
--- fossil/views.py
+++ fossil/views.py
@@ -193,15 +193,23 @@
193 return f'href="{base}/wiki/page/{m.group(1)}"'
194 # /tktview/HASH or /tktview?name=HASH -> ticket detail
195 m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
196 if m:
197 return f'href="{base}/tickets/{m.group(1)}/"'
 
 
 
 
198 # /timeline -> timeline
199 if url.startswith("/timeline"):
200 return f'href="{base}/timeline/"'
201 # /forum -> forum
202 if url.startswith("/forumpost") or url.startswith("/forum"):
 
 
 
 
203 return f'href="{base}/forum/"'
204 # /www/file.wiki or /www/subdir/file -> doc page viewer
205 m = re.match(r"/(www/.+)", url)
206 if m:
207 return f'href="{base}/docs/{m.group(1)}"'
@@ -616,24 +624,35 @@
616 P.PROJECT_VIEW.check(request.user)
617 project, fossil_repo, reader = _get_repo_and_reader(slug)
618
619 with reader:
620 ticket = reader.get_ticket_detail(ticket_uuid)
 
621
622 if not ticket:
623 raise Http404("Ticket not found")
624
625 body_html = mark_safe(_render_fossil_content(ticket.body, project_slug=slug)) if ticket.body else ""
 
 
 
 
 
 
 
 
 
626
627 return render(
628 request,
629 "fossil/ticket_detail.html",
630 {
631 "project": project,
632 "fossil_repo": fossil_repo,
633 "ticket": ticket,
634 "body_html": body_html,
 
635 "active_tab": "tickets",
636 },
637 )
638
639
@@ -993,10 +1012,76 @@
993 "results": results,
994 "active_tab": "code",
995 },
996 )
997
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
998
999 # --- File History ---
1000
1001
1002 @login_required
1003
--- fossil/views.py
+++ fossil/views.py
@@ -193,15 +193,23 @@
193 return f'href="{base}/wiki/page/{m.group(1)}"'
194 # /tktview/HASH or /tktview?name=HASH -> ticket detail
195 m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
196 if m:
197 return f'href="{base}/tickets/{m.group(1)}/"'
198 # /vdiff?from=X&to=Y -> compare view
199 m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
200 if m:
201 return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
202 # /timeline -> timeline
203 if url.startswith("/timeline"):
204 return f'href="{base}/timeline/"'
205 # /forumpost/HASH -> forum thread
206 m = re.match(r"/forumpost/([0-9a-f]+)", url)
207 if m:
208 return f'href="{base}/forum/{m.group(1)}/"'
209 # /forum -> forum list
210 if url.startswith("/forum"):
211 return f'href="{base}/forum/"'
212 # /www/file.wiki or /www/subdir/file -> doc page viewer
213 m = re.match(r"/(www/.+)", url)
214 if m:
215 return f'href="{base}/docs/{m.group(1)}"'
@@ -616,24 +624,35 @@
624 P.PROJECT_VIEW.check(request.user)
625 project, fossil_repo, reader = _get_repo_and_reader(slug)
626
627 with reader:
628 ticket = reader.get_ticket_detail(ticket_uuid)
629 comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
630
631 if not ticket:
632 raise Http404("Ticket not found")
633
634 body_html = mark_safe(_render_fossil_content(ticket.body, project_slug=slug)) if ticket.body else ""
635 rendered_comments = []
636 for c in comments:
637 rendered_comments.append(
638 {
639 "user": c["user"],
640 "timestamp": c["timestamp"],
641 "html": mark_safe(_render_fossil_content(c["comment"], project_slug=slug)),
642 }
643 )
644
645 return render(
646 request,
647 "fossil/ticket_detail.html",
648 {
649 "project": project,
650 "fossil_repo": fossil_repo,
651 "ticket": ticket,
652 "body_html": body_html,
653 "comments": rendered_comments,
654 "active_tab": "tickets",
655 },
656 )
657
658
@@ -993,10 +1012,76 @@
1012 "results": results,
1013 "active_tab": "code",
1014 },
1015 )
1016
1017
1018 # --- RSS Feed ---
1019
1020
1021 @login_required
1022 def timeline_rss(request, slug):
1023 """RSS feed of recent timeline entries."""
1024 P.PROJECT_VIEW.check(request.user)
1025 project, fossil_repo, reader = _get_repo_and_reader(slug)
1026
1027 with reader:
1028 entries = reader.get_timeline(limit=30, event_type="ci")
1029
1030 from django.http import HttpResponse as DjHttpResponse
1031 from django.utils.html import escape
1032
1033 items = []
1034 for e in entries:
1035 link = request.build_absolute_uri(f"/projects/{slug}/fossil/checkin/{e.uuid}/")
1036 items.append(
1037 f"<item><title>{escape(e.comment)}</title><link>{link}</link>"
1038 f"<author>{escape(e.user)}</author>"
1039 f"<pubDate>{e.timestamp.strftime('%a, %d %b %Y %H:%M:%S +0000')}</pubDate>"
1040 f"<guid>{e.uuid}</guid></item>"
1041 )
1042
1043 tl_link = request.build_absolute_uri(f"/projects/{slug}/fossil/timeline/")
1044 rss = (
1045 '<?xml version="1.0" encoding="UTF-8"?>'
1046 '<rss version="2.0"><channel>'
1047 f"<title>{escape(project.name)} — Timeline</title>"
1048 f"<link>{tl_link}</link>"
1049 f"<description>Recent checkins for {escape(project.name)}</description>"
1050 f"{''.join(items)}"
1051 "</channel></rss>"
1052 )
1053 return DjHttpResponse(rss, content_type="application/rss+xml")
1054
1055
1056 # --- CSV Export ---
1057
1058
1059 @login_required
1060 def tickets_csv(request, slug):
1061 """Export all tickets as CSV."""
1062 P.PROJECT_VIEW.check(request.user)
1063 project, fossil_repo, reader = _get_repo_and_reader(slug)
1064
1065 with reader:
1066 tickets = reader.get_tickets(limit=5000)
1067
1068 import csv
1069 import io
1070
1071 from django.http import HttpResponse as DjHttpResponse
1072
1073 output = io.StringIO()
1074 writer = csv.writer(output)
1075 writer.writerow(["UUID", "Title", "Status", "Type", "Priority", "Severity", "Created"])
1076 for t in tickets:
1077 writer.writerow([t.uuid, t.title, t.status, t.type, t.priority, t.severity, t.created.isoformat() if t.created else ""])
1078
1079 response = DjHttpResponse(output.getvalue(), content_type="text/csv")
1080 response["Content-Disposition"] = f'attachment; filename="{slug}-tickets.csv"'
1081 return response
1082
1083
1084 # --- File History ---
1085
1086
1087 @login_required
1088
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -64,6 +64,27 @@
6464
{{ body_html }}
6565
</div>
6666
</div>
6767
{% endif %}
6868
</div>
69
+
70
+{% if comments %}
71
+<div class="mt-6">
72
+ <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Comments ({{ comments|length }})</h3>
73
+ <div class="space-y-3">
74
+ {% for c in comments %}
75
+ <div class="rounded-lg bg-gray-800 border border-gray-700">
76
+ <div class="px-5 py-3 border-b border-gray-700 flex items-center justify-between">
77
+ <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ c.user }}</a>
78
+ <span class="text-xs text-gray-500">{{ c.timestamp|timesince }} ago</span>
79
+ </div>
80
+ <div class="px-5 py-4">
81
+ <div class="prose prose-invert prose-gray prose-sm max-w-none">
82
+ {{ c.html }}
83
+ </div>
84
+ </div>
85
+ </div>
86
+ {% endfor %}
87
+ </div>
88
+</div>
89
+{% endif %}
6990
{% endblock %}
7091
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -64,6 +64,27 @@
64 {{ body_html }}
65 </div>
66 </div>
67 {% endif %}
68 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69 {% endblock %}
70
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -64,6 +64,27 @@
64 {{ body_html }}
65 </div>
66 </div>
67 {% endif %}
68 </div>
69
70 {% if comments %}
71 <div class="mt-6">
72 <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Comments ({{ comments|length }})</h3>
73 <div class="space-y-3">
74 {% for c in comments %}
75 <div class="rounded-lg bg-gray-800 border border-gray-700">
76 <div class="px-5 py-3 border-b border-gray-700 flex items-center justify-between">
77 <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ c.user }}</a>
78 <span class="text-xs text-gray-500">{{ c.timestamp|timesince }} ago</span>
79 </div>
80 <div class="px-5 py-4">
81 <div class="prose prose-invert prose-gray prose-sm max-w-none">
82 {{ c.html }}
83 </div>
84 </div>
85 </div>
86 {% endfor %}
87 </div>
88 </div>
89 {% endif %}
90 {% endblock %}
91
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -16,10 +16,11 @@
1616
class="rounded-full px-2.5 py-1 {% if status_filter == 'Fixed' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Fixed</a>
1717
<a href="{% url 'fossil:tickets' slug=project.slug %}?status=Closed"
1818
class="rounded-full px-2.5 py-1 {% if status_filter == 'Closed' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Closed</a>
1919
</div>
2020
<div class="flex items-center gap-3">
21
+ <a href="{% url 'fossil:tickets_csv' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light">Export CSV</a>
2122
{% if perms.projects.change_project %}
2223
<a href="{% url 'fossil:ticket_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Ticket</a>
2324
{% endif %}
2425
<input type="search"
2526
name="search"
2627
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -16,10 +16,11 @@
16 class="rounded-full px-2.5 py-1 {% if status_filter == 'Fixed' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Fixed</a>
17 <a href="{% url 'fossil:tickets' slug=project.slug %}?status=Closed"
18 class="rounded-full px-2.5 py-1 {% if status_filter == 'Closed' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Closed</a>
19 </div>
20 <div class="flex items-center gap-3">
 
21 {% if perms.projects.change_project %}
22 <a href="{% url 'fossil:ticket_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Ticket</a>
23 {% endif %}
24 <input type="search"
25 name="search"
26
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -16,10 +16,11 @@
16 class="rounded-full px-2.5 py-1 {% if status_filter == 'Fixed' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Fixed</a>
17 <a href="{% url 'fossil:tickets' slug=project.slug %}?status=Closed"
18 class="rounded-full px-2.5 py-1 {% if status_filter == 'Closed' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Closed</a>
19 </div>
20 <div class="flex items-center gap-3">
21 <a href="{% url 'fossil:tickets_csv' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light">Export CSV</a>
22 {% if perms.projects.change_project %}
23 <a href="{% url 'fossil:ticket_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Ticket</a>
24 {% endif %}
25 <input type="search"
26 name="search"
27
--- templates/fossil/timeline.html
+++ templates/fossil/timeline.html
@@ -3,20 +3,26 @@
33
44
{% block content %}
55
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
66
{% include "fossil/_project_nav.html" %}
77
8
-<div class="flex items-center gap-2 mb-4 text-xs text-gray-500">
8
+<div class="flex items-center justify-between mb-4">
9
+<div class="flex items-center gap-2 text-xs text-gray-500">
910
<span>Filter:</span>
1011
<a href="{% url 'fossil:timeline' slug=project.slug %}"
1112
class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
1213
<a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
1314
class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
1415
<a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
1516
class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
1617
<a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
1718
class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
19
+</div>
20
+<a href="{% url 'fossil:timeline_rss' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light flex items-center gap-1" title="RSS Feed">
21
+ <svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>
22
+ RSS
23
+</a>
1824
</div>
1925
2026
{% include "fossil/partials/timeline_entries.html" %}
2127
2228
{% if entries|length == 50 %}
2329
--- templates/fossil/timeline.html
+++ templates/fossil/timeline.html
@@ -3,20 +3,26 @@
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center gap-2 mb-4 text-xs text-gray-500">
 
9 <span>Filter:</span>
10 <a href="{% url 'fossil:timeline' slug=project.slug %}"
11 class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
12 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
13 class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
14 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
15 class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
16 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
17 class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
 
 
 
 
 
18 </div>
19
20 {% include "fossil/partials/timeline_entries.html" %}
21
22 {% if entries|length == 50 %}
23
--- templates/fossil/timeline.html
+++ templates/fossil/timeline.html
@@ -3,20 +3,26 @@
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center justify-between mb-4">
9 <div class="flex items-center gap-2 text-xs text-gray-500">
10 <span>Filter:</span>
11 <a href="{% url 'fossil:timeline' slug=project.slug %}"
12 class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
13 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
14 class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
15 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
16 class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
17 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
18 class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
19 </div>
20 <a href="{% url 'fossil:timeline_rss' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light flex items-center gap-1" title="RSS Feed">
21 <svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>
22 RSS
23 </a>
24 </div>
25
26 {% include "fossil/partials/timeline_entries.html" %}
27
28 {% if entries|length == 50 %}
29

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button