|
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 |
) |