FossilRepo

fossilrepo / fossil / views.py
Blame History Raw 4576 lines
1
import contextlib
2
import math
3
import re
4
from datetime import datetime
5
6
import markdown as md
7
from django.contrib.auth.decorators import login_required
8
from django.core.paginator import Paginator
9
from django.http import Http404, HttpResponse, JsonResponse
10
from django.shortcuts import get_object_or_404, redirect, render
11
from django.utils.safestring import mark_safe
12
from django.views.decorators.csrf import csrf_exempt
13
14
from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
15
from core.sanitize import sanitize_html
16
from projects.models import Project
17
18
from .models import FossilRepository
19
from .reader import FossilReader
20
21
22
def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
23
"""Render content that may be Fossil wiki markup, HTML, or Markdown.
24
25
Fossil wiki pages can contain:
26
- Raw HTML (most Fossil wiki pages)
27
- Fossil-specific markup: [link|text], <verbatim>...</verbatim>
28
- Markdown (newer pages)
29
30
base_path: directory of the current file (e.g. "www/") for resolving relative links.
31
"""
32
if not content:
33
return ""
34
35
# Detect format from the raw content BEFORE any transformations
36
is_markdown = _is_markdown(content)
37
38
if is_markdown:
39
# Markdown: convert Fossil [path | text] links to markdown links first
40
def _fossil_to_md_link(m):
41
path = m.group(1).strip()
42
text = m.group(2).strip()
43
if path.startswith("./"):
44
path = "/" + base_path + path[2:]
45
elif not path.startswith("/") and not path.startswith("http"):
46
path = "/" + base_path + path if base_path else "/wiki/" + path
47
return f"[{text}]({path})"
48
49
content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
50
content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
51
html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
52
53
# Post-process: render pikchr fenced code blocks to SVG
54
def _render_pikchr_md(m):
55
try:
56
from fossil.cli import FossilCLI
57
58
cli = FossilCLI()
59
svg = cli.render_pikchr(m.group(1))
60
if svg:
61
return f'<div class="pikchr-diagram">{svg}</div>'
62
except Exception:
63
pass
64
return m.group(0)
65
66
html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
67
html = _rewrite_fossil_links(html, project_slug) if project_slug else html
68
return _rewrite_img_srcs(html, project_slug, base_path) if project_slug else html
69
70
# Fossil wiki / HTML: convert Fossil-specific syntax to HTML
71
# Fossil links: [path | text] or [path|text] — spaces around pipe are optional
72
def _fossil_link_replace(match):
73
path = match.group(1).strip()
74
text = match.group(2).strip()
75
# Convert relative paths to absolute using base_path
76
if path.startswith("./"):
77
path = "/" + base_path + path[2:]
78
elif not path.startswith("/") and not path.startswith("http"):
79
path = "/" + base_path + path if base_path else "/wiki/" + path
80
return f'<a href="{path}">{text}</a>'
81
82
# Match [path | text] with flexible whitespace around the pipe
83
content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
84
# Interwiki links: [wikipedia:Article] -> external link
85
content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
86
# Anchor links: [#anchor-name] -> local anchor
87
content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
88
# Bare wiki links: [PageName] (no pipe, not a URL) — use /wiki/ prefix so _rewrite_fossil_links maps it correctly
89
content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="/wiki/\1">\1</a>', content)
90
91
# Verbatim blocks
92
# Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
93
def _render_pikchr_block(m):
94
try:
95
from fossil.cli import FossilCLI
96
97
cli = FossilCLI()
98
svg = cli.render_pikchr(m.group(1))
99
if svg:
100
return f'<div class="pikchr-diagram">{svg}</div>'
101
except Exception:
102
pass
103
return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
104
105
content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
106
# Regular verbatim blocks
107
content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
108
# <nowiki> blocks — strip the tags, content passes through as-is
109
content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
110
111
# Convert Fossil wiki list syntax: * bullets and # enumeration
112
lines = content.split("\n")
113
result = []
114
in_list = False
115
list_type = "ul"
116
for line in lines:
117
stripped_line = line.strip()
118
is_bullet = re.match(r"^\*\s", stripped_line)
119
is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
120
if is_bullet or is_enum:
121
new_type = "ol" if is_enum else "ul"
122
if not in_list:
123
list_type = new_type
124
result.append(f"<{list_type}>")
125
in_list = True
126
elif new_type != list_type:
127
result.append(f"</{list_type}>")
128
list_type = new_type
129
result.append(f"<{list_type}>")
130
item_text = re.sub(r"^[\*#\d+\.\)]\s*", "", stripped_line)
131
result.append(f"<li>{item_text}</li>")
132
else:
133
if in_list:
134
result.append(f"</{list_type}>")
135
in_list = False
136
result.append(line)
137
if in_list:
138
result.append(f"</{list_type}>")
139
140
content = "\n".join(result)
141
142
# Wrap bare text blocks in <p> tags (lines not inside HTML tags)
143
content = re.sub(r"\n\n(?!<)", "\n\n<p>", content)
144
145
content = _rewrite_fossil_links(content, project_slug) if project_slug else content
146
return _rewrite_img_srcs(content, project_slug, base_path) if project_slug else content
147
148
149
def _is_markdown(content: str) -> bool:
150
"""Detect if content is Markdown vs Fossil wiki/HTML.
151
152
Heuristic: if the content starts with markdown-style headings (#),
153
or has significant markdown syntax patterns, treat as markdown.
154
"""
155
stripped = content.strip()
156
# Starts with markdown heading
157
if re.match(r"^#{1,6}\s", stripped):
158
return True
159
# Has multiple markdown headings
160
if len(re.findall(r"^#{1,6}\s", stripped, re.MULTILINE)) >= 2:
161
return True
162
# Has markdown link references [text][ref]
163
if re.search(r"\[.+\]\[.+\]", stripped):
164
return True
165
# Has markdown code fences
166
if "```" in stripped:
167
return True
168
# Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
169
return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
170
171
172
def _rewrite_fossil_links(html: str, project_slug: str) -> str:
173
"""Rewrite internal Fossil URLs to our app's URL structure.
174
175
Fossil links like /doc/trunk/www/file.wiki, /info/HASH, /wiki/PageName,
176
/tktview/HASH get mapped to our fossil app URLs.
177
"""
178
if not project_slug:
179
return html
180
181
base = f"/projects/{project_slug}/fossil"
182
183
def replace_link(match):
184
url = match.group(1)
185
# /info/HASH -> checkin detail
186
m = re.match(r"/info/([0-9a-f]+)", url)
187
if m:
188
return f'href="{base}/checkin/{m.group(1)}/"'
189
# /doc/trunk/www/file or /doc/tip/... -> code file view
190
m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", url)
191
if m:
192
return f'href="{base}/code/file/{m.group(1)}"'
193
# /wiki?name=PageName -> wiki page (query string format)
194
m = re.match(r"/wiki\?name=(.+)", url)
195
if m:
196
return f'href="{base}/wiki/page/{m.group(1)}"'
197
# /wiki/PageName -> wiki page (path format)
198
m = re.match(r"/wiki/(.+)", url)
199
if m:
200
return f'href="{base}/wiki/page/{m.group(1)}"'
201
# /tktview/HASH or /tktview?name=HASH -> ticket detail
202
m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
203
if m:
204
return f'href="{base}/tickets/{m.group(1)}/"'
205
# /vdiff?from=X&to=Y -> compare view
206
m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
207
if m:
208
return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
209
# /timeline -> timeline
210
if url.startswith("/timeline"):
211
return f'href="{base}/timeline/"'
212
# /forumpost/HASH -> forum thread
213
m = re.match(r"/forumpost/([0-9a-f]+)", url)
214
if m:
215
return f'href="{base}/forum/{m.group(1)}/"'
216
# /forum -> forum list
217
if url.startswith("/forum"):
218
return f'href="{base}/forum/"'
219
# /www/file.wiki or /www/subdir/file -> doc page viewer
220
m = re.match(r"/(www/.+)", url)
221
if m:
222
return f'href="{base}/docs/{m.group(1)}"'
223
# /help/command -> Fossil help (link to fossil docs)
224
m = re.match(r"/help/(.+)", url)
225
if m:
226
return f'href="{base}/docs/www/help.wiki"'
227
# Bare .wiki or .md file paths (from relative link resolution)
228
m = re.match(r"/([^/]+\.(?:wiki|md|html))", url)
229
if m:
230
return f'href="{base}/docs/www/{m.group(1)}"'
231
# /dir -> our code browser
232
if url == "/dir" or url.startswith("/dir?"):
233
return f'href="{base}/code/"'
234
# /builtin/path -> code file (these are embedded skin files)
235
m = re.match(r"/builtin/(.+)", url)
236
if m:
237
return f'href="{base}/code/file/skins/{m.group(1)}"'
238
# /setup_*, /admin_* -> Fossil server routes, no mapping
239
if re.match(r"/(setup_|admin_)", url):
240
return match.group(0)
241
# Keep external and unrecognized links as-is
242
return match.group(0)
243
244
def replace_scheme_link(match):
245
"""Handle Fossil URI schemes like forum:/forumpost/HASH, wiki:PageName, info:HASH."""
246
scheme = match.group(1)
247
path = match.group(2)
248
if scheme == "forum":
249
# forum:/forumpost/HASH -> our forum thread
250
m = re.match(r"/forumpost/([0-9a-f]+)", path)
251
if m:
252
return f'href="{base}/forum/{m.group(1)}/"'
253
elif scheme == "info":
254
return f'href="{base}/checkin/{path}/"'
255
elif scheme == "wiki":
256
return f'href="{base}/wiki/page/{path}"'
257
return match.group(0)
258
259
# Rewrite href="/..." links (internal Fossil paths)
260
html = re.sub(r'href="(/[^"]*)"', replace_link, html)
261
# Rewrite Fossil URI schemes: forum:/..., info:..., wiki:...
262
html = re.sub(r'href="(forum|info|wiki):([^"]*)"', replace_scheme_link, html)
263
264
# Rewrite external fossil-scm.org/home links (source repo) to local views
265
# Do NOT rewrite fossil-scm.org/forum links — those are a separate repo/instance
266
def replace_external_fossil(match):
267
path = match.group(1)
268
# /info/HASH
269
m = re.match(r"/info/([0-9a-f]+)", path)
270
if m:
271
return f'href="{base}/checkin/{m.group(1)}/"'
272
# /wiki/PageName
273
m = re.match(r"/wiki/(.+)", path)
274
if m:
275
return f'href="{base}/wiki/page/{m.group(1)}"'
276
# /doc/trunk/www/file -> docs
277
m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", path)
278
if m:
279
return f'href="{base}/docs/{m.group(1)}"'
280
return match.group(0)
281
282
html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/home(/[^"]*)"', replace_external_fossil, html)
283
284
# Do NOT rewrite fossil-scm.org/forum links — that's a separate Fossil
285
# instance. If we have it locally as a different project, the user can
286
# navigate there directly. Rewriting cross-repo links is fragile.
287
return html
288
289
290
def _rewrite_img_srcs(html: str, project_slug: str, base_path: str) -> str:
291
"""Rewrite relative img src attributes to the raw file endpoint.
292
293
Markdown files often reference images with relative paths (e.g. docs/tour.gif).
294
After rendering, those paths would resolve relative to the current page URL
295
(code/file/...) which returns HTML, not the image binary. Rewrite them to
296
code/raw/... which serves the raw file content.
297
"""
298
if not project_slug:
299
return html
300
raw_base = f"/projects/{project_slug}/fossil/code/raw/{base_path}"
301
302
def replace_src(match):
303
src = match.group(1)
304
# Leave absolute URLs, root-relative paths, and data URIs alone
305
if src.startswith(("http://", "https://", "/", "data:")):
306
return match.group(0)
307
return f'src="{raw_base}{src}"'
308
309
return re.sub(r'src="([^"]*)"', replace_src, html)
310
311
312
def _get_repo_and_reader(slug, request=None, require="read"):
313
"""Return (project, fossil_repo, reader) or raise 404/403.
314
315
require: "read", "write", or "admin"
316
"""
317
from projects.access import require_project_admin, require_project_read, require_project_write
318
319
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
320
321
# Access check
322
if request:
323
if require == "admin":
324
require_project_admin(request, project)
325
elif require == "write":
326
require_project_write(request, project)
327
else:
328
require_project_read(request, project)
329
330
fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
331
if not fossil_repo.exists_on_disk:
332
raise Http404("Repository file not found on disk")
333
reader = FossilReader(fossil_repo.full_path)
334
return project, fossil_repo, reader
335
336
337
# --- Code Browser ---
338
339
340
def code_browser(request, slug, dirpath=""):
341
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
342
343
with reader:
344
checkin_uuid = reader.get_latest_checkin_uuid()
345
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
346
metadata = reader.get_metadata()
347
latest_commit = reader.get_timeline(limit=1, event_type="ci")
348
349
# Build directory listing for the current path
350
tree = _build_file_tree(files, current_dir=dirpath)
351
352
# Check for README in current directory
353
readme_html = ""
354
prefix = (dirpath.strip("/") + "/") if dirpath else ""
355
for readme_name in ["README.md", "README", "README.txt", "README.wiki"]:
356
full_name = prefix + readme_name
357
for f in files:
358
if f.name == full_name:
359
with reader:
360
content_bytes = reader.get_file_content(f.uuid)
361
try:
362
readme_content = content_bytes.decode("utf-8")
363
doc_base = prefix if prefix else ""
364
readme_html = mark_safe(sanitize_html(_render_fossil_content(readme_content, project_slug=slug, base_path=doc_base)))
365
except (UnicodeDecodeError, Exception):
366
pass
367
break
368
if readme_html:
369
break
370
371
# Build breadcrumbs
372
breadcrumbs = []
373
if dirpath:
374
parts = dirpath.strip("/").split("/")
375
for i, part in enumerate(parts):
376
breadcrumbs.append({"name": part, "path": "/".join(parts[: i + 1])})
377
378
if request.headers.get("HX-Request"):
379
return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project, "current_dir": dirpath})
380
381
return render(
382
request,
383
"fossil/code_browser.html",
384
{
385
"project": project,
386
"fossil_repo": fossil_repo,
387
"tree": tree,
388
"current_dir": dirpath,
389
"breadcrumbs": breadcrumbs,
390
"checkin_uuid": checkin_uuid,
391
"metadata": metadata,
392
"latest_commit": latest_commit[0] if latest_commit else None,
393
"readme_html": readme_html,
394
"active_tab": "code",
395
},
396
)
397
398
399
def code_file(request, slug, filepath):
400
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
401
402
with reader:
403
checkin_uuid = reader.get_latest_checkin_uuid()
404
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
405
406
# Find the file by path
407
target = None
408
for f in files:
409
if f.name == filepath:
410
target = f
411
break
412
413
if not target:
414
raise Http404(f"File not found: {filepath}")
415
416
content_bytes = reader.get_file_content(target.uuid)
417
418
# Try to decode as text
419
try:
420
content = content_bytes.decode("utf-8")
421
is_binary = False
422
except UnicodeDecodeError:
423
content = f"Binary file ({len(content_bytes)} bytes)"
424
is_binary = True
425
426
# Determine language for syntax highlighting
427
ext = filepath.rsplit(".", 1)[-1] if "." in filepath else ""
428
429
# Build breadcrumbs for file path
430
parts = filepath.split("/")
431
file_breadcrumbs = []
432
for i, part in enumerate(parts):
433
file_breadcrumbs.append({"name": part, "path": "/".join(parts[: i + 1])})
434
435
# Split into lines for line-number display
436
lines = content.split("\n") if not is_binary else []
437
numbered_lines = [{"num": i + 1, "text": line} for i, line in enumerate(lines)]
438
439
# Check if file can be rendered (wiki, markdown, html)
440
can_render = ext in ("wiki", "md", "markdown", "html", "htm")
441
view_mode = request.GET.get("mode", "source")
442
rendered_html = ""
443
if can_render and view_mode == "rendered" and not is_binary:
444
doc_base = "/".join(filepath.split("/")[:-1])
445
if doc_base:
446
doc_base += "/"
447
rendered_html = mark_safe(sanitize_html(_render_fossil_content(content, project_slug=slug, base_path=doc_base)))
448
449
return render(
450
request,
451
"fossil/code_file.html",
452
{
453
"project": project,
454
"fossil_repo": fossil_repo,
455
"filepath": filepath,
456
"file_breadcrumbs": file_breadcrumbs,
457
"content": content,
458
"lines": numbered_lines,
459
"line_count": len(lines),
460
"is_binary": is_binary,
461
"language": ext,
462
"can_render": can_render,
463
"view_mode": view_mode,
464
"rendered_html": rendered_html,
465
"active_tab": "code",
466
},
467
)
468
469
470
# --- Diff helpers ---
471
472
473
def _parse_unified_diff_lines(raw_lines):
474
"""Parse raw unified diff output lines into structured diff_lines list.
475
476
Works with both fossil diff and difflib output.
477
Returns (diff_lines, additions, deletions) tuple.
478
"""
479
diff_lines = []
480
additions = 0
481
deletions = 0
482
old_line = 0
483
new_line = 0
484
485
for line in raw_lines:
486
if line.startswith("====="):
487
continue
488
489
line_type = "context"
490
old_num = ""
491
new_num = ""
492
493
if line.startswith("+++") or line.startswith("---"):
494
line_type = "header"
495
elif line.startswith("@@"):
496
line_type = "hunk"
497
hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
498
if hunk_match:
499
old_line = int(hunk_match.group(1))
500
new_line = int(hunk_match.group(2))
501
elif line.startswith("+"):
502
line_type = "add"
503
additions += 1
504
new_num = new_line
505
new_line += 1
506
elif line.startswith("-"):
507
line_type = "del"
508
deletions += 1
509
old_num = old_line
510
old_line += 1
511
else:
512
old_num = old_line
513
new_num = new_line
514
old_line += 1
515
new_line += 1
516
517
if line_type in ("add", "del", "context") and len(line) > 0:
518
prefix = line[0]
519
code = line[1:]
520
else:
521
prefix = ""
522
code = line
523
524
diff_lines.append(
525
{
526
"text": line,
527
"type": line_type,
528
"old_num": old_num,
529
"new_num": new_num,
530
"prefix": prefix,
531
"code": code,
532
}
533
)
534
535
return diff_lines, additions, deletions
536
537
538
def _parse_fossil_diff_output(raw_output):
539
"""Split multi-file fossil diff output into per-file parsed diffs.
540
541
Returns dict mapping filename -> (diff_lines, additions, deletions).
542
"""
543
if not raw_output or not raw_output.strip():
544
return {}
545
546
result = {}
547
current_name = None
548
current_lines = []
549
550
for line in raw_output.splitlines():
551
if line.startswith("Index: "):
552
if current_name is not None:
553
result[current_name] = _parse_unified_diff_lines(current_lines)
554
current_name = line[7:].strip()
555
current_lines = []
556
elif current_name is not None:
557
current_lines.append(line)
558
559
if current_name is not None:
560
result[current_name] = _parse_unified_diff_lines(current_lines)
561
562
return result
563
564
565
def _compute_split_lines(diff_lines):
566
"""Convert unified diff lines into parallel left/right arrays for split view.
567
568
Context lines appear on both sides. Deletions appear only on the left with
569
an empty placeholder on the right. Additions appear only on the right with
570
an empty placeholder on the left. Adjacent del+add runs are paired row-by-row
571
so moves read naturally.
572
"""
573
left = []
574
right = []
575
576
# Collect runs of consecutive del/add lines so we can pair them
577
i = 0
578
while i < len(diff_lines):
579
dl = diff_lines[i]
580
if dl["type"] in ("header", "hunk"):
581
left.append(dl)
582
right.append(dl)
583
i += 1
584
continue
585
586
if dl["type"] == "del":
587
# Gather contiguous del block, then contiguous add block
588
dels = []
589
while i < len(diff_lines) and diff_lines[i]["type"] == "del":
590
dels.append(diff_lines[i])
591
i += 1
592
adds = []
593
while i < len(diff_lines) and diff_lines[i]["type"] == "add":
594
adds.append(diff_lines[i])
595
i += 1
596
max_len = max(len(dels), len(adds))
597
for j in range(max_len):
598
left.append(dels[j] if j < len(dels) else {"text": "", "type": "empty", "old_num": "", "new_num": ""})
599
right.append(adds[j] if j < len(adds) else {"text": "", "type": "empty", "old_num": "", "new_num": ""})
600
continue
601
602
if dl["type"] == "add":
603
# Orphan add with no preceding del
604
left.append({"text": "", "type": "empty", "old_num": "", "new_num": ""})
605
right.append(dl)
606
i += 1
607
continue
608
609
# Context line
610
left.append(dl)
611
right.append(dl)
612
i += 1
613
614
return left, right
615
616
617
# --- Checkin Detail ---
618
619
620
def checkin_detail(request, slug, checkin_uuid):
621
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
622
623
with reader:
624
checkin = reader.get_checkin_detail(checkin_uuid)
625
if not checkin:
626
raise Http404("Checkin not found")
627
628
# Try fossil native diff first for accurate results matching fossil-scm.org
629
fossil_diffs = {}
630
if checkin.parent_uuid:
631
try:
632
from .cli import FossilCLI
633
634
cli = FossilCLI()
635
raw_diff = cli.diff(fossil_repo.full_path, checkin.parent_uuid, checkin.uuid)
636
if raw_diff:
637
fossil_diffs = _parse_fossil_diff_output(raw_diff)
638
except Exception:
639
pass
640
641
file_diffs = []
642
for f in checkin.files_changed:
643
ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else ""
644
645
if f["name"] in fossil_diffs:
646
diff_lines, additions, deletions = fossil_diffs[f["name"]]
647
is_binary = False
648
else:
649
# Fallback: difflib for files fossil skipped (binary, no parent, etc.)
650
import difflib
651
652
old_text = ""
653
new_text = ""
654
if f["prev_uuid"]:
655
with contextlib.suppress(Exception):
656
old_text = reader.get_file_content(f["prev_uuid"]).decode("utf-8", errors="replace")
657
if f["uuid"]:
658
with contextlib.suppress(Exception):
659
new_text = reader.get_file_content(f["uuid"]).decode("utf-8", errors="replace")
660
661
is_binary = "\x00" in old_text[:1024] or "\x00" in new_text[:1024]
662
diff_lines = []
663
additions = 0
664
deletions = 0
665
666
if not is_binary and (old_text or new_text):
667
diff = difflib.unified_diff(
668
old_text.splitlines(keepends=True),
669
new_text.splitlines(keepends=True),
670
fromfile=f"a/{f['name']}",
671
tofile=f"b/{f['name']}",
672
lineterm="",
673
n=3,
674
)
675
diff_lines, additions, deletions = _parse_unified_diff_lines(list(diff))
676
677
split_left, split_right = _compute_split_lines(diff_lines)
678
file_diffs.append(
679
{
680
"name": f["name"],
681
"change_type": f["change_type"],
682
"uuid": f["uuid"],
683
"is_binary": is_binary,
684
"diff_lines": diff_lines,
685
"split_left": split_left,
686
"split_right": split_right,
687
"additions": additions,
688
"deletions": deletions,
689
"language": ext,
690
}
691
)
692
693
# Fetch CI status checks for this checkin
694
from fossil.ci import StatusCheck
695
696
status_checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
697
698
return render(
699
request,
700
"fossil/checkin_detail.html",
701
{
702
"project": project,
703
"fossil_repo": fossil_repo,
704
"checkin": checkin,
705
"file_diffs": file_diffs,
706
"status_checks": status_checks,
707
"active_tab": "timeline",
708
},
709
)
710
711
712
# --- Timeline ---
713
714
715
def timeline(request, slug):
716
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
717
718
event_type = request.GET.get("type", "")
719
page = int(request.GET.get("page", "1"))
720
per_page = get_per_page(request, default=50)
721
offset = (page - 1) * per_page
722
723
with reader:
724
entries = reader.get_timeline(limit=per_page, offset=offset, event_type=event_type or None)
725
726
# Compute graph data for template
727
graph_entries = _compute_dag_graph(entries)
728
729
if request.headers.get("HX-Request"):
730
return render(request, "fossil/partials/timeline_entries.html", {"entries": graph_entries, "project": project})
731
732
return render(
733
request,
734
"fossil/timeline.html",
735
{
736
"project": project,
737
"fossil_repo": fossil_repo,
738
"entries": graph_entries,
739
"event_type": event_type,
740
"page": page,
741
"per_page": per_page,
742
"per_page_options": PER_PAGE_OPTIONS,
743
"active_tab": "timeline",
744
},
745
)
746
747
748
# --- Tickets ---
749
750
751
def ticket_list(request, slug):
752
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
753
754
status_filter = request.GET.get("status", "Open")
755
if status_filter == "All":
756
status_filter = ""
757
search = request.GET.get("search", "").strip()
758
page = int(request.GET.get("page", "1"))
759
per_page = get_per_page(request, default=50)
760
761
with reader:
762
tickets = reader.get_tickets(status=status_filter or None, limit=1000)
763
764
if search:
765
tickets = [t for t in tickets if search.lower() in t.title.lower()]
766
767
total = len(tickets)
768
total_pages = max(1, math.ceil(total / per_page))
769
page = min(page, total_pages)
770
tickets = tickets[(page - 1) * per_page : page * per_page]
771
has_next = page < total_pages
772
has_prev = page > 1
773
774
if request.headers.get("HX-Request"):
775
return render(request, "fossil/partials/ticket_table.html", {"tickets": tickets, "project": project})
776
777
return render(
778
request,
779
"fossil/ticket_list.html",
780
{
781
"project": project,
782
"fossil_repo": fossil_repo,
783
"tickets": tickets,
784
"status_filter": status_filter,
785
"search": search,
786
"page": page,
787
"per_page": per_page,
788
"per_page_options": PER_PAGE_OPTIONS,
789
"has_next": has_next,
790
"has_prev": has_prev,
791
"total": total,
792
"total_pages": total_pages,
793
"active_tab": "tickets",
794
},
795
)
796
797
798
def ticket_detail(request, slug, ticket_uuid):
799
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
800
801
with reader:
802
ticket = reader.get_ticket_detail(ticket_uuid)
803
comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
804
805
if not ticket:
806
raise Http404("Ticket not found")
807
808
body_html = mark_safe(sanitize_html(_render_fossil_content(ticket.body, project_slug=slug))) if ticket.body else ""
809
rendered_comments = []
810
for c in comments:
811
try:
812
comment_html = mark_safe(sanitize_html(_render_fossil_content(c["comment"], project_slug=slug)))
813
except Exception:
814
comment_html = mark_safe(f"<pre>{c['comment']}</pre>")
815
rendered_comments.append(
816
{
817
"user": c["user"],
818
"timestamp": c["timestamp"],
819
"html": comment_html,
820
}
821
)
822
823
return render(
824
request,
825
"fossil/ticket_detail.html",
826
{
827
"project": project,
828
"fossil_repo": fossil_repo,
829
"ticket": ticket,
830
"body_html": body_html,
831
"comments": rendered_comments,
832
"active_tab": "tickets",
833
},
834
)
835
836
837
# --- Wiki ---
838
839
840
def wiki_list(request, slug):
841
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
842
843
with reader:
844
pages = reader.get_wiki_pages()
845
home_page = reader.get_wiki_page("Home")
846
847
# Sort: Home first, then alphabetical
848
pages = sorted(pages, key=lambda p: "" if p.name == "Home" else "~" + p.name.lower())
849
850
search = request.GET.get("search", "").strip()
851
if search:
852
pages = [p for p in pages if search.lower() in p.name.lower()]
853
854
per_page = get_per_page(request)
855
pages, pagination = manual_paginate(pages, request, per_page=per_page)
856
857
home_content_html = ""
858
if home_page:
859
home_content_html = mark_safe(sanitize_html(_render_fossil_content(home_page.content, project_slug=slug)))
860
861
ctx = {
862
"project": project,
863
"fossil_repo": fossil_repo,
864
"pages": pages,
865
"home_page": home_page,
866
"home_content_html": home_content_html,
867
"search": search,
868
"pagination": pagination,
869
"per_page": per_page,
870
"per_page_options": PER_PAGE_OPTIONS,
871
"active_tab": "wiki",
872
}
873
874
if request.headers.get("HX-Request"):
875
return render(request, "fossil/partials/wiki_list_content.html", ctx)
876
877
return render(request, "fossil/wiki_list.html", ctx)
878
879
880
def wiki_page(request, slug, page_name):
881
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
882
883
with reader:
884
page = reader.get_wiki_page(page_name)
885
all_pages = reader.get_wiki_pages()
886
887
if not page:
888
raise Http404(f"Wiki page not found: {page_name}")
889
890
# Sort: Home first, then alphabetical
891
all_pages = sorted(all_pages, key=lambda p: "" if p.name == "Home" else "~" + p.name.lower())
892
893
content_html = mark_safe(sanitize_html(_render_fossil_content(page.content, project_slug=slug)))
894
895
return render(
896
request,
897
"fossil/wiki_page.html",
898
{
899
"project": project,
900
"fossil_repo": fossil_repo,
901
"page": page,
902
"all_pages": all_pages,
903
"content_html": content_html,
904
"active_tab": "wiki",
905
},
906
)
907
908
909
# --- Forum ---
910
911
912
def forum_list(request, slug):
913
from projects.access import can_write_project
914
915
project, fossil_repo = _get_project_and_repo(slug, request, "read")
916
917
# Read Fossil-native forum posts if the .fossil file exists
918
fossil_posts = []
919
if fossil_repo.exists_on_disk:
920
with FossilReader(fossil_repo.full_path) as reader:
921
fossil_posts = reader.get_forum_posts()
922
923
# Merge Django-backed forum posts alongside Fossil native posts
924
from fossil.forum import ForumPost as DjangoForumPost
925
926
django_threads = DjangoForumPost.objects.filter(
927
repository=fossil_repo,
928
parent__isnull=True,
929
).select_related("created_by")
930
931
# Build unified post list with a common interface
932
merged = []
933
for p in fossil_posts:
934
merged.append({"uuid": p.uuid, "title": p.title, "body": p.body, "user": p.user, "timestamp": p.timestamp, "source": "fossil"})
935
for p in django_threads:
936
merged.append(
937
{
938
"uuid": str(p.pk),
939
"title": p.title,
940
"body": p.body,
941
"user": p.created_by.username if p.created_by else "",
942
"timestamp": p.created_at,
943
"source": "django",
944
}
945
)
946
947
# Sort merged list by timestamp descending
948
merged.sort(key=lambda x: x["timestamp"], reverse=True)
949
950
search = request.GET.get("search", "").strip()
951
if search:
952
search_lower = search.lower()
953
merged = [p for p in merged if search_lower in (p.get("title") or "").lower() or search_lower in (p.get("body") or "").lower()]
954
955
per_page = get_per_page(request)
956
merged, pagination = manual_paginate(merged, request, per_page=per_page)
957
958
has_write = can_write_project(request.user, project)
959
960
return render(
961
request,
962
"fossil/forum_list.html",
963
{
964
"project": project,
965
"fossil_repo": fossil_repo,
966
"posts": merged,
967
"has_write": has_write,
968
"search": search,
969
"pagination": pagination,
970
"per_page": per_page,
971
"per_page_options": PER_PAGE_OPTIONS,
972
"active_tab": "forum",
973
},
974
)
975
976
977
def forum_thread(request, slug, thread_uuid):
978
from projects.access import can_write_project
979
980
project, fossil_repo = _get_project_and_repo(slug, request, "read")
981
982
# Check if this is a Fossil-native thread or a Django-backed thread
983
is_django_thread = False
984
from fossil.forum import ForumPost as DjangoForumPost
985
986
try:
987
django_root = DjangoForumPost.objects.get(pk=int(thread_uuid), repository=fossil_repo)
988
is_django_thread = True
989
except (ValueError, DjangoForumPost.DoesNotExist):
990
django_root = None
991
992
rendered_posts = []
993
994
if is_django_thread:
995
# Django-backed thread: root + replies
996
root = django_root
997
body_html = mark_safe(sanitize_html(md.markdown(root.body, extensions=["fenced_code", "tables"]))) if root.body else ""
998
rendered_posts.append(
999
{
1000
"post": {
1001
"user": root.created_by.username if root.created_by else "",
1002
"title": root.title,
1003
"timestamp": root.created_at,
1004
"in_reply_to": "",
1005
},
1006
"body_html": body_html,
1007
}
1008
)
1009
for reply in DjangoForumPost.objects.filter(thread_root=root).exclude(pk=root.pk).select_related("created_by"):
1010
reply_html = mark_safe(sanitize_html(md.markdown(reply.body, extensions=["fenced_code", "tables"]))) if reply.body else ""
1011
rendered_posts.append(
1012
{
1013
"post": {
1014
"user": reply.created_by.username if reply.created_by else "",
1015
"title": "",
1016
"timestamp": reply.created_at,
1017
"in_reply_to": str(root.pk),
1018
},
1019
"body_html": reply_html,
1020
}
1021
)
1022
else:
1023
# Fossil-native thread -- requires .fossil file on disk
1024
if not fossil_repo.exists_on_disk:
1025
raise Http404("Forum thread not found")
1026
1027
with FossilReader(fossil_repo.full_path) as reader:
1028
posts = reader.get_forum_thread(thread_uuid)
1029
1030
if not posts:
1031
raise Http404("Forum thread not found")
1032
1033
for post in posts:
1034
body_html = mark_safe(sanitize_html(_render_fossil_content(post.body, project_slug=slug))) if post.body else ""
1035
rendered_posts.append({"post": post, "body_html": body_html})
1036
1037
has_write = can_write_project(request.user, project)
1038
1039
return render(
1040
request,
1041
"fossil/forum_thread.html",
1042
{
1043
"project": project,
1044
"fossil_repo": fossil_repo,
1045
"posts": rendered_posts,
1046
"thread_uuid": thread_uuid,
1047
"is_django_thread": is_django_thread,
1048
"has_write": has_write,
1049
"active_tab": "forum",
1050
},
1051
)
1052
1053
1054
@login_required
1055
def forum_create(request, slug):
1056
"""Create a new Django-backed forum thread."""
1057
from django.contrib import messages
1058
1059
project, fossil_repo = _get_project_and_repo(slug, request, "write")
1060
1061
if request.method == "POST":
1062
title = request.POST.get("title", "").strip()
1063
body = request.POST.get("body", "")
1064
if title and body:
1065
from fossil.forum import ForumPost as DjangoForumPost
1066
1067
post = DjangoForumPost.objects.create(
1068
repository=fossil_repo,
1069
title=title,
1070
body=body,
1071
created_by=request.user,
1072
)
1073
# Thread root is self for root posts
1074
post.thread_root = post
1075
post.save(update_fields=["thread_root", "updated_at", "version"])
1076
messages.success(request, f'Thread "{title}" created.')
1077
return redirect("fossil:forum_thread", slug=slug, thread_uuid=str(post.pk))
1078
1079
return render(
1080
request,
1081
"fossil/forum_form.html",
1082
{
1083
"project": project,
1084
"fossil_repo": fossil_repo,
1085
"form_title": "New Thread",
1086
"active_tab": "forum",
1087
},
1088
)
1089
1090
1091
@login_required
1092
def forum_reply(request, slug, post_id):
1093
"""Reply to a Django-backed forum thread."""
1094
from django.contrib import messages
1095
1096
project, fossil_repo = _get_project_and_repo(slug, request, "write")
1097
1098
from fossil.forum import ForumPost as DjangoForumPost
1099
1100
parent = get_object_or_404(DjangoForumPost, pk=post_id, repository=fossil_repo, deleted_at__isnull=True)
1101
1102
# Determine the thread root
1103
thread_root = parent.thread_root if parent.thread_root else parent
1104
1105
if request.method == "POST":
1106
body = request.POST.get("body", "")
1107
if body:
1108
DjangoForumPost.objects.create(
1109
repository=fossil_repo,
1110
title="",
1111
body=body,
1112
parent=parent,
1113
thread_root=thread_root,
1114
created_by=request.user,
1115
)
1116
messages.success(request, "Reply posted.")
1117
return redirect("fossil:forum_thread", slug=slug, thread_uuid=str(thread_root.pk))
1118
1119
return render(
1120
request,
1121
"fossil/forum_form.html",
1122
{
1123
"project": project,
1124
"fossil_repo": fossil_repo,
1125
"parent": parent,
1126
"form_title": f"Reply to: {thread_root.title}",
1127
"active_tab": "forum",
1128
},
1129
)
1130
1131
1132
# --- Webhook Management ---
1133
1134
1135
@login_required
1136
def webhook_list(request, slug):
1137
"""List webhooks for a project."""
1138
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1139
1140
from fossil.webhooks import Webhook
1141
1142
webhooks = Webhook.objects.filter(repository=fossil_repo)
1143
1144
search = request.GET.get("search", "").strip()
1145
if search:
1146
webhooks = webhooks.filter(url__icontains=search)
1147
1148
per_page = get_per_page(request)
1149
paginator = Paginator(webhooks, per_page)
1150
page_obj = paginator.get_page(request.GET.get("page", 1))
1151
1152
return render(
1153
request,
1154
"fossil/webhook_list.html",
1155
{
1156
"project": project,
1157
"fossil_repo": fossil_repo,
1158
"webhooks": page_obj,
1159
"page_obj": page_obj,
1160
"search": search,
1161
"per_page": per_page,
1162
"per_page_options": PER_PAGE_OPTIONS,
1163
"active_tab": "settings",
1164
},
1165
)
1166
1167
1168
@login_required
1169
def webhook_create(request, slug):
1170
"""Create a new webhook."""
1171
from django.contrib import messages
1172
1173
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1174
1175
from fossil.webhooks import Webhook
1176
1177
if request.method == "POST":
1178
url = request.POST.get("url", "").strip()
1179
secret = request.POST.get("secret", "").strip()
1180
events = request.POST.getlist("events")
1181
is_active = request.POST.get("is_active") == "on"
1182
1183
if url:
1184
from core.url_validation import is_safe_outbound_url
1185
1186
is_safe, url_error = is_safe_outbound_url(url)
1187
if not is_safe:
1188
messages.error(request, f"Invalid webhook URL: {url_error}")
1189
else:
1190
events_str = ",".join(events) if events else "all"
1191
Webhook.objects.create(
1192
repository=fossil_repo,
1193
url=url,
1194
secret=secret,
1195
events=events_str,
1196
is_active=is_active,
1197
created_by=request.user,
1198
)
1199
messages.success(request, "Webhook created.")
1200
return redirect("fossil:webhooks", slug=slug)
1201
1202
return render(
1203
request,
1204
"fossil/webhook_form.html",
1205
{
1206
"project": project,
1207
"fossil_repo": fossil_repo,
1208
"form_title": "Create Webhook",
1209
"submit_label": "Create Webhook",
1210
"event_choices": Webhook.EventType.choices,
1211
"active_tab": "settings",
1212
},
1213
)
1214
1215
1216
@login_required
1217
def webhook_edit(request, slug, webhook_id):
1218
"""Edit an existing webhook."""
1219
from django.contrib import messages
1220
1221
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1222
1223
from fossil.webhooks import Webhook
1224
1225
webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1226
1227
if request.method == "POST":
1228
url = request.POST.get("url", "").strip()
1229
secret = request.POST.get("secret", "").strip()
1230
events = request.POST.getlist("events")
1231
is_active = request.POST.get("is_active") == "on"
1232
1233
if url:
1234
from core.url_validation import is_safe_outbound_url
1235
1236
is_safe, url_error = is_safe_outbound_url(url)
1237
if not is_safe:
1238
messages.error(request, f"Invalid webhook URL: {url_error}")
1239
else:
1240
webhook.url = url
1241
if secret:
1242
webhook.secret = secret
1243
webhook.events = ",".join(events) if events else "all"
1244
webhook.is_active = is_active
1245
webhook.updated_by = request.user
1246
webhook.save()
1247
messages.success(request, "Webhook updated.")
1248
return redirect("fossil:webhooks", slug=slug)
1249
1250
return render(
1251
request,
1252
"fossil/webhook_form.html",
1253
{
1254
"project": project,
1255
"fossil_repo": fossil_repo,
1256
"webhook": webhook,
1257
"form_title": f"Edit Webhook: {webhook.url}",
1258
"submit_label": "Update Webhook",
1259
"event_choices": Webhook.EventType.choices,
1260
"active_tab": "settings",
1261
},
1262
)
1263
1264
1265
@login_required
1266
def webhook_delete(request, slug, webhook_id):
1267
"""Soft-delete a webhook."""
1268
from django.contrib import messages
1269
1270
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1271
1272
from fossil.webhooks import Webhook
1273
1274
webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1275
1276
if request.method == "POST":
1277
webhook.soft_delete(user=request.user)
1278
messages.success(request, f"Webhook for {webhook.url} deleted.")
1279
return redirect("fossil:webhooks", slug=slug)
1280
1281
return redirect("fossil:webhooks", slug=slug)
1282
1283
1284
@login_required
1285
def webhook_deliveries(request, slug, webhook_id):
1286
"""View delivery log for a webhook."""
1287
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
1288
1289
from fossil.webhooks import Webhook, WebhookDelivery
1290
1291
webhook = get_object_or_404(Webhook, pk=webhook_id, repository=fossil_repo, deleted_at__isnull=True)
1292
deliveries = WebhookDelivery.objects.filter(webhook=webhook)[:100]
1293
1294
return render(
1295
request,
1296
"fossil/webhook_deliveries.html",
1297
{
1298
"project": project,
1299
"fossil_repo": fossil_repo,
1300
"webhook": webhook,
1301
"deliveries": deliveries,
1302
"active_tab": "settings",
1303
},
1304
)
1305
1306
1307
# --- Wiki CRUD ---
1308
1309
1310
@login_required
1311
def wiki_create(request, slug):
1312
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1313
1314
if request.method == "POST":
1315
page_name = request.POST.get("name", "").strip()
1316
content = request.POST.get("content", "")
1317
if page_name:
1318
from fossil.cli import FossilCLI
1319
1320
cli = FossilCLI()
1321
# Try create first, fall back to commit (update)
1322
success = cli.wiki_create(fossil_repo.full_path, page_name, content)
1323
if not success:
1324
success = cli.wiki_commit(fossil_repo.full_path, page_name, content)
1325
if success:
1326
from django.contrib import messages
1327
1328
messages.success(request, f'Wiki page "{page_name}" created.')
1329
from django.shortcuts import redirect
1330
1331
return redirect("fossil:wiki_page", slug=slug, page_name=page_name)
1332
1333
return render(request, "fossil/wiki_form.html", {"project": project, "active_tab": "wiki", "title": "New Wiki Page"})
1334
1335
1336
@login_required
1337
def wiki_edit(request, slug, page_name):
1338
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1339
1340
with reader:
1341
page = reader.get_wiki_page(page_name)
1342
1343
if not page:
1344
raise Http404(f"Wiki page not found: {page_name}")
1345
1346
if request.method == "POST":
1347
content = request.POST.get("content", "")
1348
from fossil.cli import FossilCLI
1349
1350
cli = FossilCLI()
1351
success = cli.wiki_commit(fossil_repo.full_path, page_name, content)
1352
if success:
1353
from django.contrib import messages
1354
1355
messages.success(request, f'Wiki page "{page_name}" updated.')
1356
from django.shortcuts import redirect
1357
1358
return redirect("fossil:wiki_page", slug=slug, page_name=page_name)
1359
1360
return render(
1361
request,
1362
"fossil/wiki_form.html",
1363
{"project": project, "page": page, "active_tab": "wiki", "title": f"Edit: {page_name}"},
1364
)
1365
1366
1367
# --- Ticket CRUD ---
1368
1369
1370
@login_required
1371
def ticket_create(request, slug):
1372
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1373
1374
from fossil.ticket_fields import TicketFieldDefinition
1375
1376
try:
1377
custom_fields = list(TicketFieldDefinition.objects.filter(repository=fossil_repo))
1378
except Exception:
1379
custom_fields = []
1380
1381
if request.method == "POST":
1382
title = request.POST.get("title", "").strip()
1383
body = request.POST.get("body", "")
1384
ticket_type = request.POST.get("type", "Code_Defect")
1385
severity = request.POST.get("severity", "")
1386
if title:
1387
from fossil.cli import FossilCLI
1388
1389
cli = FossilCLI()
1390
priority = request.POST.get("priority", "")
1391
fields = {"title": title, "type": ticket_type, "comment": body, "status": "Open"}
1392
if severity:
1393
fields["severity"] = severity
1394
if priority:
1395
fields["priority"] = priority
1396
# Collect custom field values
1397
for cf in custom_fields:
1398
if cf.field_type == "checkbox":
1399
val = "1" if request.POST.get(f"custom_{cf.name}") == "on" else "0"
1400
else:
1401
val = request.POST.get(f"custom_{cf.name}", "").strip()
1402
if val:
1403
fields[cf.name] = val
1404
success = cli.ticket_add(fossil_repo.full_path, fields)
1405
if success:
1406
from django.contrib import messages
1407
1408
messages.success(request, f'Ticket "{title}" created.')
1409
from django.shortcuts import redirect
1410
1411
return redirect("fossil:tickets", slug=slug)
1412
1413
return render(
1414
request,
1415
"fossil/ticket_form.html",
1416
{"project": project, "active_tab": "tickets", "title": "New Ticket", "custom_fields": custom_fields},
1417
)
1418
1419
1420
@login_required
1421
def ticket_edit(request, slug, ticket_uuid):
1422
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1423
1424
from fossil.ticket_fields import TicketFieldDefinition
1425
1426
try:
1427
custom_fields = list(TicketFieldDefinition.objects.filter(repository=fossil_repo))
1428
except Exception:
1429
custom_fields = []
1430
1431
with reader:
1432
ticket = reader.get_ticket_detail(ticket_uuid)
1433
if not ticket:
1434
raise Http404("Ticket not found")
1435
1436
if request.method == "POST":
1437
from fossil.cli import FossilCLI
1438
1439
cli = FossilCLI()
1440
fields = {}
1441
for field in ["title", "status", "type", "severity", "priority", "resolution", "subsystem"]:
1442
val = request.POST.get(field, "").strip()
1443
if val:
1444
fields[field] = val
1445
# Collect custom field values
1446
for cf in custom_fields:
1447
if cf.field_type == "checkbox":
1448
val = "1" if request.POST.get(f"custom_{cf.name}") == "on" else "0"
1449
else:
1450
val = request.POST.get(f"custom_{cf.name}", "").strip()
1451
if val:
1452
fields[cf.name] = val
1453
if fields:
1454
success = cli.ticket_change(fossil_repo.full_path, ticket.uuid, fields)
1455
if success:
1456
from django.contrib import messages
1457
1458
messages.success(request, f'Ticket "{ticket.title}" updated.')
1459
from django.shortcuts import redirect
1460
1461
return redirect("fossil:ticket_detail", slug=slug, ticket_uuid=ticket.uuid)
1462
1463
return render(
1464
request,
1465
"fossil/ticket_edit.html",
1466
{"project": project, "ticket": ticket, "custom_fields": custom_fields, "active_tab": "tickets"},
1467
)
1468
1469
1470
@login_required
1471
def ticket_comment(request, slug, ticket_uuid):
1472
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1473
1474
if request.method == "POST":
1475
comment = request.POST.get("comment", "").strip()
1476
if comment:
1477
from django.contrib import messages
1478
1479
try:
1480
from fossil.cli import FossilCLI
1481
1482
cli = FossilCLI()
1483
success = cli.ticket_change(fossil_repo.full_path, ticket_uuid, {"icomment": comment})
1484
if success:
1485
messages.success(request, "Comment added.")
1486
else:
1487
messages.error(request, "Failed to add comment.")
1488
except Exception:
1489
messages.error(request, "Failed to add comment.")
1490
from django.shortcuts import redirect
1491
1492
return redirect("fossil:ticket_detail", slug=slug, ticket_uuid=ticket_uuid)
1493
1494
1495
# --- User Activity ---
1496
1497
1498
def user_activity(request, slug, username):
1499
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1500
1501
with reader:
1502
activity = reader.get_user_activity(username)
1503
1504
import json
1505
1506
heatmap_json = json.dumps(activity.get("daily_activity", {}))
1507
1508
return render(
1509
request,
1510
"fossil/user_activity.html",
1511
{
1512
"project": project,
1513
"fossil_repo": fossil_repo,
1514
"username": username,
1515
"activity": activity,
1516
"heatmap_json": heatmap_json,
1517
"active_tab": "timeline",
1518
},
1519
)
1520
1521
1522
# --- Sync ---
1523
1524
1525
@login_required
1526
def sync_pull(request, slug):
1527
"""Sync configuration and pull from upstream remote."""
1528
from constance import config
1529
1530
if not config.FEATURE_SYNC:
1531
raise Http404
1532
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1533
1534
from fossil.cli import FossilCLI
1535
1536
cli = FossilCLI()
1537
result = None
1538
action = request.POST.get("action", "") if request.method == "POST" else ""
1539
1540
# Auto-detect remote from .fossil file if not saved yet
1541
detected_remote = ""
1542
if not fossil_repo.remote_url and cli.is_available():
1543
detected_remote = cli.get_remote_url(fossil_repo.full_path)
1544
1545
if action == "configure":
1546
# Save remote URL configuration
1547
url = request.POST.get("remote_url", "").strip()
1548
if url:
1549
from core.url_validation import is_safe_outbound_url
1550
1551
is_safe, url_error = is_safe_outbound_url(url)
1552
if not is_safe:
1553
from django.contrib import messages
1554
1555
messages.error(request, f"Invalid remote URL: {url_error}")
1556
from django.shortcuts import redirect
1557
1558
return redirect("fossil:sync", slug=slug)
1559
1560
fossil_repo.remote_url = url
1561
fossil_repo.save(update_fields=["remote_url", "updated_at", "version"])
1562
cli.ensure_default_user(fossil_repo.full_path)
1563
from django.contrib import messages
1564
1565
messages.success(request, f"Sync configured: {url}")
1566
from django.shortcuts import redirect
1567
1568
return redirect("fossil:sync", slug=slug)
1569
1570
elif action == "disable":
1571
fossil_repo.remote_url = ""
1572
fossil_repo.last_sync_at = None
1573
fossil_repo.upstream_artifacts_available = 0
1574
fossil_repo.save(update_fields=["remote_url", "last_sync_at", "upstream_artifacts_available", "updated_at", "version"])
1575
from django.contrib import messages
1576
1577
messages.info(request, "Sync disabled.")
1578
from django.shortcuts import redirect
1579
1580
return redirect("fossil:sync", slug=slug)
1581
1582
elif action in ("push", "sync_bidirectional") and fossil_repo.remote_url:
1583
from django.contrib import messages
1584
1585
from projects.access import can_admin_project
1586
1587
# Enforce branch protection — non-admins blocked if any protected branch restricts push
1588
push_blocked = False
1589
if not can_admin_project(request.user, project):
1590
from fossil.branch_protection import BranchProtection
1591
1592
has_restrictions = BranchProtection.objects.filter(repository=fossil_repo, restrict_push=True, deleted_at__isnull=True).exists()
1593
if has_restrictions:
1594
push_blocked = True
1595
messages.error(
1596
request,
1597
"Push blocked: branch protection rules restrict push to admins only.",
1598
)
1599
1600
if not push_blocked and cli.is_available():
1601
cli.ensure_default_user(fossil_repo.full_path)
1602
if action == "push":
1603
result = cli.push(fossil_repo.full_path)
1604
if result["success"]:
1605
from django.utils import timezone
1606
1607
fossil_repo.last_sync_at = timezone.now()
1608
fossil_repo.save(update_fields=["last_sync_at", "updated_at", "version"])
1609
if result.get("artifacts_sent", 0) > 0:
1610
messages.success(request, f"Pushed {result['artifacts_sent']} artifacts to remote.")
1611
else:
1612
messages.info(request, "Remote is already up to date.")
1613
else:
1614
messages.error(request, f"Push failed: {result.get('message', 'Unknown error')}")
1615
else:
1616
result = cli.sync(fossil_repo.full_path)
1617
if result["success"]:
1618
from django.utils import timezone
1619
1620
fossil_repo.last_sync_at = timezone.now()
1621
with reader:
1622
fossil_repo.checkin_count = reader.get_checkin_count()
1623
fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
1624
fossil_repo.save(update_fields=["last_sync_at", "checkin_count", "file_size_bytes", "updated_at", "version"])
1625
messages.success(request, "Bidirectional sync complete.")
1626
else:
1627
messages.error(request, f"Sync failed: {result.get('message', 'Unknown error')}")
1628
1629
elif action == "pull" and fossil_repo.remote_url:
1630
if cli.is_available():
1631
cli.ensure_default_user(fossil_repo.full_path)
1632
result = cli.pull(fossil_repo.full_path)
1633
if result["success"]:
1634
from django.utils import timezone
1635
1636
fossil_repo.last_sync_at = timezone.now()
1637
if result["artifacts_received"] > 0:
1638
with reader:
1639
fossil_repo.checkin_count = reader.get_checkin_count()
1640
fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
1641
fossil_repo.upstream_artifacts_available = 0
1642
fossil_repo.save(
1643
update_fields=[
1644
"last_sync_at",
1645
"checkin_count",
1646
"file_size_bytes",
1647
"upstream_artifacts_available",
1648
"updated_at",
1649
"version",
1650
]
1651
)
1652
from django.contrib import messages
1653
1654
if result["artifacts_received"] > 0:
1655
messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.")
1656
else:
1657
messages.info(request, "Already up to date.")
1658
1659
from fossil.sync_models import GitMirror
1660
1661
mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True)
1662
1663
return render(
1664
request,
1665
"fossil/sync.html",
1666
{
1667
"project": project,
1668
"fossil_repo": fossil_repo,
1669
"detected_remote": detected_remote,
1670
"sync_configured": bool(fossil_repo.remote_url),
1671
"result": result,
1672
"mirrors": mirrors,
1673
"active_tab": "sync",
1674
},
1675
)
1676
1677
1678
# --- Repository Settings ---
1679
1680
1681
@login_required
1682
def repo_settings(request, slug):
1683
"""Repository settings: remote URL, storage info, danger zone."""
1684
from projects.access import require_project_admin
1685
1686
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1687
require_project_admin(request, project)
1688
fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1689
1690
if request.method == "POST":
1691
action = request.POST.get("action", "")
1692
1693
if action == "update_remote":
1694
remote_url = request.POST.get("remote_url", "").strip()
1695
fossil_repo.remote_url = remote_url
1696
fossil_repo.save(update_fields=["remote_url", "updated_at", "version"])
1697
from django.contrib import messages
1698
1699
messages.success(request, "Remote URL updated.")
1700
1701
elif action == "sync_metadata":
1702
# Refresh metadata from the .fossil file
1703
if fossil_repo.exists_on_disk:
1704
with contextlib.suppress(Exception), FossilReader(fossil_repo.full_path) as reader:
1705
meta = reader.get_metadata()
1706
fossil_repo.checkin_count = meta.checkin_count
1707
fossil_repo.fossil_project_code = meta.project_code
1708
fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
1709
fossil_repo.save(
1710
update_fields=[
1711
"checkin_count",
1712
"fossil_project_code",
1713
"file_size_bytes",
1714
"updated_at",
1715
"version",
1716
]
1717
)
1718
from django.contrib import messages
1719
1720
messages.success(request, "Metadata synced from repository file.")
1721
1722
elif action == "pull_remote":
1723
if fossil_repo.remote_url and fossil_repo.exists_on_disk:
1724
from fossil.cli import FossilCLI
1725
1726
cli = FossilCLI()
1727
if cli.is_available():
1728
cli.ensure_default_user(fossil_repo.full_path)
1729
result = cli.pull(fossil_repo.full_path)
1730
from django.contrib import messages
1731
1732
if result["success"]:
1733
from django.utils import timezone
1734
1735
fossil_repo.last_sync_at = timezone.now()
1736
if result["artifacts_received"] > 0:
1737
with FossilReader(fossil_repo.full_path) as rdr:
1738
fossil_repo.checkin_count = rdr.get_checkin_count()
1739
fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
1740
fossil_repo.save(
1741
update_fields=[
1742
"last_sync_at",
1743
"checkin_count",
1744
"file_size_bytes",
1745
"updated_at",
1746
"version",
1747
]
1748
)
1749
if result["artifacts_received"] > 0:
1750
messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.")
1751
else:
1752
messages.info(request, "Already up to date.")
1753
else:
1754
messages.warning(request, f"Pull failed: {result['message']}")
1755
1756
return redirect("fossil:repo_settings", slug=slug)
1757
1758
# Gather repo info for display
1759
repo_info = {
1760
"exists_on_disk": fossil_repo.exists_on_disk,
1761
}
1762
if fossil_repo.exists_on_disk:
1763
repo_info["file_size"] = fossil_repo.full_path.stat().st_size
1764
repo_info["file_path"] = str(fossil_repo.full_path)
1765
with contextlib.suppress(Exception), FossilReader(fossil_repo.full_path) as reader:
1766
meta = reader.get_metadata()
1767
repo_info["project_name"] = meta.project_name
1768
repo_info["project_code"] = meta.project_code
1769
repo_info["checkin_count"] = meta.checkin_count
1770
repo_info["ticket_count"] = meta.ticket_count
1771
repo_info["wiki_page_count"] = meta.wiki_page_count
1772
1773
return render(
1774
request,
1775
"fossil/repo_settings.html",
1776
{
1777
"project": project,
1778
"fossil_repo": fossil_repo,
1779
"repo_info": repo_info,
1780
"active_tab": "settings",
1781
},
1782
)
1783
1784
1785
# --- Git Mirror ---
1786
1787
1788
@login_required
1789
def git_mirror_config(request, slug, mirror_id=None):
1790
"""Configure Git mirror sync for a project.
1791
1792
If mirror_id is provided, edit that mirror. Otherwise show the add form.
1793
"""
1794
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1795
1796
from fossil.sync_models import GitMirror
1797
1798
mirrors = GitMirror.objects.filter(repository=fossil_repo, deleted_at__isnull=True)
1799
1800
editing_mirror = None
1801
if mirror_id:
1802
editing_mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True)
1803
1804
if request.method == "POST":
1805
action = request.POST.get("action", "")
1806
1807
if action in ("create", "update"):
1808
git_url = request.POST.get("git_remote_url", "").strip()
1809
auth_method = request.POST.get("auth_method", "token")
1810
auth_credential = request.POST.get("auth_credential", "").strip()
1811
# Use OAuth token from session if available and no manual credential provided
1812
if not auth_credential:
1813
if auth_method == "oauth_github" and request.session.get("github_oauth_token"):
1814
auth_credential = request.session.pop("github_oauth_token")
1815
elif auth_method == "oauth_gitlab" and request.session.get("gitlab_oauth_token"):
1816
auth_credential = request.session.pop("gitlab_oauth_token")
1817
sync_mode = request.POST.get("sync_mode", "scheduled")
1818
sync_direction = request.POST.get("sync_direction", "push")
1819
sync_schedule = request.POST.get("sync_schedule", "*/15 * * * *").strip()
1820
git_branch = request.POST.get("git_branch", "main").strip()
1821
fossil_branch = request.POST.get("fossil_branch", "trunk").strip()
1822
sync_tickets = request.POST.get("sync_tickets") == "on"
1823
sync_wiki = request.POST.get("sync_wiki") == "on"
1824
1825
if git_url:
1826
from django.contrib import messages
1827
1828
if action == "update" and editing_mirror:
1829
editing_mirror.git_remote_url = git_url
1830
editing_mirror.auth_method = auth_method
1831
if auth_credential: # Only update credential if a new one was provided
1832
editing_mirror.auth_credential = auth_credential
1833
editing_mirror.sync_mode = sync_mode
1834
editing_mirror.sync_direction = sync_direction
1835
editing_mirror.sync_schedule = sync_schedule
1836
editing_mirror.git_branch = git_branch
1837
editing_mirror.fossil_branch = fossil_branch
1838
editing_mirror.sync_tickets = sync_tickets
1839
editing_mirror.sync_wiki = sync_wiki
1840
editing_mirror.updated_by = request.user
1841
editing_mirror.save()
1842
messages.success(request, f"Mirror updated: {git_url}")
1843
else:
1844
GitMirror.objects.create(
1845
repository=fossil_repo,
1846
git_remote_url=git_url,
1847
auth_method=auth_method,
1848
auth_credential=auth_credential,
1849
sync_mode=sync_mode,
1850
sync_direction=sync_direction,
1851
sync_schedule=sync_schedule,
1852
git_branch=git_branch,
1853
fossil_branch=fossil_branch,
1854
sync_tickets=sync_tickets,
1855
sync_wiki=sync_wiki,
1856
created_by=request.user,
1857
)
1858
messages.success(request, f"Git mirror configured: {git_url}")
1859
1860
return redirect("fossil:git_mirror", slug=slug)
1861
1862
return render(
1863
request,
1864
"fossil/git_mirror.html",
1865
{
1866
"project": project,
1867
"fossil_repo": fossil_repo,
1868
"mirrors": mirrors,
1869
"editing_mirror": editing_mirror,
1870
"auth_method_choices": GitMirror.AuthMethod.choices,
1871
"sync_mode_choices": GitMirror.SyncMode.choices,
1872
"sync_direction_choices": GitMirror.SyncDirection.choices,
1873
"active_tab": "sync",
1874
},
1875
)
1876
1877
1878
@login_required
1879
def git_mirror_delete(request, slug, mirror_id):
1880
"""Delete (soft-delete) a git mirror after confirmation."""
1881
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1882
1883
from fossil.sync_models import GitMirror
1884
1885
mirror = get_object_or_404(GitMirror, pk=mirror_id, repository=fossil_repo, deleted_at__isnull=True)
1886
1887
if request.method == "POST":
1888
from django.contrib import messages
1889
1890
mirror.soft_delete(user=request.user)
1891
messages.success(request, f"Mirror to {mirror.git_remote_url} removed.")
1892
return redirect("fossil:sync", slug=slug)
1893
1894
return render(
1895
request,
1896
"fossil/git_mirror_delete.html",
1897
{
1898
"project": project,
1899
"fossil_repo": fossil_repo,
1900
"mirror": mirror,
1901
"active_tab": "sync",
1902
},
1903
)
1904
1905
1906
@login_required
1907
def git_mirror_run(request, slug, mirror_id):
1908
"""Manually trigger a Git sync for a specific mirror."""
1909
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1910
1911
if request.method == "POST":
1912
from fossil.tasks import run_git_sync
1913
1914
try:
1915
run_git_sync.delay(mirror_id)
1916
from django.contrib import messages
1917
1918
messages.info(request, "Git sync triggered in background.")
1919
except Exception:
1920
# Celery not available — run synchronously
1921
run_git_sync(mirror_id)
1922
from django.contrib import messages
1923
1924
messages.success(request, "Git sync completed.")
1925
1926
from django.shortcuts import redirect
1927
1928
return redirect("fossil:git_mirror", slug=slug)
1929
1930
1931
# --- Fossil Wire Protocol Proxy (clone / push / pull) ---
1932
1933
1934
@csrf_exempt
1935
def fossil_xfer(request, slug):
1936
"""Proxy Fossil sync protocol (clone/push/pull) through Django.
1937
1938
GET — informational page with clone URL.
1939
POST — pipe the request body through ``fossil http`` in CGI mode.
1940
1941
Access control:
1942
- Public repos: anonymous clone/pull allowed (no --localauth).
1943
- Authenticated users with write access: full push/pull (--localauth).
1944
- Private/internal repos: require at least read permission.
1945
1946
Supports HTTP Basic Auth for fossil CLI clients (push/pull/clone).
1947
"""
1948
import base64
1949
1950
from django.contrib.auth import authenticate
1951
1952
from projects.access import can_read_project, can_write_project
1953
1954
from .cli import FossilCLI
1955
1956
# Fossil CLI sends HTTP Basic Auth — Django's session middleware ignores it,
1957
# so we authenticate manually from the Authorization header.
1958
if not request.user.is_authenticated:
1959
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
1960
if auth_header.startswith("Basic "):
1961
try:
1962
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
1963
username, password = decoded.split(":", 1)
1964
user = authenticate(request, username=username, password=password)
1965
if user and user.is_active:
1966
request.user = user
1967
except (ValueError, UnicodeDecodeError):
1968
pass
1969
1970
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1971
fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1972
1973
if request.method == "GET":
1974
if not can_read_project(request.user, project):
1975
from django.core.exceptions import PermissionDenied
1976
1977
raise PermissionDenied
1978
import html as html_mod
1979
1980
clone_url = request.build_absolute_uri()
1981
is_public = project.visibility == "public"
1982
auth_note = "" if is_public else "<p>Authentication is required.</p>"
1983
safe_name = html_mod.escape(project.name)
1984
safe_slug = html_mod.escape(project.slug)
1985
safe_url = html_mod.escape(clone_url)
1986
response_html = (
1987
f"<html><head><title>{safe_name} — Fossil Sync</title></head>"
1988
f"<body>"
1989
f"<h1>{safe_name}</h1>"
1990
f"<p>This is the Fossil sync endpoint for <strong>{safe_name}</strong>.</p>"
1991
f"<p>Clone with:</p>"
1992
f"<pre>fossil clone {safe_url} {safe_slug}.fossil</pre>"
1993
f"{auth_note}"
1994
f"</body></html>"
1995
)
1996
return HttpResponse(response_html)
1997
1998
if request.method == "POST":
1999
if not fossil_repo.exists_on_disk:
2000
raise Http404("Repository file not found on disk.")
2001
2002
from projects.access import can_admin_project
2003
2004
has_write = can_write_project(request.user, project)
2005
has_read = can_read_project(request.user, project)
2006
2007
if not has_read:
2008
from django.core.exceptions import PermissionDenied
2009
2010
raise PermissionDenied
2011
2012
# With --localauth, fossil grants full push access (for authenticated
2013
# writers). Without it, fossil only allows pull/clone (for anonymous
2014
# or read-only users on public repos).
2015
localauth = has_write
2016
2017
# Branch protection enforcement: if any protected branches restrict
2018
# push, only admins get --localauth (push access). Non-admins are
2019
# downgraded to read-only.
2020
if localauth and not can_admin_project(request.user, project):
2021
from fossil.branch_protection import BranchProtection
2022
2023
has_restrictions = BranchProtection.objects.filter(repository=fossil_repo, restrict_push=True, deleted_at__isnull=True).exists()
2024
if has_restrictions:
2025
localauth = False
2026
2027
# Required status checks enforcement: if any protected branches require
2028
# status checks, verify all required CI contexts have a passing latest
2029
# result before granting push access.
2030
if localauth and not can_admin_project(request.user, project):
2031
from fossil.branch_protection import BranchProtection
2032
from fossil.ci import StatusCheck
2033
2034
protections_requiring_checks = BranchProtection.objects.filter(
2035
repository=fossil_repo, require_status_checks=True, deleted_at__isnull=True
2036
)
2037
for protection in protections_requiring_checks:
2038
required_contexts = protection.get_required_contexts_list()
2039
for context in required_contexts:
2040
latest = StatusCheck.objects.filter(repository=fossil_repo, context=context).order_by("-created_at").first()
2041
if not latest or latest.state != "success":
2042
localauth = False
2043
break
2044
if not localauth:
2045
break
2046
2047
cli = FossilCLI()
2048
body, content_type = cli.http_proxy(
2049
fossil_repo.full_path,
2050
request.body,
2051
request.content_type,
2052
localauth=localauth,
2053
)
2054
return HttpResponse(body, content_type=content_type)
2055
2056
return HttpResponse(status=405)
2057
2058
2059
# --- Watch / Notifications ---
2060
2061
2062
@login_required
2063
def toggle_watch(request, slug):
2064
"""Toggle project watch on/off."""
2065
from fossil.notifications import ProjectWatch
2066
2067
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
2068
2069
if request.method == "POST":
2070
watch = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).first()
2071
if watch:
2072
watch.soft_delete(user=request.user)
2073
from django.contrib import messages
2074
2075
messages.info(request, f"Unwatched {project.name}.")
2076
else:
2077
event_filter = request.POST.get("event_filter", "all")
2078
ProjectWatch.objects.create(user=request.user, project=project, event_filter=event_filter, created_by=request.user)
2079
from django.contrib import messages
2080
2081
messages.success(request, f"Watching {project.name}. You'll get email notifications.")
2082
2083
return redirect("projects:detail", slug=slug)
2084
2085
2086
# --- OAuth ---
2087
2088
2089
@login_required
2090
def oauth_github_start(request, slug):
2091
"""Start GitHub OAuth flow."""
2092
from fossil.oauth import github_authorize_url
2093
2094
url = github_authorize_url(request, slug)
2095
if not url:
2096
from django.contrib import messages
2097
2098
messages.error(request, "GitHub OAuth not configured. Set GITHUB_OAUTH_CLIENT_ID in admin settings.")
2099
return redirect("fossil:git_mirror", slug=slug)
2100
return redirect(url)
2101
2102
2103
@login_required
2104
def oauth_gitlab_start(request, slug):
2105
"""Start GitLab OAuth flow."""
2106
from fossil.oauth import gitlab_authorize_url
2107
2108
url = gitlab_authorize_url(request, slug)
2109
if not url:
2110
from django.contrib import messages
2111
2112
messages.error(request, "GitLab OAuth not configured. Set GITLAB_OAUTH_CLIENT_ID in admin settings.")
2113
return redirect("fossil:git_mirror", slug=slug)
2114
return redirect(url)
2115
2116
2117
@login_required
2118
def oauth_github_callback(request, slug):
2119
"""Handle GitHub OAuth callback."""
2120
from fossil.oauth import github_exchange_token
2121
2122
result = github_exchange_token(request, slug)
2123
from django.contrib import messages
2124
2125
if result["token"]:
2126
# Store token in session for the mirror config form to pick up
2127
request.session["github_oauth_token"] = result["token"]
2128
request.session["github_oauth_user"] = result.get("username", "")
2129
messages.success(request, f"Connected to GitHub as {result.get('username', 'unknown')}. Now configure your mirror.")
2130
else:
2131
messages.error(request, f"GitHub OAuth failed: {result.get('error', 'Unknown error')}")
2132
2133
return redirect("fossil:git_mirror", slug=slug)
2134
2135
2136
@login_required
2137
def oauth_gitlab_callback(request, slug):
2138
"""Handle GitLab OAuth callback."""
2139
from fossil.oauth import gitlab_exchange_token
2140
2141
result = gitlab_exchange_token(request, slug)
2142
from django.contrib import messages
2143
2144
if result["token"]:
2145
request.session["gitlab_oauth_token"] = result["token"]
2146
messages.success(request, "Connected to GitLab. Now configure your mirror.")
2147
else:
2148
messages.error(request, f"GitLab OAuth failed: {result.get('error', 'Unknown error')}")
2149
2150
return redirect("fossil:git_mirror", slug=slug)
2151
2152
2153
# --- Technotes ---
2154
2155
2156
def technote_list(request, slug):
2157
from projects.access import can_write_project
2158
2159
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2160
2161
with reader:
2162
notes = reader.get_technotes()
2163
2164
search = request.GET.get("search", "").strip()
2165
if search:
2166
search_lower = search.lower()
2167
notes = [n for n in notes if search_lower in (n.comment or "").lower()]
2168
2169
per_page = get_per_page(request)
2170
notes, pagination = manual_paginate(notes, request, per_page=per_page)
2171
2172
has_write = can_write_project(request.user, project)
2173
2174
return render(
2175
request,
2176
"fossil/technote_list.html",
2177
{
2178
"project": project,
2179
"notes": notes,
2180
"has_write": has_write,
2181
"search": search,
2182
"pagination": pagination,
2183
"per_page": per_page,
2184
"per_page_options": PER_PAGE_OPTIONS,
2185
"active_tab": "wiki",
2186
},
2187
)
2188
2189
2190
@login_required
2191
def technote_create(request, slug):
2192
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
2193
2194
if request.method == "POST":
2195
title = request.POST.get("title", "").strip()
2196
body = request.POST.get("body", "")
2197
timestamp = request.POST.get("timestamp", "").strip()
2198
if title:
2199
from fossil.cli import FossilCLI
2200
2201
cli = FossilCLI()
2202
ts = timestamp if timestamp else None
2203
success = cli.technote_create(
2204
fossil_repo.full_path,
2205
title,
2206
body,
2207
timestamp=ts,
2208
user=request.user.username,
2209
)
2210
if success:
2211
from django.contrib import messages
2212
2213
messages.success(request, f'Technote "{title}" created.')
2214
return redirect("fossil:technotes", slug=slug)
2215
2216
return render(
2217
request,
2218
"fossil/technote_form.html",
2219
{"project": project, "active_tab": "wiki", "form_title": "New Technote"},
2220
)
2221
2222
2223
def technote_detail(request, slug, technote_id):
2224
from projects.access import can_write_project
2225
2226
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2227
2228
with reader:
2229
note = reader.get_technote_detail(technote_id)
2230
2231
if not note:
2232
raise Http404("Technote not found")
2233
2234
body_html = ""
2235
if note["body"]:
2236
body_html = mark_safe(sanitize_html(md.markdown(note["body"], extensions=["footnotes", "tables", "fenced_code"])))
2237
2238
has_write = can_write_project(request.user, project)
2239
2240
return render(
2241
request,
2242
"fossil/technote_detail.html",
2243
{
2244
"project": project,
2245
"note": note,
2246
"body_html": body_html,
2247
"has_write": has_write,
2248
"active_tab": "wiki",
2249
},
2250
)
2251
2252
2253
@login_required
2254
def technote_edit(request, slug, technote_id):
2255
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
2256
2257
with reader:
2258
note = reader.get_technote_detail(technote_id)
2259
2260
if not note:
2261
raise Http404("Technote not found")
2262
2263
if request.method == "POST":
2264
body = request.POST.get("body", "")
2265
from fossil.cli import FossilCLI
2266
2267
cli = FossilCLI()
2268
success = cli.technote_edit(
2269
fossil_repo.full_path,
2270
technote_id,
2271
body,
2272
user=request.user.username,
2273
)
2274
if success:
2275
from django.contrib import messages
2276
2277
messages.success(request, "Technote updated.")
2278
return redirect("fossil:technote_detail", slug=slug, technote_id=technote_id)
2279
2280
return render(
2281
request,
2282
"fossil/technote_form.html",
2283
{
2284
"project": project,
2285
"note": note,
2286
"form_title": f"Edit Technote: {note['comment'][:60]}",
2287
"active_tab": "wiki",
2288
},
2289
)
2290
2291
2292
# --- Unversioned Content ---
2293
2294
2295
def unversioned_list(request, slug):
2296
from constance import config
2297
2298
if not config.FEATURE_FILES:
2299
raise Http404
2300
from projects.access import can_admin_project
2301
2302
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2303
2304
with reader:
2305
files = reader.get_unversioned_files()
2306
2307
search = request.GET.get("search", "").strip()
2308
if search:
2309
search_lower = search.lower()
2310
files = [f for f in files if search_lower in f.name.lower()]
2311
2312
per_page = get_per_page(request)
2313
files, pagination = manual_paginate(files, request, per_page=per_page)
2314
2315
has_admin = can_admin_project(request.user, project)
2316
2317
return render(
2318
request,
2319
"fossil/unversioned_list.html",
2320
{
2321
"project": project,
2322
"files": files,
2323
"has_admin": has_admin,
2324
"search": search,
2325
"pagination": pagination,
2326
"per_page": per_page,
2327
"per_page_options": PER_PAGE_OPTIONS,
2328
"active_tab": "files",
2329
},
2330
)
2331
2332
2333
def unversioned_download(request, slug, filename):
2334
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2335
2336
import mimetypes
2337
2338
from fossil.cli import FossilCLI
2339
2340
cli = FossilCLI()
2341
try:
2342
content = cli.uv_cat(fossil_repo.full_path, filename)
2343
except FileNotFoundError as exc:
2344
raise Http404(f"Unversioned file not found: {filename}") from exc
2345
2346
content_type, _ = mimetypes.guess_type(filename)
2347
if not content_type:
2348
content_type = "application/octet-stream"
2349
2350
response = HttpResponse(content, content_type=content_type)
2351
response["Content-Disposition"] = f'attachment; filename="{filename.split("/")[-1]}"'
2352
response["Content-Length"] = len(content)
2353
return response
2354
2355
2356
@login_required
2357
def unversioned_upload(request, slug):
2358
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
2359
2360
if request.method != "POST":
2361
return redirect("fossil:unversioned", slug=slug)
2362
2363
uploaded_file = request.FILES.get("file")
2364
if not uploaded_file:
2365
from django.contrib import messages
2366
2367
messages.error(request, "No file selected.")
2368
return redirect("fossil:unversioned", slug=slug)
2369
2370
import tempfile
2371
2372
from fossil.cli import FossilCLI
2373
2374
cli = FossilCLI()
2375
2376
# Write uploaded file to a temp location, then add via CLI
2377
with tempfile.NamedTemporaryFile(delete=False) as tmp:
2378
for chunk in uploaded_file.chunks():
2379
tmp.write(chunk)
2380
tmp_path = tmp.name
2381
2382
from pathlib import Path
2383
2384
try:
2385
success = cli.uv_add(fossil_repo.full_path, uploaded_file.name, Path(tmp_path))
2386
from django.contrib import messages
2387
2388
if success:
2389
messages.success(request, f'File "{uploaded_file.name}" uploaded.')
2390
else:
2391
messages.error(request, f'Failed to upload "{uploaded_file.name}".')
2392
finally:
2393
Path(tmp_path).unlink(missing_ok=True)
2394
2395
return redirect("fossil:unversioned", slug=slug)
2396
2397
2398
# --- Compare Checkins ---
2399
2400
2401
def compare_checkins(request, slug):
2402
"""Compare two checkins side by side."""
2403
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2404
2405
from_uuid = request.GET.get("from", "")
2406
to_uuid = request.GET.get("to", "")
2407
2408
from_detail = None
2409
to_detail = None
2410
file_diffs = []
2411
2412
if from_uuid and to_uuid:
2413
with reader:
2414
from_detail = reader.get_checkin_detail(from_uuid)
2415
to_detail = reader.get_checkin_detail(to_uuid)
2416
2417
if from_detail and to_detail:
2418
# Try fossil native diff first
2419
fossil_diffs = {}
2420
try:
2421
from .cli import FossilCLI
2422
2423
cli = FossilCLI()
2424
raw_diff = cli.diff(fossil_repo.full_path, from_uuid, to_uuid)
2425
if raw_diff:
2426
fossil_diffs = _parse_fossil_diff_output(raw_diff)
2427
except Exception:
2428
pass
2429
2430
if fossil_diffs:
2431
for fname, (diff_lines, additions, deletions) in fossil_diffs.items():
2432
if diff_lines:
2433
split_left, split_right = _compute_split_lines(diff_lines)
2434
file_diffs.append(
2435
{
2436
"name": fname,
2437
"diff_lines": diff_lines,
2438
"split_left": split_left,
2439
"split_right": split_right,
2440
"additions": additions,
2441
"deletions": deletions,
2442
}
2443
)
2444
else:
2445
# Fallback to difflib
2446
import difflib
2447
2448
from_files = {f["name"]: f for f in from_detail.files_changed}
2449
to_files = {f["name"]: f for f in to_detail.files_changed}
2450
all_files = sorted(set(list(from_files.keys()) + list(to_files.keys())))
2451
2452
for fname in all_files[:20]:
2453
old_text = ""
2454
new_text = ""
2455
f_from = from_files.get(fname, {})
2456
f_to = to_files.get(fname, {})
2457
2458
if f_from.get("uuid"):
2459
with contextlib.suppress(Exception):
2460
old_text = reader.get_file_content(f_from["uuid"]).decode("utf-8", errors="replace")
2461
if f_to.get("uuid"):
2462
with contextlib.suppress(Exception):
2463
new_text = reader.get_file_content(f_to["uuid"]).decode("utf-8", errors="replace")
2464
2465
if old_text != new_text:
2466
diff = difflib.unified_diff(
2467
old_text.splitlines(keepends=True),
2468
new_text.splitlines(keepends=True),
2469
fromfile=f"a/{fname}",
2470
tofile=f"b/{fname}",
2471
n=3,
2472
)
2473
diff_lines, additions, deletions = _parse_unified_diff_lines(list(diff))
2474
2475
if diff_lines:
2476
split_left, split_right = _compute_split_lines(diff_lines)
2477
file_diffs.append(
2478
{
2479
"name": fname,
2480
"diff_lines": diff_lines,
2481
"split_left": split_left,
2482
"split_right": split_right,
2483
"additions": additions,
2484
"deletions": deletions,
2485
}
2486
)
2487
2488
return render(
2489
request,
2490
"fossil/compare.html",
2491
{
2492
"project": project,
2493
"from_uuid": from_uuid,
2494
"to_uuid": to_uuid,
2495
"from_detail": from_detail,
2496
"to_detail": to_detail,
2497
"file_diffs": file_diffs,
2498
"active_tab": "timeline",
2499
},
2500
)
2501
2502
2503
# --- Search ---
2504
2505
2506
def search(request, slug):
2507
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2508
2509
query = request.GET.get("q", "").strip()
2510
results = None
2511
if query:
2512
with reader:
2513
results = reader.search(query, limit=20)
2514
2515
return render(
2516
request,
2517
"fossil/search.html",
2518
{
2519
"project": project,
2520
"query": query,
2521
"results": results,
2522
"active_tab": "code",
2523
},
2524
)
2525
2526
2527
# --- RSS Feed ---
2528
2529
2530
def timeline_rss(request, slug):
2531
"""RSS feed of recent timeline entries."""
2532
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2533
2534
with reader:
2535
entries = reader.get_timeline(limit=30, event_type="ci")
2536
2537
from django.http import HttpResponse as DjHttpResponse
2538
from django.utils.html import escape
2539
2540
items = []
2541
for e in entries:
2542
link = request.build_absolute_uri(f"/projects/{slug}/fossil/checkin/{e.uuid}/")
2543
items.append(
2544
f"<item><title>{escape(e.comment)}</title><link>{link}</link>"
2545
f"<author>{escape(e.user)}</author>"
2546
f"<pubDate>{e.timestamp.strftime('%a, %d %b %Y %H:%M:%S +0000')}</pubDate>"
2547
f"<guid>{e.uuid}</guid></item>"
2548
)
2549
2550
tl_link = request.build_absolute_uri(f"/projects/{slug}/fossil/timeline/")
2551
rss = (
2552
'<?xml version="1.0" encoding="UTF-8"?>'
2553
'<rss version="2.0"><channel>'
2554
f"<title>{escape(project.name)} — Timeline</title>"
2555
f"<link>{tl_link}</link>"
2556
f"<description>Recent checkins for {escape(project.name)}</description>"
2557
f"{''.join(items)}"
2558
"</channel></rss>"
2559
)
2560
return DjHttpResponse(rss, content_type="application/rss+xml")
2561
2562
2563
# --- CSV Export ---
2564
2565
2566
def tickets_csv(request, slug):
2567
"""Export all tickets as CSV."""
2568
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2569
2570
with reader:
2571
tickets = reader.get_tickets(limit=5000)
2572
2573
import csv
2574
import io
2575
2576
from django.http import HttpResponse as DjHttpResponse
2577
2578
output = io.StringIO()
2579
writer = csv.writer(output)
2580
writer.writerow(["UUID", "Title", "Status", "Type", "Priority", "Severity", "Created"])
2581
for t in tickets:
2582
writer.writerow([t.uuid, t.title, t.status, t.type, t.priority, t.severity, t.created.isoformat() if t.created else ""])
2583
2584
response = DjHttpResponse(output.getvalue(), content_type="text/csv")
2585
response["Content-Disposition"] = f'attachment; filename="{slug}-tickets.csv"'
2586
return response
2587
2588
2589
# --- File History ---
2590
2591
2592
def file_history(request, slug, filepath):
2593
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2594
2595
with reader:
2596
history = reader.get_file_history(filepath)
2597
2598
return render(
2599
request,
2600
"fossil/file_history.html",
2601
{
2602
"project": project,
2603
"filepath": filepath,
2604
"history": history,
2605
"active_tab": "code",
2606
},
2607
)
2608
2609
2610
# --- Branches ---
2611
2612
2613
def branch_list(request, slug):
2614
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2615
2616
with reader:
2617
branches = reader.get_branches()
2618
2619
search = request.GET.get("search", "").strip()
2620
if search:
2621
search_lower = search.lower()
2622
branches = [b for b in branches if search_lower in b.name.lower()]
2623
2624
per_page = get_per_page(request)
2625
branches, pagination = manual_paginate(branches, request, per_page=per_page)
2626
2627
return render(
2628
request,
2629
"fossil/branch_list.html",
2630
{
2631
"project": project,
2632
"fossil_repo": fossil_repo,
2633
"branches": branches,
2634
"search": search,
2635
"pagination": pagination,
2636
"per_page": per_page,
2637
"per_page_options": PER_PAGE_OPTIONS,
2638
"active_tab": "branches",
2639
},
2640
)
2641
2642
2643
# --- Tags ---
2644
2645
2646
def tag_list(request, slug):
2647
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2648
2649
with reader:
2650
tags = reader.get_tags()
2651
2652
search = request.GET.get("search", "").strip()
2653
if search:
2654
search_lower = search.lower()
2655
tags = [t for t in tags if search_lower in t.name.lower()]
2656
2657
per_page = get_per_page(request)
2658
tags, pagination = manual_paginate(tags, request, per_page=per_page)
2659
2660
return render(
2661
request,
2662
"fossil/tag_list.html",
2663
{
2664
"project": project,
2665
"tags": tags,
2666
"search": search,
2667
"pagination": pagination,
2668
"per_page": per_page,
2669
"per_page_options": PER_PAGE_OPTIONS,
2670
"active_tab": "code",
2671
},
2672
)
2673
2674
2675
# --- Raw File Download ---
2676
2677
2678
def code_raw(request, slug, filepath):
2679
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2680
2681
with reader:
2682
checkin_uuid = reader.get_latest_checkin_uuid()
2683
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
2684
target = None
2685
for f in files:
2686
if f.name == filepath:
2687
target = f
2688
break
2689
if not target:
2690
raise Http404(f"File not found: {filepath}")
2691
content_bytes = reader.get_file_content(target.uuid)
2692
2693
from django.http import HttpResponse as DjHttpResponse
2694
2695
filename = filepath.split("/")[-1]
2696
response = DjHttpResponse(content_bytes, content_type="application/octet-stream")
2697
response["Content-Disposition"] = f'attachment; filename="{filename}"'
2698
return response
2699
2700
2701
# --- File Blame ---
2702
2703
2704
def code_blame(request, slug, filepath):
2705
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2706
2707
from fossil.cli import FossilCLI
2708
2709
cli = FossilCLI()
2710
blame_lines = []
2711
if cli.is_available():
2712
blame_lines = cli.blame(fossil_repo.full_path, filepath)
2713
2714
# Compute age-based coloring for blame annotations
2715
if blame_lines:
2716
dates = []
2717
for line in blame_lines:
2718
try:
2719
d = datetime.strptime(line["date"], "%Y-%m-%d")
2720
dates.append(d)
2721
line["_parsed_date"] = d
2722
except (ValueError, KeyError):
2723
line["_parsed_date"] = None
2724
2725
if dates:
2726
min_date = min(dates)
2727
max_date = max(dates)
2728
date_range = (max_date - min_date).days or 1
2729
2730
for line in blame_lines:
2731
age = (line["_parsed_date"] - min_date).days / date_range if line.get("_parsed_date") else 0.5
2732
# Interpolate from gray-500 (#6b7280) to brand (#DC394C)
2733
r = int(107 + age * (220 - 107))
2734
g = int(114 + age * (57 - 114))
2735
b = int(128 + age * (76 - 128))
2736
line["age_color"] = f"rgb({r},{g},{b})"
2737
line["age_bg"] = f"rgba({r},{g},{b},0.08)"
2738
else:
2739
for line in blame_lines:
2740
line["age_color"] = "rgb(107,114,128)"
2741
line["age_bg"] = "transparent"
2742
2743
parts = filepath.split("/")
2744
file_breadcrumbs = [{"name": p, "path": "/".join(parts[: i + 1])} for i, p in enumerate(parts)]
2745
2746
return render(
2747
request,
2748
"fossil/code_blame.html",
2749
{
2750
"project": project,
2751
"filepath": filepath,
2752
"file_breadcrumbs": file_breadcrumbs,
2753
"blame_lines": blame_lines,
2754
"line_count": len(blame_lines),
2755
"active_tab": "code",
2756
},
2757
)
2758
2759
2760
# --- Repository Statistics ---
2761
2762
2763
def repo_stats(request, slug):
2764
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2765
2766
with reader:
2767
stats = reader.get_repo_statistics()
2768
top_contributors = reader.get_top_contributors(limit=15)
2769
activity = reader.get_commit_activity(weeks=52)
2770
2771
import json
2772
2773
return render(
2774
request,
2775
"fossil/repo_stats.html",
2776
{
2777
"project": project,
2778
"stats": stats,
2779
"top_contributors": top_contributors,
2780
"activity_json": json.dumps([c["count"] for c in activity]),
2781
"active_tab": "code",
2782
},
2783
)
2784
2785
2786
# --- Fossil Docs ---
2787
2788
FOSSIL_SCM_SLUG = "fossil-scm"
2789
2790
2791
def fossil_docs(request, slug):
2792
"""Curated Fossil documentation index page."""
2793
from projects.access import require_project_read
2794
2795
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
2796
require_project_read(request, project)
2797
return render(request, "fossil/docs_index.html", {"project": project, "fossil_scm_slug": slug, "active_tab": "wiki"})
2798
2799
2800
def fossil_doc_page(request, slug, doc_path):
2801
"""Render a documentation file from the Fossil repo source tree."""
2802
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2803
2804
with reader:
2805
checkin_uuid = reader.get_latest_checkin_uuid()
2806
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
2807
2808
target = None
2809
# Strip trailing slash for directory-style links
2810
clean_path = doc_path.rstrip("/")
2811
for f in files:
2812
if f.name == clean_path:
2813
target = f
2814
break
2815
2816
# If not found, try index files for directory links
2817
if not target:
2818
for index_name in [f"{clean_path}/index.html", f"{clean_path}/index.md", f"{clean_path}/index.wiki"]:
2819
for f in files:
2820
if f.name == index_name:
2821
target = f
2822
doc_path = index_name
2823
break
2824
if target:
2825
break
2826
2827
if not target:
2828
raise Http404(f"Documentation file not found: {doc_path}")
2829
2830
content_bytes = reader.get_file_content(target.uuid)
2831
2832
try:
2833
content = content_bytes.decode("utf-8")
2834
except UnicodeDecodeError as e:
2835
raise Http404("Binary file cannot be rendered as documentation") from e
2836
2837
# Compute base_path for relative link resolution (e.g. "www/" for "www/concepts.wiki")
2838
doc_base = "/".join(doc_path.split("/")[:-1])
2839
if doc_base:
2840
doc_base += "/"
2841
content_html = mark_safe(sanitize_html(_render_fossil_content(content, project_slug=slug, base_path=doc_base)))
2842
2843
return render(
2844
request,
2845
"fossil/doc_page.html",
2846
{"project": project, "doc_path": doc_path, "content_html": content_html, "active_tab": "wiki"},
2847
)
2848
2849
2850
# --- Helpers ---
2851
2852
2853
def _build_file_tree(files, current_dir=""):
2854
"""Build a flat sorted list for the directory view at a given path.
2855
2856
Shows immediate children (dirs and files) of current_dir. Directories first.
2857
Each directory gets the most recent commit info from its descendants.
2858
"""
2859
prefix = (current_dir.strip("/") + "/") if current_dir else ""
2860
prefix_len = len(prefix)
2861
2862
dirs = {} # immediate child dir name -> most recent file entry
2863
dir_files = [] # immediate child files
2864
2865
for f in files:
2866
# Skip files with characters that break URL routing
2867
if "\n" in f.name or "\r" in f.name or "\x00" in f.name:
2868
continue
2869
# Only consider files under current_dir
2870
if not f.name.startswith(prefix):
2871
continue
2872
# Get the relative path after prefix
2873
relative = f.name[prefix_len:]
2874
parts = relative.split("/")
2875
2876
if len(parts) > 1:
2877
# This file is inside a subdirectory
2878
child_dir = parts[0]
2879
if child_dir not in dirs or (
2880
f.last_commit_time and (not dirs[child_dir].last_commit_time or f.last_commit_time > dirs[child_dir].last_commit_time)
2881
):
2882
dirs[child_dir] = f
2883
else:
2884
dir_files.append(f)
2885
2886
entries = []
2887
# Directories first (sorted)
2888
for dir_name in sorted(dirs):
2889
f = dirs[dir_name]
2890
full_dir_path = (prefix + dir_name) if prefix else dir_name
2891
entries.append(
2892
{
2893
"name": dir_name,
2894
"path": full_dir_path,
2895
"is_dir": True,
2896
"commit_message": f.last_commit_message,
2897
"commit_time": f.last_commit_time,
2898
}
2899
)
2900
# Then files (sorted)
2901
for f in sorted(dir_files, key=lambda x: x.name):
2902
filename = f.name[prefix_len:] if prefix else f.name
2903
entries.append(
2904
{
2905
"name": filename,
2906
"path": f.name,
2907
"is_dir": False,
2908
"file": f,
2909
"size": f.size,
2910
"commit_message": f.last_commit_message,
2911
"commit_time": f.last_commit_time,
2912
}
2913
)
2914
2915
return entries
2916
2917
2918
_RAIL_COLORS = [
2919
"#ef4444", # 0: red
2920
"#3b82f6", # 1: blue
2921
"#22c55e", # 2: green
2922
"#f59e0b", # 3: amber
2923
"#8b5cf6", # 4: purple
2924
"#06b6d4", # 5: cyan
2925
"#ec4899", # 6: pink
2926
"#f97316", # 7: orange
2927
]
2928
2929
2930
def _rail_color(rail: int) -> str:
2931
return _RAIL_COLORS[rail % len(_RAIL_COLORS)]
2932
2933
2934
def _compute_dag_graph(entries):
2935
"""Compute DAG graph positions for timeline entries.
2936
2937
Tracks active rails through each row and draws fork/merge connectors
2938
where a child is on a different rail than its parent. Detects forks
2939
(first commit on a rail whose parent is on a different rail), merges
2940
(commits with multiple parents), and leaf tips (no child on the same rail).
2941
"""
2942
if not entries:
2943
return []
2944
2945
rail_pitch = 16
2946
rail_offset = 20
2947
max_rail = max((e.rail for e in entries if e.rail >= 0), default=0)
2948
graph_width = rail_offset + (max_rail + 2) * rail_pitch
2949
2950
# Build rid-to-index and rid-to-rail lookups
2951
rid_to_idx: dict[int, int] = {}
2952
rid_to_rail: dict[int, int] = {}
2953
for i, entry in enumerate(entries):
2954
rid_to_idx[entry.rid] = i
2955
if entry.event_type == "ci":
2956
rid_to_rail[entry.rid] = max(entry.rail, 0)
2957
2958
# Track which rids have a child on the same rail (for leaf detection).
2959
has_child_on_rail: set[int] = set() # parent rids that have a same-rail child
2960
2961
for _i, entry in enumerate(entries):
2962
if entry.event_type != "ci":
2963
continue
2964
rail = max(entry.rail, 0)
2965
# Mark the primary parent as having a child on this rail
2966
if entry.parent_rid in rid_to_rail and rid_to_rail[entry.parent_rid] == rail:
2967
has_child_on_rail.add(entry.parent_rid)
2968
2969
# Precompute: for each checkin, the range of rows its vertical line spans
2970
# (from the entry's row down to its parent's row, since entries are newest-first)
2971
active_spans: list[tuple[int, int, int]] = [] # (rail, start_idx, end_idx)
2972
for i, entry in enumerate(entries):
2973
if entry.event_type == "ci" and entry.parent_rid in rid_to_idx:
2974
parent_idx = rid_to_idx[entry.parent_rid]
2975
if parent_idx > i:
2976
rail = max(entry.rail, 0)
2977
active_spans.append((rail, i, parent_idx))
2978
2979
# Precompute fork and merge connectors per row.
2980
# Fork: first entry on a rail whose primary parent is on a different rail.
2981
# Merge: entry with merge_parent_rids on different rails.
2982
row_connectors: dict[int, list[dict]] = {}
2983
row_fork_from: dict[int, int | None] = {}
2984
row_merge_to: dict[int, int | None] = {}
2985
2986
for i, entry in enumerate(entries):
2987
if entry.event_type != "ci":
2988
continue
2989
child_rail = max(entry.rail, 0)
2990
2991
# Fork detection: primary parent is on a different rail = actual branch point.
2992
# Draw the connector at the fork commit itself (the oldest entry on the branch),
2993
# not at the newest entry. In newest-first order, this is the entry whose parent
2994
# is on a different rail — there is exactly one such entry per branch.
2995
if entry.parent_rid in rid_to_rail:
2996
parent_rail = rid_to_rail[entry.parent_rid]
2997
if child_rail != parent_rail:
2998
row_fork_from[i] = parent_rail
2999
# Draw the fork connector at this row (where the branch starts)
3000
left_rail = min(child_rail, parent_rail)
3001
right_rail = max(child_rail, parent_rail)
3002
left_x = rail_offset + left_rail * rail_pitch
3003
right_x = rail_offset + right_rail * rail_pitch
3004
conn = {
3005
"left": left_x,
3006
"width": right_x - left_x,
3007
"type": "fork",
3008
"from_rail": parent_rail,
3009
"to_rail": child_rail,
3010
"color": _rail_color(child_rail),
3011
}
3012
row_connectors.setdefault(i, []).append(conn)
3013
3014
# Merge detection: non-primary parents on different rails
3015
for merge_rid in entry.merge_parent_rids:
3016
if merge_rid in rid_to_rail:
3017
merge_rail = rid_to_rail[merge_rid]
3018
if merge_rail != child_rail:
3019
row_merge_to[i] = child_rail
3020
left_rail = min(child_rail, merge_rail)
3021
right_rail = max(child_rail, merge_rail)
3022
left_x = rail_offset + left_rail * rail_pitch
3023
right_x = rail_offset + right_rail * rail_pitch
3024
conn = {
3025
"left": left_x,
3026
"width": right_x - left_x,
3027
"type": "merge",
3028
"from_rail": merge_rail,
3029
"to_rail": child_rail,
3030
"color": _rail_color(merge_rail),
3031
}
3032
row_connectors.setdefault(i, []).append(conn)
3033
3034
result = []
3035
for i, entry in enumerate(entries):
3036
rail = max(entry.rail, 0) if entry.rail >= 0 else 0
3037
node_x = rail_offset + rail * rail_pitch
3038
3039
# Active rails at this row: any span that covers this row
3040
active_rails = set()
3041
for span_rail, span_start, span_end in active_spans:
3042
if span_start <= i <= span_end:
3043
active_rails.add(span_rail)
3044
3045
lines = [{"x": rail_offset + r * rail_pitch, "color": _rail_color(r)} for r in sorted(active_rails)]
3046
connectors = row_connectors.get(i, [])
3047
3048
# A leaf is a checkin that has no child on the same rail within this page
3049
is_leaf = entry.event_type == "ci" and entry.rid not in has_child_on_rail
3050
fork_from = row_fork_from.get(i)
3051
merge_to = row_merge_to.get(i)
3052
3053
result.append(
3054
{
3055
"entry": entry,
3056
"node_x": node_x,
3057
"node_color": _rail_color(rail),
3058
"lines": lines,
3059
"connectors": connectors,
3060
"graph_width": graph_width,
3061
"fork_from": fork_from,
3062
"merge_to": merge_to,
3063
"is_merge": entry.is_merge,
3064
"is_leaf": is_leaf,
3065
}
3066
)
3067
3068
return result
3069
3070
3071
# --- Releases ---
3072
3073
3074
def _get_project_and_repo(slug, request=None, require="read"):
3075
"""Return (project, fossil_repo) without opening the .fossil file.
3076
3077
Used by release views that only need Django ORM access, not Fossil SQLite queries.
3078
"""
3079
from projects.access import require_project_admin, require_project_read, require_project_write
3080
3081
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
3082
3083
if request:
3084
if require == "admin":
3085
require_project_admin(request, project)
3086
elif require == "write":
3087
require_project_write(request, project)
3088
else:
3089
require_project_read(request, project)
3090
3091
fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
3092
return project, fossil_repo
3093
3094
3095
def release_list(request, slug):
3096
from constance import config
3097
3098
if not config.FEATURE_RELEASES:
3099
raise Http404
3100
from projects.access import can_write_project
3101
3102
project, fossil_repo = _get_project_and_repo(slug, request, "read")
3103
3104
from fossil.releases import Release
3105
3106
releases = Release.objects.filter(repository=fossil_repo)
3107
3108
has_write = can_write_project(request.user, project)
3109
if not has_write:
3110
releases = releases.filter(is_draft=False)
3111
3112
search = request.GET.get("search", "").strip()
3113
if search:
3114
releases = releases.filter(tag_name__icontains=search) | releases.filter(name__icontains=search)
3115
releases = releases.distinct()
3116
3117
per_page = get_per_page(request)
3118
paginator = Paginator(releases, per_page)
3119
page_obj = paginator.get_page(request.GET.get("page", 1))
3120
3121
return render(
3122
request,
3123
"fossil/release_list.html",
3124
{
3125
"project": project,
3126
"fossil_repo": fossil_repo,
3127
"releases": page_obj,
3128
"page_obj": page_obj,
3129
"has_write": has_write,
3130
"search": search,
3131
"per_page": per_page,
3132
"per_page_options": PER_PAGE_OPTIONS,
3133
"active_tab": "releases",
3134
},
3135
)
3136
3137
3138
def release_detail(request, slug, tag_name):
3139
from projects.access import can_admin_project, can_write_project
3140
3141
project, fossil_repo = _get_project_and_repo(slug, request, "read")
3142
3143
from fossil.releases import Release
3144
3145
release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3146
3147
# Drafts are only visible to writers
3148
if release.is_draft:
3149
from projects.access import require_project_write
3150
3151
require_project_write(request, project)
3152
3153
body_html = ""
3154
if release.body:
3155
body_html = mark_safe(sanitize_html(md.markdown(release.body, extensions=["footnotes", "tables", "fenced_code"])))
3156
3157
assets = release.assets.filter(deleted_at__isnull=True)
3158
has_write = can_write_project(request.user, project)
3159
has_admin = can_admin_project(request.user, project)
3160
3161
return render(
3162
request,
3163
"fossil/release_detail.html",
3164
{
3165
"project": project,
3166
"fossil_repo": fossil_repo,
3167
"release": release,
3168
"body_html": body_html,
3169
"assets": assets,
3170
"has_write": has_write,
3171
"has_admin": has_admin,
3172
"active_tab": "releases",
3173
},
3174
)
3175
3176
3177
@login_required
3178
def release_create(request, slug):
3179
from django.contrib import messages
3180
from django.utils import timezone
3181
3182
project, fossil_repo = _get_project_and_repo(slug, request, "write")
3183
3184
# Fetch recent checkins for the optional dropdown
3185
recent_checkins = []
3186
with contextlib.suppress(Exception):
3187
reader = FossilReader(fossil_repo.full_path)
3188
with reader:
3189
recent_checkins = reader.get_timeline(limit=20, event_type="ci")
3190
3191
if request.method == "POST":
3192
from fossil.releases import Release
3193
3194
tag_name = request.POST.get("tag_name", "").strip()
3195
name = request.POST.get("name", "").strip()
3196
body = request.POST.get("body", "")
3197
is_prerelease = request.POST.get("is_prerelease") == "on"
3198
is_draft = request.POST.get("is_draft") == "on"
3199
checkin_uuid = request.POST.get("checkin_uuid", "").strip()
3200
3201
if tag_name and name:
3202
published_at = None if is_draft else timezone.now()
3203
release = Release.objects.create(
3204
repository=fossil_repo,
3205
tag_name=tag_name,
3206
name=name,
3207
body=body,
3208
is_prerelease=is_prerelease,
3209
is_draft=is_draft,
3210
published_at=published_at,
3211
checkin_uuid=checkin_uuid,
3212
created_by=request.user,
3213
)
3214
messages.success(request, f'Release "{release.tag_name}" created.')
3215
return redirect("fossil:release_detail", slug=slug, tag_name=release.tag_name)
3216
3217
return render(
3218
request,
3219
"fossil/release_form.html",
3220
{
3221
"project": project,
3222
"fossil_repo": fossil_repo,
3223
"recent_checkins": recent_checkins,
3224
"form_title": "Create Release",
3225
"submit_label": "Create Release",
3226
"active_tab": "releases",
3227
},
3228
)
3229
3230
3231
@login_required
3232
def release_edit(request, slug, tag_name):
3233
from django.contrib import messages
3234
from django.utils import timezone
3235
3236
project, fossil_repo = _get_project_and_repo(slug, request, "write")
3237
3238
from fossil.releases import Release
3239
3240
release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3241
3242
# Fetch recent checkins for the optional dropdown
3243
recent_checkins = []
3244
with contextlib.suppress(Exception):
3245
reader = FossilReader(fossil_repo.full_path)
3246
with reader:
3247
recent_checkins = reader.get_timeline(limit=20, event_type="ci")
3248
3249
if request.method == "POST":
3250
new_tag_name = request.POST.get("tag_name", "").strip()
3251
name = request.POST.get("name", "").strip()
3252
body = request.POST.get("body", "")
3253
is_prerelease = request.POST.get("is_prerelease") == "on"
3254
is_draft = request.POST.get("is_draft") == "on"
3255
checkin_uuid = request.POST.get("checkin_uuid", "").strip()
3256
3257
if new_tag_name and name:
3258
was_draft = release.is_draft
3259
release.tag_name = new_tag_name
3260
release.name = name
3261
release.body = body
3262
release.is_prerelease = is_prerelease
3263
release.is_draft = is_draft
3264
release.checkin_uuid = checkin_uuid
3265
release.updated_by = request.user
3266
# Set published_at when transitioning from draft to published
3267
if was_draft and not is_draft and not release.published_at:
3268
release.published_at = timezone.now()
3269
release.save()
3270
messages.success(request, f'Release "{release.tag_name}" updated.')
3271
return redirect("fossil:release_detail", slug=slug, tag_name=release.tag_name)
3272
3273
return render(
3274
request,
3275
"fossil/release_form.html",
3276
{
3277
"project": project,
3278
"fossil_repo": fossil_repo,
3279
"release": release,
3280
"recent_checkins": recent_checkins,
3281
"form_title": f"Edit Release: {release.tag_name}",
3282
"submit_label": "Update Release",
3283
"active_tab": "releases",
3284
},
3285
)
3286
3287
3288
@login_required
3289
def release_delete(request, slug, tag_name):
3290
from django.contrib import messages
3291
3292
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3293
3294
from fossil.releases import Release
3295
3296
release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3297
3298
if request.method == "POST":
3299
release.soft_delete(user=request.user)
3300
messages.success(request, f'Release "{release.tag_name}" deleted.')
3301
return redirect("fossil:releases", slug=slug)
3302
3303
return redirect("fossil:release_detail", slug=slug, tag_name=tag_name)
3304
3305
3306
@login_required
3307
def release_asset_upload(request, slug, tag_name):
3308
from django.contrib import messages
3309
3310
project, fossil_repo = _get_project_and_repo(slug, request, "write")
3311
3312
from fossil.releases import Release, ReleaseAsset
3313
3314
release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3315
3316
if request.method == "POST" and request.FILES.get("file"):
3317
uploaded = request.FILES["file"]
3318
asset = ReleaseAsset.objects.create(
3319
release=release,
3320
name=uploaded.name,
3321
file=uploaded,
3322
file_size_bytes=uploaded.size,
3323
content_type=uploaded.content_type or "",
3324
created_by=request.user,
3325
)
3326
messages.success(request, f'Asset "{asset.name}" uploaded.')
3327
3328
return redirect("fossil:release_detail", slug=slug, tag_name=tag_name)
3329
3330
3331
def release_asset_download(request, slug, tag_name, asset_id):
3332
from django.db import models as db_models
3333
from django.http import FileResponse
3334
3335
project, fossil_repo = _get_project_and_repo(slug, request, "read")
3336
3337
from fossil.releases import Release, ReleaseAsset
3338
3339
release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3340
3341
if release.is_draft:
3342
from projects.access import require_project_write
3343
3344
require_project_write(request, project)
3345
3346
asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True)
3347
3348
# Increment download count atomically
3349
ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
3350
3351
return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
3352
3353
3354
def release_source_archive(request, slug, tag_name, fmt):
3355
"""Download source archive (tar.gz or zip) for a release's checkin."""
3356
from django.http import FileResponse
3357
3358
project, fossil_repo = _get_project_and_repo(slug, request, "read")
3359
3360
from fossil.releases import Release
3361
3362
release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3363
3364
if release.is_draft:
3365
from projects.access import require_project_write
3366
3367
require_project_write(request, project)
3368
3369
if not release.checkin_uuid:
3370
raise Http404("No checkin linked to this release.")
3371
3372
from .cli import FossilCLI
3373
3374
cli = FossilCLI()
3375
if fmt == "tar.gz":
3376
data = cli.tarball(fossil_repo.full_path, release.checkin_uuid)
3377
content_type = "application/gzip"
3378
filename = f"{project.slug}-{tag_name}.tar.gz"
3379
elif fmt == "zip":
3380
data = cli.zip_archive(fossil_repo.full_path, release.checkin_uuid)
3381
content_type = "application/zip"
3382
filename = f"{project.slug}-{tag_name}.zip"
3383
else:
3384
raise Http404
3385
3386
if not data:
3387
raise Http404("Failed to generate archive.")
3388
3389
import io
3390
3391
return FileResponse(io.BytesIO(data), as_attachment=True, filename=filename, content_type=content_type)
3392
3393
3394
# --- CI Status Check API ---
3395
3396
3397
@csrf_exempt
3398
def status_check_api(request, slug):
3399
"""API endpoint for CI to report status checks.
3400
3401
POST /projects/<slug>/fossil/api/status
3402
Authorization: Bearer <api_token>
3403
{
3404
"checkin": "abc123...",
3405
"context": "ci/tests",
3406
"state": "success",
3407
"description": "All 200 tests passed",
3408
"target_url": "https://ci.example.com/build/123"
3409
}
3410
3411
GET /projects/<slug>/fossil/api/status?checkin=<uuid>
3412
Returns status checks for a specific checkin (public if project is public).
3413
"""
3414
import json
3415
3416
from fossil.api_tokens import authenticate_api_token
3417
from fossil.ci import StatusCheck
3418
3419
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
3420
fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
3421
3422
if request.method == "GET":
3423
# Read access -- use normal project visibility rules
3424
from projects.access import can_read_project
3425
3426
if not can_read_project(request.user, project):
3427
return JsonResponse({"error": "Access denied"}, status=403)
3428
3429
checkin_uuid = request.GET.get("checkin", "")
3430
if not checkin_uuid:
3431
return JsonResponse({"error": "checkin parameter required"}, status=400)
3432
3433
checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
3434
data = [
3435
{
3436
"context": c.context,
3437
"state": c.state,
3438
"description": c.description,
3439
"target_url": c.target_url,
3440
"created_at": c.created_at.isoformat() if c.created_at else None,
3441
}
3442
for c in checks
3443
]
3444
return JsonResponse({"checkin": checkin_uuid, "checks": data})
3445
3446
if request.method == "POST":
3447
token = authenticate_api_token(request, fossil_repo)
3448
if not token:
3449
return JsonResponse({"error": "Invalid or expired token"}, status=401)
3450
3451
if not token.has_permission("status:write"):
3452
return JsonResponse({"error": "Token lacks status:write permission"}, status=403)
3453
3454
try:
3455
body = json.loads(request.body)
3456
except (json.JSONDecodeError, ValueError):
3457
return JsonResponse({"error": "Invalid JSON"}, status=400)
3458
3459
checkin_uuid = body.get("checkin", "").strip()
3460
context = body.get("context", "").strip()
3461
state = body.get("state", "").strip()
3462
description = body.get("description", "").strip()
3463
target_url = body.get("target_url", "").strip()
3464
3465
if not checkin_uuid:
3466
return JsonResponse({"error": "checkin is required"}, status=400)
3467
if not context:
3468
return JsonResponse({"error": "context is required"}, status=400)
3469
if state not in StatusCheck.State.values:
3470
return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400)
3471
if len(context) > 200:
3472
return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400)
3473
if len(description) > 500:
3474
return JsonResponse({"error": "description must be 500 characters or fewer"}, status=400)
3475
if target_url:
3476
from urllib.parse import urlparse
3477
3478
parsed = urlparse(target_url)
3479
if parsed.scheme not in ("http", "https"):
3480
return JsonResponse({"error": "target_url must use http or https scheme"}, status=400)
3481
3482
check, created = StatusCheck.objects.update_or_create(
3483
repository=fossil_repo,
3484
checkin_uuid=checkin_uuid,
3485
context=context,
3486
defaults={
3487
"state": state,
3488
"description": description,
3489
"target_url": target_url,
3490
"created_by": None,
3491
},
3492
)
3493
3494
return JsonResponse(
3495
{
3496
"id": check.pk,
3497
"context": check.context,
3498
"state": check.state,
3499
"description": check.description,
3500
"target_url": check.target_url,
3501
"created": created,
3502
},
3503
status=201 if created else 200,
3504
)
3505
3506
return JsonResponse({"error": "Method not allowed"}, status=405)
3507
3508
3509
def status_badge(request, slug, checkin_uuid):
3510
"""SVG badge for CI status (like shields.io).
3511
3512
Returns an SVG image showing the aggregate status for all checks on a checkin.
3513
"""
3514
from fossil.ci import StatusCheck
3515
3516
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
3517
3518
# Badge endpoint is public for embeddability (like shields.io)
3519
fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
3520
3521
checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
3522
3523
if not checks.exists():
3524
label = "build"
3525
message = "unknown"
3526
color = "#9ca3af" # gray
3527
else:
3528
states = set(checks.values_list("state", flat=True))
3529
if "error" in states or "failure" in states:
3530
label = "build"
3531
message = "failing"
3532
color = "#ef4444" # red
3533
elif "pending" in states:
3534
label = "build"
3535
message = "pending"
3536
color = "#eab308" # yellow
3537
else:
3538
label = "build"
3539
message = "passing"
3540
color = "#22c55e" # green
3541
3542
label_width = len(label) * 7 + 10
3543
message_width = len(message) * 7 + 10
3544
total_width = label_width + message_width
3545
3546
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="20" role="img" aria-label="{label}: {message}">
3547
<title>{label}: {message}</title>
3548
<linearGradient id="s" x2="0" y2="100%">
3549
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
3550
<stop offset="1" stop-opacity=".1"/>
3551
</linearGradient>
3552
<clipPath id="r"><rect width="{total_width}" height="20" rx="3" fill="#fff"/></clipPath>
3553
<g clip-path="url(#r)">
3554
<rect width="{label_width}" height="20" fill="#555"/>
3555
<rect x="{label_width}" width="{message_width}" height="20" fill="{color}"/>
3556
<rect width="{total_width}" height="20" fill="url(#s)"/>
3557
</g>
3558
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
3559
<text x="{label_width / 2}" y="14">{label}</text>
3560
<text x="{label_width + message_width / 2}" y="14">{message}</text>
3561
</g>
3562
</svg>"""
3563
3564
response = HttpResponse(svg, content_type="image/svg+xml")
3565
response["Cache-Control"] = "no-cache, no-store, must-revalidate"
3566
return response
3567
3568
3569
# --- API Token Management ---
3570
3571
3572
@login_required
3573
def api_token_list(request, slug):
3574
"""List API tokens for a project."""
3575
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3576
3577
from fossil.api_tokens import APIToken
3578
3579
tokens = APIToken.objects.filter(repository=fossil_repo)
3580
3581
search = request.GET.get("search", "").strip()
3582
if search:
3583
tokens = tokens.filter(name__icontains=search)
3584
3585
per_page = get_per_page(request)
3586
paginator = Paginator(tokens, per_page)
3587
page_obj = paginator.get_page(request.GET.get("page", 1))
3588
3589
return render(
3590
request,
3591
"fossil/api_token_list.html",
3592
{
3593
"project": project,
3594
"fossil_repo": fossil_repo,
3595
"tokens": page_obj,
3596
"page_obj": page_obj,
3597
"search": search,
3598
"per_page": per_page,
3599
"per_page_options": PER_PAGE_OPTIONS,
3600
"active_tab": "settings",
3601
},
3602
)
3603
3604
3605
@login_required
3606
def api_token_create(request, slug):
3607
"""Generate a new API token. Shows the raw token once."""
3608
from django.contrib import messages
3609
3610
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3611
3612
from fossil.api_tokens import APIToken
3613
3614
raw_token = None
3615
3616
if request.method == "POST":
3617
name = request.POST.get("name", "").strip()
3618
permissions = request.POST.get("permissions", "status:write").strip()
3619
expires_at = request.POST.get("expires_at", "").strip() or None
3620
3621
if not name:
3622
messages.error(request, "Token name is required.")
3623
else:
3624
raw, token_hash, prefix = APIToken.generate()
3625
APIToken.objects.create(
3626
repository=fossil_repo,
3627
name=name,
3628
token_hash=token_hash,
3629
token_prefix=prefix,
3630
permissions=permissions,
3631
expires_at=expires_at,
3632
created_by=request.user,
3633
)
3634
raw_token = raw
3635
messages.success(request, f'Token "{name}" created. Copy it now -- it won\'t be shown again.')
3636
3637
return render(
3638
request,
3639
"fossil/api_token_create.html",
3640
{
3641
"project": project,
3642
"fossil_repo": fossil_repo,
3643
"raw_token": raw_token,
3644
"active_tab": "settings",
3645
},
3646
)
3647
3648
3649
@login_required
3650
def api_token_delete(request, slug, token_id):
3651
"""Revoke (soft-delete) an API token."""
3652
from django.contrib import messages
3653
3654
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3655
3656
from fossil.api_tokens import APIToken
3657
3658
token = get_object_or_404(APIToken, pk=token_id, repository=fossil_repo, deleted_at__isnull=True)
3659
3660
if request.method == "POST":
3661
token.soft_delete(user=request.user)
3662
messages.success(request, f'Token "{token.name}" revoked.')
3663
return redirect("fossil:api_tokens", slug=slug)
3664
3665
return redirect("fossil:api_tokens", slug=slug)
3666
3667
3668
# --- Branch Protection ---
3669
3670
3671
@login_required
3672
def branch_protection_list(request, slug):
3673
"""List branch protection rules."""
3674
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3675
3676
from fossil.branch_protection import BranchProtection
3677
3678
rules = BranchProtection.objects.filter(repository=fossil_repo)
3679
3680
search = request.GET.get("search", "").strip()
3681
if search:
3682
rules = rules.filter(branch_pattern__icontains=search)
3683
3684
per_page = get_per_page(request)
3685
paginator = Paginator(rules, per_page)
3686
page_obj = paginator.get_page(request.GET.get("page", 1))
3687
3688
return render(
3689
request,
3690
"fossil/branch_protection_list.html",
3691
{
3692
"project": project,
3693
"fossil_repo": fossil_repo,
3694
"rules": page_obj,
3695
"page_obj": page_obj,
3696
"search": search,
3697
"per_page": per_page,
3698
"per_page_options": PER_PAGE_OPTIONS,
3699
"active_tab": "settings",
3700
},
3701
)
3702
3703
3704
@login_required
3705
def branch_protection_create(request, slug):
3706
"""Create a new branch protection rule."""
3707
from django.contrib import messages
3708
3709
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3710
3711
from fossil.branch_protection import BranchProtection
3712
3713
if request.method == "POST":
3714
branch_pattern = request.POST.get("branch_pattern", "").strip()
3715
require_status_checks = request.POST.get("require_status_checks") == "on"
3716
required_contexts = request.POST.get("required_contexts", "").strip()
3717
restrict_push = request.POST.get("restrict_push") == "on"
3718
3719
if not branch_pattern:
3720
messages.error(request, "Branch pattern is required.")
3721
elif BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exists():
3722
messages.error(request, f'A rule for "{branch_pattern}" already exists.')
3723
else:
3724
BranchProtection.objects.create(
3725
repository=fossil_repo,
3726
branch_pattern=branch_pattern,
3727
require_status_checks=require_status_checks,
3728
required_contexts=required_contexts,
3729
restrict_push=restrict_push,
3730
created_by=request.user,
3731
)
3732
messages.success(request, f'Branch protection rule for "{branch_pattern}" created.')
3733
return redirect("fossil:branch_protections", slug=slug)
3734
3735
return render(
3736
request,
3737
"fossil/branch_protection_form.html",
3738
{
3739
"project": project,
3740
"fossil_repo": fossil_repo,
3741
"form_title": "Create Branch Protection Rule",
3742
"submit_label": "Create Rule",
3743
"active_tab": "settings",
3744
},
3745
)
3746
3747
3748
@login_required
3749
def branch_protection_edit(request, slug, pk):
3750
"""Edit a branch protection rule."""
3751
from django.contrib import messages
3752
3753
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3754
3755
from fossil.branch_protection import BranchProtection
3756
3757
rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3758
3759
if request.method == "POST":
3760
branch_pattern = request.POST.get("branch_pattern", "").strip()
3761
require_status_checks = request.POST.get("require_status_checks") == "on"
3762
required_contexts = request.POST.get("required_contexts", "").strip()
3763
restrict_push = request.POST.get("restrict_push") == "on"
3764
3765
if not branch_pattern:
3766
messages.error(request, "Branch pattern is required.")
3767
else:
3768
# Check uniqueness if pattern changed
3769
conflict = BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exclude(pk=rule.pk).exists()
3770
if conflict:
3771
messages.error(request, f'A rule for "{branch_pattern}" already exists.')
3772
else:
3773
rule.branch_pattern = branch_pattern
3774
rule.require_status_checks = require_status_checks
3775
rule.required_contexts = required_contexts
3776
rule.restrict_push = restrict_push
3777
rule.updated_by = request.user
3778
rule.save()
3779
messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" updated.')
3780
return redirect("fossil:branch_protections", slug=slug)
3781
3782
return render(
3783
request,
3784
"fossil/branch_protection_form.html",
3785
{
3786
"project": project,
3787
"fossil_repo": fossil_repo,
3788
"rule": rule,
3789
"form_title": f"Edit Rule: {rule.branch_pattern}",
3790
"submit_label": "Update Rule",
3791
"active_tab": "settings",
3792
},
3793
)
3794
3795
3796
@login_required
3797
def branch_protection_delete(request, slug, pk):
3798
"""Soft-delete a branch protection rule."""
3799
from django.contrib import messages
3800
3801
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3802
3803
from fossil.branch_protection import BranchProtection
3804
3805
rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3806
3807
if request.method == "POST":
3808
rule.soft_delete(user=request.user)
3809
messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" deleted.')
3810
return redirect("fossil:branch_protections", slug=slug)
3811
3812
return redirect("fossil:branch_protections", slug=slug)
3813
3814
3815
# ---------------------------------------------------------------------------
3816
# Custom Ticket Fields
3817
# ---------------------------------------------------------------------------
3818
3819
3820
@login_required
3821
def ticket_fields_list(request, slug):
3822
"""List custom ticket field definitions for a project. Admin only."""
3823
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3824
3825
from fossil.ticket_fields import TicketFieldDefinition
3826
3827
try:
3828
fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
3829
search = request.GET.get("search", "").strip()
3830
if search:
3831
fields = fields.filter(label__icontains=search) | fields.filter(name__icontains=search)
3832
fields = fields.distinct()
3833
except Exception:
3834
fields = TicketFieldDefinition.objects.none()
3835
3836
per_page = get_per_page(request)
3837
paginator = Paginator(fields, per_page)
3838
page_obj = paginator.get_page(request.GET.get("page", 1))
3839
3840
return render(
3841
request,
3842
"fossil/ticket_fields_list.html",
3843
{
3844
"project": project,
3845
"fossil_repo": fossil_repo,
3846
"fields": page_obj,
3847
"page_obj": page_obj,
3848
"search": search,
3849
"per_page": per_page,
3850
"per_page_options": PER_PAGE_OPTIONS,
3851
"active_tab": "settings",
3852
},
3853
)
3854
3855
3856
@login_required
3857
def ticket_fields_create(request, slug):
3858
"""Create a new custom ticket field."""
3859
from django.contrib import messages
3860
3861
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3862
3863
from fossil.ticket_fields import TicketFieldDefinition
3864
3865
if request.method == "POST":
3866
name = request.POST.get("name", "").strip()
3867
label = request.POST.get("label", "").strip()
3868
field_type = request.POST.get("field_type", "text")
3869
choices_text = request.POST.get("choices", "").strip()
3870
is_required = request.POST.get("is_required") == "on"
3871
sort_order = int(request.POST.get("sort_order", "0") or "0")
3872
3873
if name and label:
3874
if TicketFieldDefinition.objects.filter(repository=fossil_repo, name=name).exists():
3875
messages.error(request, f'A field named "{name}" already exists.')
3876
else:
3877
TicketFieldDefinition.objects.create(
3878
repository=fossil_repo,
3879
name=name,
3880
label=label,
3881
field_type=field_type,
3882
choices=choices_text,
3883
is_required=is_required,
3884
sort_order=sort_order,
3885
created_by=request.user,
3886
)
3887
messages.success(request, f'Custom field "{label}" created.')
3888
return redirect("fossil:ticket_fields", slug=slug)
3889
3890
return render(
3891
request,
3892
"fossil/ticket_fields_form.html",
3893
{
3894
"project": project,
3895
"form_title": "Add Custom Ticket Field",
3896
"submit_label": "Create Field",
3897
"field_type_choices": TicketFieldDefinition.FieldType.choices,
3898
"active_tab": "settings",
3899
},
3900
)
3901
3902
3903
@login_required
3904
def ticket_fields_edit(request, slug, pk):
3905
"""Edit an existing custom ticket field."""
3906
from django.contrib import messages
3907
3908
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3909
3910
from fossil.ticket_fields import TicketFieldDefinition
3911
3912
field_def = get_object_or_404(TicketFieldDefinition, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3913
3914
if request.method == "POST":
3915
name = request.POST.get("name", "").strip()
3916
label = request.POST.get("label", "").strip()
3917
field_type = request.POST.get("field_type", "text")
3918
choices_text = request.POST.get("choices", "").strip()
3919
is_required = request.POST.get("is_required") == "on"
3920
sort_order = int(request.POST.get("sort_order", "0") or "0")
3921
3922
if name and label:
3923
dupe = TicketFieldDefinition.objects.filter(repository=fossil_repo, name=name).exclude(pk=field_def.pk).exists()
3924
if dupe:
3925
messages.error(request, f'A field named "{name}" already exists.')
3926
else:
3927
field_def.name = name
3928
field_def.label = label
3929
field_def.field_type = field_type
3930
field_def.choices = choices_text
3931
field_def.is_required = is_required
3932
field_def.sort_order = sort_order
3933
field_def.updated_by = request.user
3934
field_def.save()
3935
messages.success(request, f'Custom field "{label}" updated.')
3936
return redirect("fossil:ticket_fields", slug=slug)
3937
3938
return render(
3939
request,
3940
"fossil/ticket_fields_form.html",
3941
{
3942
"project": project,
3943
"field_def": field_def,
3944
"form_title": f"Edit Field: {field_def.label}",
3945
"submit_label": "Save Changes",
3946
"field_type_choices": TicketFieldDefinition.FieldType.choices,
3947
"active_tab": "settings",
3948
},
3949
)
3950
3951
3952
@login_required
3953
def ticket_fields_delete(request, slug, pk):
3954
"""Soft-delete a custom ticket field."""
3955
from django.contrib import messages
3956
3957
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3958
3959
from fossil.ticket_fields import TicketFieldDefinition
3960
3961
field_def = get_object_or_404(TicketFieldDefinition, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3962
3963
if request.method == "POST":
3964
field_def.soft_delete(user=request.user)
3965
messages.success(request, f'Custom field "{field_def.label}" deleted.')
3966
return redirect("fossil:ticket_fields", slug=slug)
3967
3968
return redirect("fossil:ticket_fields", slug=slug)
3969
3970
3971
# ---------------------------------------------------------------------------
3972
# Custom Ticket Reports
3973
# ---------------------------------------------------------------------------
3974
3975
3976
def ticket_reports_list(request, slug):
3977
"""List available ticket reports for a project."""
3978
from projects.access import can_admin_project
3979
3980
project, fossil_repo = _get_project_and_repo(slug, request, "read")
3981
3982
from fossil.ticket_reports import TicketReport
3983
3984
reports = TicketReport.objects.filter(repository=fossil_repo)
3985
is_admin = can_admin_project(request.user, project)
3986
if not is_admin:
3987
reports = reports.filter(is_public=True)
3988
3989
search = request.GET.get("search", "").strip()
3990
if search:
3991
reports = reports.filter(title__icontains=search) | reports.filter(description__icontains=search)
3992
reports = reports.distinct()
3993
3994
per_page = get_per_page(request)
3995
paginator = Paginator(reports, per_page)
3996
page_obj = paginator.get_page(request.GET.get("page", 1))
3997
3998
return render(
3999
request,
4000
"fossil/ticket_reports_list.html",
4001
{
4002
"project": project,
4003
"fossil_repo": fossil_repo,
4004
"reports": page_obj,
4005
"page_obj": page_obj,
4006
"can_admin": is_admin,
4007
"search": search,
4008
"per_page": per_page,
4009
"per_page_options": PER_PAGE_OPTIONS,
4010
"active_tab": "tickets",
4011
},
4012
)
4013
4014
4015
@login_required
4016
def ticket_report_create(request, slug):
4017
"""Create a new ticket report. Admin only."""
4018
from django.contrib import messages
4019
4020
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
4021
4022
from fossil.ticket_reports import TicketReport
4023
4024
if request.method == "POST":
4025
title = request.POST.get("title", "").strip()
4026
description = request.POST.get("description", "").strip()
4027
sql_query = request.POST.get("sql_query", "").strip()
4028
is_public = request.POST.get("is_public") == "on"
4029
4030
if title and sql_query:
4031
error = TicketReport.validate_sql(sql_query)
4032
if error:
4033
messages.error(request, f"Invalid SQL: {error}")
4034
else:
4035
TicketReport.objects.create(
4036
repository=fossil_repo,
4037
title=title,
4038
description=description,
4039
sql_query=sql_query,
4040
is_public=is_public,
4041
created_by=request.user,
4042
)
4043
messages.success(request, f'Report "{title}" created.')
4044
return redirect("fossil:ticket_reports", slug=slug)
4045
4046
return render(
4047
request,
4048
"fossil/ticket_report_form.html",
4049
{
4050
"project": project,
4051
"form_title": "Create Ticket Report",
4052
"submit_label": "Create Report",
4053
"active_tab": "tickets",
4054
},
4055
)
4056
4057
4058
@login_required
4059
def ticket_report_edit(request, slug, pk):
4060
"""Edit an existing ticket report. Admin only."""
4061
from django.contrib import messages
4062
4063
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
4064
4065
from fossil.ticket_reports import TicketReport
4066
4067
report = get_object_or_404(TicketReport, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
4068
4069
if request.method == "POST":
4070
title = request.POST.get("title", "").strip()
4071
description = request.POST.get("description", "").strip()
4072
sql_query = request.POST.get("sql_query", "").strip()
4073
is_public = request.POST.get("is_public") == "on"
4074
4075
if title and sql_query:
4076
error = TicketReport.validate_sql(sql_query)
4077
if error:
4078
messages.error(request, f"Invalid SQL: {error}")
4079
else:
4080
report.title = title
4081
report.description = description
4082
report.sql_query = sql_query
4083
report.is_public = is_public
4084
report.updated_by = request.user
4085
report.save()
4086
messages.success(request, f'Report "{title}" updated.')
4087
return redirect("fossil:ticket_reports", slug=slug)
4088
4089
return render(
4090
request,
4091
"fossil/ticket_report_form.html",
4092
{
4093
"project": project,
4094
"report": report,
4095
"form_title": f"Edit Report: {report.title}",
4096
"submit_label": "Save Changes",
4097
"active_tab": "tickets",
4098
},
4099
)
4100
4101
4102
def ticket_report_run(request, slug, pk):
4103
"""Execute a ticket report and display results."""
4104
import sqlite3
4105
4106
from projects.access import can_admin_project
4107
4108
project, fossil_repo = _get_project_and_repo(slug, request, "read")
4109
4110
from fossil.ticket_reports import TicketReport
4111
4112
report = get_object_or_404(TicketReport, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
4113
4114
# Non-public reports require admin access
4115
if not report.is_public and not can_admin_project(request.user, project):
4116
from django.core.exceptions import PermissionDenied
4117
4118
raise PermissionDenied("This report is not public.")
4119
4120
# Re-validate the SQL at execution time (defense in depth)
4121
error = TicketReport.validate_sql(report.sql_query)
4122
columns = []
4123
rows = []
4124
4125
if error:
4126
pass # error is shown in template
4127
else:
4128
# Replace placeholders with named parameters for safe execution
4129
sql = report.sql_query
4130
status_param = request.GET.get("status", "")
4131
type_param = request.GET.get("type", "")
4132
sql = sql.replace("{status}", ":status").replace("{type}", ":type")
4133
params = {"status": status_param, "type": type_param}
4134
4135
# Execute against the Fossil SQLite file in read-only mode
4136
repo_path = fossil_repo.full_path
4137
uri = f"file:{repo_path}?mode=ro"
4138
try:
4139
conn = sqlite3.connect(uri, uri=True)
4140
try:
4141
cursor = conn.execute(sql, params)
4142
columns = [desc[0] for desc in cursor.description] if cursor.description else []
4143
rows = [list(row) for row in cursor.fetchall()[:1000]]
4144
except sqlite3.OperationalError as e:
4145
error = f"SQL error: {e}"
4146
finally:
4147
conn.close()
4148
except sqlite3.Error as e:
4149
error = f"Database error: {e}"
4150
4151
return render(
4152
request,
4153
"fossil/ticket_report_results.html",
4154
{
4155
"project": project,
4156
"fossil_repo": fossil_repo,
4157
"report": report,
4158
"columns": columns,
4159
"rows": rows,
4160
"error": error,
4161
"active_tab": "tickets",
4162
},
4163
)
4164
4165
4166
# --- Artifact Shunning ---
4167
4168
4169
@login_required
4170
def shun_list_view(request, slug):
4171
"""List shunned artifacts and provide form to shun new ones. Admin only."""
4172
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
4173
4174
shunned = []
4175
if fossil_repo.exists_on_disk:
4176
from fossil.cli import FossilCLI
4177
4178
cli = FossilCLI()
4179
if cli.is_available():
4180
shunned = cli.shun_list(fossil_repo.full_path)
4181
4182
return render(
4183
request,
4184
"fossil/shun_list.html",
4185
{
4186
"project": project,
4187
"fossil_repo": fossil_repo,
4188
"shunned": shunned,
4189
"active_tab": "settings",
4190
},
4191
)
4192
4193
4194
@login_required
4195
def shun_artifact(request, slug):
4196
"""Shun (permanently remove) an artifact. POST only. Admin only."""
4197
from django.contrib import messages
4198
4199
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
4200
4201
if request.method != "POST":
4202
return redirect("fossil:shun_list", slug=slug)
4203
4204
artifact_uuid = request.POST.get("artifact_uuid", "").strip()
4205
confirmation = request.POST.get("confirmation", "").strip()
4206
reason = request.POST.get("reason", "").strip()
4207
4208
if not artifact_uuid:
4209
messages.error(request, "Artifact UUID is required.")
4210
return redirect("fossil:shun_list", slug=slug)
4211
4212
# Validate UUID format: should be hex characters, 4-64 chars (Fossil uses SHA1/SHA3 hashes)
4213
if not re.match(r"^[0-9a-fA-F]{4,64}$", artifact_uuid):
4214
messages.error(request, "Invalid artifact UUID format. Must be a hex hash (4-64 characters).")
4215
return redirect("fossil:shun_list", slug=slug)
4216
4217
# Require the user to type the first 8 chars of the UUID to confirm
4218
expected_confirmation = artifact_uuid[:8].lower()
4219
if confirmation.lower() != expected_confirmation:
4220
messages.error(request, f'Confirmation failed. You must type "{expected_confirmation}" to confirm shunning.')
4221
return redirect("fossil:shun_list", slug=slug)
4222
4223
if not fossil_repo.exists_on_disk:
4224
messages.error(request, "Repository file not found on disk.")
4225
return redirect("fossil:shun_list", slug=slug)
4226
4227
from fossil.cli import FossilCLI
4228
4229
cli = FossilCLI()
4230
if not cli.is_available():
4231
messages.error(request, "Fossil binary is not available.")
4232
return redirect("fossil:shun_list", slug=slug)
4233
4234
result = cli.shun(fossil_repo.full_path, artifact_uuid, reason=reason)
4235
if result["success"]:
4236
messages.success(request, f"Artifact {artifact_uuid[:12]}... has been permanently shunned.")
4237
else:
4238
messages.error(request, f"Failed to shun artifact: {result['message']}")
4239
4240
return redirect("fossil:shun_list", slug=slug)
4241
4242
4243
# --- SQLite Explorer ---
4244
4245
# Known relationships between Fossil tables (SQLite FKs are not enforced in .fossil files).
4246
FOSSIL_RELATIONSHIPS = [
4247
("event", "blob", "objid -> rid"),
4248
("mlink", "blob", "mid -> rid, fid -> rid"),
4249
("plink", "blob", "cid -> rid, pid -> rid"),
4250
("tagxref", "tag", "tagid -> tagid"),
4251
("tagxref", "blob", "srcid -> rid, origid -> rid"),
4252
("delta", "blob", "rid -> rid, srcid -> rid"),
4253
("leaf", "blob", "rid -> rid"),
4254
("phantom", "blob", "rid -> rid"),
4255
("ticketchng", "blob", "tkt_rid -> rid"),
4256
("forumpost", "blob", "fpid -> rid"),
4257
]
4258
4259
# Category colors for the schema visualization.
4260
FOSSIL_TABLE_CATEGORIES = {
4261
# VCS core
4262
"blob": "blue",
4263
"delta": "blue",
4264
"event": "blue",
4265
"mlink": "blue",
4266
"plink": "blue",
4267
"leaf": "blue",
4268
"phantom": "blue",
4269
"rcvfrom": "blue",
4270
"filename": "blue",
4271
"repo_cksum": "blue",
4272
"config": "blue",
4273
# Tagging / branching
4274
"tag": "indigo",
4275
"tagxref": "indigo",
4276
# Tickets
4277
"ticket": "green",
4278
"ticketchng": "green",
4279
# Wiki
4280
"backlink": "purple",
4281
# Forum
4282
"forumpost": "orange",
4283
"forumthread": "orange",
4284
# Other
4285
"unversioned": "gray",
4286
"shun": "gray",
4287
"private": "gray",
4288
"concealed": "gray",
4289
"accesslog": "gray",
4290
"user": "gray",
4291
}
4292
4293
# Regex for validating table names (alphanumerics + underscore, must start with letter or underscore).
4294
_TABLE_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
4295
4296
4297
@login_required
4298
def repo_explorer(request, slug):
4299
"""Main schema explorer page -- admin only."""
4300
project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4301
4302
with reader:
4303
conn = reader.conn
4304
cursor = conn.cursor()
4305
4306
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4307
table_names = [row[0] for row in cursor.fetchall()]
4308
4309
tables = []
4310
for name in table_names:
4311
cursor.execute(f"SELECT count(*) FROM [{name}]") # noqa: S608
4312
count = cursor.fetchone()[0]
4313
category = FOSSIL_TABLE_CATEGORIES.get(name, "gray")
4314
tables.append({"name": name, "count": count, "category": category})
4315
4316
# Build relationships that involve tables actually present in this repo.
4317
present = {t["name"] for t in tables}
4318
relationships = [r for r in FOSSIL_RELATIONSHIPS if r[0] in present and r[1] in present]
4319
4320
import json
4321
4322
return render(
4323
request,
4324
"fossil/explorer.html",
4325
{
4326
"project": project,
4327
"fossil_repo": fossil_repo,
4328
"tables": tables,
4329
"relationships": relationships,
4330
"relationships_json": json.dumps(relationships),
4331
"tables_json": json.dumps(tables),
4332
"active_tab": "explorer",
4333
},
4334
)
4335
4336
4337
@login_required
4338
def repo_explorer_table(request, slug, table_name):
4339
"""Return table detail as an HTMX partial -- admin only."""
4340
project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4341
4342
if not _TABLE_NAME_RE.match(table_name):
4343
raise Http404("Invalid table name")
4344
4345
with reader:
4346
conn = reader.conn
4347
cursor = conn.cursor()
4348
4349
# Verify the table exists.
4350
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
4351
if not cursor.fetchone():
4352
raise Http404("Table not found")
4353
4354
# Column metadata.
4355
cursor.execute(f"PRAGMA table_info([{table_name}])")
4356
columns = [{"cid": row[0], "name": row[1], "type": row[2] or "BLOB", "notnull": row[3], "pk": row[5]} for row in cursor.fetchall()]
4357
4358
# Paginated rows.
4359
try:
4360
page = max(1, int(request.GET.get("page", 1)))
4361
except (ValueError, TypeError):
4362
page = 1
4363
per_page = 25
4364
offset = (page - 1) * per_page
4365
4366
cursor.execute(f"SELECT * FROM [{table_name}] LIMIT ? OFFSET ?", (per_page, offset)) # noqa: S608
4367
col_names = [desc[0] for desc in cursor.description] if cursor.description else []
4368
raw_rows = cursor.fetchall()
4369
4370
# Truncate long binary/text values for display.
4371
rows = []
4372
for raw in raw_rows:
4373
display = []
4374
for cell in raw:
4375
if isinstance(cell, bytes):
4376
display.append(f"<{len(cell)} bytes>")
4377
elif isinstance(cell, str) and len(cell) > 200:
4378
display.append(cell[:200] + "...")
4379
else:
4380
display.append(cell)
4381
rows.append(display)
4382
4383
cursor.execute(f"SELECT count(*) FROM [{table_name}]") # noqa: S608
4384
total = cursor.fetchone()[0]
4385
4386
return render(
4387
request,
4388
"fossil/partials/explorer_table.html",
4389
{
4390
"project": project,
4391
"fossil_repo": fossil_repo,
4392
"table_name": table_name,
4393
"columns": columns,
4394
"col_names": col_names,
4395
"rows": rows,
4396
"total": total,
4397
"page": page,
4398
"per_page": per_page,
4399
"has_next": offset + per_page < total,
4400
"has_prev": page > 1,
4401
},
4402
)
4403
4404
4405
@login_required
4406
def repo_explorer_query(request, slug):
4407
"""Run a custom read-only SQL query against the .fossil file -- admin only."""
4408
from fossil.ticket_reports import TicketReport
4409
4410
project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4411
4412
sql = request.GET.get("sql", "").strip()
4413
results = None
4414
columns = []
4415
error = ""
4416
4417
if sql:
4418
validation_error = TicketReport.validate_sql(sql)
4419
if validation_error:
4420
error = validation_error
4421
else:
4422
try:
4423
with reader:
4424
cursor = reader.conn.cursor()
4425
cursor.execute(sql)
4426
columns = [desc[0] for desc in cursor.description] if cursor.description else []
4427
raw = cursor.fetchmany(500)
4428
# Truncate long values for display.
4429
results = []
4430
for raw_row in raw:
4431
display = []
4432
for cell in raw_row:
4433
if isinstance(cell, bytes):
4434
display.append(f"<{len(cell)} bytes>")
4435
elif isinstance(cell, str) and len(cell) > 200:
4436
display.append(cell[:200] + "...")
4437
else:
4438
display.append(cell)
4439
results.append(display)
4440
except Exception as e:
4441
error = str(e)
4442
4443
# Provide table names for the helper sidebar.
4444
table_names = []
4445
if not sql or error:
4446
try:
4447
with reader:
4448
cursor = reader.conn.cursor()
4449
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4450
table_names = [row[0] for row in cursor.fetchall()]
4451
except Exception:
4452
pass
4453
4454
return render(
4455
request,
4456
"fossil/explorer_query.html",
4457
{
4458
"project": project,
4459
"fossil_repo": fossil_repo,
4460
"sql": sql,
4461
"columns": columns,
4462
"results": results,
4463
"error": error,
4464
"table_names": table_names,
4465
"active_tab": "explorer",
4466
},
4467
)
4468
4469
4470
# --- Bundle Export / Import ---
4471
4472
4473
@login_required
4474
def bundle_export(request, slug):
4475
"""Export a Fossil bundle file for download."""
4476
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
4477
branch = request.GET.get("branch", "").strip()
4478
checkin = request.GET.get("checkin", "").strip()
4479
4480
from fossil.cli import FossilCLI
4481
4482
cli = FossilCLI()
4483
data = cli.bundle_export(fossil_repo.full_path, branch=branch, checkin=checkin)
4484
if not data:
4485
raise Http404("Bundle export failed")
4486
4487
filename = f"{project.slug}-{branch or checkin or 'trunk'}.bundle"
4488
response = HttpResponse(data, content_type="application/octet-stream")
4489
response["Content-Disposition"] = f'attachment; filename="{filename}"'
4490
return response
4491
4492
4493
@login_required
4494
def bundle_import(request, slug):
4495
"""Import a Fossil bundle file into the repository."""
4496
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
4497
4498
if request.method == "POST":
4499
bundle_file = request.FILES.get("bundle")
4500
publish = request.POST.get("publish") == "on"
4501
if bundle_file:
4502
from fossil.cli import FossilCLI
4503
4504
cli = FossilCLI()
4505
ok = cli.bundle_import(fossil_repo.full_path, bundle_file.read(), publish=publish)
4506
4507
from django.contrib import messages
4508
4509
if ok:
4510
messages.success(request, "Bundle imported successfully.")
4511
else:
4512
messages.error(request, "Bundle import failed. Check the file is a valid Fossil bundle.")
4513
return redirect("fossil:repo_settings", slug=slug)
4514
4515
return render(request, "fossil/bundle_import.html", {"project": project, "active_tab": "settings"})
4516
4517
4518
# --- Chat ---
4519
4520
4521
@login_required
4522
def chat_room(request, slug):
4523
from constance import config
4524
4525
if not config.FEATURE_CHAT:
4526
raise Http404
4527
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
4528
from fossil.chat import ChatMessage
4529
4530
messages = ChatMessage.objects.filter(repository=fossil_repo).select_related("user").order_by("-created_at")[:50]
4531
messages = list(reversed(messages))
4532
return render(
4533
request,
4534
"fossil/chat.html",
4535
{
4536
"project": project,
4537
"fossil_repo": fossil_repo,
4538
"messages": messages,
4539
"active_tab": "chat",
4540
},
4541
)
4542
4543
4544
@login_required
4545
def chat_send(request, slug):
4546
from constance import config
4547
4548
if not config.FEATURE_CHAT:
4549
raise Http404
4550
from fossil.chat import ChatMessage
4551
4552
if request.method == "POST":
4553
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
4554
body = request.POST.get("body", "").strip()
4555
if body:
4556
ChatMessage.objects.create(
4557
repository=fossil_repo,
4558
user=request.user,
4559
username=request.user.username,
4560
body=body[:2000],
4561
)
4562
else:
4563
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
4564
4565
# Return the updated message list partial for HTMX swap
4566
chat_messages = ChatMessage.objects.filter(repository=fossil_repo).select_related("user").order_by("-created_at")[:50]
4567
chat_messages = list(reversed(chat_messages))
4568
return render(
4569
request,
4570
"fossil/partials/chat_messages.html",
4571
{
4572
"project": project,
4573
"messages": chat_messages,
4574
},
4575
)
4576

Keyboard Shortcuts

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