master
ブランチ master
LittleBeast / src / Site / Controller / GitlerPulls.php
21.1 KB、402行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Site\Controller;
39
40use Std\Lib\Git\Diff;
41use Std\Lib\Git\GitException;
42use Std\Lib\Git\Repository;
43use Std\Lib\Gitler\Federation;
44use Std\Lib\Gitler\Gitler as Hosting;
45use Std\Lib\Gitler\Issues;
46use Std\Lib\Gitler\Notify;
47use Std\Lib\Gitler\Pulls;
48
49/**
50 * Gitlerのプルリクエスト(比較・作成、コミット、変更されたファイルと行へのコメント、レビュー、マージ)とフォーク
51 *
52 * 会話のページはGitlerIssuesのissue()(プルリクエストならgitler/pull)。
53 */
54trait GitlerPulls {
55 //////// フォーク
56
57 /** …/fork(POST) */
58 public function fork(array $params): void {
59 $this->load($params);
60 if (!$this->user) $this->redirect('/login');
61 if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !$this->csrfOk()) $this->redirect($this->repoUrl());
62 $res = Hosting::fork($this->user, $this->owner, $this->name);
63 if (!$res->isSuccess) $this->message((string)$res->message, $this->repoUrl());
64 $this->redirect('/gitler/'.$res->data['owner'].'/'.rawurlencode($res->data['name']));
65 }
66
67 /** …/forks */
68 public function forks(array $params): void {
69 $this->load($params);
70 $list = [];
71 foreach (Hosting::forks($this->owner, $this->name) as [$o, $n]) {
72 if (Hosting::access($this->user, $o, $n) === Hosting::NONE) continue;
73 $list[] = ['owner' => $o, 'name' => $n, 'url' => '/gitler/'.$o.'/'.rawurlencode($n)];
74 }
75 $this->renderRepo('gitler/forks', 'フォーク', ['list' => $list]);
76 }
77
78 //////// 比較と作成
79
80 /** …/compare?base=&head=(headは「ブランチ」又は「所有者/名前:ブランチ」(フォーク)) */
81 public function compare(array $params): void {
82 $this->loadPulls($params);
83 $base = trim((string)($_REQUEST['base'] ?? $this->defaultBranch()));
84 $head = trim((string)($_REQUEST['head'] ?? ''));
85 $error = '';
86 $heads = $this->headChoices();
87 $baseId = $this->repo->refs->resolve('refs/heads/'.$base);
88 $found = $head !== '' ? $this->resolveHead($head) : null;
89 $vars = ['base' => $base, 'head' => $head, 'branches' => array_map(fn($r) => substr($r, 11), array_keys($this->repo->refs->all('refs/heads/'))), 'heads' => $heads,
90 'commits' => [], 'files' => [], 'stats' => null, 'canPull' => $this->canPull(), 'in' => ['title' => '', 'body' => ''], 'ready' => false, 'note' => ''];
91 if ($head !== '' && ($found === null || $baseId === null)) {
92 $vars['note'] = 'ブランチが有りません。';
93 } else if ($found !== null) {
94 [$kind, $headArr, $headId] = $found;
95 $cmp = Pulls::compare($this->repo, $baseId, $headId);
96 if (empty($cmp['commits'])) {
97 $vars['note'] = "「{$base}」に無いコミットが有りません。";
98 } else {
99 $vars['commits'] = array_map(fn($id) => $this->presentCommit($this->repo->commit($id)), array_reverse($cmp['commits']));
100 [$vars['files'], $vars['stats']] = $this->diffFiles($cmp['mergeBase'], $headId, null, []);
101 $first = $this->repo->commit($cmp['commits'][0]);
102 $vars['in'] = count($cmp['commits']) === 1
103 ? ['title' => $first->subject(), 'body' => trim(substr($first->message, strlen($first->subject())))]
104 : ['title' => $kind === 'fork' ? $headArr['branch'] : $head, 'body' => ''];
105 $vars['ready'] = true;
106 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
107 $vars['in'] = ['title' => (string)($_POST['title'] ?? ''), 'body' => str_replace("\r\n", "\n", (string)($_POST['body'] ?? ''))];
108 if (!$this->csrfOk()) {
109 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
110 } else if (!$this->canPull()) {
111 $error = 'プルリクエストを作る権限が有りません(管理者に「Gitのプルリクエスト」のロールを付けて貰って下さい)。';
112 } else {
113 $res = Pulls::open($this->owner, $this->name, $this->me(), $vars['in']['title'], $vars['in']['body'], $base, $kind, $headArr, $headId);
114 if ($res->isSuccess) $this->redirect($this->repoUrl().'/pulls/'.$res->data['number']);
115 if (isset($res->data['number'])) $this->redirect($this->repoUrl().'/pulls/'.$res->data['number']);
116 $error = (string)$res->message;
117 }
118 }
119 }
120 }
121 $this->renderRepo('gitler/compare', 'プルリクエストを作る', $vars + ['error' => $error]);
122 }
123
124 /** headの選択肢(このリポジトリのブランチと、見られるフォークのブランチ) */
125 private function headChoices(): array {
126 $out = [['label' => "{$this->owner}/{$this->name}", 'items' => array_map(fn($r) => substr($r, 11), array_keys($this->repo->refs->all('refs/heads/')))]];
127 foreach (Hosting::forks($this->owner, $this->name) as [$o, $n]) {
128 if (Hosting::access($this->user, $o, $n) === Hosting::NONE) continue;
129 $out[] = ['label' => "{$o}/{$n}", 'items' => array_map(fn($r) => "{$o}/{$n}:".substr($r, 11), array_keys(Hosting::open($o, $n)->refs->all('refs/heads/')))];
130 }
131 return $out;
132 }
133
134 /**
135 * headを解決する(フォークのコミットはこのリポジトリに持って来る)
136 *
137 * @return array|null [kind, head, headId]
138 */
139 private function resolveHead(string $head): ?array {
140 if (preg_match('#^([A-Za-z0-9]+)/([A-Za-z0-9._-]+):(.+)$#', $head, $m)) {
141 [, $o, $n, $branch] = $m;
142 if (!Hosting::exists($o, $n) || Hosting::meta($o, $n)['forkOf'] !== "{$this->owner}/{$this->name}" || Hosting::access($this->user, $o, $n) === Hosting::NONE) return null;
143 $fork = Hosting::open($o, $n);
144 $id = $fork->refs->resolve('refs/heads/'.$branch);
145 if ($id === null) return null;
146 try {
147 Pulls::fetch($fork, $this->repo, $id);
148 } catch (GitException) {
149 return null;
150 }
151 return ['fork', ['owner' => $o, 'name' => $n, 'branch' => $branch], $id];
152 }
153 $id = $this->repo->refs->resolve('refs/heads/'.$head);
154 return $id === null ? null : ['branch', ['owner' => $this->owner, 'name' => $this->name, 'branch' => $head], $id];
155 }
156
157 //////// プルリクエストのページ
158
159 /** …/pulls/{n}/commits */
160 public function pullCommits(array $params): void {
161 [$issue, $range] = $this->loadPull($params);
162 $list = [];
163 if ($range !== null) foreach (array_reverse(Pulls::compare($this->repo, $range[0], $range[1])['commits']) as $id) $list[] = $this->presentCommit($this->repo->commit($id));
164 $this->renderRepo('gitler/pullcommits', '#'.$issue['number'].' '.$issue['title'], $this->pullHead($issue) + ['list' => $list, 'tab' => 'commits']);
165 }
166
167 /** …/pulls/{n}/files?path=&side=&line=(行を選ぶとコメントの欄) */
168 public function pullFiles(array $params): void {
169 [$issue, $range] = $this->loadPull($params);
170 $n = $issue['number'];
171 $error = '';
172 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
173 $error = $this->csrfOk() ? $this->issueAction($issue) : 'セッションの有効期限が切れました。もう一度お試し下さい。';
174 $issue = $this->issueDb->get($n) ?? $issue;
175 }
176 $files = [];
177 $stats = null;
178 if ($range !== null) {
179 $mb = (new \Std\Lib\Git\Walk($this->repo))->mergeBase($range[0], $range[1]);
180 $form = isset($_GET['path'], $_GET['line']) ? ['path' => (string)$_GET['path'], 'side' => ($_GET['side'] ?? 'new') === 'old' ? 'old' : 'new', 'line' => (int)$_GET['line']] : null;
181 [$files, $stats] = $this->diffFiles($mb, $range[1], $this->canReview() ? $form : null, $issue);
182 }
183 $me = $this->user ? Issues::authorKey($this->me()) : '';
184 $pending = count(array_filter($issue['comments'], fn($c) => !empty($c['pending']) && Issues::authorKey($c['author']) === $me));
185 $this->renderRepo('gitler/pullfiles', '#'.$n.' '.$issue['title'], $this->pullHead($issue) + [
186 'files' => $files, 'stats' => $stats, 'tab' => 'files', 'error' => $error, 'pending' => $pending, 'canReview' => $this->canReview(),
187 'canApprove' => $this->canReview() && ($issue['author']['user'] ?? null) !== $this->user?->username, 'filesUrl' => $this->repoUrl().'/pulls/'.$n.'/files',
188 ]);
189 }
190
191 /** 会話のページ(issue())に足す物 */
192 private function pullVars(array $issue): array {
193 $p = $issue['pull'];
194 try {
195 $m = Pulls::mergeability($this->repo, $issue);
196 } catch (GitException) {
197 $m = ['state' => 'error', 'conflicts' => [], 'baseId' => null];
198 }
199 // レビュアー毎の最後の承認・変更の依頼
200 $latest = [];
201 foreach ($issue['reviews'] ?? [] as $r) if ($r['state'] !== 'commented') $latest[Issues::authorKey($r['author'])] = $r;
202 $branchId = $p['kind'] === 'branch' ? $this->repo->refs->resolve('refs/heads/'.$p['head']['branch']) : null;
203 $msg = "Merge pull request #{$issue['number']} from ".$this->headLabel($p)."\n\n{$issue['title']}";
204 return $this->pullHead($issue) + [
205 'merge' => $m, 'tab' => 'conversation',
206 'approvals' => array_values(array_map(fn($r) => ['who' => $this->who($r['author']), 'state' => $r['state']], $latest)),
207 'canMerge' => !$this->meta['archived'] && $this->access >= Hosting::WRITE && in_array($m['state'], ['ff', 'clean', 'conflict'], true),
208 'mergeMessage' => $msg, 'squashMessage' => "{$issue['title']} (#{$issue['number']})",
209 'canDeleteBranch' => !$this->meta['archived'] && $this->access >= Hosting::WRITE && $issue['state'] === 'closed' && $branchId !== null && $branchId === $p['headId']
210 && $p['head']['branch'] !== $this->defaultBranch(),
211 'fetchCommand' => 'git fetch origin '.Pulls::ref($issue['number']).':pr-'.$issue['number'],
212 ];
213 }
214
215 /** プルリクエストのページの頭(タブ等) */
216 private function pullHead(array $issue): array {
217 $p = $issue['pull'];
218 $u = $this->repoUrl().'/pulls/'.$issue['number'];
219 $range = Pulls::range($this->repo, $issue);
220 $count = $range !== null ? count(Pulls::compare($this->repo, $range[0], $range[1])['commits']) : 0;
221 $headUrl = match ($p['kind']) {
222 'branch' => $this->url('tree', $p['head']['branch'], ''),
223 'fork' => '/gitler/'.$p['head']['owner'].'/'.rawurlencode($p['head']['name']).'/tree?ref='.rawurlencode($p['head']['branch']),
224 default => '',
225 };
226 return ['pull' => $p, 'pullUrl' => $u, 'commitsTabUrl' => $u.'/commits', 'filesTabUrl' => $u.'/files', 'commitCount' => $count,
227 'headLabel' => $this->headLabel($p), 'headUrl' => $headUrl, 'issue' => $issue, 'who' => $this->who($issue['author']),
228 'merged' => !empty($p['merged']), 'mergedBy' => !empty($p['merged']) ? $this->who($p['merged']['by']) : null];
229 }
230
231 private function headLabel(array $p): string {
232 return match ($p['kind']) {
233 'fork' => "{$p['head']['owner']}/{$p['head']['name']}:{$p['head']['branch']}",
234 'agit' => "{$p['head']['user']}:{$p['head']['topic']}(AGit)",
235 default => (string)$p['head']['branch'],
236 };
237 }
238
239 /**
240 * プルリクエストのPOST(GitlerIssuesのissueActionから)
241 *
242 * @return string エラー(成功したらリダイレクト)
243 */
244 private function pullAction(array $issue, string $do): string {
245 $n = $issue['number'];
246 $url = $this->repoUrl().'/pulls/'.$n;
247 if (!$this->user) return 'ログインして下さい。';
248 if ($this->meta['archived']) return 'アーカイブされたリポジトリです(読むだけ)。';
249 $me = $this->me();
250 $body = str_replace("\r\n", "\n", (string)($_POST['body'] ?? ''));
251
252 if ($do === 'merge') {
253 if ($this->access < Hosting::WRITE) return 'マージする権限が有りません。';
254 $res = Pulls::merge($this->owner, $this->name, $n, $this->user, (string)($_POST['method'] ?? 'merge'), (string)($_POST['message'] ?? ''));
255 if (!$res->isSuccess) return (string)$res->message;
256 $this->redirect($url);
257 }
258 if ($do === 'deleteBranch') {
259 $p = $issue['pull'];
260 if ($this->access < Hosting::WRITE || $p['kind'] !== 'branch' || $p['head']['branch'] === $this->defaultBranch()) return '削除出来ません。';
261 try {
262 $this->repo->refs->update('refs/heads/'.$p['head']['branch'], null, $p['headId']);
263 } catch (GitException) {
264 return 'ブランチが変わっているので削除出来ません。';
265 }
266 $this->issueDb->update($n, function(array $i) use ($me, $p): array {
267 $i['events'][] = ['type' => 'branchDeleted', 'by' => $me, 'at' => time(), 'branch' => $p['head']['branch']];
268 return $i;
269 });
270 $this->redirect($url);
271 }
272 if (!$this->canReview()) return 'コメントする権限が有りません。';
273 if ($do === 'inline' || $do === 'inlinePending') {
274 $at = ['path' => (string)($_POST['path'] ?? ''), 'side' => ($_POST['side'] ?? 'new') === 'old' ? 'old' : 'new', 'line' => (int)($_POST['line'] ?? 0),
275 'commit' => $issue['pull']['headId'], 'pending' => $do === 'inlinePending'];
276 $at['hunk'] = $this->hunkFor($issue, $at['path'], $at['side'], $at['line']);
277 if ($at['hunk'] === null) return 'その行が有りません。';
278 $res = $this->issueDb->inlineComment($n, $me, $body, $at);
279 if (!$res->isSuccess) return (string)$res->message;
280 if ($do === 'inline') $this->notifyComment($res->data['issue'], $res->data['comment']);
281 $this->redirect($url.'/files#c'.$res->data['comment']['id']);
282 }
283 if ($do === 'reply') {
284 $res = $this->issueDb->inlineComment($n, $me, $body, ['replyTo' => (int)($_POST['id'] ?? 0)]);
285 if (!$res->isSuccess) return (string)$res->message;
286 $this->notifyComment($res->data['issue'], $res->data['comment']);
287 $back = ($_POST['back'] ?? '') === 'files' ? $url.'/files' : $url;
288 $this->redirect($back.'#c'.$res->data['comment']['id']);
289 }
290 if ($do === 'review') {
291 $state = (string)($_POST['state'] ?? 'commented');
292 if ($state !== 'commented' && ($issue['author']['user'] ?? null) === $this->user->username) return '自分のプルリクエストは承認・変更の依頼が出来ません。';
293 $res = $this->issueDb->submitReview($n, $me, $state, $body, $issue['pull']['headId']);
294 if (!$res->isSuccess) return (string)$res->message;
295 Notify::reviewed($this->owner, $this->name, $this->meta, $this->issueDb, $res->data['issue'], $res->data['review']);
296 $this->redirect($url.'#r'.$res->data['review']['id']);
297 }
298 return '';
299 }
300
301 private function notifyComment(array $issue, array $c): void {
302 Federation::commentAdded($this->owner, $this->name, $this->meta, $issue, $c);
303 Notify::commented($this->owner, $this->name, $this->meta, $this->issueDb, $issue, $c);
304 }
305
306 /** 行へのコメントの表示用の前の行(無ければnull) */
307 private function hunkFor(array $issue, string $path, string $side, int $line): ?array {
308 $range = Pulls::range($this->repo, $issue);
309 if ($range === null || $path === '' || $line < 1) return null;
310 $mb = (new \Std\Lib\Git\Walk($this->repo))->mergeBase($range[0], $range[1]);
311 foreach (Diff::trees($this->repo, $mb !== null ? $this->repo->commit($mb)->tree : null, $this->repo->commit($range[1])->tree) as $ch) {
312 if ((($ch['new'] ?? $ch['old'])[1]) !== $path) continue;
313 $add = $del = 0;
314 $budget = self::MAX_DIFF_TOTAL;
315 $f = $this->presentChange($ch, $range[1], $add, $del, $budget);
316 foreach ($f['hunks'] as $h) {
317 foreach ($h['rows'] as $k => $r) {
318 if ($r[$side] !== $line || ($side === 'new' && $r['op'] === '-') || ($side === 'old' && $r['op'] !== '-')) continue;
319 return array_map(fn($x) => $x['op'].$x['text'], array_slice($h['rows'], max(0, $k - 3), min(4, $k + 1)));
320 }
321 }
322 }
323 return null;
324 }
325
326 /**
327 * 変更されたファイル(行へのコメント付き)
328 *
329 * @param array|null $form コメントの欄を出す行(path、side、line)
330 * @param array $issue プルリクエスト(コメントを付けない場合は空)
331 * @return array [ファイル, 統計]
332 */
333 private function diffFiles(?string $from, string $to, ?array $form, array $issue): array {
334 $changes = Diff::trees($this->repo, $from !== null ? $this->repo->commit($from)->tree : null, $this->repo->commit($to)->tree);
335 $add = $del = 0;
336 $budget = self::MAX_DIFF_TOTAL;
337 // スレッド(今のheadへのコメントだけ)
338 $threads = [];
339 $replies = [];
340 $me = $this->user ? Issues::authorKey($this->me()) : '';
341 foreach ($issue['comments'] ?? [] as $c) {
342 if (!isset($c['path']) || (!empty($c['pending']) && Issues::authorKey($c['author']) !== $me)) continue;
343 if ($c['replyTo'] !== null) $replies[$c['replyTo']][] = $c;
344 else if ($c['commit'] === $to) $threads["{$c['path']}\0{$c['side']}\0{$c['line']}"][] = $c;
345 }
346 $files = [];
347 foreach (array_slice($changes, 0, self::MAX_DIFF_FILES) as $i => $ch) {
348 $f = $this->presentChange($ch, $to, $add, $del, $budget);
349 $f['index'] = $i;
350 foreach ($f['hunks'] as $hk => $h) {
351 foreach ($h['rows'] as $rk => $r) {
352 $side = $r['op'] === '-' ? 'old' : 'new';
353 $line = (int)$r[$side];
354 $key = "{$f['path']}\0{$side}\0{$line}";
355 $r['anchor'] = "f{$i}-{$side}{$line}";
356 $r['link'] = !empty($issue) && $this->canReview() ? '?'.http_build_query(['path' => $f['path'], 'side' => $side, 'line' => $line]).'#'.$r['anchor'] : '';
357 $r['threads'] = array_map(fn($c) => $this->presentInline($c, $replies[$c['id']] ?? []), $threads[$key] ?? []);
358 $r['form'] = $form !== null && $form['path'] === $f['path'] && $form['side'] === $side && $form['line'] === $line ? ['path' => $f['path'], 'side' => $side, 'line' => $line] : null;
359 $f['hunks'][$hk]['rows'][$rk] = $r;
360 }
361 }
362 $files[] = $f;
363 }
364 return [$files, ['files' => count($changes), 'add' => $add, 'del' => $del, 'truncated' => count($changes) > self::MAX_DIFF_FILES]];
365 }
366
367 /** 行へのコメントとその返信 */
368 private function presentInline(array $c, array $replies): array {
369 $one = function(array $x): array {
370 $mine = $this->user && ($x['author']['user'] ?? null) === $this->user->username;
371 return ['id' => $x['id'], 'who' => $this->who($x['author']), 'html' => $this->bodyHtml($x['body'], !empty($x['html'])), 'date' => date('Y-m-d H:i', $x['created']),
372 'pending' => !empty($x['pending']), 'canDelete' => !$this->meta['archived'] && ($mine || $this->access >= Hosting::ADMIN)];
373 };
374 return $one($c) + ['path' => $c['path'], 'line' => $c['line'], 'side' => $c['side'], 'hunk' => $c['hunk'] ?? [], 'replies' => array_map($one, $replies)];
375 }
376
377 //////// 共通
378
379 private function loadPulls(array $params): void {
380 $this->load($params);
381 if (!$this->meta['pulls']) $this->notFound();
382 $this->issueDb = new Issues($this->repo);
383 }
384
385 /** @return array [プルリクエスト, 範囲] */
386 private function loadPull(array $params): array {
387 $this->loadPulls($params);
388 $issue = $this->issueDb->get((int)($params['n'] ?? 0));
389 if ($issue === null || !isset($issue['pull'])) $this->notFound();
390 return [$issue, Pulls::range($this->repo, $issue)];
391 }
392
393 /** プルリクエストを作れるか */
394 private function canPull(): bool {
395 return $this->user !== null && $this->meta['pulls'] && !$this->meta['archived'] && Hosting::userCan($this->user, 'pulls');
396 }
397
398 /** レビューとコメントが出来るか */
399 private function canReview(): bool {
400 return $this->user !== null && !$this->meta['archived'] && (Hosting::userCan($this->user, 'issues') || Hosting::userCan($this->user, 'pulls'));
401 }
402}