1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""Merge request create / fast-forward HTTP endpoints and tag view response."""
from __future__ import annotations
from typing import Annotated
from urllib.parse import quote, urlencode
import pygit2
from fastapi import APIRouter, Depends, Form, HTTPException, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from pygittools.merge import (
MergeRequest,
merge_request_status_fields,
resolve_merge_request_tag_to_tip,
)
from pygittools.validation import is_valid_ref_format
from pygitweb.auth import access_token_from_request, principal_from_session, require_permission
from pygitweb.dependencies import ValidatedReadableFormProject, ValidatedReadableQueryProject
from pygitweb.git_helpers import open_repo, resolve_mr_opener
from pygitweb.permissions import Permission
from pygitweb.templates_env import render_page
def _branch_short_name(ref: str) -> str:
return ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
merge_router = APIRouter(tags=["merge_requests"])
def _head_view_url(project: str, repo: pygit2.Repository, head_hex: str) -> tuple[str, str]:
try:
oid = pygit2.Oid(hex=head_hex)
obj = repo[oid]
except (KeyError, ValueError, TypeError):
return head_hex[:7], ""
t = getattr(obj, "type_str", "") or ""
short = head_hex[:7]
if t == "commit":
return short, f"/project/{project}?a=commit&h={quote(head_hex, safe='')}"
if t == "tag":
return short, f"/project/{project}?a=tag&h={quote(head_hex, safe='')}"
return short, ""
def merge_request_tag_response(
project: str,
repo: pygit2.Repository,
mr: MergeRequest,
tag_name: str,
tag_oid: str,
tagger: str,
tagger_date: str,
notice: str | None = None,
) -> HTMLResponse | RedirectResponse:
new_oid = mr.refresh_from_theirs(repo)
if new_oid is not None:
return RedirectResponse(
url=f"/project/{project}?a=tag&h={quote(str(new_oid), safe='')}",
status_code=302,
)
status_fields = merge_request_status_fields(repo, mr)
head_short, head_url = _head_view_url(project, repo, str(mr.target))
ours_ref = mr.ours
ours_url = f"/project/{project}?a=log&h={quote(ours_ref, safe='')}"
theirs_ref = mr.theirs or ""
theirs_url = f"/project/{project}?a=log&h={quote(theirs_ref, safe='')}" if mr.theirs else ""
comments_rows: list[tuple[str, str]] = []
for c in mr.comments:
ch = str(c)
comments_rows.append((ch[:7], f"/project/{project}?a=tag&h={quote(ch, safe='')}"))
current_tag_oid = str(repo.references[mr.name].resolve().target)
ctx = {
"project": project,
"project_url": f"/project/{project}",
"tag_name": tag_name,
"tag_oid": current_tag_oid,
"tag_view_url": f"/project/{project}?a=tag&h={quote(current_tag_oid, safe='')}",
"mr_title": mr.title,
"mr_description": mr.description or "",
"ours_ref": ours_ref,
"ours_url": ours_url,
"theirs_ref": theirs_ref,
"theirs_url": theirs_url,
"head_short": head_short,
"head_url": head_url,
"head_full": str(mr.target),
"tagger": tagger,
"tagger_date": tagger_date,
"opener": str(mr.opener),
"created_at": mr.created_at.isoformat() if mr.created_at else "",
"updated_at": mr.updated_at.isoformat() if mr.updated_at else "",
"comments": comments_rows,
"merge_action_url": f"/mr/ff?project={quote(project, safe='')}&h={quote(current_tag_oid, safe='')}",
"notice": notice or "",
**status_fields,
}
title = f"Merge request: {mr.title or ''} — {project or ''}"
return render_page("merge_request_tag.html", title=title, **ctx)
@merge_router.post(
"/create",
dependencies=[Depends(require_permission(Permission.MR_CREATE, project_from_form=True))],
)
def merge_request_create(
project: ValidatedReadableFormProject,
theirs: str = Form(...),
ours: str = Form(...),
title: str = Form(""),
token: Annotated[str | None, Depends(access_token_from_request)] = None,
) -> RedirectResponse:
theirs_ref = theirs.strip()
ours_ref = ours.strip()
if not theirs_ref or not ours_ref:
raise HTTPException(status_code=400, detail="theirs and ours are required")
if not is_valid_ref_format(theirs_ref) or not is_valid_ref_format(ours_ref):
raise HTTPException(status_code=400, detail="Invalid ref format")
repo = open_repo(project)
try:
theirs_tip = repo.revparse_single(theirs_ref).peel(pygit2.Commit).id
except (KeyError, ValueError, pygit2.GitError) as exc:
raise HTTPException(status_code=404, detail=f"Cannot resolve theirs ref {theirs_ref!r}") from exc
try:
repo.revparse_single(ours_ref)
except (KeyError, ValueError, pygit2.GitError) as exc:
raise HTTPException(status_code=404, detail=f"Cannot resolve ours ref {ours_ref!r}") from exc
opener, opener_warning = resolve_mr_opener(repo, principal_from_session(token))
t = title.strip()
if not t:
t = f"Merge {_branch_short_name(theirs_ref)} into {_branch_short_name(ours_ref)}"
mr = MergeRequest(
target=theirs_tip,
opener=opener,
ours=ours_ref,
theirs=theirs_ref,
title=t,
description="",
)
oid = mr.write(repo)
params: dict[str, str] = {"a": "tag", "h": str(oid)}
if opener_warning:
params["notice"] = opener_warning
dest = f"/project/{project}?{urlencode(params)}"
return RedirectResponse(url=dest, status_code=303)
@merge_router.post(
"/ff",
dependencies=[Depends(require_permission(Permission.MR_MERGE, project_from_query=True))],
)
def merge_fast_forward(
project: ValidatedReadableQueryProject,
h: str = Query(..., description="Annotated tag object id (hex)"),
) -> RedirectResponse:
if not h or not is_valid_ref_format(h):
raise HTTPException(status_code=400, detail="Invalid tag hash (h)")
repo = open_repo(project)
try:
obj = repo.revparse_single(h)
except (KeyError, pygit2.GitError, OSError) as exc:
raise HTTPException(status_code=404, detail="Tag not found") from exc
if not isinstance(obj, pygit2.Tag):
raise HTTPException(status_code=400, detail="Not an annotated tag")
resolved = resolve_merge_request_tag_to_tip(repo, obj)
if resolved is None:
raise HTTPException(status_code=400, detail="Not a merge request tag")
obj, mr = resolved
mr.refresh_from_theirs(repo)
try:
mr.merge(repo)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
tag_oid = str(repo.references[mr.name].resolve().target)
dest = f"/project/{project}?a=tag&h={quote(tag_oid, safe='')}"
return RedirectResponse(url=dest, status_code=303)