| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, 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 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Site\Controller; |
| 39 | |
| 40 | use Std\Lib\Git\Diff; |
| 41 | use Std\Lib\Git\GitException; |
| 42 | use Std\Lib\Git\Repository; |
| 43 | use Std\Lib\Gitler\Federation; |
| 44 | use Std\Lib\Gitler\Gitler as Hosting; |
| 45 | use Std\Lib\Gitler\Issues; |
| 46 | use Std\Lib\Gitler\Notify; |
| 47 | use Std\Lib\Gitler\Pulls; |
| 48 | |
| 49 | /** |
| 50 | * Gitlerのプルリクエスト(比較・作成、コミット、変更されたファイルと行へのコメント、レビュー、マージ)とフォーク |
| 51 | * |
| 52 | * 会話のページはGitlerIssuesのissue()(プルリクエストならgitler/pull)。 |
| 53 | */ |
| 54 | trait 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 | } |