FossilRepo

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

Keyboard Shortcuts

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