load($params); if (!$this->user) $this->redirect('/login'); if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !$this->csrfOk()) $this->redirect($this->repoUrl()); $res = Hosting::fork($this->user, $this->owner, $this->name); if (!$res->isSuccess) $this->message((string)$res->message, $this->repoUrl()); $this->redirect('/gitler/'.$res->data['owner'].'/'.rawurlencode($res->data['name'])); } /** …/forks */ public function forks(array $params): void { $this->load($params); $list = []; foreach (Hosting::forks($this->owner, $this->name) as [$o, $n]) { if (Hosting::access($this->user, $o, $n) === Hosting::NONE) continue; $list[] = ['owner' => $o, 'name' => $n, 'url' => '/gitler/'.$o.'/'.rawurlencode($n)]; } $this->renderRepo('gitler/forks', 'フォーク', ['list' => $list]); } //////// 比較と作成 /** …/compare?base=&head=(headは「ブランチ」又は「所有者/名前:ブランチ」(フォーク)) */ public function compare(array $params): void { $this->loadPulls($params); $base = trim((string)($_REQUEST['base'] ?? $this->defaultBranch())); $head = trim((string)($_REQUEST['head'] ?? '')); $error = ''; $heads = $this->headChoices(); $baseId = $this->repo->refs->resolve('refs/heads/'.$base); $found = $head !== '' ? $this->resolveHead($head) : null; $vars = ['base' => $base, 'head' => $head, 'branches' => array_map(fn($r) => substr($r, 11), array_keys($this->repo->refs->all('refs/heads/'))), 'heads' => $heads, 'commits' => [], 'files' => [], 'stats' => null, 'canPull' => $this->canPull(), 'in' => ['title' => '', 'body' => ''], 'ready' => false, 'note' => '']; if ($head !== '' && ($found === null || $baseId === null)) { $vars['note'] = 'ブランチが有りません。'; } else if ($found !== null) { [$kind, $headArr, $headId] = $found; $cmp = Pulls::compare($this->repo, $baseId, $headId); if (empty($cmp['commits'])) { $vars['note'] = "「{$base}」に無いコミットが有りません。"; } else { $vars['commits'] = array_map(fn($id) => $this->presentCommit($this->repo->commit($id)), array_reverse($cmp['commits'])); [$vars['files'], $vars['stats']] = $this->diffFiles($cmp['mergeBase'], $headId, null, []); $first = $this->repo->commit($cmp['commits'][0]); $vars['in'] = count($cmp['commits']) === 1 ? ['title' => $first->subject(), 'body' => trim(substr($first->message, strlen($first->subject())))] : ['title' => $kind === 'fork' ? $headArr['branch'] : $head, 'body' => '']; $vars['ready'] = true; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $vars['in'] = ['title' => (string)($_POST['title'] ?? ''), 'body' => str_replace("\r\n", "\n", (string)($_POST['body'] ?? ''))]; if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else if (!$this->canPull()) { $error = 'プルリクエストを作る権限が有りません(管理者に「Gitのプルリクエスト」のロールを付けて貰って下さい)。'; } else { $res = Pulls::open($this->owner, $this->name, $this->me(), $vars['in']['title'], $vars['in']['body'], $base, $kind, $headArr, $headId); if ($res->isSuccess) $this->redirect($this->repoUrl().'/pulls/'.$res->data['number']); if (isset($res->data['number'])) $this->redirect($this->repoUrl().'/pulls/'.$res->data['number']); $error = (string)$res->message; } } } } $this->renderRepo('gitler/compare', 'プルリクエストを作る', $vars + ['error' => $error]); } /** headの選択肢(このリポジトリのブランチと、見られるフォークのブランチ) */ private function headChoices(): array { $out = [['label' => "{$this->owner}/{$this->name}", 'items' => array_map(fn($r) => substr($r, 11), array_keys($this->repo->refs->all('refs/heads/')))]]; foreach (Hosting::forks($this->owner, $this->name) as [$o, $n]) { if (Hosting::access($this->user, $o, $n) === Hosting::NONE) continue; $out[] = ['label' => "{$o}/{$n}", 'items' => array_map(fn($r) => "{$o}/{$n}:".substr($r, 11), array_keys(Hosting::open($o, $n)->refs->all('refs/heads/')))]; } return $out; } /** * headを解決する(フォークのコミットはこのリポジトリに持って来る) * * @return array|null [kind, head, headId] */ private function resolveHead(string $head): ?array { if (preg_match('#^([A-Za-z0-9]+)/([A-Za-z0-9._-]+):(.+)$#', $head, $m)) { [, $o, $n, $branch] = $m; if (!Hosting::exists($o, $n) || Hosting::meta($o, $n)['forkOf'] !== "{$this->owner}/{$this->name}" || Hosting::access($this->user, $o, $n) === Hosting::NONE) return null; $fork = Hosting::open($o, $n); $id = $fork->refs->resolve('refs/heads/'.$branch); if ($id === null) return null; try { Pulls::fetch($fork, $this->repo, $id); } catch (GitException) { return null; } return ['fork', ['owner' => $o, 'name' => $n, 'branch' => $branch], $id]; } $id = $this->repo->refs->resolve('refs/heads/'.$head); return $id === null ? null : ['branch', ['owner' => $this->owner, 'name' => $this->name, 'branch' => $head], $id]; } //////// プルリクエストのページ /** …/pulls/{n}/commits */ public function pullCommits(array $params): void { [$issue, $range] = $this->loadPull($params); $list = []; if ($range !== null) foreach (array_reverse(Pulls::compare($this->repo, $range[0], $range[1])['commits']) as $id) $list[] = $this->presentCommit($this->repo->commit($id)); $this->renderRepo('gitler/pullcommits', '#'.$issue['number'].' '.$issue['title'], $this->pullHead($issue) + ['list' => $list, 'tab' => 'commits']); } /** …/pulls/{n}/files?path=&side=&line=(行を選ぶとコメントの欄) */ public function pullFiles(array $params): void { [$issue, $range] = $this->loadPull($params); $n = $issue['number']; $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $error = $this->csrfOk() ? $this->issueAction($issue) : 'セッションの有効期限が切れました。もう一度お試し下さい。'; $issue = $this->issueDb->get($n) ?? $issue; } $files = []; $stats = null; if ($range !== null) { $mb = (new \Std\Lib\Git\Walk($this->repo))->mergeBase($range[0], $range[1]); $form = isset($_GET['path'], $_GET['line']) ? ['path' => (string)$_GET['path'], 'side' => ($_GET['side'] ?? 'new') === 'old' ? 'old' : 'new', 'line' => (int)$_GET['line']] : null; [$files, $stats] = $this->diffFiles($mb, $range[1], $this->canReview() ? $form : null, $issue); } $me = $this->user ? Issues::authorKey($this->me()) : ''; $pending = count(array_filter($issue['comments'], fn($c) => !empty($c['pending']) && Issues::authorKey($c['author']) === $me)); $this->renderRepo('gitler/pullfiles', '#'.$n.' '.$issue['title'], $this->pullHead($issue) + [ 'files' => $files, 'stats' => $stats, 'tab' => 'files', 'error' => $error, 'pending' => $pending, 'canReview' => $this->canReview(), 'canApprove' => $this->canReview() && ($issue['author']['user'] ?? null) !== $this->user?->username, 'filesUrl' => $this->repoUrl().'/pulls/'.$n.'/files', ]); } /** 会話のページ(issue())に足す物 */ private function pullVars(array $issue): array { $p = $issue['pull']; try { $m = Pulls::mergeability($this->repo, $issue); } catch (GitException) { $m = ['state' => 'error', 'conflicts' => [], 'baseId' => null]; } // レビュアー毎の最後の承認・変更の依頼 $latest = []; foreach ($issue['reviews'] ?? [] as $r) if ($r['state'] !== 'commented') $latest[Issues::authorKey($r['author'])] = $r; $branchId = $p['kind'] === 'branch' ? $this->repo->refs->resolve('refs/heads/'.$p['head']['branch']) : null; $msg = "Merge pull request #{$issue['number']} from ".$this->headLabel($p)."\n\n{$issue['title']}"; return $this->pullHead($issue) + [ 'merge' => $m, 'tab' => 'conversation', 'approvals' => array_values(array_map(fn($r) => ['who' => $this->who($r['author']), 'state' => $r['state']], $latest)), 'canMerge' => !$this->meta['archived'] && $this->access >= Hosting::WRITE && in_array($m['state'], ['ff', 'clean', 'conflict'], true), 'mergeMessage' => $msg, 'squashMessage' => "{$issue['title']} (#{$issue['number']})", 'canDeleteBranch' => !$this->meta['archived'] && $this->access >= Hosting::WRITE && $issue['state'] === 'closed' && $branchId !== null && $branchId === $p['headId'] && $p['head']['branch'] !== $this->defaultBranch(), 'fetchCommand' => 'git fetch origin '.Pulls::ref($issue['number']).':pr-'.$issue['number'], ]; } /** プルリクエストのページの頭(タブ等) */ private function pullHead(array $issue): array { $p = $issue['pull']; $u = $this->repoUrl().'/pulls/'.$issue['number']; $range = Pulls::range($this->repo, $issue); $count = $range !== null ? count(Pulls::compare($this->repo, $range[0], $range[1])['commits']) : 0; $headUrl = match ($p['kind']) { 'branch' => $this->url('tree', $p['head']['branch'], ''), 'fork' => '/gitler/'.$p['head']['owner'].'/'.rawurlencode($p['head']['name']).'/tree?ref='.rawurlencode($p['head']['branch']), default => '', }; return ['pull' => $p, 'pullUrl' => $u, 'commitsTabUrl' => $u.'/commits', 'filesTabUrl' => $u.'/files', 'commitCount' => $count, 'headLabel' => $this->headLabel($p), 'headUrl' => $headUrl, 'issue' => $issue, 'who' => $this->who($issue['author']), 'merged' => !empty($p['merged']), 'mergedBy' => !empty($p['merged']) ? $this->who($p['merged']['by']) : null]; } private function headLabel(array $p): string { return match ($p['kind']) { 'fork' => "{$p['head']['owner']}/{$p['head']['name']}:{$p['head']['branch']}", 'agit' => "{$p['head']['user']}:{$p['head']['topic']}(AGit)", default => (string)$p['head']['branch'], }; } /** * プルリクエストのPOST(GitlerIssuesのissueActionから) * * @return string エラー(成功したらリダイレクト) */ private function pullAction(array $issue, string $do): string { $n = $issue['number']; $url = $this->repoUrl().'/pulls/'.$n; if (!$this->user) return 'ログインして下さい。'; if ($this->meta['archived']) return 'アーカイブされたリポジトリです(読むだけ)。'; $me = $this->me(); $body = str_replace("\r\n", "\n", (string)($_POST['body'] ?? '')); if ($do === 'merge') { if ($this->access < Hosting::WRITE) return 'マージする権限が有りません。'; $res = Pulls::merge($this->owner, $this->name, $n, $this->user, (string)($_POST['method'] ?? 'merge'), (string)($_POST['message'] ?? '')); if (!$res->isSuccess) return (string)$res->message; $this->redirect($url); } if ($do === 'deleteBranch') { $p = $issue['pull']; if ($this->access < Hosting::WRITE || $p['kind'] !== 'branch' || $p['head']['branch'] === $this->defaultBranch()) return '削除出来ません。'; try { $this->repo->refs->update('refs/heads/'.$p['head']['branch'], null, $p['headId']); } catch (GitException) { return 'ブランチが変わっているので削除出来ません。'; } $this->issueDb->update($n, function(array $i) use ($me, $p): array { $i['events'][] = ['type' => 'branchDeleted', 'by' => $me, 'at' => time(), 'branch' => $p['head']['branch']]; return $i; }); $this->redirect($url); } if (!$this->canReview()) return 'コメントする権限が有りません。'; if ($do === 'inline' || $do === 'inlinePending') { $at = ['path' => (string)($_POST['path'] ?? ''), 'side' => ($_POST['side'] ?? 'new') === 'old' ? 'old' : 'new', 'line' => (int)($_POST['line'] ?? 0), 'commit' => $issue['pull']['headId'], 'pending' => $do === 'inlinePending']; $at['hunk'] = $this->hunkFor($issue, $at['path'], $at['side'], $at['line']); if ($at['hunk'] === null) return 'その行が有りません。'; $res = $this->issueDb->inlineComment($n, $me, $body, $at); if (!$res->isSuccess) return (string)$res->message; if ($do === 'inline') $this->notifyComment($res->data['issue'], $res->data['comment']); $this->redirect($url.'/files#c'.$res->data['comment']['id']); } if ($do === 'reply') { $res = $this->issueDb->inlineComment($n, $me, $body, ['replyTo' => (int)($_POST['id'] ?? 0)]); if (!$res->isSuccess) return (string)$res->message; $this->notifyComment($res->data['issue'], $res->data['comment']); $back = ($_POST['back'] ?? '') === 'files' ? $url.'/files' : $url; $this->redirect($back.'#c'.$res->data['comment']['id']); } if ($do === 'review') { $state = (string)($_POST['state'] ?? 'commented'); if ($state !== 'commented' && ($issue['author']['user'] ?? null) === $this->user->username) return '自分のプルリクエストは承認・変更の依頼が出来ません。'; $res = $this->issueDb->submitReview($n, $me, $state, $body, $issue['pull']['headId']); if (!$res->isSuccess) return (string)$res->message; Notify::reviewed($this->owner, $this->name, $this->meta, $this->issueDb, $res->data['issue'], $res->data['review']); $this->redirect($url.'#r'.$res->data['review']['id']); } return ''; } private function notifyComment(array $issue, array $c): void { Federation::commentAdded($this->owner, $this->name, $this->meta, $issue, $c); Notify::commented($this->owner, $this->name, $this->meta, $this->issueDb, $issue, $c); } /** 行へのコメントの表示用の前の行(無ければnull) */ private function hunkFor(array $issue, string $path, string $side, int $line): ?array { $range = Pulls::range($this->repo, $issue); if ($range === null || $path === '' || $line < 1) return null; $mb = (new \Std\Lib\Git\Walk($this->repo))->mergeBase($range[0], $range[1]); foreach (Diff::trees($this->repo, $mb !== null ? $this->repo->commit($mb)->tree : null, $this->repo->commit($range[1])->tree) as $ch) { if ((($ch['new'] ?? $ch['old'])[1]) !== $path) continue; $add = $del = 0; $budget = self::MAX_DIFF_TOTAL; $f = $this->presentChange($ch, $range[1], $add, $del, $budget); foreach ($f['hunks'] as $h) { foreach ($h['rows'] as $k => $r) { if ($r[$side] !== $line || ($side === 'new' && $r['op'] === '-') || ($side === 'old' && $r['op'] !== '-')) continue; return array_map(fn($x) => $x['op'].$x['text'], array_slice($h['rows'], max(0, $k - 3), min(4, $k + 1))); } } } return null; } /** * 変更されたファイル(行へのコメント付き) * * @param array|null $form コメントの欄を出す行(path、side、line) * @param array $issue プルリクエスト(コメントを付けない場合は空) * @return array [ファイル, 統計] */ private function diffFiles(?string $from, string $to, ?array $form, array $issue): array { $changes = Diff::trees($this->repo, $from !== null ? $this->repo->commit($from)->tree : null, $this->repo->commit($to)->tree); $add = $del = 0; $budget = self::MAX_DIFF_TOTAL; // スレッド(今のheadへのコメントだけ) $threads = []; $replies = []; $me = $this->user ? Issues::authorKey($this->me()) : ''; foreach ($issue['comments'] ?? [] as $c) { if (!isset($c['path']) || (!empty($c['pending']) && Issues::authorKey($c['author']) !== $me)) continue; if ($c['replyTo'] !== null) $replies[$c['replyTo']][] = $c; else if ($c['commit'] === $to) $threads["{$c['path']}\0{$c['side']}\0{$c['line']}"][] = $c; } $files = []; foreach (array_slice($changes, 0, self::MAX_DIFF_FILES) as $i => $ch) { $f = $this->presentChange($ch, $to, $add, $del, $budget); $f['index'] = $i; foreach ($f['hunks'] as $hk => $h) { foreach ($h['rows'] as $rk => $r) { $side = $r['op'] === '-' ? 'old' : 'new'; $line = (int)$r[$side]; $key = "{$f['path']}\0{$side}\0{$line}"; $r['anchor'] = "f{$i}-{$side}{$line}"; $r['link'] = !empty($issue) && $this->canReview() ? '?'.http_build_query(['path' => $f['path'], 'side' => $side, 'line' => $line]).'#'.$r['anchor'] : ''; $r['threads'] = array_map(fn($c) => $this->presentInline($c, $replies[$c['id']] ?? []), $threads[$key] ?? []); $r['form'] = $form !== null && $form['path'] === $f['path'] && $form['side'] === $side && $form['line'] === $line ? ['path' => $f['path'], 'side' => $side, 'line' => $line] : null; $f['hunks'][$hk]['rows'][$rk] = $r; } } $files[] = $f; } return [$files, ['files' => count($changes), 'add' => $add, 'del' => $del, 'truncated' => count($changes) > self::MAX_DIFF_FILES]]; } /** 行へのコメントとその返信 */ private function presentInline(array $c, array $replies): array { $one = function(array $x): array { $mine = $this->user && ($x['author']['user'] ?? null) === $this->user->username; 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']), 'pending' => !empty($x['pending']), 'canDelete' => !$this->meta['archived'] && ($mine || $this->access >= Hosting::ADMIN)]; }; return $one($c) + ['path' => $c['path'], 'line' => $c['line'], 'side' => $c['side'], 'hunk' => $c['hunk'] ?? [], 'replies' => array_map($one, $replies)]; } //////// 共通 private function loadPulls(array $params): void { $this->load($params); if (!$this->meta['pulls']) $this->notFound(); $this->issueDb = new Issues($this->repo); } /** @return array [プルリクエスト, 範囲] */ private function loadPull(array $params): array { $this->loadPulls($params); $issue = $this->issueDb->get((int)($params['n'] ?? 0)); if ($issue === null || !isset($issue['pull'])) $this->notFound(); return [$issue, Pulls::range($this->repo, $issue)]; } /** プルリクエストを作れるか */ private function canPull(): bool { return $this->user !== null && $this->meta['pulls'] && !$this->meta['archived'] && Hosting::userCan($this->user, 'pulls'); } /** レビューとコメントが出来るか */ private function canReview(): bool { return $this->user !== null && !$this->meta['archived'] && (Hosting::userCan($this->user, 'issues') || Hosting::userCan($this->user, 'pulls')); } }