refs->resolve('refs/heads/'.$p['base']) ?? $p['baseId']; return $repo->objects->exists($base) && $repo->objects->exists($p['headId']) ? [$base, $p['headId']] : null; } /** * 比べる(headにだけ有るコミット) * * @return array mergeBase、commits(古い順のID)、truncated */ public static function compare(Repository $repo, string $base, string $head): array { $walk = new Walk($repo); [$ids] = $walk->commitsBetween([$head], [$base]); return ['mergeBase' => $walk->mergeBase($base, $head), 'commits' => array_reverse(array_slice($ids, 0, self::MAX_COMMITS)), 'truncated' => count($ids) > self::MAX_COMMITS]; } /** * プルリクエストを作る * * @param array $head branch・fork:owner、name、branch。agit:user、topic * @param array $opt labels、assignees、milestone */ public static function open(string $owner, string $name, array $author, string $title, string $body, string $base, string $kind, array $head, string $headId, array $opt = []): Result { $meta = Gitler::meta($owner, $name); if (!$meta['pulls']) return Result::Error('このリポジトリではプルリクエストは使えません。'); if ($meta['archived']) return Result::Error('アーカイブされたリポジトリです(読むだけ)。'); $repo = Gitler::open($owner, $name); $baseId = $repo->refs->resolve('refs/heads/'.$base); if ($baseId === null) return Result::Error("ブランチ「{$base}」が有りません。"); if (!$repo->objects->exists($headId)) return Result::Error('コミットが有りません。'); if (empty(self::compare($repo, $baseId, $headId)['commits'])) return Result::Error("「{$base}」に無いコミットが有りません。"); $issues = new Issues($repo); foreach ($issues->search(['pull' => true]) as $s) { $p = $issues->get($s['number'])['pull'] ?? null; if ($p !== null && $p['kind'] === $kind && $p['head'] === $head && $p['base'] === $base) return Result::Error("同じプルリクエスト#{$s['number']}が有ります。", ['number' => $s['number']]); } $res = $issues->create($author, $title, $body, $opt + ['pull' => ['kind' => $kind, 'base' => $base, 'head' => $head, 'headId' => $headId, 'baseId' => $baseId, 'merged' => null]]); if (!$res->isSuccess) return $res; $repo->refs->update(self::ref($res->data['number']), $headId); Federation::issueOpened($owner, $name, $meta, $res->data['issue']); Notify::issueOpened($owner, $name, $meta, $issues, $res->data['issue']); return $res; } /** 新しいコミット(refs/pull/N/headと出来事) */ public static function setHead(Repository $repo, Issues $issues, int $n, string $new, array $by): ?array { $issue = $issues->get($n); if ($issue === null || !isset($issue['pull']) || $issue['pull']['headId'] === $new) return null; $old = $issue['pull']['headId']; $count = count((new Walk($repo))->commitsBetween([$new], [$old])[0]); $repo->refs->update(self::ref($n), $new); return $issues->update($n, function(array $i) use ($old, $new, $count, $by): array { $i['pull']['headId'] = $new; $i['events'][] = ['type' => 'pushed', 'by' => $by, 'at' => time(), 'from' => $old, 'to' => $new, 'count' => $count]; return $i; }); } /** フォーク等の他のリポジトリからコミット(と必要なオブジェクト)を持って来る */ public static function fetch(Repository $from, Repository $to, string $id): void { if ($to->objects->exists($id)) return; $haves = []; foreach (array_merge($to->refs->all('refs/heads/'), $to->refs->all('refs/tags/')) as $h) { // 注釈付きタグはコミットにする $c = $from->objects->exists($h) ? $from->peel($h, 'commit') : null; if ($c !== null) $haves[$c] = true; } $haves = array_keys($haves); $objects = (new Walk($from))->objects([$id], $haves); $tmp = fopen('php://temp', 'w+b'); try { (new PackWriter($from))->write($objects, function(string $b) use ($tmp): void { fwrite($tmp, $b); }); rewind($tmp); (new PackIndexer($to))->receive($tmp); } finally { fclose($tmp); } } //////// マージ /** * マージ出来るか * * @return array state(merged、closed、uptodate(既に入っている)、ff、clean、conflict、nobase)、conflicts、baseId */ public static function mergeability(Repository $repo, array $issue): array { $p = $issue['pull']; if (!empty($p['merged'])) return ['state' => 'merged', 'conflicts' => [], 'baseId' => null]; if ($issue['state'] === 'closed') return ['state' => 'closed', 'conflicts' => [], 'baseId' => null]; $baseId = $repo->refs->resolve('refs/heads/'.$p['base']); if ($baseId === null) return ['state' => 'nobase', 'conflicts' => [], 'baseId' => null]; $walk = new Walk($repo); if ($walk->isAncestor($p['headId'], $baseId)) return ['state' => 'uptodate', 'conflicts' => [], 'baseId' => $baseId]; if ($walk->isAncestor($baseId, $p['headId'])) return ['state' => 'ff', 'conflicts' => [], 'baseId' => $baseId]; $mb = $walk->mergeBase($baseId, $p['headId']); $r = Merge::trees($repo, $mb !== null ? $repo->commit($mb)->tree : null, $repo->commit($baseId)->tree, $repo->commit($p['headId'])->tree); return ['state' => $r['tree'] !== null ? 'clean' : 'conflict', 'conflicts' => $r['conflicts'], 'baseId' => $baseId]; } /** * マージする * * @param string $method merge(マージコミット)、squash、rebase(出来ればfast-forward) * @param \stdClass $user マージする人(コミッター) */ public static function merge(string $owner, string $name, int $n, \stdClass $user, string $method, string $message): Result { $repo = Gitler::open($owner, $name); $issues = new Issues($repo); $issue = $issues->get($n); if ($issue === null || !isset($issue['pull'])) return Result::Error('プルリクエストが有りません。'); $m = self::mergeability($repo, $issue); if (in_array($m['state'], ['merged', 'closed', 'nobase', 'uptodate'], true)) return Result::Error('マージ出来ません。'); if (!in_array($method, ['merge', 'squash', 'rebase'], true)) return Result::Error('マージの方法が不正です。'); $p = $issue['pull']; $baseId = $m['baseId']; $head = $p['headId']; $me = self::person($user); $message = trim(str_replace("\r\n", "\n", $message)); $walk = new Walk($repo); $mb = $walk->mergeBase($baseId, $head); if ($method === 'rebase') { if ($m['state'] === 'ff') { $new = $head; } else { $new = $baseId; foreach (self::compare($repo, $baseId, $head)['commits'] as $id) { $c = $repo->commit($id); if (count($c->parents) > 1) return Result::Error('マージコミットが有るのでリベース出来ません。'); $tree = Merge::pick($repo, $id, $new); if ($tree === null) return Result::Error('コンフリクトが有るのでリベース出来ません。'); $new = $repo->writeCommit(Commit::create($tree, [$new], $c->author ?? $me, $me, $c->message)); } } } else { if ($m['state'] === 'conflict') return Result::Error('コンフリクトが有ります。'); $tree = Merge::trees($repo, $mb !== null ? $repo->commit($mb)->tree : null, $repo->commit($baseId)->tree, $repo->commit($head)->tree)['tree']; if ($tree === null) return Result::Error('コンフリクトが有ります。'); if ($method === 'merge') { $msg = $message !== '' ? $message : "Merge pull request #{$n}\n\n{$issue['title']}"; $new = $repo->writeCommit(Commit::create($tree, [$baseId, $head], $me, $me, $msg."\n")); } else { $subjects = array_map(fn($id) => '* '.$repo->commit($id)->subject(), self::compare($repo, $baseId, $head)['commits']); $msg = $message !== '' ? $message : "{$issue['title']} (#{$n})\n\n".implode("\n", $subjects); $author = isset($issue['author']['user']) && ($u = Gitler::user((string)$issue['author']['user'])) !== null ? self::person($u) : $me; $new = $repo->writeCommit(Commit::create($tree, [$baseId], $author, $me, $msg."\n")); } } try { $repo->refs->update('refs/heads/'.$p['base'], $new, $baseId); } catch (GitException) { return Result::Error('ブランチが変わりました。もう一度お試し下さい。'); } $by = ['user' => (string)$user->username]; $merged = self::markMerged($owner, $name, $issues, $n, $by, $new, $method, $baseId); // マージで入ったコミットとプルリクエストの本文の「fixes #3」でイシューを閉じる self::afterBaseUpdate($owner, $name, $repo, $issues, 'refs/heads/'.$p['base'], $baseId, $new, $by); $meta = Gitler::meta($owner, $name); foreach ($issues->processPush([['refs/heads/'.$p['base'], $baseId, $new]], $repo->headRef() ?? '') as $x) { $closed = $issues->get($x); if ($closed === null) continue; Federation::stateChanged($owner, $name, $meta, $closed); Notify::stateChanged($owner, $name, $meta, $closed, end($closed['events'])['by'] ?? $by); } if (preg_match_all('/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/i', $issue['body'], $cm) && 'refs/heads/'.$p['base'] === ($repo->headRef() ?? '')) { foreach (array_unique(array_map('intval', $cm[1])) as $x) { $target = $issues->get($x); if ($target === null || isset($target['pull'])) continue; $closed = $issues->setState($x, false, $by, ['pull' => $n]); if ($closed !== null) { Federation::stateChanged($owner, $name, Gitler::meta($owner, $name), $closed); Notify::stateChanged($owner, $name, Gitler::meta($owner, $name), $closed, $by); } } } return Result::Success('マージしました。', ['commit' => $new, 'issue' => $merged]); } /** * マージした事にする(閉じて、出来事を記録して、通知する) * * @param string $before マージする前のマージ先(コミットと変更の表示に使う) */ public static function markMerged(string $owner, string $name, Issues $issues, int $n, array $by, string $commit, string $method, string $before): ?array { $issue = $issues->update($n, function(array $i) use ($by, $commit, $method, $before): ?array { if (!isset($i['pull']) || !empty($i['pull']['merged'])) return null; $i['pull']['merged'] = ['by' => $by, 'at' => time(), 'commit' => $commit, 'method' => $method, 'before' => $before]; $i['state'] = 'closed'; $i['closed'] = time(); $i['events'][] = ['type' => 'merged', 'by' => $by, 'at' => time(), 'commit' => $commit, 'base' => $i['pull']['base'], 'method' => $method]; return $i; }); if ($issue !== null) { $meta = Gitler::meta($owner, $name); Federation::stateChanged($owner, $name, $meta, $issue); Notify::merged($owner, $name, $meta, $issue, $by); } return $issue; } /** ブランチが進んだ:入ったプルリクエストをマージ済みにする */ private static function afterBaseUpdate(string $owner, string $name, Repository $repo, Issues $issues, string $ref, string $old, string $new, array $by): void { $walk = new Walk($repo); foreach ($issues->search(['pull' => true]) as $s) { $i = $issues->get($s['number']); if ($i === null || 'refs/heads/'.$i['pull']['base'] !== $ref || !$repo->objects->exists($i['pull']['headId'])) continue; if ($walk->isAncestor($i['pull']['headId'], $new)) self::markMerged($owner, $name, $issues, $s['number'], $by, $new, 'manual', $old); } } //////// プッシュ /** * refs/for/ へのプッシュを受け付けるか(ReceivePackのupdate) * * @return string|null エラー */ public static function checkAgit(string $owner, string $name, ?\stdClass $user, string $ref, string $new, string $zero): ?string { $meta = Gitler::meta($owner, $name); if (!$meta['pulls'] || $meta['archived'] || $user === null || !Gitler::userCan($user, 'pulls') || Gitler::access($user, $owner, $name) < Gitler::READ) { return 'pull requests are not allowed'; } if ($new === $zero) return 'cannot delete'; return self::parseAgit(Gitler::open($owner, $name), $ref) === null ? 'use refs/for//' : null; } /** * プッシュの後(ReceivePackのprocess):AGitのプルリクエスト、ブランチのプルリクエストの更新、マージの検出 * * @param array $updates [参照, 前, 後] * @return array クライアントに表示する行 */ public static function afterPush(string $owner, string $name, ?\stdClass $user, array $updates): array { $repo = Gitler::open($owner, $name); $meta = Gitler::meta($owner, $name); $zero = $repo->zeroId(); $issues = new Issues($repo); $by = $user !== null ? ['user' => (string)$user->username] : []; $base = rtrim((string)(SITEINFO['url'] ?? ''), '/') ?: Fedi::base(); $out = []; foreach ($updates as [$ref, $old, $new]) { try { if (str_starts_with($ref, 'refs/for/')) { $out = array_merge($out, self::agit($owner, $name, $repo, $issues, $user, $ref, $new, $base)); continue; } if (!str_starts_with($ref, 'refs/heads/') || $new === $zero) continue; $branch = substr($ref, 11); // このリポジトリのブランチからのプルリクエスト foreach ($issues->search(['pull' => true]) as $s) { $p = $issues->get($s['number'])['pull'] ?? null; if ($p !== null && $p['kind'] === 'branch' && $p['head']['branch'] === $branch) self::setHead($repo, $issues, $s['number'], $new, $by); } // フォーク元へのプルリクエスト if ($meta['forkOf'] !== '') { [$po, $pn] = explode('/', $meta['forkOf'], 2); if (Gitler::exists($po, $pn)) { $parent = Gitler::open($po, $pn); $pi = new Issues($parent); foreach ($pi->search(['pull' => true]) as $s) { $p = $pi->get($s['number'])['pull'] ?? null; if ($p === null || $p['kind'] !== 'fork' || $p['head'] !== ['owner' => $owner, 'name' => $name, 'branch' => $branch]) continue; self::fetch($repo, $parent, $new); self::setHead($parent, $pi, $s['number'], $new, $by); } } } // マージ先が進んだ self::afterBaseUpdate($owner, $name, $repo, $issues, $ref, $old, $new, $by); // 新しいブランチ:プルリクエストを作るURL(フォークならフォーク元に) if ($old === $zero && $ref !== $repo->headRef() && $user !== null && Gitler::userCan($user, 'pulls')) { [$po, $pn] = $meta['forkOf'] !== '' ? explode('/', $meta['forkOf'], 2) : ['', '']; if ($po !== '' && Gitler::exists($po, $pn) && Gitler::meta($po, $pn)['pulls']) { $out[] = 'プルリクエストを作る:'.$base.'/gitler/'.$po.'/'.rawurlencode($pn).'/compare?head='.rawurlencode("{$owner}/{$name}:{$branch}"); } else if ($meta['pulls'] && !$meta['archived']) { $out[] = 'プルリクエストを作る:'.$base.'/gitler/'.$owner.'/'.rawurlencode($name).'/compare?head='.rawurlencode($branch); } } } catch (\Throwable $e) { @error_log('Gitler: '.$e->getMessage()); } } return $out; } /** refs/for/<ブランチ>/<トピック>(ブランチは一番長い有る物) */ public static function parseAgit(Repository $repo, string $ref): ?array { $rest = substr($ref, 9); $parts = explode('/', $rest); for ($k = count($parts) - 1; $k >= 1; $k--) { $branch = implode('/', array_slice($parts, 0, $k)); $topic = implode('/', array_slice($parts, $k)); if ($repo->refs->resolve('refs/heads/'.$branch) !== null && preg_match(self::TOPIC_RE, $topic)) return [$branch, $topic]; } return null; } private static function agit(string $owner, string $name, Repository $repo, Issues $issues, ?\stdClass $user, string $ref, string $new, string $site): array { $parsed = self::parseAgit($repo, $ref); if ($parsed === null || $user === null) return []; [$branch, $topic] = $parsed; $head = ['user' => (string)$user->username, 'topic' => $topic]; $by = ['user' => (string)$user->username]; $url = fn(int $n) => $site.'/gitler/'.$owner.'/'.rawurlencode($name).'/pulls/'.$n; foreach ($issues->search(['pull' => true]) as $s) { $p = $issues->get($s['number'])['pull'] ?? null; if ($p === null || $p['kind'] !== 'agit' || $p['head'] !== $head || $p['base'] !== $branch) continue; self::setHead($repo, $issues, $s['number'], $new, $by); return ["プルリクエスト#{$s['number']}を更新しました:".$url($s['number'])]; } // 題名と本文は最初のコミットから $baseId = $repo->refs->resolve('refs/heads/'.$branch); $commits = $baseId !== null ? self::compare($repo, $baseId, $new)['commits'] : []; if (empty($commits)) return ["「{$branch}」に無いコミットが有りません。"]; $first = $repo->commit($commits[0]); $body = count($commits) === 1 ? trim(substr($first->message, strlen($first->subject()))) : implode("\n", array_map(fn($id) => '* '.$repo->commit($id)->subject(), $commits)); $res = self::open($owner, $name, $by, $first->subject(), $body, $branch, 'agit', $head, $new); return $res->isSuccess ? ["プルリクエスト#{$res->data['number']}を作りました:".$url($res->data['number'])] : [(string)$res->message]; } /** コミットの名前とメールアドレス(メールアドレスは公開しない) */ public static function person(\stdClass $u): Person { $host = (string)parse_url(rtrim((string)(SITEINFO['url'] ?? ''), '/') ?: Fedi::base(), PHP_URL_HOST) ?: 'localhost'; return Person::now(($u->displayname ?? '') !== '' ? (string)$u->displayname : (string)$u->username, $u->username.'@users.noreply.'.$host); } }