プルリク

守矢諏訪子 <suwako@076.moe>、2026-11-13 16:05
コミット 8379199f1a57b49e6d51abde59ca29bf5de18c00 ・親 0748c3b ・ファイルを見る

31個のファイル、+2153 -215

変更 README-JP.md
@@ -306,6 +306,7 @@
306306 * Fediverse(マルチユーザーの ActivityPub が有効な場合):公開のリポジトリは `@所有者.名前@ホスト` のアカウントになり、Mastodon 等からフォローするとイシューとコメントが届き、返信するとコメント、リポジトリをメンションした投稿は新しいイシューになります。ForgeFed の `Offer{Ticket}` と `Resolve` にも対応します(イシューの Ticket は `…/issues/3/ticket`)。リポジトリの設定でオフにしたり、アカウントやドメインをブロック出来ます(サイト全体のドメインのブロックも効きます)。配信には cron の `apdeliver` を使い、SSH のプッシュ(`git` ユーザー)で閉じたイシューは `gitler/spool/` に溜めて cron の `notify` が送ります。鍵の作成に GMP が必要です。
307307 * 通知:所有者と共同作業者は最初からリポジトリをウォッチし(新しいイシューと全部の動き)、他の人は作った・コメントした・担当の・メンション(`@ユーザー`)されたイシューの動きを受け取ります。リポジトリのページの「通知」でウォッチ・参加しているイシューだけ・無視を、イシューのページで受け取る・止めるを選べます。通知はサイトの「通知」に届き、`MAILER_ENABLED` ならメールでも届きます(`/gitler/-/keys` で止められます)。メールと SSH のプッシュの通知は cron の `notify` が送るので追加して下さい:`php cron.php add "* * * * *" notify`。
308308 * アーカイブ:リポジトリの設定でアーカイブすると読むだけになります(プッシュ、イシュー・コメント・ラベル・マイルストーンの変更、Fediverse からのコメントを止めます)。何時でも解除出来ます。
309+* プルリクエスト(「Gitのプルリクエスト」のロールが必要):同じリポジトリのブランチ、フォーク(リポジトリのページの「フォークする」、「Gitのリポジトリ」のロールが必要)のブランチ、又はフォークしないで `git push origin HEAD:refs/for/master/トピック`(AGit、読めるだけのリポジトリにも。同じトピックにもう一度プッシュすると更新)から作れます。新しいブランチをプッシュすると、プルリクエストを作る URL が表示されます。会話、コミット、変更されたファイル(行番号を押すとその行にコメント、下書きをまとめてレビュー(コメント・承認・変更を依頼))が有り、書き込み権限の有る人がマージコミット・スカッシュ・リベース(出来れば fast-forward)でマージします(コンフリクトは PHP の3方向のマージで調べ、有ればローカルで解決)。本文の「fixes #3」でマージした時にイシューを閉じ、マージ先にプッシュで入ったプルリクエストはマージ済みになります。番号はイシューと同じで、変更は `refs/pull/番号/head` に有ります(`git fetch origin refs/pull/3/head:pr-3`)。リポジトリ毎に設定で無効に出来ます。
309310
310311 ### TLS 1.3・1.2(php_openssl は不要)
311312 HTTPS のリクエスト(`Curl`、ActivityPub)、SMTP の STARTTLS(`Mailer`)、MySQL・PostgreSQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント `Std\Lib\Tls`(`php_gmp` が必要)を使うので、`php_openssl` は不要です。RSA の鍵と署名(ActivityPub)は `Std\Lib\Crypto\Rsa` を使い、今までの PEM の鍵もそのまま使えます。
変更 README.md
@@ -304,6 +304,7 @@
304304 * Fediverse (when multi-user ActivityPub is on): public repositories become `@owner.name@host` accounts. Follow one from Mastodon and the like to receive issues and comments; replies become comments, and posts mentioning the repository open new issues. ForgeFed `Offer{Ticket}` and `Resolve` are supported too (the issue's Ticket is at `…/issues/3/ticket`). Repository settings can turn it off and block accounts or domains (site-wide domain blocks apply as well). Delivery uses the `apdeliver` cron job; issues closed by SSH pushes (the `git` user) are queued in `gitler/spool/` and sent by the `notify` cron job. Generating the keys needs GMP.
305305 * Notifications: owners and collaborators watch their repositories from the start (new issues and all activity); others get activity on issues they opened, commented on, are assigned to or are mentioned in (`@user`). The repository page's "通知" menu switches between watching, participating only and ignoring, and each issue page can subscribe or unsubscribe. Notifications appear on the site's notifications page, and by email too when `MAILER_ENABLED` is on (users can turn email off at `/gitler/-/keys`). Emails and notifications from SSH pushes are sent by the `notify` cron job, so add it: `php cron.php add "* * * * *" notify`.
306306 * Archiving: archiving a repository in its settings makes it read-only (no pushes, no changes to issues, comments, labels or milestones, no comments from the Fediverse). It can be undone at any time.
307+* Pull requests (need the "Gitのプルリクエスト" role): open them from a branch in the same repository, from a branch in a fork ("フォークする" on the repository page, needs the "Gitのリポジトリ" role), or without forking via `git push origin HEAD:refs/for/master/topic` (AGit, also for repositories you can only read; pushing to the same topic again updates it). Pushing a new branch prints a URL for opening a pull request. Pull requests have a conversation, commits, and changed files (click a line number to comment on that line; collect draft comments into a review that comments, approves or requests changes). People with write access merge with a merge commit, squash, or rebase (fast-forward when possible); conflicts are checked with a 3-way merge in PHP and must be resolved locally. `fixes #3` in the description closes the issue on merge, and pull requests whose changes reach the target branch by a push are marked merged. They share numbering with issues, and the changes live in `refs/pull/N/head` (`git fetch origin refs/pull/3/head:pr-3`). They can be turned off per repository in its settings.
307308
308309 ### TLS 1.3 and 1.2 (no php_openssl)
309310 HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL and PostgreSQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working.
変更 gitler.php
@@ -112,18 +112,22 @@
112112 // gitユーザーはdata/に書けないので、通知とFediverseへの配信はリポジトリに溜める(cronのnotifyが送る)
113113 Gitler::$deferred = true;
114114 $owner = $repoName = '';
115- exit(Ssh::handle($command, function(string $path, bool $write) use ($user, &$owner, &$repoName): ?Repository {
115+ $repoAccess = Gitler::NONE;
116+ exit(Ssh::handle($command, function(string $path, bool $write) use ($user, &$owner, &$repoName, &$repoAccess): ?Repository {
116117 // 「所有者/名前.git」又は「所有者/名前」(前に「gitler/」が有っても良い)
117118 if (!preg_match('#^(?:gitler/)?([A-Za-z0-9]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$#', $path, $m)) return null;
118119 $access = Gitler::access($user, $m[1], $m[2]);
119- if ($access < ($write ? Gitler::WRITE : Gitler::READ)) return null;
120- if ($write && Gitler::meta($m[1], $m[2])['archived']) {
120+ $meta = $access > Gitler::NONE ? Gitler::meta($m[1], $m[2]) : [];
121+ // 読めてプルリクエストのロールが有ればrefs/for/(AGit)にだけプッシュ出来る
122+ $agit = $write && $access === Gitler::READ && $meta['pulls'] && !$meta['archived'] && Gitler::userCan($user, 'pulls');
123+ if ($access < ($write && !$agit ? Gitler::WRITE : Gitler::READ)) return null;
124+ if ($write && $meta['archived']) {
121125 fwrite(STDERR, "アーカイブされたリポジトリにはプッシュ出来ません。\n");
122126 return null;
123127 }
124- [$owner, $repoName] = [$m[1], $m[2]];
128+ [$owner, $repoName, $repoAccess] = [$m[1], $m[2], $access];
125129 return Gitler::open($m[1], $m[2]);
126- }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions($owner, $repoName)]));
130+ }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions($owner, $repoName, $user, $repoAccess)]));
127131
128132 case 'gc':
129133 $res = Gitler::gcAll();
@@ -612,6 +612,111 @@
612612 border-color: #2c980c;
613613 }
614614
615+.gl-state-merged {
616+ color: #a371f7;
617+}
618+
619+.gl-badge-merged {
620+ background-color: #8250df;
621+}
622+
623+.gl-subtabs {
624+ margin-top: 8px;
625+}
626+
627+.gl-forkinfo {
628+ display: flex;
629+ flex-wrap: wrap;
630+ align-items: center;
631+ gap: 4px 12px;
632+ margin: 4px 0;
633+ font-size: 0.9em;
634+}
635+
636+.gl-inlineform {
637+ display: inline;
638+}
639+
640+.gl-mergebox {
641+ border: 1px solid #746c75;
642+ border-left: 4px solid #2c980c;
643+ border-radius: 2px;
644+ padding: 4px 8px;
645+ margin: 8px 0;
646+}
647+
648+.gl-merge-conflict, .gl-merge-nobase, .gl-merge-error {
649+ border-left-color: #ee4030;
650+}
651+
652+.gl-merge-merged {
653+ border-left-color: #8250df;
654+}
655+
656+.gl-merge-closed, .gl-merge-uptodate {
657+ border-left-color: #746c75;
658+}
659+
660+.gl-approval {
661+ display: inline-block;
662+ margin-right: 12px;
663+}
664+
665+.gl-approval-approved {
666+ color: #2c980c;
667+}
668+
669+.gl-approval-changes {
670+ color: #ee4030;
671+}
672+
673+.gl-review-approved > .gl-comment-head {
674+ border-left: 4px solid #2c980c;
675+}
676+
677+.gl-review-changes > .gl-comment-head {
678+ border-left: 4px solid #ee4030;
679+}
680+
681+.gl-inline {
682+ border: 1px solid #746c75;
683+ border-radius: 2px;
684+ margin: 8px;
685+ font-family: inherit;
686+ white-space: normal;
687+}
688+
689+.gl-inline-path {
690+ padding: 2px 8px;
691+ border-bottom: 1px solid #746c75;
692+ font-size: 0.9em;
693+}
694+
695+.gl-inline-hunk {
696+ margin: 0;
697+ padding: 4px 8px;
698+ overflow-x: auto;
699+ border-bottom: 1px solid #746c75;
700+ font-size: 0.85em;
701+}
702+
703+.gl-inline-reply {
704+ border-top: 1px dashed #746c75;
705+}
706+
707+.gl-inline-replyform {
708+ padding: 2px 8px 6px;
709+}
710+
711+.gl-threadrow > td {
712+ padding: 2px 0;
713+ white-space: normal;
714+}
715+
716+.gl-inlinenew {
717+ margin: 8px;
718+}
719+
615720 .gl-archived {
616721 border-left: 4px solid #f1e05a;
617722 padding: 2px 8px;
変更 route.php
@@ -176,10 +176,17 @@
176176 $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/issues/{n}/comments/{c}', Gitler::class.'@issueComment');
177177 $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/issues/{n}', Gitler::class.'@issue');
178178 $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/issues/{n}', Gitler::class.'@issue');
179- foreach (['issues', 'labels', 'milestones', 'outbox', 'followers'] as $page) {
179+ // プルリクエストとフォーク
180+ $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/pulls', Gitler::class.'@issues', ['pull' => true]);
181+ $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/pulls/{n}/commits', Gitler::class.'@pullCommits');
182+ $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/pulls/{n}/files', Gitler::class.'@pullFiles');
183+ $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/pulls/{n}/files', Gitler::class.'@pullFiles');
184+ $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/pulls/{n}', Gitler::class.'@issue', ['pull' => true]);
185+ $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/pulls/{n}', Gitler::class.'@issue', ['pull' => true]);
186+ foreach (['issues', 'labels', 'milestones', 'outbox', 'followers', 'compare', 'forks'] as $page) {
180187 $routes[] = Route::add('GET', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page);
181188 }
182- foreach (['labels', 'milestones', 'inbox', 'watch'] as $page) {
189+ foreach (['labels', 'milestones', 'inbox', 'watch', 'compare', 'fork'] as $page) {
183190 $routes[] = Route::add('POST', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page);
184191 }
185192 foreach (['tree', 'blob', 'raw', 'blame', 'commits', 'branches', 'tags', 'archive', 'settings'] as $page) {
@@ -52,6 +52,7 @@
5252 use Std\Lib\Gitler\Issues;
5353 use Std\Lib\Gitler\Keys;
5454 use Std\Lib\Gitler\Notify;
55+use Std\Lib\Gitler\Pulls;
5556 use Std\Lib\Gitler\Languages;
5657 use Std\Lib\Markdown;
5758 use Std\Lib\Syntax;
@@ -62,11 +63,12 @@
6263 * Gitler(/gitler):Gitのリポジトリのホスティング。JavaScriptなし
6364 *
6465 * リポジトリのページはクエリでリビジョンとパスを渡す(例:/gitler/user/repo/blob?ref=master&path=src/a.php)。
65- * git clone https://…/gitler/user/repo.git と SSH(gitler.php)に対応。イシュー等はGitlerIssues。
66+ * git clone https://…/gitler/user/repo.git と SSH(gitler.php)に対応。イシュー等はGitlerIssues、プルリクエストとフォークはGitlerPulls。
6667 */
6768 class Gitler {
6869 use Mods;
6970 use GitlerIssues;
71+ use GitlerPulls;
7072
7173 /** 1ページのコミットの数 */
7274 private const PER_PAGE = 30;
@@ -486,6 +488,7 @@
486488 }
487489 } else if ($do === 'issues') {
488490 $meta['issues'] = isset($_POST['issues']);
491+ $meta['pulls'] = isset($_POST['pulls']);
489492 $meta['federate'] = isset($_POST['federate']);
490493 Hosting::saveMeta($this->owner, $this->name, $meta);
491494 $message = '保存しました。';
@@ -534,7 +537,10 @@
534537 $user ??= $this->user;
535538 $exists = Hosting::validOwner($owner) && Hosting::validName($name) && Hosting::exists($owner, $name);
536539 $access = $exists ? Hosting::access($user, $owner, $name) : Hosting::NONE;
537- $need = $write ? Hosting::WRITE : Hosting::READ;
540+ // 読めてプルリクエストのロールが有ればrefs/for/(AGit)にだけプッシュ出来る
541+ $meta = $exists ? Hosting::meta($owner, $name) : [];
542+ $agit = $write && $access === Hosting::READ && $user !== null && ($meta['pulls'] ?? false) && !($meta['archived'] ?? true) && Hosting::userCan($user, 'pulls');
543+ $need = $write && !$agit ? Hosting::WRITE : Hosting::READ;
538544 if ($write && $access >= Hosting::WRITE && Hosting::meta($owner, $name)['archived']) {
539545 http_response_code(403);
540546 echo "アーカイブされたリポジトリにはプッシュ出来ません。\n";
@@ -553,17 +559,25 @@
553559 exit;
554560 }
555561 $repo = Hosting::open($owner, $name);
556- Http::handle($repo, $action, ['push' => $write, 'agent' => 'Gitler', 'receive' => self::receiveOptions($owner, $name)]);
562+ Http::handle($repo, $action, ['push' => $write, 'agent' => 'Gitler', 'receive' => self::receiveOptions($owner, $name, $user, $access)]);
557563 exit;
558564 }
559565
560566 /**
561- * プッシュの制限(ブランチとタグだけ)と、プッシュの後のイシューの処理
562- * SSHではリポジトリが後で決まるので、所有者と名前は参照で受け取る
567+ * プッシュの制限(書けるならブランチとタグ、AGitのrefs/for/)と、プッシュの後のプルリクエストとイシューの処理
568+ * SSHではリポジトリが後で決まるので、所有者、名前、権限は参照で受け取る
563569 */
564- public static function receiveOptions(string &$owner, string &$name): array {
570+ public static function receiveOptions(string &$owner, string &$name, ?\stdClass $user, int &$access): array {
565571 return [
566- 'update' => fn(string $ref, string $old, string $new): ?string => str_starts_with($ref, 'refs/heads/') || str_starts_with($ref, 'refs/tags/') ? null : 'only branches and tags',
572+ 'update' => function(string $ref, string $old, string $new) use (&$owner, &$name, $user, &$access): ?string {
573+ if (str_starts_with($ref, 'refs/for/')) return Pulls::checkAgit($owner, $name, $user, $ref, $new, str_repeat('0', strlen($new)));
574+ if ($access < Hosting::WRITE) return 'only refs/for/<branch>/<topic> (pull request)';
575+ return str_starts_with($ref, 'refs/heads/') || str_starts_with($ref, 'refs/tags/') ? null : 'only branches and tags';
576+ },
577+ 'intercept' => fn(string $ref): bool => str_starts_with($ref, 'refs/for/'),
578+ 'process' => function(array $done) use (&$owner, &$name, $user): array {
579+ return $owner !== '' && $name !== '' ? Pulls::afterPush($owner, $name, $user, $done) : [];
580+ },
567581 'after' => function(array $done) use (&$owner, &$name): void {
568582 if ($owner !== '' && $name !== '') self::afterPush($owner, $name, $done);
569583 },
@@ -813,8 +827,21 @@
813827 'tgzUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=tar.gz' : '',
814828 'issuesOn' => $this->meta['issues'], 'openIssues' => $this->meta['issues'] ? (new Issues($this->repo))->counts()['open'] : 0,
815829 'archived' => $this->meta['archived'], 'here' => (string)($_SERVER['REQUEST_URI'] ?? ''),
830+ 'pullsOn' => $this->meta['pulls'], 'openPulls' => $this->meta['pulls'] ? (new Issues($this->repo))->counts(true)['open'] : 0,
831+ 'forkOf' => $this->meta['forkOf'], 'forkCount' => count(Hosting::forks($this->owner, $this->name)),
832+ 'canFork' => $this->user !== null && $this->user->username !== $this->owner && Hosting::userCan($this->user, 'repos'),
833+ 'parentCompare' => $this->forkCompareUrl($ref),
816834 'watch' => $this->user ? Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) : '',
817835 ]);
836+ }
837+
838+ /** フォーク元にプルリクエストを作るURL(フォークでなければ空) */
839+ private function forkCompareUrl(string $ref): string {
840+ if ($this->meta['forkOf'] === '' || !$this->user || !Hosting::userCan($this->user, 'pulls')) return '';
841+ [$o, $n] = explode('/', $this->meta['forkOf'], 2);
842+ if (!Hosting::exists($o, $n) || !Hosting::meta($o, $n)['pulls'] || Hosting::access($this->user, $o, $n) === Hosting::NONE) return '';
843+ $branch = $ref !== '' && $this->repo->refs->resolve('refs/heads/'.$ref) !== null ? $ref : $this->defaultBranch();
844+ return '/gitler/'.$o.'/'.rawurlencode($n).'/compare?head='.rawurlencode("{$this->owner}/{$this->name}:{$branch}");
818845 }
819846
820847 private function render(string $view, string $title, array $vars): void {
@@ -56,13 +56,14 @@
5656
5757 //////// イシュー
5858
59- /** …/issues?state=&label=&milestone=&assignee=&q=&sort=&page= */
59+ /** …/issues?state=&label=&milestone=&assignee=&q=&sort=&page=(…/pullsはプルリクエスト) */
6060 public function issues(array $params): void {
61- $this->loadIssues($params);
61+ $pull = !empty($params['pull']);
62+ $this->loadIssues($params, $pull);
6263 $f = ['state' => in_array($_GET['state'] ?? '', ['closed', 'all'], true) ? $_GET['state'] : 'open'];
6364 foreach (['label', 'milestone'] as $k) if (isset($_GET[$k]) && $_GET[$k] !== '') $f[$k] = (int)$_GET[$k];
6465 foreach (['assignee', 'q', 'sort'] as $k) if (is_string($_GET[$k] ?? null) && $_GET[$k] !== '') $f[$k] = trim($_GET[$k]);
65- $all = $this->issueDb->search(['state' => 'all'] + $f);
66+ $all = $this->issueDb->search(['state' => 'all', 'pull' => $pull] + $f);
6667 $counts = ['open' => 0, 'closed' => 0];
6768 foreach ($all as $s) $counts[$s['state']]++;
6869 $list = $f['state'] === 'all' ? $all : array_values(array_filter($all, fn($s) => $s['state'] === $f['state']));
@@ -70,13 +71,16 @@
7071 $pages = max(1, (int)ceil(count($list) / self::PER_PAGE));
7172 $labels = $this->issueDb->labels();
7273 $milestones = $this->issueDb->milestones();
73- $query = fn(array $change) => $this->repoUrl().'/issues?'.http_build_query(array_filter($change + $f + ['page' => null], fn($v) => $v !== null && $v !== ''));
74- $this->renderRepo('gitler/issues', 'イシュー', [
74+ $listUrl = $this->repoUrl().($pull ? '/pulls' : '/issues');
75+ $query = fn(array $change) => $listUrl.'?'.http_build_query(array_filter($change + $f + ['page' => null], fn($v) => $v !== null && $v !== ''));
76+ $this->renderRepo('gitler/issues', $pull ? 'プルリクエスト' : 'イシュー', [
77+ 'isPull' => $pull, 'listUrl' => $listUrl, 'newUrl' => $this->repoUrl().($pull ? '/compare' : '/issues/new'),
78+ 'agitHelp' => $pull ? 'git push origin HEAD:refs/for/'.$this->defaultBranch().'/トピック' : '',
7579 'list' => array_map(fn($s) => $this->presentSummary($s, $labels, $milestones), array_slice($list, ($page - 1) * self::PER_PAGE, self::PER_PAGE)),
7680 'counts' => $counts, 'f' => $f + ['label' => '', 'milestone' => '', 'assignee' => '', 'q' => '', 'sort' => 'created'],
7781 'openUrl' => $query(['state' => 'open']), 'closedUrl' => $query(['state' => 'closed']),
7882 'prevUrl' => $page > 1 ? $query(['page' => $page - 1]) : '', 'nextUrl' => $page < $pages ? $query(['page' => $page + 1]) : '',
79- 'labels' => $this->presentLabels($labels), 'milestones' => $milestones, 'people' => $this->assignable(), 'canIssue' => $this->canIssue(),
83+ 'labels' => $this->presentLabels($labels), 'milestones' => $milestones, 'people' => $this->assignable(), 'canIssue' => $pull ? $this->canPull() : $this->canIssue(),
8084 ]);
8185 }
8286
@@ -110,29 +114,44 @@
110114 ]);
111115 }
112116
113- /** …/issues/{n}(ActivityPubではNote) */
117+ /** …/issues/{n}(ActivityPubではNote)、…/pulls/{n}(プルリクエストの会話) */
114118 public function issue(array $params): void {
115119 $n = (int)($params['n'] ?? 0);
116120 if ($this->wantsActivity()) {
117121 [$owner, $name, $issue] = $this->apIssue($params);
118122 $this->apJson(['@context' => Federation::CONTEXT] + Federation::issueNote($owner, $name, $issue));
119123 }
120- $this->loadIssues($params);
124+ $this->loadIssues($params, null);
121125 $issue = $this->issueDb->get($n);
122126 if ($issue === null) $this->notFound();
127+ // イシューとプルリクエストのURLが違う
128+ if (isset($issue['pull']) !== !empty($params['pull'])) $this->redirect($this->issuePage($issue));
129+ if (!(isset($issue['pull']) ? $this->meta['pulls'] : $this->meta['issues'])) $this->notFound();
123130 $error = '';
124131 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
125132 $error = $this->csrfOk() ? $this->issueAction($issue) : 'セッションの有効期限が切れました。もう一度お試し下さい。';
126133 $issue = $this->issueDb->get($n);
127134 if ($issue === null) $this->redirect($this->repoUrl().'/issues');
128135 }
136+ $pull = isset($issue['pull']);
129137 header('Vary: Accept');
130138 $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username;
131139 $archived = $this->meta['archived'];
132140 $labels = $this->issueDb->labels();
133141 $milestones = $this->issueDb->milestones();
134142 $timeline = [];
143+ $replies = [];
144+ foreach ($issue['comments'] as $c) if (isset($c['path']) && $c['replyTo'] !== null && empty($c['pending'])) $replies[$c['replyTo']][] = $c;
145+ $byReview = [];
135146 foreach ($issue['comments'] as $c) {
147+ // 行へのコメント:レビューの物はレビューに、他はそれだけ(返信はスレッドに、下書きは出さない)
148+ if (isset($c['path'])) {
149+ if (!empty($c['pending']) || $c['replyTo'] !== null) continue;
150+ $inline = $this->presentInline($c, $replies[$c['id']] ?? []) + ['outdated' => $c['commit'] !== $issue['pull']['headId']];
151+ if ($c['review'] !== null) $byReview[$c['review']][] = $inline;
152+ else $timeline[] = ['kind' => 'inline', 'at' => $c['created'], 'c' => $inline];
153+ continue;
154+ }
136155 $mine = $this->user && ($c['author']['user'] ?? null) === $this->user->username;
137156 $timeline[] = ['kind' => 'comment', 'at' => $c['created'], 'id' => $c['id'], 'who' => $this->who($c['author']), 'html' => $this->bodyHtml($c['body'], !empty($c['html'])),
138157 'date' => date('Y-m-d H:i', $c['created']), 'edited' => $c['updated'] > $c['created'], 'body' => $c['body'], 'canEdit' => !$archived && $mine && empty($c['html']),
@@ -140,21 +159,27 @@
140159 'remote' => preg_match('#^https?://#i', (string)($c['remoteId'] ?? '')) ? $c['remoteId'] : ''];
141160 }
142161 foreach ($issue['events'] as $e) $timeline[] = ['kind' => 'event', 'at' => $e['at'], 'html' => $this->eventHtml($e, $labels, $milestones), 'date' => date('Y-m-d H:i', $e['at'])];
143- usort($timeline, fn($a, $b) => $a['at'] <=> $b['at']);
162+ $states = ['approved' => '承認しました', 'changes' => '変更を依頼しました', 'commented' => 'レビューしました'];
163+ foreach ($issue['reviews'] ?? [] as $r) {
164+ $timeline[] = ['kind' => 'review', 'at' => $r['at'], 'id' => $r['id'], 'who' => $this->who($r['author']), 'state' => $r['state'], 'stateText' => $states[$r['state']] ?? '',
165+ 'html' => trim($r['body']) !== '' ? $this->bodyHtml($r['body'], false) : '', 'date' => date('Y-m-d H:i', $r['at']), 'comments' => $byReview[$r['id']] ?? []];
166+ }
167+ // 同じ時刻なら出来事(閉じた、マージした等)を後に
168+ usort($timeline, fn($a, $b) => [$a['at'], $a['kind'] === 'event'] <=> [$b['at'], $b['kind'] === 'event']);
144169 $fedi = Federation::enabled($this->meta) && !$this->meta['archived'];
145170 // 通知:受け取っている(true)、止めた(false)、ウォッチで受け取る、受け取っていない
146171 $sub = $this->user ? ($issue['subscribers'][$this->user->username] ?? null) : null;
147172 $watching = $this->user && Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) === Notify::WATCHING;
148- $this->renderRepo('gitler/issue', '#'.$n.' '.$issue['title'], [
173+ $this->renderRepo($pull ? 'gitler/pull' : 'gitler/issue', '#'.$n.' '.$issue['title'], ($pull ? $this->pullVars($issue) : []) + [
149174 'issue' => $issue, 'who' => $this->who($issue['author']), 'bodyHtml' => $this->bodyHtml($issue['body'], !empty($issue['html'])),
150175 'date' => date('Y-m-d H:i', $issue['created']), 'timeline' => $timeline, 'error' => $error,
151- 'canIssue' => $this->canIssue(), 'canEdit' => !$archived && (($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE),
152- 'canClose' => !$archived && ($isAuthor || $this->access >= Hosting::WRITE), 'canTriage' => !$archived && $this->access >= Hosting::WRITE,
176+ 'canIssue' => $pull ? $this->canReview() : $this->canIssue(), 'canEdit' => !$archived && (($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE),
177+ 'canClose' => !$archived && ($isAuthor || $this->access >= Hosting::WRITE) && empty($issue['pull']['merged']), 'canTriage' => !$archived && $this->access >= Hosting::WRITE,
153178 'canAdmin' => !$archived && $this->access >= Hosting::ADMIN,
154179 'labels' => $this->presentLabels($labels, $issue['labels']), 'issueLabels' => $this->presentLabels(array_intersect_key($labels, array_flip($issue['labels']))),
155180 'milestones' => $milestones, 'milestone' => $issue['milestone'] !== null ? ($milestones[$issue['milestone']]['title'] ?? '') : '',
156181 'people' => array_map(fn($p) => ['name' => $p, 'checked' => in_array($p, $issue['assignees'], true)], array_unique(array_merge($this->assignable(), $issue['assignees']))),
157- 'issueUrl' => $this->repoUrl().'/issues/'.$n, 'fedi' => $fedi,
182+ 'issueUrl' => $this->issuePage($issue), 'fedi' => $fedi, 'canReview' => $pull && $this->canReview(),
158183 'fediHandle' => $fedi ? '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host() : '',
159184 'fediUrl' => $fedi ? Federation::issueUrl($this->owner, $this->name, $n) : '',
160185 'subscribed' => $sub === true || ($sub === null && $watching),
@@ -183,8 +208,9 @@
183208 /** イシューのページのPOST(エラーのメッセージ、成功したらリダイレクト) */
184209 private function issueAction(array $issue): string {
185210 $n = $issue['number'];
186- $url = $this->repoUrl().'/issues/'.$n;
211+ $url = $this->issuePage($issue);
187212 $do = (string)($_POST['do'] ?? '');
213+ if (isset($issue['pull']) && in_array($do, ['merge', 'deleteBranch', 'inline', 'inlinePending', 'reply', 'review'], true)) return $this->pullAction($issue, $do);
188214 $me = $this->me();
189215 $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username;
190216 $canClose = $isAuthor || $this->access >= Hosting::WRITE;
@@ -198,7 +224,8 @@
198224 if ($this->meta['archived']) return 'アーカイブされたリポジトリです(読むだけ)。';
199225
200226 if (in_array($do, ['comment', 'commentClose', 'commentReopen', 'close', 'reopen'], true)) {
201- if (!$this->canIssue()) return 'コメントする権限が有りません。';
227+ if (!(isset($issue['pull']) ? $this->canReview() : $this->canIssue())) return 'コメントする権限が有りません。';
228+ if (!empty($issue['pull']['merged']) && $do !== 'comment') return 'マージしたプルリクエストは開き直せません。';
202229 $anchor = '';
203230 if ($do === 'comment' || trim($body) !== '') {
204231 $res = $this->issueDb->comment($n, $me, $body);
@@ -254,7 +281,13 @@
254281 if (!isset($_POST['confirm'])) return '確認にチェックを入れて下さい。';
255282 $this->issueDb->delete($n);
256283 Federation::issueDeleted($this->owner, $this->name, $this->meta, $issue);
257- $this->redirect($this->repoUrl().'/issues');
284+ if (isset($issue['pull'])) {
285+ try {
286+ $this->repo->refs->update(\Std\Lib\Gitler\Pulls::ref($n), null);
287+ } catch (\Std\Lib\Git\GitException) {
288+ }
289+ }
290+ $this->redirect($this->repoUrl().(isset($issue['pull']) ? '/pulls' : '/issues'));
258291 }
259292 if ($do === 'block') {
260293 if ($this->access < Hosting::ADMIN) return 'ブロックする権限が有りません。';
@@ -279,7 +312,7 @@
279312
280313 /** …/labels */
281314 public function labels(array $params): void {
282- $this->loadIssues($params);
315+ $this->loadIssues($params, null);
283316 $error = '';
284317 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
285318 if (!$this->csrfOk()) {
@@ -305,7 +338,7 @@
305338
306339 /** …/milestones */
307340 public function milestones(array $params): void {
308- $this->loadIssues($params);
341+ $this->loadIssues($params, null);
309342 $error = '';
310343 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
311344 $do = (string)($_POST['do'] ?? '');
@@ -453,12 +486,19 @@
453486
454487 //////// 共通
455488
456- private function loadIssues(array $params): void {
489+ /** @param bool|null $pull プルリクエスト(nullならイシューかプルリクエストのどちらかが使えれば良い) */
490+ private function loadIssues(array $params, ?bool $pull = false): void {
457491 $this->load($params);
458- if (!$this->meta['issues']) $this->notFound();
492+ $on = $pull === null ? $this->meta['issues'] || $this->meta['pulls'] : ($pull ? $this->meta['pulls'] : $this->meta['issues']);
493+ if (!$on) $this->notFound();
459494 $this->issueDb = new Issues($this->repo);
460495 }
461496
497+ /** イシュー又はプルリクエストのページ */
498+ private function issuePage(array $issue): string {
499+ return $this->repoUrl().(isset($issue['pull']) ? '/pulls/' : '/issues/').$issue['number'];
500+ }
501+
462502 /** イシューを作ってコメント出来るか */
463503 private function canIssue(): bool {
464504 return $this->user !== null && $this->meta['issues'] && !$this->meta['archived'] && Hosting::userCan($this->user, 'issues');
@@ -508,7 +548,8 @@
508548
509549 private function presentSummary(array $s, array $labels, array $milestones): array {
510550 $who = $this->who($s['author']);
511- return ['number' => $s['number'], 'title' => $s['title'], 'url' => $this->repoUrl().'/issues/'.$s['number'], 'state' => $s['state'],
551+ return ['number' => $s['number'], 'title' => $s['title'], 'url' => $this->repoUrl().(empty($s['pull']) ? '/issues/' : '/pulls/').$s['number'],
552+ 'state' => !empty($s['merged']) ? 'merged' : $s['state'],
512553 'labels' => $this->presentLabels(array_intersect_key($labels, array_flip($s['labels']))), 'author' => $who['name'],
513554 'date' => date('Y-m-d', $s['created']), 'closedDate' => $s['closed'] ? date('Y-m-d', $s['closed']) : '', 'comments' => $s['comments'],
514555 'milestone' => $s['milestone'] !== null ? ($milestones[$s['milestone']]['title'] ?? '') : '', 'assignees' => $s['assignees']];
@@ -526,7 +567,11 @@
526567 };
527568 $list = fn(array $xs, callable $f) => implode('、', array_map($f, $xs));
528569 return match ($e['type']) {
529- 'closed' => isset($e['commit']) ? 'コミット'.$commit($e['commit']).'('.$by.')で閉じました' : $by.'が閉じました',
570+ 'closed' => isset($e['commit']) ? 'コミット'.$commit($e['commit']).'('.$by.')で閉じました'
571+ : (isset($e['pull']) ? $by.'がプルリクエスト<a href="'.$h($this->repoUrl().'/pulls/'.$e['pull']).'">#'.(int)$e['pull'].'</a>のマージで閉じました' : $by.'が閉じました'),
572+ 'pushed' => $by.'がコミットを'.(int)$e['count'].'個プッシュしました('.$commit($e['from']).' → '.$commit($e['to']).')',
573+ 'merged' => $by.'が「'.$h($e['base']).'」にマージしました('.$commit($e['commit']).($e['method'] !== 'manual' ? '、'.['merge' => 'マージコミット', 'squash' => 'スカッシュ', 'rebase' => 'リベース'][$e['method']] : '').')',
574+ 'branchDeleted' => $by.'がブランチ「'.$h($e['branch']).'」を削除しました',
530575 'reopened' => $by.'が開き直しました',
531576 'title' => $by.'が題名を「'.$h($e['from']).'」から「'.$h($e['to']).'」に変えました',
532577 'labels' => $by.'が'.trim(($e['add'] ? 'ラベル'.$list($e['add'], $chip).'を付け' : '').($e['add'] && $e['remove'] ? '、' : '').($e['remove'] ? 'ラベル'.$list($e['remove'], $chip).'を外し' : '')).'ました',
@@ -0,0 +1,402 @@
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+}
@@ -0,0 +1,218 @@
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 Std\Lib\Git;
39+
40+/**
41+ * 3方向のマージ(行とツリー)
42+ *
43+ * 行:共通の祖先からの両方の変更を合わせる(diff3)。重なるか隣り合う変更が違えばコンフリクト(gitと同じ)。
44+ * ツリー:片方だけの変更はそのまま、両方が変えたファイルは行でマージする。
45+ * 削除と変更、ファイルとディレクトリ、バイナリ、シンボリックリンク等の両方の変更はコンフリクト。
46+ */
47+final class Merge {
48+ /**
49+ * 行のマージ(行は改行を含む)
50+ *
51+ * @return array lines(コンフリクトはマーカー付き)、conflicts(コンフリクトの数)
52+ */
53+ public static function lines(array $base, array $ours, array $theirs, string $ourName = 'ours', string $theirName = 'theirs'): array {
54+ $changes = [];
55+ foreach (self::changes($base, $ours) as $c) $changes[] = $c + [4 => 'o'];
56+ foreach (self::changes($base, $theirs) as $c) $changes[] = $c + [4 => 't'];
57+ usort($changes, fn($a, $b) => [$a[0], $a[1]] <=> [$b[0], $b[1]]);
58+ $out = [];
59+ $conflicts = 0;
60+ $pos = 0;
61+ $n = count($changes);
62+ for ($i = 0; $i < $n;) {
63+ // 重なる・隣り合う変更をまとめる
64+ $group = [$changes[$i]];
65+ $start = $changes[$i][0];
66+ $end = $changes[$i][1];
67+ for ($i++; $i < $n && $changes[$i][0] <= $end; $i++) {
68+ $group[] = $changes[$i];
69+ $end = max($end, $changes[$i][1]);
70+ }
71+ for ($k = $pos; $k < $start; $k++) $out[] = $base[$k];
72+ $o = array_values(array_filter($group, fn($c) => $c[4] === 'o'));
73+ $t = array_values(array_filter($group, fn($c) => $c[4] === 't'));
74+ if (empty($t)) {
75+ array_push($out, ...self::region($o, $start, $end, $ours));
76+ } else if (empty($o)) {
77+ array_push($out, ...self::region($t, $start, $end, $theirs));
78+ } else {
79+ $a = self::region($o, $start, $end, $ours);
80+ $b = self::region($t, $start, $end, $theirs);
81+ if ($a === $b) {
82+ array_push($out, ...$a);
83+ } else {
84+ $conflicts++;
85+ $eol = fn(array $l) => $l !== [] && !str_ends_with((string)end($l), "\n") ? ["\n"] : [];
86+ array_push($out, "<<<<<<< {$ourName}\n", ...$a, ...$eol($a));
87+ array_push($out, "=======\n", ...$b, ...$eol($b));
88+ $out[] = ">>>>>>> {$theirName}\n";
89+ }
90+ }
91+ $pos = $end;
92+ }
93+ for ($k = $pos; $k < count($base); $k++) $out[] = $base[$k];
94+ return ['lines' => $out, 'conflicts' => $conflicts];
95+ }
96+
97+ /** 文字列を行に分ける(改行を含む) */
98+ public static function split(string $s): array {
99+ return $s === '' ? [] : preg_split('/(?<=\n)/', $s, -1, PREG_SPLIT_NO_EMPTY);
100+ }
101+
102+ /**
103+ * ファイルの内容のマージ
104+ *
105+ * @return string|null コンフリクトやバイナリならnull
106+ */
107+ public static function text(string $base, string $ours, string $theirs): ?string {
108+ if ($ours === $theirs) return $ours;
109+ if ($base === $ours) return $theirs;
110+ if ($base === $theirs) return $ours;
111+ if (Diff::isBinary($base) || Diff::isBinary($ours) || Diff::isBinary($theirs)) return null;
112+ if (strlen($base) + strlen($ours) + strlen($theirs) > 3 * Diff::MAX_BYTES) return null;
113+ $r = self::lines(self::split($base), self::split($ours), self::split($theirs));
114+ return $r['conflicts'] === 0 ? implode('', $r['lines']) : null;
115+ }
116+
117+ /**
118+ * ツリーのマージ
119+ *
120+ * @param string|null $base 共通の祖先のツリー(無ければnull)
121+ * @return array tree(新しいツリーのID、コンフリクトが有ればnull)、conflicts(パスの配列)
122+ */
123+ public static function trees(Repository $repo, ?string $base, string $ours, string $theirs): array {
124+ $conflicts = [];
125+ $tree = self::mergeTree($repo, $base, $ours, $theirs, '', $conflicts);
126+ return ['tree' => empty($conflicts) ? ($tree ?? $repo->writeTree([])) : null, 'conflicts' => $conflicts];
127+ }
128+
129+ /**
130+ * コミットを他のコミットの上に当てる(cherry-pick)
131+ *
132+ * @return string|null 新しいツリー、コンフリクトならnull
133+ */
134+ public static function pick(Repository $repo, string $commit, string $onto): ?string {
135+ $c = $repo->commit($commit);
136+ $parent = $c->parents[0] ?? null;
137+ $r = self::trees($repo, $parent !== null ? $repo->commit($parent)->tree : null, $repo->commit($onto)->tree, $c->tree);
138+ return $r['tree'];
139+ }
140+
141+ //////// 内部
142+
143+ /** 変更([元の始め, 元の終わり, 新しい始め, 新しい終わり]、終わりは含まない) */
144+ private static function changes(array $a, array $b): array {
145+ $out = [];
146+ $pi = 0;
147+ $pj = 0;
148+ foreach (array_merge(Diff::match($a, $b), [[count($a), count($b)]]) as [$i, $j]) {
149+ if ($i > $pi || $j > $pj) $out[] = [$pi, $i, $pj, $j];
150+ $pi = $i + 1;
151+ $pj = $j + 1;
152+ }
153+ return $out;
154+ }
155+
156+ /** 元の[start, end)に当たる片方の行 */
157+ private static function region(array $changes, int $start, int $end, array $lines): array {
158+ $first = $changes[0];
159+ $last = end($changes);
160+ $from = $first[2] - ($first[0] - $start);
161+ $to = $last[3] + ($end - $last[1]);
162+ return array_slice($lines, $from, $to - $from);
163+ }
164+
165+ /** @return string|null 空ならnull */
166+ private static function mergeTree(Repository $repo, ?string $base, ?string $ours, ?string $theirs, string $prefix, array &$conflicts): ?string {
167+ if ($ours === $theirs) return $ours;
168+ if ($base === $ours) return $theirs;
169+ if ($base === $theirs) return $ours;
170+ $entries = fn(?string $t) => $t === null ? [] : array_column($repo->tree($t)->entries, null, 1);
171+ $b = $entries($base);
172+ $o = $entries($ours);
173+ $t = $entries($theirs);
174+ $out = [];
175+ foreach (array_unique(array_merge(array_keys($b), array_keys($o), array_keys($t))) as $name) {
176+ $name = (string)$name;
177+ $be = $b[$name] ?? null;
178+ $oe = $o[$name] ?? null;
179+ $te = $t[$name] ?? null;
180+ $path = $prefix.$name;
181+ if ($oe === $te) $e = $oe;
182+ else if ($be === $oe) $e = $te;
183+ else if ($be === $te) $e = $oe;
184+ else $e = self::mergeEntry($repo, $be, $oe, $te, $path, $conflicts);
185+ if ($e !== null) $out[] = $e;
186+ }
187+ return empty($out) ? null : $repo->writeTree($out);
188+ }
189+
190+ /** 両方が変えたエントリー([mode, name, id]又はnull) */
191+ private static function mergeEntry(Repository $repo, ?array $be, ?array $oe, ?array $te, string $path, array &$conflicts): ?array {
192+ $isDir = fn(?array $e) => $e !== null && $e[0] === Tree::DIR;
193+ $isFile = fn(?array $e) => $e !== null && ($e[0] === Tree::FILE || $e[0] === Tree::EXEC);
194+ // 両方ディレクトリ
195+ if ($isDir($oe) && $isDir($te)) {
196+ $id = self::mergeTree($repo, $isDir($be) ? $be[2] : null, $oe[2], $te[2], $path.'/', $conflicts);
197+ return $id === null ? null : [Tree::DIR, $oe[1], $id];
198+ }
199+ // 両方普通のファイル(追加・追加も)
200+ if ($isFile($oe) && $isFile($te) && ($be === null || $isFile($be))) {
201+ $text = self::text($be !== null ? $repo->blob($be[2]) : '', $repo->blob($oe[2]), $repo->blob($te[2]));
202+ if ($text === null) {
203+ $conflicts[] = $path;
204+ return $oe;
205+ }
206+ // 実行権限は変えた方
207+ $mode = $be !== null && $oe[0] === $be[0] ? $te[0] : $oe[0];
208+ if ($be === null && $oe[0] !== $te[0]) {
209+ $conflicts[] = $path;
210+ return $oe;
211+ }
212+ return [$mode, $oe[1], $repo->writeBlob($text)];
213+ }
214+ // 削除と変更、種類の違い等
215+ $conflicts[] = $path;
216+ return $oe ?? $te;
217+ }
218+}
@@ -53,7 +53,9 @@
5353 * @param array $opt agent、unpackLimit(これより少ないオブジェクトはルーズにする、既定100)、
5454 * check(fn(array $commands): ?string、全部の更新の前、エラーのメッセージで全部拒否)、
5555 * update(fn(string $ref, string $old, string $new): ?string、参照毎、エラーならその参照を拒否)、
56- * after(fn(array $commands)、更新した参照 [ref, old, new])、
56+ * after(fn(array $commands)、更新した参照 [ref, old, new]、結果を送った後)、
57+ * intercept(fn(string $ref): bool、trueの参照は書かずにprocessに渡す。AGitのrefs/for/等)、
58+ * process(fn(array $commands): array、成功した参照 [ref, old, new]、結果を送る前。返した行はクライアントに表示する)、
5759 * denyNonFastForwards、denyDeletes(既定はconfigのreceive.*)
5860 */
5961 public function __construct(private Repository $repo, private array $opt = []) {
@@ -146,6 +148,15 @@
146148 foreach ($commands as $i => $c) $commands[$i]['error'] ??= 'atomic push failed';
147149 }
148150 $commands = $this->apply($commands, $atomic);
151+ // 参照の処理(プルリクエスト等)とクライアントへのメッセージ
152+ if (isset($this->opt['process'])) {
153+ $ok = array_values(array_map(fn($c) => [$c['ref'], $c['old'], $c['new']], array_filter($commands, fn($c) => $c['error'] === null)));
154+ $lines = empty($ok) ? [] : ($this->opt['process'])($ok);
155+ if ($this->sideband && !empty($lines)) {
156+ foreach ($lines as $line) $this->out->write(PktLine::band(2, rtrim((string)$line, "\n")."\n"));
157+ $this->out->flush();
158+ }
159+ }
149160 }
150161
151162 if ($report) {
@@ -196,7 +207,9 @@
196207 /** 参照を更新する(古い値が変わっていれば失敗) */
197208 private function apply(array $commands, bool $atomic): array {
198209 $zero = $this->repo->zeroId();
199- $ok = array_filter($commands, fn($c) => $c['error'] === null);
210+ // interceptの参照は書かない
211+ $skip = fn(array $c) => isset($this->opt['intercept']) && ($this->opt['intercept'])($c['ref']);
212+ $ok = array_filter($commands, fn($c) => $c['error'] === null && !$skip($c));
200213 if (empty($ok)) return $commands;
201214 $tx = fn(array $c) => [$c['ref'], $c['new'] === $zero ? null : $c['new'], $c['old']];
202215 if ($atomic) {
@@ -208,7 +221,7 @@
208221 return $commands;
209222 }
210223 foreach ($commands as $i => $c) {
211- if ($c['error'] !== null) continue;
224+ if ($c['error'] !== null || $skip($c)) continue;
212225 try {
213226 $this->repo->refs->transaction([$tx($c)]);
214227 } catch (GitException $e) {
@@ -91,6 +91,11 @@
9191 return self::actorUrl($owner, $name).'/issues/'.$n;
9292 }
9393
94+ /** ブラウザで見るページ(プルリクエストは…/pulls/3) */
95+ public static function pageUrl(string $owner, string $name, array $issue): string {
96+ return self::actorUrl($owner, $name).(isset($issue['pull']) ? '/pulls/' : '/issues/').$issue['number'];
97+ }
98+
9499 /** 「所有者.名前」(ユーザー名には「.」が無いので重ならない) */
95100 public static function handle(string $owner, string $name): string {
96101 return $owner.'.'.$name;
@@ -264,11 +269,14 @@
264269 $url = self::issueUrl($owner, $name, $issue['number']);
265270 $repo = self::actorUrl($owner, $name);
266271 $closed = $issue['state'] === 'closed';
267- $content = '<p>'.($closed ? '【解決済み】' : '').'<a href="'.Html::esc($url).'">#'.$issue['number'].' '.Html::esc($issue['title']).'</a></p>'
268- .'<p>'.Html::esc(self::authorName($issue['author'])).'さんが'.Html::esc("{$owner}/{$name}").'にイシューを作りました。</p>'
272+ $pull = isset($issue['pull']);
273+ $page = self::pageUrl($owner, $name, $issue);
274+ $state = !empty($issue['pull']['merged']) ? '【マージ済み】' : ($closed ? ($pull ? '【閉じた】' : '【解決済み】') : '');
275+ $content = '<p>'.$state.'<a href="'.Html::esc($page).'">#'.$issue['number'].' '.Html::esc($issue['title']).'</a></p>'
276+ .'<p>'.Html::esc(self::authorName($issue['author'])).'さんが'.Html::esc("{$owner}/{$name}").'に'.($pull ? 'プルリクエスト' : 'イシュー').'を作りました。</p>'
269277 .self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html']));
270278 $note = [
271- 'id' => $url, 'type' => 'Note', 'attributedTo' => $repo, 'context' => "{$url}/ticket", 'url' => $url,
279+ 'id' => $url, 'type' => 'Note', 'attributedTo' => $repo, 'context' => "{$url}/ticket", 'url' => $page,
272280 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], self::participants($owner, $name, $issue)),
273281 'published' => Fedi::now($issue['created']), 'content' => $content, 'mediaType' => 'text/html',
274282 'tag' => self::mentionTags(self::participants($owner, $name, $issue)),
@@ -284,7 +292,7 @@
284292 '@context' => self::CONTEXT, 'id' => "{$url}/ticket", 'type' => 'Ticket', 'attributedTo' => self::authorActor($owner, $name, $issue['author']),
285293 'context' => self::actorUrl($owner, $name), 'name' => $issue['title'], 'summary' => Html::esc($issue['title']),
286294 'content' => self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html'])), 'mediaType' => 'text/html',
287- 'published' => Fedi::now($issue['created']), 'updated' => Fedi::now($issue['updated']), 'isResolved' => $issue['state'] === 'closed', 'url' => $url,
295+ 'published' => Fedi::now($issue['created']), 'updated' => Fedi::now($issue['updated']), 'isResolved' => $issue['state'] === 'closed', 'url' => self::pageUrl($owner, $name, $issue),
288296 ];
289297 if (empty($issue['html'])) $t['source'] = ['content' => $issue['body'], 'mediaType' => 'text/markdown'];
290298 return $t;
@@ -297,7 +305,7 @@
297305 $people = self::participants($owner, $name, $issue);
298306 $note = [
299307 'id' => "{$url}/comments/{$c['id']}", 'type' => 'Note', 'attributedTo' => $repo, 'inReplyTo' => $url, 'context' => "{$url}/ticket",
300- 'url' => "{$url}#c{$c['id']}", 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], $people),
308+ 'url' => self::pageUrl($owner, $name, $issue)."#c{$c['id']}", 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], $people),
301309 'published' => Fedi::now($c['created']), 'mediaType' => 'text/html', 'tag' => self::mentionTags($people),
302310 'content' => '<p><a href="'.Html::esc($url).'">#'.$issue['number'].'</a> '.Html::esc(self::authorName($c['author'])).':</p>'
303311 .self::bodyHtml($owner, $name, $c['body'], !empty($c['html'])),
@@ -130,13 +130,15 @@
130130 * リポジトリの情報
131131 *
132132 * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、
133- * issues(イシューを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ)
133+ * issues(イシューを使う)、pulls(プルリクエストを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ)、
134+ * forkOf(フォーク元の「所有者/名前」又は空)
134135 */
135136 public static function meta(string $owner, string $name): array {
136137 $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true);
137138 $j = is_array($j) ? $j : [];
138- $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => [], 'issues' => true, 'federate' => true, 'archived' => false];
139- foreach (['private', 'issues', 'federate', 'archived'] as $k) $j[$k] = (bool)$j[$k];
139+ $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => [], 'issues' => true, 'pulls' => true, 'federate' => true, 'archived' => false, 'forkOf' => ''];
140+ foreach (['private', 'issues', 'pulls', 'federate', 'archived'] as $k) $j[$k] = (bool)$j[$k];
141+ $j['forkOf'] = (string)$j['forkOf'];
140142 $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : [];
141143 return $j;
142144 }
@@ -230,6 +232,62 @@
230232 return Result::Success("リポジトリ「{$owner}/{$name}」を作りました。", ['owner' => $owner, 'name' => $name]);
231233 }
232234
235+ /**
236+ * フォークする(自分のアカウントに同じ名前で。有れば「名前-2」等)
237+ * パックはハードリンク(出来なければコピー)、ブランチとタグだけ持って来る。非公開のリポジトリのフォークは非公開
238+ *
239+ * @return Result data:owner、name
240+ */
241+ public static function fork(\stdClass $user, string $owner, string $name): Result {
242+ if (!self::userCan($user, 'repos')) return Result::Error('リポジトリを作る権限が有りません(管理者に「Gitのリポジトリ」のロールを付けて貰って下さい)。');
243+ if (self::access($user, $owner, $name) < self::READ) return Result::Error('リポジトリが有りません。');
244+ $me = (string)$user->username;
245+ if ($me === $owner) return Result::Error('自分のリポジトリはフォーク出来ません。');
246+ foreach (self::list($me) as $r) {
247+ if ($r['meta']['forkOf'] === "{$owner}/{$name}") return Result::Success('既にフォークしています。', ['owner' => $me, 'name' => $r['name']]);
248+ }
249+ $max = (int)self::config()['maxRepos'];
250+ if ($max > 0 && count(self::list($me)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。");
251+ $new = $name;
252+ for ($i = 2; self::exists($me, $new) || is_dir(self::path($me, $new)); $i++) $new = "{$name}-{$i}";
253+ if (!self::validName($new)) return Result::Error('フォークの名前を決められません。');
254+ $src = self::open($owner, $name);
255+ $meta = self::meta($owner, $name);
256+ $head = $src->headRef();
257+ $branch = $head !== null && str_starts_with($head, 'refs/heads/') ? substr($head, 11) : (string)self::config()['branch'];
258+ try {
259+ $dst = Repository::init(self::path($me, $new), $src->hashAlgo, $branch, 'group', $meta['description']);
260+ $from = $src->objects->dir();
261+ $to = $dst->objects->dir();
262+ foreach (glob($from.'/pack/pack-*') ?: [] as $f) {
263+ $t = $to.'/pack/'.basename($f);
264+ $dst->mkdir(dirname($t));
265+ if (!@link($f, $t) && !@copy($f, $t)) throw new GitException("コピー出来ません:{$f}");
266+ }
267+ foreach (glob($from.'/[0-9a-f][0-9a-f]/*') ?: [] as $f) {
268+ $t = $to.'/'.basename(dirname($f)).'/'.basename($f);
269+ $dst->mkdir(dirname($t));
270+ if (!@copy($f, $t)) throw new GitException("コピー出来ません:{$f}");
271+ $dst->chmodFile($t, true);
272+ }
273+ $refs = [];
274+ foreach (['refs/heads/', 'refs/tags/'] as $prefix) foreach ($src->refs->all($prefix) as $ref => $id) $refs[] = [$ref, $id, null];
275+ if (!empty($refs)) $dst->refs->transaction($refs);
276+ } catch (GitException $e) {
277+ if (Repository::isRepository(self::path($me, $new))) self::delete($me, $new);
278+ return Result::Error('フォーク出来ません:'.$e->getMessage());
279+ }
280+ self::saveMeta($me, $new, ['description' => $meta['description'], 'private' => $meta['private'], 'created' => time(), 'collaborators' => [], 'forkOf' => "{$owner}/{$name}"]);
281+ return Result::Success("{$owner}/{$name}を{$me}/{$new}にフォークしました。", ['owner' => $me, 'name' => $new]);
282+ }
283+
284+ /** フォーク([所有者, 名前]の配列) */
285+ public static function forks(string $owner, string $name): array {
286+ $out = [];
287+ foreach (self::list() as $r) if ($r['meta']['forkOf'] === "{$owner}/{$name}") $out[] = [$r['owner'], $r['name']];
288+ return $out;
289+ }
290+
233291 /** 名前を変える */
234292 public static function rename(string $owner, string $name, string $new): Result {
235293 if (!self::validName($new)) return Result::Error('不正なリポジトリの名前です。');
@@ -248,7 +306,8 @@
248306 if ($f === '.' || $f === '..') continue;
249307 $p = "{$d}/{$f}";
250308 if (is_dir($p) && !is_link($p)) $rm($p);
251- else {
309+ // 読むだけのファイル(Windows)だけ権限を変える(フォークのパックはハードリンクで元と共有)
310+ else if (!@unlink($p)) {
252311 @chmod($p, 0666);
253312 @unlink($p);
254313 }
@@ -52,7 +52,8 @@
5252 *
5353 * 書く者:local({user})、remote({actor, name, handle, icon, url}、Fediverse)、commit({commit, name})。
5454 * 本文はMarkdown(htmlがtrueならリモートのHTML、受け取った時に安全にした物)。
55- * 番号は後でプルリクエストと共有する。
55+ * プルリクエストも同じ番号のイシュー(pull:Pullsを参照)。インラインのコメントはpath・side・line・commit等を持ち、
56+ * レビュー(reviews:id、author、state(commented、approved、changes)、body、at、commit)に属する。
5657 */
5758 final class Issues {
5859 public const MAX_TITLE = 200;
@@ -84,23 +85,27 @@
8485 }
8586
8687 /** 開いている・閉じたイシューの数 */
87- public function counts(): array {
88+ /** 開いている・閉じた数($pullならプルリクエスト) */
89+ public function counts(bool $pull = false): array {
8890 $c = ['open' => 0, 'closed' => 0];
89- foreach ($this->index()['issues'] as $s) $c[$s['state'] === 'closed' ? 'closed' : 'open']++;
91+ foreach ($this->index()['issues'] as $s) if (!empty($s['pull']) === $pull) $c[$s['state'] === 'closed' ? 'closed' : 'open']++;
9092 return $c;
9193 }
9294
9395 /**
9496 * 探す
9597 *
96- * @param array $f state(open、closed、all)、label(ID)、milestone(ID、0は無し)、assignee、author、q、sort(created、updated、comments)
98+ * @param array $f state(open、closed、all)、label(ID)、milestone(ID、0は無し)、assignee、author、q、sort(created、updated、comments)、
99+ * pull(trueならプルリクエストだけ、falseならイシューだけ、既定はfalse、nullなら両方)
97100 * @return array 概要の配列(新しい順)
98101 */
99102 public function search(array $f = []): array {
100103 $state = $f['state'] ?? 'open';
101104 $q = mb_strtolower(trim((string)($f['q'] ?? '')));
102105 $out = [];
106+ $pull = array_key_exists('pull', $f) ? $f['pull'] : false;
103107 foreach ($this->index()['issues'] as $s) {
108+ if ($pull !== null && !empty($s['pull']) !== $pull) continue;
104109 if ($state !== 'all' && $s['state'] !== $state) continue;
105110 if (isset($f['label']) && !in_array((int)$f['label'], $s['labels'], true)) continue;
106111 if (isset($f['milestone']) && (int)($s['milestone'] ?? 0) !== (int)$f['milestone']) continue;
@@ -132,7 +137,7 @@
132137 * イシューを作る
133138 *
134139 * @param array $author 書いた者
135- * @param array $opt html(本文がHTML)、remoteId、labels、assignees、milestone
140+ * @param array $opt html(本文がHTML)、remoteId、labels、assignees、milestone、pull(プルリクエストの情報)
136141 */
137142 public function create(array $author, string $title, string $body, array $opt = []): Result {
138143 $title = self::cleanTitle($title);
@@ -149,6 +154,7 @@
149154 'assignees' => array_values(array_unique(array_map('strval', $opt['assignees'] ?? []))), 'milestone' => $this->validMilestone($opt['milestone'] ?? null),
150155 'remoteId' => $opt['remoteId'] ?? null, 'comments' => [], 'events' => [], 'seq' => 1, 'subscribers' => [],
151156 ];
157+ if (isset($opt['pull'])) $issue += ['pull' => $opt['pull'], 'reviews' => []];
152158 // 作った人と担当者は通知を受け取る
153159 foreach (array_merge(isset($author['user']) ? [$author['user']] : [], $issue['assignees']) as $u) $issue['subscribers'][$u] = true;
154160 $idx['next'] = $n + 1;
@@ -218,6 +224,67 @@
218224 return Result::Success('コメントしました。', ['issue' => $issue, 'comment' => $added]);
219225 }
220226
227+ /**
228+ * プルリクエストの行へのコメント
229+ *
230+ * @param array $at path、side(old、new)、line、commit、hunk(表示用の前の行)、replyTo(スレッドの最初のコメント)、pending(レビューの下書き)
231+ */
232+ public function inlineComment(int $n, array $author, string $body, array $at): Result {
233+ if (trim($body) === '') return Result::Error('コメントを入力して下さい。');
234+ if (strlen($body) > self::MAX_BODY) return Result::Error('コメントが長過ぎます。');
235+ $added = null;
236+ $issue = $this->update($n, function(array $i) use ($author, $body, $at, &$added): ?array {
237+ if (!isset($i['pull'])) return null;
238+ $root = null;
239+ if (!empty($at['replyTo'])) {
240+ foreach ($i['comments'] as $c) if ($c['id'] === (int)$at['replyTo'] && isset($c['path'])) $root = $c;
241+ if ($root === null) return null;
242+ }
243+ $added = ['id' => (int)$i['seq'], 'author' => $author, 'body' => $body, 'html' => false, 'created' => time(), 'updated' => time(), 'remoteId' => null,
244+ 'path' => (string)($root['path'] ?? $at['path']), 'side' => ($root['side'] ?? $at['side']) === 'old' ? 'old' : 'new', 'line' => (int)($root['line'] ?? $at['line']),
245+ 'commit' => (string)($root['commit'] ?? $at['commit']), 'hunk' => $root !== null ? [] : array_values(array_map('strval', (array)($at['hunk'] ?? []))),
246+ 'replyTo' => $root !== null ? $root['id'] : null, 'review' => null, 'pending' => !empty($at['pending'])];
247+ $i['seq'] = (int)$i['seq'] + 1;
248+ $i['comments'][] = $added;
249+ if (isset($author['user'])) $i['subscribers'][$author['user']] = true;
250+ return $i;
251+ });
252+ if ($issue === null || $added === null) return Result::Error('コメント出来ません。');
253+ return Result::Success('コメントしました。', ['issue' => $issue, 'comment' => $added]);
254+ }
255+
256+ /**
257+ * レビューを出す(その人の下書きのコメントをレビューに入れる)
258+ *
259+ * @param string $state commented、approved、changes
260+ */
261+ public function submitReview(int $n, array $author, string $state, string $body, string $commit): Result {
262+ if (!in_array($state, ['commented', 'approved', 'changes'], true)) return Result::Error('不正なレビューです。');
263+ if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。');
264+ $review = null;
265+ $key = self::authorKey($author);
266+ $issue = $this->update($n, function(array $i) use ($author, $state, $body, $commit, $key, &$review): ?array {
267+ if (!isset($i['pull'])) return null;
268+ $pending = array_filter($i['comments'], fn($c) => !empty($c['pending']) && self::authorKey($c['author']) === $key);
269+ if ($state === 'commented' && trim($body) === '' && empty($pending)) return null;
270+ $id = (int)($i['rseq'] ?? 1);
271+ $review = ['id' => $id, 'author' => $author, 'state' => $state, 'body' => $body, 'at' => time(), 'commit' => $commit];
272+ $i['rseq'] = $id + 1;
273+ $i['reviews'][] = $review;
274+ foreach ($i['comments'] as $k => $c) {
275+ if (!empty($c['pending']) && self::authorKey($c['author']) === $key) {
276+ $i['comments'][$k]['pending'] = false;
277+ $i['comments'][$k]['review'] = $id;
278+ $i['comments'][$k]['created'] = time();
279+ }
280+ }
281+ if (isset($author['user'])) $i['subscribers'][$author['user']] = true;
282+ return $i;
283+ });
284+ if ($issue === null || $review === null) return Result::Error('コメントを入力して下さい。');
285+ return Result::Success('レビューを出しました。', ['issue' => $issue, 'review' => $review]);
286+ }
287+
221288 /** コメントを変える(nullなら削除) */
222289 public function editComment(int $n, int $id, ?string $body): ?array {
223290 if ($body !== null && (trim($body) === '' || strlen($body) > self::MAX_BODY)) return null;
@@ -330,10 +397,11 @@
330397 $closes = array_map('intval', $cm[1]);
331398 $by = ['commit' => $id, 'name' => $c->author?->name ?? ''];
332399 foreach (array_unique(array_map('intval', $m[1])) as $n) {
333- $close = $ref === $default && in_array($n, $closes, true);
400+ $close = $ref === $default && in_array($n, $closes, true) && empty($this->index()['issues'][$n]['pull']);
334401 $didClose = false;
335402 $this->update($n, function(array $i) use ($id, $c, $by, $close, $ref, &$didClose): ?array {
336- $seen = false;
403+ // マージしたプルリクエスト自身のコミットは記録しない
404+ $seen = ($i['pull']['merged']['commit'] ?? '') === $id;
337405 foreach ($i['events'] as $e) if ($e['type'] === 'commit' && $e['commit'] === $id) $seen = true;
338406 if (!$seen) $i['events'][] = ['type' => 'commit', 'by' => $by, 'at' => time(), 'commit' => $id, 'subject' => $c->subject(), 'branch' => substr($ref, 11)];
339407 $didClose = $close && $i['state'] === 'open';
@@ -496,7 +564,8 @@
496564 $idx['issues'][(string)$issue['number']] = [
497565 'number' => $issue['number'], 'title' => $issue['title'], 'state' => $issue['state'], 'author' => $issue['author'], 'authorKey' => self::authorKey($issue['author']),
498566 'created' => $issue['created'], 'updated' => $issue['updated'], 'closed' => $issue['closed'], 'labels' => $issue['labels'], 'assignees' => $issue['assignees'],
499- 'milestone' => $issue['milestone'], 'comments' => count($issue['comments']), 'last' => $last,
567+ 'milestone' => $issue['milestone'], 'comments' => count(array_filter($issue['comments'], fn($c) => empty($c['pending']))), 'last' => $last,
568+ 'pull' => isset($issue['pull']), 'merged' => !empty($issue['pull']['merged']),
500569 ];
501570 $this->repo->mkdir($this->dir.'/issues');
502571 $this->writeJson($this->file($issue['number']), $issue);
@@ -125,6 +125,21 @@
125125 self::send($owner, $name, $meta, $issue, $by, $to, '', false, '');
126126 }
127127
128+ /** プルリクエストをマージした */
129+ public static function merged(string $owner, string $name, array $meta, array $issue, array $by): void {
130+ $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), 'merged');
131+ self::send($owner, $name, $meta, $issue, $by, $to, '', false, '');
132+ }
133+
134+ /** プルリクエストのレビュー(state:approved、changes、commented) */
135+ public static function reviewed(string $owner, string $name, array $meta, Issues $db, array $issue, array $review): void {
136+ $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $review['body'], false);
137+ $issue = $db->get($issue['number']) ?? $issue;
138+ $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), $review['state']);
139+ foreach ($mentioned as $u) $to[$u] = 'mention';
140+ self::send($owner, $name, $meta, $issue, $review['author'], $to, $review['body'], false, '#r'.$review['id']);
141+ }
142+
128143 /** 担当者に追加した */
129144 public static function assigned(string $owner, string $name, array $meta, array $issue, array $users, array $by): void {
130145 self::send($owner, $name, $meta, $issue, $by, array_fill_keys($users, 'assigned'), '', false, '');
@@ -144,7 +159,8 @@
144159 }
145160
146161 /**
147- * @param array $to ユーザー名 => 種類(opened、comment、mention、closed、closedByCommit、reopened、assigned)
162+ * @param array $to ユーザー名 => 種類(opened、comment、mention、closed、closedByCommit、reopened、assigned、merged、
163+ * approved・changes・commented(レビュー))
148164 * @param string $anchor コメントの「#c5」等
149165 */
150166 private static function send(string $owner, string $name, array $meta, array $issue, array $by, array $to, string $body, bool $html, string $anchor): void {
@@ -153,7 +169,8 @@
153169 $repoName = "{$owner}/{$name}";
154170 $title = "{$repoName}#{$n}「{$issue['title']}」";
155171 $commit = isset($by['commit']) ? substr((string)$by['commit'], 0, 7) : '';
156- $path = '/gitler/'.$owner.'/'.rawurlencode($name).'/issues/'.$n.$anchor;
172+ $kind = isset($issue['pull']) ? 'プルリクエスト' : 'イシュー';
173+ $path = '/gitler/'.$owner.'/'.rawurlencode($name).(isset($issue['pull']) ? '/pulls/' : '/issues/').$n.$anchor;
157174 $excerpt = $body !== '' ? Html::text($html ? $body : Issues::render($body, '/gitler/'.$owner.'/'.rawurlencode($name).'/issues')) : '';
158175 // Markdownの整形の空白を除く
159176 $excerpt = trim((string)preg_replace(['/[ \t]*\n[ \t]*/', '/\n{3,}/'], ["\n", "\n\n"], $excerpt));
@@ -169,7 +186,11 @@
169186 $u = Gitler::user($user);
170187 if ($u === null || Gitler::access($u, $owner, $name) < Gitler::READ) continue;
171188 $label = match ($type) {
172- 'opened' => "が{$repoName}にイシュー#{$n}「{$issue['title']}」を作りました",
189+ 'opened' => "が{$repoName}に{$kind}#{$n}「{$issue['title']}」を作りました",
190+ 'merged' => "が{$title}をマージしました",
191+ 'approved' => "が{$title}を承認しました",
192+ 'changes' => "が{$title}に変更を依頼しました",
193+ 'commented' => "が{$title}をレビューしました",
173194 'comment' => "が{$title}にコメントしました",
174195 'mention' => "が{$title}であなたをメンションしました",
175196 'closed' => "が{$title}を閉じました",
@@ -0,0 +1,403 @@
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 Std\Lib\Gitler;
39+
40+use Std\Lib\Fedi\Fedi;
41+use Std\Lib\Git\Commit;
42+use Std\Lib\Git\GitException;
43+use Std\Lib\Git\Merge;
44+use Std\Lib\Git\PackIndexer;
45+use Std\Lib\Git\PackWriter;
46+use Std\Lib\Git\Person;
47+use Std\Lib\Git\Repository;
48+use Std\Lib\Git\Walk;
49+use Result;
50+
51+/**
52+ * Gitlerのプルリクエスト(イシューのpull)
53+ *
54+ * pull:kind(branch:同じリポジトリのブランチ、fork:フォークのブランチ、agit:refs/for/へのプッシュ)、base(マージ先のブランチ)、
55+ * head(branch・fork:owner、name、branch。agit:user、topic)、headId、baseId(作った時)、merged(by、at、commit、method)
56+ * 変更はリポジトリのrefs/pull/{番号}/head(フォークからはオブジェクトをコピーする)。
57+ *
58+ * AGit:git push origin HEAD:refs/for/master/topic で作り、同じトピックにもう一度プッシュすると更新する。
59+ */
60+final class Pulls {
61+ /** 比べるコミットの最大数 */
62+ public const MAX_COMMITS = 250;
63+ /** トピックの名前 */
64+ private const TOPIC_RE = '#^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$#';
65+
66+ public static function ref(int $n): string {
67+ return "refs/pull/{$n}/head";
68+ }
69+
70+ /**
71+ * プルリクエストのコミットの範囲([マージ先, head]、マージ後はマージする前のマージ先)
72+ *
73+ * @return array|null マージ先のブランチが無ければnull
74+ */
75+ public static function range(Repository $repo, array $issue): ?array {
76+ $p = $issue['pull'];
77+ $base = $p['merged']['before'] ?? $repo->refs->resolve('refs/heads/'.$p['base']) ?? $p['baseId'];
78+ return $repo->objects->exists($base) && $repo->objects->exists($p['headId']) ? [$base, $p['headId']] : null;
79+ }
80+
81+ /**
82+ * 比べる(headにだけ有るコミット)
83+ *
84+ * @return array mergeBase、commits(古い順のID)、truncated
85+ */
86+ public static function compare(Repository $repo, string $base, string $head): array {
87+ $walk = new Walk($repo);
88+ [$ids] = $walk->commitsBetween([$head], [$base]);
89+ return ['mergeBase' => $walk->mergeBase($base, $head), 'commits' => array_reverse(array_slice($ids, 0, self::MAX_COMMITS)), 'truncated' => count($ids) > self::MAX_COMMITS];
90+ }
91+
92+ /**
93+ * プルリクエストを作る
94+ *
95+ * @param array $head branch・fork:owner、name、branch。agit:user、topic
96+ * @param array $opt labels、assignees、milestone
97+ */
98+ 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 {
99+ $meta = Gitler::meta($owner, $name);
100+ if (!$meta['pulls']) return Result::Error('このリポジトリではプルリクエストは使えません。');
101+ if ($meta['archived']) return Result::Error('アーカイブされたリポジトリです(読むだけ)。');
102+ $repo = Gitler::open($owner, $name);
103+ $baseId = $repo->refs->resolve('refs/heads/'.$base);
104+ if ($baseId === null) return Result::Error("ブランチ「{$base}」が有りません。");
105+ if (!$repo->objects->exists($headId)) return Result::Error('コミットが有りません。');
106+ if (empty(self::compare($repo, $baseId, $headId)['commits'])) return Result::Error("「{$base}」に無いコミットが有りません。");
107+ $issues = new Issues($repo);
108+ foreach ($issues->search(['pull' => true]) as $s) {
109+ $p = $issues->get($s['number'])['pull'] ?? null;
110+ if ($p !== null && $p['kind'] === $kind && $p['head'] === $head && $p['base'] === $base) return Result::Error("同じプルリクエスト#{$s['number']}が有ります。", ['number' => $s['number']]);
111+ }
112+ $res = $issues->create($author, $title, $body, $opt + ['pull' => ['kind' => $kind, 'base' => $base, 'head' => $head, 'headId' => $headId, 'baseId' => $baseId, 'merged' => null]]);
113+ if (!$res->isSuccess) return $res;
114+ $repo->refs->update(self::ref($res->data['number']), $headId);
115+ Federation::issueOpened($owner, $name, $meta, $res->data['issue']);
116+ Notify::issueOpened($owner, $name, $meta, $issues, $res->data['issue']);
117+ return $res;
118+ }
119+
120+ /** 新しいコミット(refs/pull/N/headと出来事) */
121+ public static function setHead(Repository $repo, Issues $issues, int $n, string $new, array $by): ?array {
122+ $issue = $issues->get($n);
123+ if ($issue === null || !isset($issue['pull']) || $issue['pull']['headId'] === $new) return null;
124+ $old = $issue['pull']['headId'];
125+ $count = count((new Walk($repo))->commitsBetween([$new], [$old])[0]);
126+ $repo->refs->update(self::ref($n), $new);
127+ return $issues->update($n, function(array $i) use ($old, $new, $count, $by): array {
128+ $i['pull']['headId'] = $new;
129+ $i['events'][] = ['type' => 'pushed', 'by' => $by, 'at' => time(), 'from' => $old, 'to' => $new, 'count' => $count];
130+ return $i;
131+ });
132+ }
133+
134+ /** フォーク等の他のリポジトリからコミット(と必要なオブジェクト)を持って来る */
135+ public static function fetch(Repository $from, Repository $to, string $id): void {
136+ if ($to->objects->exists($id)) return;
137+ $haves = [];
138+ foreach (array_merge($to->refs->all('refs/heads/'), $to->refs->all('refs/tags/')) as $h) {
139+ // 注釈付きタグはコミットにする
140+ $c = $from->objects->exists($h) ? $from->peel($h, 'commit') : null;
141+ if ($c !== null) $haves[$c] = true;
142+ }
143+ $haves = array_keys($haves);
144+ $objects = (new Walk($from))->objects([$id], $haves);
145+ $tmp = fopen('php://temp', 'w+b');
146+ try {
147+ (new PackWriter($from))->write($objects, function(string $b) use ($tmp): void {
148+ fwrite($tmp, $b);
149+ });
150+ rewind($tmp);
151+ (new PackIndexer($to))->receive($tmp);
152+ } finally {
153+ fclose($tmp);
154+ }
155+ }
156+
157+ //////// マージ
158+
159+ /**
160+ * マージ出来るか
161+ *
162+ * @return array state(merged、closed、uptodate(既に入っている)、ff、clean、conflict、nobase)、conflicts、baseId
163+ */
164+ public static function mergeability(Repository $repo, array $issue): array {
165+ $p = $issue['pull'];
166+ if (!empty($p['merged'])) return ['state' => 'merged', 'conflicts' => [], 'baseId' => null];
167+ if ($issue['state'] === 'closed') return ['state' => 'closed', 'conflicts' => [], 'baseId' => null];
168+ $baseId = $repo->refs->resolve('refs/heads/'.$p['base']);
169+ if ($baseId === null) return ['state' => 'nobase', 'conflicts' => [], 'baseId' => null];
170+ $walk = new Walk($repo);
171+ if ($walk->isAncestor($p['headId'], $baseId)) return ['state' => 'uptodate', 'conflicts' => [], 'baseId' => $baseId];
172+ if ($walk->isAncestor($baseId, $p['headId'])) return ['state' => 'ff', 'conflicts' => [], 'baseId' => $baseId];
173+ $mb = $walk->mergeBase($baseId, $p['headId']);
174+ $r = Merge::trees($repo, $mb !== null ? $repo->commit($mb)->tree : null, $repo->commit($baseId)->tree, $repo->commit($p['headId'])->tree);
175+ return ['state' => $r['tree'] !== null ? 'clean' : 'conflict', 'conflicts' => $r['conflicts'], 'baseId' => $baseId];
176+ }
177+
178+ /**
179+ * マージする
180+ *
181+ * @param string $method merge(マージコミット)、squash、rebase(出来ればfast-forward)
182+ * @param \stdClass $user マージする人(コミッター)
183+ */
184+ public static function merge(string $owner, string $name, int $n, \stdClass $user, string $method, string $message): Result {
185+ $repo = Gitler::open($owner, $name);
186+ $issues = new Issues($repo);
187+ $issue = $issues->get($n);
188+ if ($issue === null || !isset($issue['pull'])) return Result::Error('プルリクエストが有りません。');
189+ $m = self::mergeability($repo, $issue);
190+ if (in_array($m['state'], ['merged', 'closed', 'nobase', 'uptodate'], true)) return Result::Error('マージ出来ません。');
191+ if (!in_array($method, ['merge', 'squash', 'rebase'], true)) return Result::Error('マージの方法が不正です。');
192+ $p = $issue['pull'];
193+ $baseId = $m['baseId'];
194+ $head = $p['headId'];
195+ $me = self::person($user);
196+ $message = trim(str_replace("\r\n", "\n", $message));
197+ $walk = new Walk($repo);
198+ $mb = $walk->mergeBase($baseId, $head);
199+ if ($method === 'rebase') {
200+ if ($m['state'] === 'ff') {
201+ $new = $head;
202+ } else {
203+ $new = $baseId;
204+ foreach (self::compare($repo, $baseId, $head)['commits'] as $id) {
205+ $c = $repo->commit($id);
206+ if (count($c->parents) > 1) return Result::Error('マージコミットが有るのでリベース出来ません。');
207+ $tree = Merge::pick($repo, $id, $new);
208+ if ($tree === null) return Result::Error('コンフリクトが有るのでリベース出来ません。');
209+ $new = $repo->writeCommit(Commit::create($tree, [$new], $c->author ?? $me, $me, $c->message));
210+ }
211+ }
212+ } else {
213+ if ($m['state'] === 'conflict') return Result::Error('コンフリクトが有ります。');
214+ $tree = Merge::trees($repo, $mb !== null ? $repo->commit($mb)->tree : null, $repo->commit($baseId)->tree, $repo->commit($head)->tree)['tree'];
215+ if ($tree === null) return Result::Error('コンフリクトが有ります。');
216+ if ($method === 'merge') {
217+ $msg = $message !== '' ? $message : "Merge pull request #{$n}\n\n{$issue['title']}";
218+ $new = $repo->writeCommit(Commit::create($tree, [$baseId, $head], $me, $me, $msg."\n"));
219+ } else {
220+ $subjects = array_map(fn($id) => '* '.$repo->commit($id)->subject(), self::compare($repo, $baseId, $head)['commits']);
221+ $msg = $message !== '' ? $message : "{$issue['title']} (#{$n})\n\n".implode("\n", $subjects);
222+ $author = isset($issue['author']['user']) && ($u = Gitler::user((string)$issue['author']['user'])) !== null ? self::person($u) : $me;
223+ $new = $repo->writeCommit(Commit::create($tree, [$baseId], $author, $me, $msg."\n"));
224+ }
225+ }
226+ try {
227+ $repo->refs->update('refs/heads/'.$p['base'], $new, $baseId);
228+ } catch (GitException) {
229+ return Result::Error('ブランチが変わりました。もう一度お試し下さい。');
230+ }
231+ $by = ['user' => (string)$user->username];
232+ $merged = self::markMerged($owner, $name, $issues, $n, $by, $new, $method, $baseId);
233+ // マージで入ったコミットとプルリクエストの本文の「fixes #3」でイシューを閉じる
234+ self::afterBaseUpdate($owner, $name, $repo, $issues, 'refs/heads/'.$p['base'], $baseId, $new, $by);
235+ $meta = Gitler::meta($owner, $name);
236+ foreach ($issues->processPush([['refs/heads/'.$p['base'], $baseId, $new]], $repo->headRef() ?? '') as $x) {
237+ $closed = $issues->get($x);
238+ if ($closed === null) continue;
239+ Federation::stateChanged($owner, $name, $meta, $closed);
240+ Notify::stateChanged($owner, $name, $meta, $closed, end($closed['events'])['by'] ?? $by);
241+ }
242+ 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() ?? '')) {
243+ foreach (array_unique(array_map('intval', $cm[1])) as $x) {
244+ $target = $issues->get($x);
245+ if ($target === null || isset($target['pull'])) continue;
246+ $closed = $issues->setState($x, false, $by, ['pull' => $n]);
247+ if ($closed !== null) {
248+ Federation::stateChanged($owner, $name, Gitler::meta($owner, $name), $closed);
249+ Notify::stateChanged($owner, $name, Gitler::meta($owner, $name), $closed, $by);
250+ }
251+ }
252+ }
253+ return Result::Success('マージしました。', ['commit' => $new, 'issue' => $merged]);
254+ }
255+
256+ /**
257+ * マージした事にする(閉じて、出来事を記録して、通知する)
258+ *
259+ * @param string $before マージする前のマージ先(コミットと変更の表示に使う)
260+ */
261+ public static function markMerged(string $owner, string $name, Issues $issues, int $n, array $by, string $commit, string $method, string $before): ?array {
262+ $issue = $issues->update($n, function(array $i) use ($by, $commit, $method, $before): ?array {
263+ if (!isset($i['pull']) || !empty($i['pull']['merged'])) return null;
264+ $i['pull']['merged'] = ['by' => $by, 'at' => time(), 'commit' => $commit, 'method' => $method, 'before' => $before];
265+ $i['state'] = 'closed';
266+ $i['closed'] = time();
267+ $i['events'][] = ['type' => 'merged', 'by' => $by, 'at' => time(), 'commit' => $commit, 'base' => $i['pull']['base'], 'method' => $method];
268+ return $i;
269+ });
270+ if ($issue !== null) {
271+ $meta = Gitler::meta($owner, $name);
272+ Federation::stateChanged($owner, $name, $meta, $issue);
273+ Notify::merged($owner, $name, $meta, $issue, $by);
274+ }
275+ return $issue;
276+ }
277+
278+ /** ブランチが進んだ:入ったプルリクエストをマージ済みにする */
279+ private static function afterBaseUpdate(string $owner, string $name, Repository $repo, Issues $issues, string $ref, string $old, string $new, array $by): void {
280+ $walk = new Walk($repo);
281+ foreach ($issues->search(['pull' => true]) as $s) {
282+ $i = $issues->get($s['number']);
283+ if ($i === null || 'refs/heads/'.$i['pull']['base'] !== $ref || !$repo->objects->exists($i['pull']['headId'])) continue;
284+ if ($walk->isAncestor($i['pull']['headId'], $new)) self::markMerged($owner, $name, $issues, $s['number'], $by, $new, 'manual', $old);
285+ }
286+ }
287+
288+ //////// プッシュ
289+
290+ /**
291+ * refs/for/ へのプッシュを受け付けるか(ReceivePackのupdate)
292+ *
293+ * @return string|null エラー
294+ */
295+ public static function checkAgit(string $owner, string $name, ?\stdClass $user, string $ref, string $new, string $zero): ?string {
296+ $meta = Gitler::meta($owner, $name);
297+ if (!$meta['pulls'] || $meta['archived'] || $user === null || !Gitler::userCan($user, 'pulls') || Gitler::access($user, $owner, $name) < Gitler::READ) {
298+ return 'pull requests are not allowed';
299+ }
300+ if ($new === $zero) return 'cannot delete';
301+ return self::parseAgit(Gitler::open($owner, $name), $ref) === null ? 'use refs/for/<branch>/<topic>' : null;
302+ }
303+
304+ /**
305+ * プッシュの後(ReceivePackのprocess):AGitのプルリクエスト、ブランチのプルリクエストの更新、マージの検出
306+ *
307+ * @param array $updates [参照, 前, 後]
308+ * @return array クライアントに表示する行
309+ */
310+ public static function afterPush(string $owner, string $name, ?\stdClass $user, array $updates): array {
311+ $repo = Gitler::open($owner, $name);
312+ $meta = Gitler::meta($owner, $name);
313+ $zero = $repo->zeroId();
314+ $issues = new Issues($repo);
315+ $by = $user !== null ? ['user' => (string)$user->username] : [];
316+ $base = rtrim((string)(SITEINFO['url'] ?? ''), '/') ?: Fedi::base();
317+ $out = [];
318+ foreach ($updates as [$ref, $old, $new]) {
319+ try {
320+ if (str_starts_with($ref, 'refs/for/')) {
321+ $out = array_merge($out, self::agit($owner, $name, $repo, $issues, $user, $ref, $new, $base));
322+ continue;
323+ }
324+ if (!str_starts_with($ref, 'refs/heads/') || $new === $zero) continue;
325+ $branch = substr($ref, 11);
326+ // このリポジトリのブランチからのプルリクエスト
327+ foreach ($issues->search(['pull' => true]) as $s) {
328+ $p = $issues->get($s['number'])['pull'] ?? null;
329+ if ($p !== null && $p['kind'] === 'branch' && $p['head']['branch'] === $branch) self::setHead($repo, $issues, $s['number'], $new, $by);
330+ }
331+ // フォーク元へのプルリクエスト
332+ if ($meta['forkOf'] !== '') {
333+ [$po, $pn] = explode('/', $meta['forkOf'], 2);
334+ if (Gitler::exists($po, $pn)) {
335+ $parent = Gitler::open($po, $pn);
336+ $pi = new Issues($parent);
337+ foreach ($pi->search(['pull' => true]) as $s) {
338+ $p = $pi->get($s['number'])['pull'] ?? null;
339+ if ($p === null || $p['kind'] !== 'fork' || $p['head'] !== ['owner' => $owner, 'name' => $name, 'branch' => $branch]) continue;
340+ self::fetch($repo, $parent, $new);
341+ self::setHead($parent, $pi, $s['number'], $new, $by);
342+ }
343+ }
344+ }
345+ // マージ先が進んだ
346+ self::afterBaseUpdate($owner, $name, $repo, $issues, $ref, $old, $new, $by);
347+ // 新しいブランチ:プルリクエストを作るURL(フォークならフォーク元に)
348+ if ($old === $zero && $ref !== $repo->headRef() && $user !== null && Gitler::userCan($user, 'pulls')) {
349+ [$po, $pn] = $meta['forkOf'] !== '' ? explode('/', $meta['forkOf'], 2) : ['', ''];
350+ if ($po !== '' && Gitler::exists($po, $pn) && Gitler::meta($po, $pn)['pulls']) {
351+ $out[] = 'プルリクエストを作る:'.$base.'/gitler/'.$po.'/'.rawurlencode($pn).'/compare?head='.rawurlencode("{$owner}/{$name}:{$branch}");
352+ } else if ($meta['pulls'] && !$meta['archived']) {
353+ $out[] = 'プルリクエストを作る:'.$base.'/gitler/'.$owner.'/'.rawurlencode($name).'/compare?head='.rawurlencode($branch);
354+ }
355+ }
356+ } catch (\Throwable $e) {
357+ @error_log('Gitler: '.$e->getMessage());
358+ }
359+ }
360+ return $out;
361+ }
362+
363+ /** refs/for/<ブランチ>/<トピック>(ブランチは一番長い有る物) */
364+ public static function parseAgit(Repository $repo, string $ref): ?array {
365+ $rest = substr($ref, 9);
366+ $parts = explode('/', $rest);
367+ for ($k = count($parts) - 1; $k >= 1; $k--) {
368+ $branch = implode('/', array_slice($parts, 0, $k));
369+ $topic = implode('/', array_slice($parts, $k));
370+ if ($repo->refs->resolve('refs/heads/'.$branch) !== null && preg_match(self::TOPIC_RE, $topic)) return [$branch, $topic];
371+ }
372+ return null;
373+ }
374+
375+ private static function agit(string $owner, string $name, Repository $repo, Issues $issues, ?\stdClass $user, string $ref, string $new, string $site): array {
376+ $parsed = self::parseAgit($repo, $ref);
377+ if ($parsed === null || $user === null) return [];
378+ [$branch, $topic] = $parsed;
379+ $head = ['user' => (string)$user->username, 'topic' => $topic];
380+ $by = ['user' => (string)$user->username];
381+ $url = fn(int $n) => $site.'/gitler/'.$owner.'/'.rawurlencode($name).'/pulls/'.$n;
382+ foreach ($issues->search(['pull' => true]) as $s) {
383+ $p = $issues->get($s['number'])['pull'] ?? null;
384+ if ($p === null || $p['kind'] !== 'agit' || $p['head'] !== $head || $p['base'] !== $branch) continue;
385+ self::setHead($repo, $issues, $s['number'], $new, $by);
386+ return ["プルリクエスト#{$s['number']}を更新しました:".$url($s['number'])];
387+ }
388+ // 題名と本文は最初のコミットから
389+ $baseId = $repo->refs->resolve('refs/heads/'.$branch);
390+ $commits = $baseId !== null ? self::compare($repo, $baseId, $new)['commits'] : [];
391+ if (empty($commits)) return ["「{$branch}」に無いコミットが有りません。"];
392+ $first = $repo->commit($commits[0]);
393+ $body = count($commits) === 1 ? trim(substr($first->message, strlen($first->subject()))) : implode("\n", array_map(fn($id) => '* '.$repo->commit($id)->subject(), $commits));
394+ $res = self::open($owner, $name, $by, $first->subject(), $body, $branch, 'agit', $head, $new);
395+ return $res->isSuccess ? ["プルリクエスト#{$res->data['number']}を作りました:".$url($res->data['number'])] : [(string)$res->message];
396+ }
397+
398+ /** コミットの名前とメールアドレス(メールアドレスは公開しない) */
399+ public static function person(\stdClass $u): Person {
400+ $host = (string)parse_url(rtrim((string)(SITEINFO['url'] ?? ''), '/') ?: Fedi::base(), PHP_URL_HOST) ?: 'localhost';
401+ return Person::now(($u->displayname ?? '') !== '' ? (string)$u->displayname : (string)$u->username, $u->username.'@users.noreply.'.$host);
402+ }
403+}
@@ -47,6 +47,7 @@
4747 use Std\Lib\Git\Diff;
4848 use Std\Lib\Git\GitException;
4949 use Std\Lib\Git\Maintenance;
50+use Std\Lib\Git\Merge;
5051 use Std\Lib\Git\PackIndexer;
5152 use Std\Lib\Git\PackWriter;
5253 use Std\Lib\Git\Person;
@@ -542,6 +543,14 @@
542543 $test->assertTrue($r[0] !== 'unpack ok');
543544 $test->assertSame('ng refs/heads/broken unpacker error', $r[1]);
544545 $test->assertSame(['unpack ok', 'ng HEAD funny refname', 0], $push(["{$zero} {$c1} HEAD"], $pack([$c1])));
546+ // intercept(書かない)とprocess(結果の前に呼ぶ)
547+ $processed = null;
548+ $r = $push(["{$zero} {$c3} refs/for/main/topic"], $pack([$c3], [$c2]), ['intercept' => fn(string $ref) => str_starts_with($ref, 'refs/for/'),
549+ 'process' => function(array $done) use (&$processed): array { $processed = $done; return ['作りました']; }]);
550+ $test->assertSame(['unpack ok', 'ok refs/for/main/topic', 0], $r);
551+ $test->assertNull($repo->refs->resolve('refs/for/main/topic'), '書かない');
552+ $test->assertTrue($repo->objects->exists($c3), 'オブジェクトは受け取る');
553+ $test->assertSame([['refs/for/main/topic', $zero, $c3]], $processed);
545554 });
546555
547556 $test->it('SSHのコマンドを解析するはず', function($test): void {
@@ -550,6 +559,49 @@
550559 $test->assertSame(['git-upload-pack', "it's.git"], Ssh::parse("git upload-pack 'it'\\''s.git'"));
551560 $test->assertSame(['git-upload-pack', 'a/b.git'], Ssh::parse('git-upload-pack ~/a/b.git'));
552561 foreach (['ls -la', "git-upload-pack '../etc'", "git-upload-pack 'a/../../b'", "git-upload-pack 'a", 'git-upload-pack'] as $bad) $test->assertNull(Ssh::parse($bad), $bad);
562+ });
563+});
564+
565+$test->describe('Git(マージ)', function($test) use ($tmp, $me, $git, $run): void {
566+ $l = fn(string $s) => Merge::split($s);
567+ $test->it('行の3方向のマージ(離れた変更、同じ変更、コンフリクト)', function($test) use ($l): void {
568+ $base = $l("1\n2\n3\n4\n5\n6\n7\n");
569+ $r = Merge::lines($base, $l("1\nA\n3\n4\n5\n6\n7\n"), $l("1\n2\n3\n4\n5\nB\n7\n"));
570+ $test->assertSame(0, $r['conflicts']);
571+ $test->assertSame("1\nA\n3\n4\n5\nB\n7\n", implode('', $r['lines']));
572+ $r = Merge::lines($base, $l("1\nX\n3\n4\n5\n6\n7\n"), $l("1\nX\n3\n4\n5\n6\n7\n8\n"));
573+ $test->assertSame("1\nX\n3\n4\n5\n6\n7\n8\n", implode('', $r['lines']), '同じ変更');
574+ $r = Merge::lines($base, $l("1\nA\n3\n4\n5\n6\n7\n"), $l("1\nB\n3\n4\n5\n6\n7\n"), 'main', 'topic');
575+ $test->assertSame(1, $r['conflicts']);
576+ $test->assertSame("1\n<<<<<<< main\nA\n=======\nB\n>>>>>>> topic\n3\n4\n5\n6\n7\n", implode('', $r['lines']));
577+ $test->assertSame(1, Merge::lines($base, $l("1\nA\n3\n4\n5\n6\n7\n"), $l("1\n2\nB\n4\n5\n6\n7\n"))['conflicts'], '隣り合う変更');
578+ $test->assertSame("x\ny", Merge::text("x\n", "x\ny", "x\n"), '最後の改行');
579+ $test->assertNull(Merge::text("a\n", "b\n", "c\n"));
580+ $test->assertNull(Merge::text("a\0", "b\0", "c\0"), 'バイナリ');
581+ });
582+
583+ $test->it('ツリーの3方向のマージとコミットの適用(cherry-pick)', function($test) use ($tmp, $me): void {
584+ $repo = Repository::init("{$tmp}/merge.git");
585+ $base = $repo->updateTree(null, ['a.txt' => [Tree::FILE, "1\n2\n3\n4\n5\n"], 'dir/b.txt' => [Tree::FILE, "b\n"], 'gone.txt' => [Tree::FILE, "g\n"]]);
586+ $ours = $repo->updateTree($base, ['a.txt' => [Tree::FILE, "1\nO\n3\n4\n5\n"], 'dir/new.txt' => [Tree::FILE, "n\n"], 'gone.txt' => null]);
587+ $theirs = $repo->updateTree($base, ['a.txt' => [Tree::FILE, "1\n2\n3\n4\nT\n"], 'dir/b.txt' => [Tree::EXEC, "b\n"], 'c.txt' => [Tree::FILE, "c\n"]]);
588+ $r = Merge::trees($repo, $base, $ours, $theirs);
589+ $test->assertSame([], $r['conflicts']);
590+ $test->assertSame("1\nO\n3\n4\nT\n", $repo->blob($repo->entry($r['tree'], 'a.txt')[2]));
591+ $test->assertSame(Tree::EXEC, $repo->entry($r['tree'], 'dir/b.txt')[0]);
592+ $test->assertNotNull($repo->entry($r['tree'], 'dir/new.txt'));
593+ $test->assertNotNull($repo->entry($r['tree'], 'c.txt'));
594+ $test->assertNull($repo->entry($r['tree'], 'gone.txt'), '削除');
595+ // コンフリクト:同じ行、削除と変更
596+ $t2 = $repo->updateTree($base, ['a.txt' => [Tree::FILE, "1\nT\n3\n4\n5\n"], 'gone.txt' => [Tree::FILE, "changed\n"]]);
597+ $r = Merge::trees($repo, $base, $ours, $t2);
598+ $test->assertNull($r['tree']);
599+ $test->assertSame(['a.txt', 'gone.txt'], $r['conflicts']);
600+ // cherry-pick
601+ $c0 = $repo->writeCommit(Commit::create($base, [], $me, $me, "0\n"));
602+ $c1 = $repo->writeCommit(Commit::create($theirs, [$c0], $me, $me, "1\n"));
603+ $c2 = $repo->writeCommit(Commit::create($ours, [$c0], $me, $me, "2\n"));
604+ $test->assertSame($r = Merge::trees($repo, $base, $ours, $theirs)['tree'], Merge::pick($repo, $c1, $c2));
553605 });
554606 });
555607
@@ -53,6 +53,7 @@
5353 use Std\Lib\Gitler\Issues;
5454 use Std\Lib\Gitler\Keys;
5555 use Std\Lib\Gitler\Notify;
56+use Std\Lib\Gitler\Pulls;
5657 use Std\Lib\Notifications;
5758 use Std\Lib\Gitler\Languages;
5859 use Roles;
@@ -758,6 +759,162 @@
758759 $test->assertSame('アーカイブされています', $res[1]['status']);
759760 }
760761 $test->assertSame(1, count($db->get(1)['comments']));
762+ });
763+ Notifications::$dataDir = null;
764+});
765+
766+$test->describe('Gitler(プルリクエストとフォーク)', function($test) use ($base, $admin, $owner, $mkUser): void {
767+ Notifications::$dataDir = "{$base}/data";
768+ $forker = $mkUser(7, 'forker01', Roles::MEMBER | Roles::GIT_REPOS | Roles::GIT_PULLS);
769+ $reader = $mkUser(8, 'reader01', Roles::MEMBER);
770+ Gitler::create($admin, 'base1');
771+ $repo = Gitler::open('admin001', 'base1');
772+ $me = new Person('諏訪子', 's@x', 1700000000, '+0900');
773+ $commit = function(Repository $r, string $branch, array $files, string $msg) use ($me): string {
774+ $parent = $r->refs->resolve('refs/heads/'.$branch);
775+ $c = $r->writeCommit(Commit::create($r->updateTree($parent ? $r->commit($parent)->tree : null, $files), $parent ? [$parent] : [], $me, $me, $msg."\n"));
776+ $r->refs->update('refs/heads/'.$branch, $c);
777+ return $c;
778+ };
779+ $c0 = $commit($repo, 'master', ['a.txt' => [Tree::FILE, "1\n2\n3\n4\n5\n"]], '最初');
780+ $issues = new Issues($repo);
781+ $bug = $issues->create(['user' => 'admin001'], 'バグ', '')->data['number'];
782+
783+ $test->it('同じリポジトリのブランチからプルリクエストを作ってマージするはず', function($test) use ($repo, $commit, $admin, $issues, $bug): void {
784+ $repo->refs->update('refs/heads/topic', $repo->resolve('master'));
785+ $head = $commit($repo, 'topic', ['b.txt' => [Tree::FILE, "b\n"]], 'bを追加');
786+ $test->assertFalse(Pulls::open('admin001', 'base1', ['user' => 'admin001'], 'x', '', 'master', 'branch', ['owner' => 'admin001', 'name' => 'base1', 'branch' => 'master'], $repo->resolve('master'))->isSuccess, '差分が無い');
787+ $res = Pulls::open('admin001', 'base1', ['user' => 'admin001'], 'bを追加', "fixes #{$bug}", 'master', 'branch', ['owner' => 'admin001', 'name' => 'base1', 'branch' => 'topic'], $head);
788+ $test->assertTrue($res->isSuccess, (string)$res->message);
789+ $n = $res->data['number'];
790+ $test->assertSame($bug + 1, $n, 'イシューと同じ番号');
791+ $test->assertSame($head, $repo->refs->resolve(Pulls::ref($n)));
792+ $test->assertSame([$n], array_column($issues->search(['pull' => true]), 'number'));
793+ $test->assertSame([$bug], array_column($issues->search(), 'number'), 'イシューの一覧には出ない');
794+ $test->assertSame(['open' => 1, 'closed' => 0], $issues->counts(true));
795+ $test->assertFalse(Pulls::open('admin001', 'base1', ['user' => 'admin001'], 'y', '', 'master', 'branch', ['owner' => 'admin001', 'name' => 'base1', 'branch' => 'topic'], $head)->isSuccess, '同じ物');
796+ // ブランチにプッシュすると更新
797+ $head2 = $commit($repo, 'topic', ['c.txt' => [Tree::FILE, "c\n"]], 'cを追加');
798+ Pulls::afterPush('admin001', 'base1', $admin, [['refs/heads/topic', $head, $head2]]);
799+ $i = $issues->get($n);
800+ $test->assertSame($head2, $i['pull']['headId']);
801+ $test->assertSame(1, end($i['events'])['count']);
802+ $test->assertSame('ff', Pulls::mergeability($repo, $i)['state']);
803+ $old = $repo->resolve('master');
804+ $res = Pulls::merge('admin001', 'base1', $n, $admin, 'merge', '');
805+ $test->assertTrue($res->isSuccess, (string)$res->message);
806+ $m = $repo->commit($repo->resolve('master'));
807+ $test->assertSame([$old, $head2], $m->parents);
808+ $test->assertSame("Merge pull request #{$n}", $m->subject());
809+ $i = $issues->get($n);
810+ $test->assertSame('closed', $i['state']);
811+ $test->assertSame('merge', $i['pull']['merged']['method']);
812+ $test->assertSame('closed', $issues->get($bug)['state'], '本文の「fixes」');
813+ $test->assertSame([$old, $head2], Pulls::range($repo, $i));
814+ $test->assertFalse(Pulls::merge('admin001', 'base1', $n, $admin, 'merge', '')->isSuccess, '2回目');
815+ });
816+
817+ $test->it('コンフリクトを見つけ、スカッシュとリベースでマージするはず', function($test) use ($repo, $commit, $admin, $issues): void {
818+ $mk = function(string $branch, array $files) use ($repo, $commit): string {
819+ $repo->refs->update('refs/heads/'.$branch, $repo->resolve('master'));
820+ return $commit($repo, $branch, $files, $branch);
821+ };
822+ $open = fn(string $branch, string $head) => Pulls::open('admin001', 'base1', ['user' => 'admin001'], $branch, '', 'master', 'branch', ['owner' => 'admin001', 'name' => 'base1', 'branch' => $branch], $head)->data['number'];
823+ $h1 = $mk('one', ['a.txt' => [Tree::FILE, "1\nONE\n3\n4\n5\n"]]);
824+ $h2 = $mk('two', ['a.txt' => [Tree::FILE, "1\nTWO\n3\n4\n5\n"]]);
825+ $h3 = $commit($repo, 'one', ['d.txt' => [Tree::FILE, "d\n"]], 'one-2');
826+ $h4 = $mk('three', ['a.txt' => [Tree::FILE, "1\n2\n3\n4\nTHREE\n"]]);
827+ $n1 = $open('one', $h3);
828+ $n2 = $open('two', $h2);
829+ $n4 = $open('three', $h4);
830+ $base = $repo->resolve('master');
831+ $test->assertTrue(Pulls::merge('admin001', 'base1', $n1, $admin, 'squash', '')->isSuccess);
832+ $sq = $repo->commit($repo->resolve('master'));
833+ $test->assertSame([$base], $sq->parents, 'スカッシュは1つのコミット');
834+ $test->assertSame("one (#{$n1})", $sq->subject());
835+ $test->assertSame('諏訪子', $repo->commit($h3)->author->name);
836+ $test->assertSame('conflict', Pulls::mergeability($repo, $issues->get($n2))['state']);
837+ $test->assertSame(['a.txt'], Pulls::mergeability($repo, $issues->get($n2))['conflicts']);
838+ $test->assertFalse(Pulls::merge('admin001', 'base1', $n2, $admin, 'merge', '')->isSuccess);
839+ $test->assertFalse(Pulls::merge('admin001', 'base1', $n2, $admin, 'rebase', '')->isSuccess);
840+ $test->assertSame('clean', Pulls::mergeability($repo, $issues->get($n4))['state']);
841+ $before = $repo->resolve('master');
842+ $test->assertTrue(Pulls::merge('admin001', 'base1', $n4, $admin, 'rebase', '')->isSuccess);
843+ $rb = $repo->commit($repo->resolve('master'));
844+ $test->assertSame([$before], $rb->parents);
845+ $test->assertSame('three', $rb->subject());
846+ $test->assertSame("1\nONE\n3\n4\nTHREE\n", $repo->blob($repo->resolve('master:a.txt')));
847+ });
848+
849+ $test->it('フォークしてプルリクエストを作り、フォークへのプッシュで更新するはず', function($test) use ($repo, $commit, $forker, $reader, $issues): void {
850+ $test->assertFalse(Gitler::fork($reader, 'admin001', 'base1')->isSuccess, 'ロールが無い');
851+ $res = Gitler::fork($forker, 'admin001', 'base1');
852+ $test->assertTrue($res->isSuccess, (string)$res->message);
853+ $test->assertSame('base1', $res->data['name']);
854+ $test->assertSame('admin001/base1', Gitler::meta('forker01', 'base1')['forkOf']);
855+ $test->assertSame([['forker01', 'base1']], Gitler::forks('admin001', 'base1'));
856+ $test->assertSame('base1', Gitler::fork($forker, 'admin001', 'base1')->data['name'], '2回目は同じフォーク');
857+ $fork = Gitler::open('forker01', 'base1');
858+ $test->assertSame($repo->resolve('master'), $fork->resolve('master'));
859+ $test->assertSame([], $fork->refs->all('refs/pull/'));
860+ $f1 = $commit($fork, 'feature', ['f.txt' => [Tree::FILE, "f\n"]], 'フォークで追加');
861+ $test->assertFalse($repo->objects->exists($f1));
862+ Pulls::fetch($fork, $repo, $f1);
863+ $test->assertTrue($repo->objects->exists($f1));
864+ $res = Pulls::open('admin001', 'base1', ['user' => 'forker01'], 'フォークから', '', 'master', 'fork', ['owner' => 'forker01', 'name' => 'base1', 'branch' => 'feature'], $f1);
865+ $test->assertTrue($res->isSuccess, (string)$res->message);
866+ $n = $res->data['number'];
867+ $f2 = $commit($fork, 'feature', ['f.txt' => [Tree::FILE, "f2\n"]], 'もう1つ');
868+ $msgs = Pulls::afterPush('forker01', 'base1', $forker, [['refs/heads/feature', $f1, $f2]]);
869+ $test->assertSame($f2, $issues->get($n)['pull']['headId']);
870+ $test->assertTrue($repo->objects->exists($f2));
871+ $test->assertSame([], $msgs);
872+ $msgs = Pulls::afterPush('forker01', 'base1', $forker, [['refs/heads/new', $fork->zeroId(), $f2]]);
873+ $test->assertStringContains('/gitler/admin001/base1/compare?head=forker01%2Fbase1%3Anew', $msgs[0] ?? '');
874+ });
875+
876+ $test->it('refs/for/へのプッシュ(AGit)でプルリクエストを作って更新するはず', function($test) use ($repo, $forker, $reader, $issues, $me): void {
877+ $zero = $repo->zeroId();
878+ $test->assertSame('pull requests are not allowed', Pulls::checkAgit('admin001', 'base1', $reader, 'refs/for/master/x', str_repeat('1', 40), $zero));
879+ $test->assertSame('use refs/for/<branch>/<topic>', Pulls::checkAgit('admin001', 'base1', $forker, 'refs/for/nope/x', str_repeat('1', 40), $zero));
880+ $test->assertNull(Pulls::checkAgit('admin001', 'base1', $forker, 'refs/for/master/fix/typo', str_repeat('1', 40), $zero));
881+ $test->assertSame(['master', 'fix/typo'], Pulls::parseAgit($repo, 'refs/for/master/fix/typo'));
882+ $master = $repo->resolve('master');
883+ $a1 = $repo->writeCommit(Commit::create($repo->updateTree($repo->commit($master)->tree, ['typo.txt' => [Tree::FILE, "x\n"]]), [$master], $me, $me, "誤字を直す\n\n説明\n"));
884+ $msgs = Pulls::afterPush('admin001', 'base1', $forker, [['refs/for/master/fix/typo', $zero, $a1]]);
885+ $test->assertStringContains('を作りました', $msgs[0]);
886+ $n = (int)preg_replace('/^.*#(\d+).*$/u', '$1', $msgs[0]);
887+ $i = $issues->get($n);
888+ $test->assertSame('誤字を直す', $i['title']);
889+ $test->assertSame('説明', $i['body']);
890+ $test->assertSame(['user' => 'forker01', 'topic' => 'fix/typo'], $i['pull']['head']);
891+ $a2 = $repo->writeCommit(Commit::create($repo->commit($a1)->tree, [$a1], $me, $me, "2\n"));
892+ $msgs = Pulls::afterPush('admin001', 'base1', $forker, [['refs/for/master/fix/typo', $zero, $a2]]);
893+ $test->assertStringContains("#{$n}を更新しました", $msgs[0]);
894+ $test->assertSame($a2, $repo->refs->resolve(Pulls::ref($n)));
895+ // マージ先に入ったらマージ済み
896+ $old = $repo->resolve('master');
897+ $repo->refs->update('refs/heads/master', $a2);
898+ Pulls::afterPush('admin001', 'base1', null, [['refs/heads/master', $old, $a2]]);
899+ $test->assertSame('manual', $issues->get($n)['pull']['merged']['method']);
900+ });
901+
902+ $test->it('行へのコメントの下書きをレビューに入れるはず', function($test) use ($issues): void {
903+ $n = $issues->search(['pull' => true, 'state' => 'all'])[0]['number'];
904+ $at = ['path' => 'a.txt', 'side' => 'new', 'line' => 2, 'commit' => 'x', 'hunk' => [' 1', '+2'], 'pending' => true];
905+ $c = $issues->inlineComment($n, ['user' => 'reader01'], 'ここ', $at)->data['comment'];
906+ $test->assertTrue($c['pending']);
907+ $before = $issues->index()['issues'][$n]['comments'];
908+ $test->assertFalse($issues->submitReview($n, ['user' => 'other001'], 'commented', '', 'x')->isSuccess, '空のレビュー');
909+ $r = $issues->submitReview($n, ['user' => 'reader01'], 'approved', 'いいね', 'x');
910+ $test->assertTrue($r->isSuccess);
911+ $i = $issues->get($n);
912+ $test->assertSame(1, $i['reviews'][0]['id']);
913+ $test->assertFalse(end($i['comments'])['pending']);
914+ $test->assertSame(1, end($i['comments'])['review']);
915+ $test->assertSame($before + 1, $issues->index()['issues'][$n]['comments']);
916+ $reply = $issues->inlineComment($n, ['user' => 'admin001'], '直します', ['replyTo' => $c['id']])->data['comment'];
917+ $test->assertSame(['a.txt', 2, $c['id']], [$reply['path'], $reply['line'], $reply['replyTo']]);
761918 });
762919 Notifications::$dataDir = null;
763920 });
@@ -0,0 +1,59 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+ <h2 class="paragraph">プルリクエストを作る</h2>
4+ <form method="get" action="{{ $repoUrl }}/compare" class="gl-row gl-filter">
5+ <label>マージ先:
6+ <select name="base">
7+{@ foreach ($branches as $b) @}
8+ <option value="{{ $b }}"{{{ $b === $base ? ' selected="selected"' : '' }}}>{{ $b }}</option>
9+{@ endforeach @}
10+ </select>
11+ </label>
12+ ←
13+ <label>変更:
14+ <select name="head">
15+ <option value="">選んで下さい</option>
16+{@ foreach ($heads as $g) @}
17+ <optgroup label="{{ $g['label'] }}">
18+{@ foreach ($g['items'] as $h) @}
19+ <option value="{{ $h }}"{{{ $h === $head ? ' selected="selected"' : '' }}}>{{ $h }}</option>
20+{@ endforeach @}
21+ </optgroup>
22+{@ endforeach @}
23+ </select>
24+ </label>
25+ <button>比べる</button>
26+ </form>
27+{@ if ($note !== '') @}
28+ <p class="paragraph">{{ $note }}</p>
29+{@ endif @}
30+{@ if ($head === '') @}
31+ <p class="gl-muted"><small>フォークのブランチからも作れます。フォークしないで作るには:<code>git push origin HEAD:refs/for/{{ $base }}/トピック</code></small></p>
32+{@ endif @}
33+{@ if ($ready) @}
34+{@ if ($canPull) @}
35+ <form method="post" action="{{ $repoUrl }}/compare" class="gl-form gl-issueform">
36+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
37+ <input type="hidden" name="base" value="{{ $base }}" />
38+ <input type="hidden" name="head" value="{{ $head }}" />
39+ <input type="text" name="title" value="{{ $in['title'] }}" maxlength="200" placeholder="題名" required="required" />
40+ <textarea name="body" rows="8" placeholder="説明(Markdown。「fixes #3」でマージした時にイシューを閉じます)">{{ $in['body'] }}</textarea>
41+ <button>プルリクエストを作る</button>
42+ </form>
43+{@ elif ($user) @}
44+ <p class="paragraph">プルリクエストを作るには「Gitのプルリクエスト」のロールが必要です。</p>
45+{@ endif @}
46+ <h3 class="paragraph">コミット({{ count($commits) }})</h3>
47+ <ul class="gl-commits">
48+{@ foreach ($commits as $c) @}
49+ <li>
50+ <a href="{{ $c['url'] }}">{{ $c['subject'] }}</a><br />
51+ <small>{{ $c['author'] }}、{{ $c['date'] }}</small>
52+ <span class="gl-commit-links"><a href="{{ $c['url'] }}"><code>{{ $c['short'] }}</code></a></span>
53+ </li>
54+{@ endforeach @}
55+ </ul>
56+ <p>{{ $stats['files'] }}個のファイル、<span class="gl-add">+{{ $stats['add'] }}</span> <span class="gl-del">-{{ $stats['del'] }}</span></p>
57+{@ include(gitler/diffs) @}
58+{@ endif @}
59+{@ include(common/footer) @}
@@ -0,0 +1,51 @@
1+{@ foreach ($files as $f) @}
2+ <div class="gl-diff">
3+ <div class="gl-diff-head">
4+ <span class="gl-st gl-st-{{ $f['status'] }}">{{ $f['statusText'] }}</span>
5+ {{ $f['oldPath'] !== '' ? $f['oldPath'].' → ' : '' }}{@ if ($f['url'] !== '') @}<a href="{{ $f['url'] }}">{{ $f['path'] }}</a>{@ else @}{{ $f['path'] }}{@ endif @}
6+ </div>
7+{@ if ($f['note'] !== '') @}
8+ <p class="gl-note">{{ $f['note'] }}</p>
9+{@ endif @}
10+{@ if (!empty($f['hunks'])) @}
11+ <div class="gl-code">
12+ <table class="gl-lines">
13+ <tbody>
14+{@ foreach ($f['hunks'] as $h) @}
15+ <tr class="gl-hunk"><td class="gl-ln"></td><td class="gl-ln"></td><td class="gl-src">{{ $h['head'] }}</td></tr>
16+{@ foreach ($h['rows'] as $r) @}
17+ <tr class="{{ $r['op'] === '+' ? 'gl-line-add' : ($r['op'] === '-' ? 'gl-line-del' : '') }}"{{{ isset($r['anchor']) ? ' id="'.$r['anchor'].'"' : '' }}}>
18+ <td class="gl-ln">{@ if (!empty($r['link']) && $r['op'] === '-') @}<a href="{{ $r['link'] }}" title="この行にコメント">{{ $r['old'] }}</a>{@ else @}{{ $r['old'] }}{@ endif @}</td>
19+ <td class="gl-ln">{@ if (!empty($r['link']) && $r['op'] !== '-') @}<a href="{{ $r['link'] }}" title="この行にコメント">{{ $r['new'] }}</a>{@ else @}{{ $r['new'] }}{@ endif @}</td>
20+ <td class="gl-src">{{ $r['op'] }}{{ $r['text'] }}</td>
21+ </tr>
22+{@ if (!empty($r['threads']) || !empty($r['form'])) @}
23+ <tr class="gl-threadrow">
24+ <td colspan="3">
25+{@ foreach ($r['threads'] as $ic) @}
26+{@ include(gitler/inline) @}
27+{@ endforeach @}
28+{@ if (!empty($r['form'])) @}
29+ <form method="post" action="{{ $filesUrl }}" class="gl-form gl-inlinenew">
30+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
31+ <input type="hidden" name="path" value="{{ $r['form']['path'] }}" />
32+ <input type="hidden" name="side" value="{{ $r['form']['side'] }}" />
33+ <input type="hidden" name="line" value="{{ $r['form']['line'] }}" />
34+ <textarea name="body" rows="4" placeholder="{{ $r['form']['line'] }}行目へのコメント(Markdown)" required="required"></textarea>
35+ <div class="gl-row">
36+ <button name="do" value="inline">コメントする</button>
37+ <button name="do" value="inlinePending">レビューに追加(下書き)</button>
38+ </div>
39+ </form>
40+{@ endif @}
41+ </td>
42+ </tr>
43+{@ endif @}
44+{@ endforeach @}
45+{@ endforeach @}
46+ </tbody>
47+ </table>
48+ </div>
49+{@ endif @}
50+ </div>
51+{@ endforeach @}
@@ -0,0 +1,12 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+ <h2 class="paragraph">フォーク</h2>
4+{@ if (empty($list)) @}
5+ <p class="paragraph">フォークは有りません。</p>
6+{@ endif @}
7+ <ul class="gl-repos">
8+{@ foreach ($list as $r) @}
9+ <li><a href="{{ $r['url'] }}"><b>{{ $r['owner'] }}/{{ $r['name'] }}</b></a></li>
10+{@ endforeach @}
11+ </ul>
12+{@ include(common/footer) @}
@@ -0,0 +1,56 @@
1+ <div class="gl-inline" id="c{{ $ic['id'] }}">
2+{@ if (!isset($filesUrl)) @}
3+ <div class="gl-inline-path"><code>{{ $ic['path'] }}</code> {{ $ic['side'] === 'old' ? '元の' : '' }}{{ $ic['line'] }}行目{{ !empty($ic['outdated']) ? '(古いコミット)' : '' }}</div>
4+{@ if (!empty($ic['hunk'])) @}
5+ <pre class="gl-inline-hunk">{{ implode("\n", $ic['hunk']) }}</pre>
6+{@ endif @}
7+{@ endif @}
8+ <div class="gl-inline-c">
9+ <div class="gl-comment-head">
10+{@ if ($ic['who']['icon'] !== '') @}
11+ <img src="{{ $ic['who']['icon'] }}" alt="" class="gl-av" />
12+{@ endif @}
13+ <a href="{{ $ic['who']['url'] }}"><b>{{ $ic['who']['name'] }}</b></a>
14+ <small><a href="#c{{ $ic['id'] }}">{{ $ic['date'] }}</a>{{ $ic['pending'] ? '(下書き)' : '' }}</small>
15+{@ if ($ic['canDelete']) @}
16+ <form method="post" action="{{ isset($filesUrl) ? $filesUrl : $issueUrl }}" class="gl-inlineform">
17+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
18+ <input type="hidden" name="id" value="{{ $ic['id'] }}" />
19+ <button name="do" value="deleteComment" class="danger">削除</button>
20+ </form>
21+{@ endif @}
22+ </div>
23+ <div class="gl-comment-body">{{{ $ic['html'] }}}</div>
24+ </div>
25+{@ foreach ($ic['replies'] as $rc) @}
26+ <div class="gl-inline-c gl-inline-reply" id="c{{ $rc['id'] }}">
27+ <div class="gl-comment-head">
28+{@ if ($rc['who']['icon'] !== '') @}
29+ <img src="{{ $rc['who']['icon'] }}" alt="" class="gl-av" />
30+{@ endif @}
31+ <a href="{{ $rc['who']['url'] }}"><b>{{ $rc['who']['name'] }}</b></a>
32+ <small><a href="#c{{ $rc['id'] }}">{{ $rc['date'] }}</a></small>
33+{@ if ($rc['canDelete']) @}
34+ <form method="post" action="{{ isset($filesUrl) ? $filesUrl : $issueUrl }}" class="gl-inlineform">
35+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
36+ <input type="hidden" name="id" value="{{ $rc['id'] }}" />
37+ <button name="do" value="deleteComment" class="danger">削除</button>
38+ </form>
39+{@ endif @}
40+ </div>
41+ <div class="gl-comment-body">{{{ $rc['html'] }}}</div>
42+ </div>
43+{@ endforeach @}
44+{@ if ($canReview && !$ic['pending']) @}
45+ <details class="gl-edit gl-inline-replyform">
46+ <summary>返信</summary>
47+ <form method="post" action="{{ isset($filesUrl) ? $filesUrl : $issueUrl }}" class="gl-form">
48+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
49+ <input type="hidden" name="id" value="{{ $ic['id'] }}" />
50+ <input type="hidden" name="back" value="{{ isset($filesUrl) ? 'files' : '' }}" />
51+ <textarea name="body" rows="3" required="required"></textarea>
52+ <button name="do" value="reply">返信</button>
53+ </form>
54+ </details>
55+{@ endif @}
56+ </div>
@@ -20,152 +20,5 @@
2020 </details>
2121 {@ endif @}
2222 </div>
23- <div class="gl-issue">
24- <div class="gl-thread">
25- <div class="gl-comment" id="c0">
26- <div class="gl-comment-head">
27-{@ if ($who['icon'] !== '') @}
28- <img src="{{ $who['icon'] }}" alt="" class="gl-av" />
29-{@ endif @}
30- <a href="{{ $who['url'] }}"><b>{{ $who['name'] }}</b></a>{{{ $who['remote'] ? ' <span class="gl-badge">Fediverse</span>' : '' }}}
31- <small>{{ $date }}{{ !empty($issue['edited']) ? '(編集済み)' : '' }}</small>
32- </div>
33- <div class="gl-comment-body">{{{ $bodyHtml }}}</div>
34- </div>
35-{@ foreach ($timeline as $t) @}
36-{@ if ($t['kind'] === 'event') @}
37- <div class="gl-event">{{{ $t['html'] }}} <small>{{ $t['date'] }}</small></div>
38-{@ else @}
39- <div class="gl-comment" id="c{{ $t['id'] }}">
40- <div class="gl-comment-head">
41-{@ if ($t['who']['icon'] !== '') @}
42- <img src="{{ $t['who']['icon'] }}" alt="" class="gl-av" />
43-{@ endif @}
44- <a href="{{ $t['who']['url'] }}"><b>{{ $t['who']['name'] }}</b></a>
45-{@ if ($t['who']['remote']) @}
46- <span class="gl-muted">{{ $t['who']['handle'] }}</span> <span class="gl-badge">Fediverse</span>
47-{@ endif @}
48- <small><a href="#c{{ $t['id'] }}">{{ $t['date'] }}</a>{{ $t['edited'] ? '(編集済み)' : '' }}</small>
49-{@ if ($t['remote'] !== '') @}
50- <small><a href="{{ $t['remote'] }}" rel="nofollow noopener noreferrer">元の投稿</a></small>
51-{@ endif @}
52- </div>
53- <div class="gl-comment-body">{{{ $t['html'] }}}</div>
54-{@ if ($t['canEdit'] || $t['canDelete'] || ($canAdmin && $t['actor'] !== '')) @}
55- <div class="gl-row gl-comment-tools">
56-{@ if ($t['canEdit']) @}
57- <details class="gl-edit">
58- <summary>編集</summary>
59- <form method="post" action="{{ $issueUrl }}" class="gl-form">
60- <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
61- <input type="hidden" name="id" value="{{ $t['id'] }}" />
62- <textarea name="body" rows="6">{{ $t['body'] }}</textarea>
63- <button name="do" value="editComment">保存</button>
64- </form>
65- </details>
66-{@ endif @}
67-{@ if ($t['canDelete']) @}
68- <form method="post" action="{{ $issueUrl }}">
69- <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
70- <input type="hidden" name="id" value="{{ $t['id'] }}" />
71- <button name="do" value="deleteComment" class="danger">削除</button>
72- </form>
73-{@ endif @}
74-{@ if ($canAdmin && $t['actor'] !== '') @}
75- <form method="post" action="{{ $issueUrl }}">
76- <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
77- <input type="hidden" name="actor" value="{{ $t['actor'] }}" />
78- <button name="do" value="block" class="danger">この人をブロック</button>
79- </form>
80-{@ endif @}
81- </div>
82-{@ endif @}
83- </div>
84-{@ endif @}
85-{@ endforeach @}
86-{@ if ($canIssue) @}
87- <form method="post" action="{{ $issueUrl }}" class="gl-form gl-issueform">
88- <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
89- <textarea name="body" rows="6" placeholder="コメント(Markdown)"></textarea>
90- <div class="gl-row">
91- <button name="do" value="comment">コメント</button>
92-{@ if ($canClose) @}
93-{@ if ($issue['state'] === 'open') @}
94- <button name="do" value="commentClose">閉じる</button>
95-{@ else @}
96- <button name="do" value="commentReopen">開き直す</button>
97-{@ endif @}
98-{@ endif @}
99- </div>
100- <small>Markdownが使えます。「#3」で他のイシューにリンクします。コメントを書いて閉じると、コメントしてから閉じます。</small>
101- </form>
102-{@ elif (!$user) @}
103- <p class="paragraph"><a href="/login">ログイン</a>するとコメント出来ます。</p>
104-{@ endif @}
105-{@ if ($fedi) @}
106- <p class="gl-muted gl-fedi">Fediverseから:Mastodon等で<code>{{ $fediUrl }}</code>を検索して返信するとコメントになります。<code>{{ $fediHandle }}</code>をフォローすると新しいイシューが届きます。</p>
107-{@ endif @}
108- </div>
109- <aside class="gl-side">
110- <h3>ラベル</h3>
111- <p>
112-{@ foreach ($issueLabels as $l) @}
113- <a href="{{ $repoUrl }}/issues?label={{ $l['id'] }}" class="gl-label" style="background-color: {{ $l['color'] }}; color: {{ $l['fg'] }};" title="{{ $l['description'] }}">{{ $l['name'] }}</a>
114-{@ endforeach @}
115-{@ if (empty($issueLabels)) @}
116- <span class="gl-muted">無し</span>
117-{@ endif @}
118- </p>
119- <h3>担当者</h3>
120- <p>{{ !empty($issue['assignees']) ? implode('、', $issue['assignees']) : '無し' }}</p>
121- <h3>マイルストーン</h3>
122- <p>{{ $milestone !== '' ? $milestone : '無し' }}</p>
123-{@ if ($user) @}
124- <h3>通知</h3>
125- <form method="post" action="{{ $issueUrl }}">
126- <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
127- <p><small>{{ $subscribeNote }}</small></p>
128- <button name="do" value="{{ $subscribed ? 'unsubscribe' : 'subscribe' }}">{{ $subscribed ? '通知を止める' : '通知を受け取る' }}</button>
129- </form>
130-{@ endif @}
131-{@ if ($canTriage) @}
132- <details class="gl-edit">
133- <summary>変える</summary>
134- <form method="post" action="{{ $issueUrl }}" class="gl-form">
135- <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
136-{@ if (!empty($labels)) @}
137- <fieldset class="gl-checks">
138- <legend>ラベル</legend>
139-{@ foreach ($labels as $l) @}
140- <label><input type="checkbox" name="labels[]" value="{{ $l['id'] }}"{{{ $l['checked'] ? ' checked="checked"' : '' }}} /> <span class="gl-label" style="background-color: {{ $l['color'] }}; color: {{ $l['fg'] }};">{{ $l['name'] }}</span></label>
141-{@ endforeach @}
142- </fieldset>
143-{@ endif @}
144- <fieldset class="gl-checks">
145- <legend>担当者</legend>
146-{@ foreach ($people as $p) @}
147- <label><input type="checkbox" name="assignees[]" value="{{ $p['name'] }}"{{{ $p['checked'] ? ' checked="checked"' : '' }}} /> {{ $p['name'] }}</label>
148-{@ endforeach @}
149- </fieldset>
150- <label>マイルストーン:
151- <select name="milestone">
152- <option value="0">無し</option>
153-{@ foreach ($milestones as $id => $m) @}
154- <option value="{{ $id }}"{{{ $id === $issue['milestone'] ? ' selected="selected"' : '' }}}>{{ $m['title'] }}{{ $m['state'] === 'closed' ? '(終了)' : '' }}</option>
155-{@ endforeach @}
156- </select>
157- </label>
158- <button name="do" value="triage">保存</button>
159- </form>
160- </details>
161-{@ endif @}
162-{@ if ($canAdmin) @}
163- <form method="post" action="{{ $issueUrl }}" class="gl-delete">
164- <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
165- <label><input type="checkbox" name="confirm" value="1" required="required" /> 元に戻せません</label>
166- <button name="do" value="delete" class="danger">イシューを削除</button>
167- </form>
168-{@ endif @}
169- </aside>
170- </div>
23+{@ include(gitler/thread) @}
17124 {@ include(common/footer) @}
@@ -9,11 +9,11 @@
99 <a href="{{ $repoUrl }}/labels">ラベル</a>
1010 <a href="{{ $repoUrl }}/milestones">マイルストーン</a>
1111 {@ if ($canIssue) @}
12- <a href="{{ $repoUrl }}/issues/new" class="gl-button">+新しいイシュー</a>
12+ <a href="{{ $newUrl }}" class="gl-button">+{{ $isPull ? '新しいプルリクエスト' : '新しいイシュー' }}</a>
1313 {@ endif @}
1414 </span>
1515 </div>
16- <form method="get" action="{{ $repoUrl }}/issues" class="gl-row gl-filter">
16+ <form method="get" action="{{ $listUrl }}" class="gl-row gl-filter">
1717 <input type="hidden" name="state" value="{{ $f['state'] }}" />
1818 <input type="text" name="q" value="{{ $f['q'] }}" placeholder="題名・番号" />
1919 <select name="label">
@@ -42,23 +42,26 @@
4242 <button>絞り込む</button>
4343 </form>
4444 {@ if (empty($list)) @}
45- <p class="paragraph">イシューは有りません。</p>
45+ <p class="paragraph">{{ $isPull ? 'プルリクエスト' : 'イシュー' }}は有りません。</p>
4646 {@ endif @}
4747 <ul class="gl-issues">
4848 {@ foreach ($list as $i) @}
4949 <li>
50- <span class="gl-state-{{ $i['state'] }}">{{ $i['state'] === 'open' ? '●' : '✓' }}</span>
50+ <span class="gl-state-{{ $i['state'] }}">{{ $i['state'] === 'open' ? '●' : ($i['state'] === 'merged' ? '⛙' : '✓') }}</span>
5151 <a href="{{ $i['url'] }}"><b>{{ $i['title'] }}</b></a>
5252 {@ foreach ($i['labels'] as $l) @}
53- <a href="{{ $repoUrl }}/issues?label={{ $l['id'] }}" class="gl-label" style="background-color: {{ $l['color'] }}; color: {{ $l['fg'] }};" title="{{ $l['description'] }}">{{ $l['name'] }}</a>
53+ <a href="{{ $listUrl }}?label={{ $l['id'] }}" class="gl-label" style="background-color: {{ $l['color'] }}; color: {{ $l['fg'] }};" title="{{ $l['description'] }}">{{ $l['name'] }}</a>
5454 {@ endforeach @}
5555 {@ if ($i['comments'] > 0) @}
5656 <span class="gl-count">💬 {{ $i['comments'] }}</span>
5757 {@ endif @}
58- <small>#{{ $i['number'] }} {{ $i['author'] }}が{{ $i['date'] }}に作成{{ $i['closedDate'] !== '' ? '、'.$i['closedDate'].'に閉じた' : '' }}{{ $i['milestone'] !== '' ? ' 🏁 '.$i['milestone'] : '' }}{{ !empty($i['assignees']) ? ' 担当:'.implode('、', $i['assignees']) : '' }}</small>
58+ <small>#{{ $i['number'] }} {{ $i['author'] }}が{{ $i['date'] }}に作成{{ $i['closedDate'] !== '' ? '、'.$i['closedDate'].($i['state'] === 'merged' ? 'にマージ' : 'に閉じた') : '' }}{{ $i['milestone'] !== '' ? ' 🏁 '.$i['milestone'] : '' }}{{ !empty($i['assignees']) ? ' 担当:'.implode('、', $i['assignees']) : '' }}</small>
5959 </li>
6060 {@ endforeach @}
6161 </ul>
62+{@ if ($agitHelp !== '') @}
63+ <p class="gl-muted gl-fedi">フォークしないでプルリクエストを作る(AGit):<code>{{ $agitHelp }}</code>。同じトピックにもう一度プッシュすると更新します。</p>
64+{@ endif @}
6265 <p class="gl-pager">
6366 {@ if ($prevUrl !== '') @}
6467 <a href="{{ $prevUrl }}">← 前</a>
@@ -0,0 +1,51 @@
1+ <div class="gl-mergebox gl-merge-{{ $merged ? 'merged' : $merge['state'] }}" id="merge">
2+{@ if ($merged) @}
3+ <p><b>⛙ マージしました</b>:<a href="{{ $mergedBy['url'] }}">{{ $mergedBy['name'] }}</a>が「{{ $pull['base'] }}」にマージしました(<a href="{{ $repoUrl }}/commit/{{ $pull['merged']['commit'] }}"><code>{{ substr($pull['merged']['commit'], 0, 7) }}</code></a>)。</p>
4+{@ elif ($merge['state'] === 'closed') @}
5+ <p>閉じたプルリクエストです。</p>
6+{@ elif ($merge['state'] === 'nobase') @}
7+ <p>マージ先のブランチ「{{ $pull['base'] }}」が有りません。</p>
8+{@ elif ($merge['state'] === 'uptodate') @}
9+ <p>変更は既に「{{ $pull['base'] }}」に入っています。</p>
10+{@ elif ($merge['state'] === 'conflict') @}
11+ <p><b>コンフリクトが有ります</b>:{{ implode('、', array_slice($merge['conflicts'], 0, 20)) }}{{ count($merge['conflicts']) > 20 ? '…' : '' }}</p>
12+ <p><small>ローカルで「{{ $pull['base'] }}」をマージして解決し、プッシュして下さい。</small></p>
13+{@ elif ($merge['state'] === 'error') @}
14+ <p>マージ出来るか確かめられません。</p>
15+{@ else @}
16+ <p><b>✓ マージ出来ます</b>{{ $merge['state'] === 'ff' ? '(fast-forward出来ます)' : '(コンフリクトは有りません)' }}</p>
17+{@ endif @}
18+{@ if (!empty($approvals)) @}
19+ <p>
20+{@ foreach ($approvals as $a) @}
21+ <span class="gl-approval gl-approval-{{ $a['state'] }}">{{ $a['state'] === 'approved' ? '✓' : '✗' }} {{ $a['who']['name'] }}が{{ $a['state'] === 'approved' ? '承認' : '変更を依頼' }}</span>
22+{@ endforeach @}
23+ </p>
24+{@ endif @}
25+{@ if ($canMerge && in_array($merge['state'], ['ff', 'clean'], true)) @}
26+ <form method="post" action="{{ $pullUrl }}" class="gl-form">
27+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
28+ <label>方法:
29+ <select name="method">
30+ <option value="merge">マージコミットを作る</option>
31+ <option value="squash">スカッシュ(1つのコミットにする)</option>
32+ <option value="rebase">{{ $merge['state'] === 'ff' ? 'fast-forward(コミットをそのまま)' : 'リベース(コミットを上に並べる)' }}</option>
33+ </select>
34+ </label>
35+ <textarea name="message" rows="3" placeholder="コミットのメッセージ(空なら「{{ strtok($mergeMessage, "\n") }}」又は「{{ $squashMessage }}」)"></textarea>
36+ <button name="do" value="merge">マージする</button>
37+ </form>
38+{@ endif @}
39+{@ if ($canDeleteBranch) @}
40+ <form method="post" action="{{ $pullUrl }}" class="gl-inlineform">
41+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
42+ <button name="do" value="deleteBranch" class="danger">ブランチ「{{ $pull['head']['branch'] }}」を削除</button>
43+ </form>
44+{@ endif @}
45+{@ if (!$merged) @}
46+ <details class="gl-edit">
47+ <summary>ローカルで確かめる</summary>
48+ <pre class="gl-pre">{{ $fetchCommand }}{{ "\n" }}git switch pr-{{ $issue['number'] }}</pre>
49+ </details>
50+{@ endif @}
51+ </div>
@@ -0,0 +1,5 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ include(gitler/pullhead) @}
4+{@ include(gitler/thread) @}
5+{@ include(common/footer) @}
@@ -0,0 +1,16 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ include(gitler/pullhead) @}
4+{@ if (empty($list)) @}
5+ <p class="paragraph">コミットが有りません。</p>
6+{@ endif @}
7+ <ul class="gl-commits">
8+{@ foreach ($list as $c) @}
9+ <li>
10+ <a href="{{ $c['url'] }}">{{ $c['subject'] }}</a><br />
11+ <small>{{ $c['author'] }}、{{ $c['date'] }}</small>
12+ <span class="gl-commit-links"><a href="{{ $c['url'] }}"><code>{{ $c['short'] }}</code></a> <a href="{{ $c['treeUrl'] }}">ファイル</a></span>
13+ </li>
14+{@ endforeach @}
15+ </ul>
16+{@ include(common/footer) @}
@@ -0,0 +1,24 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ include(gitler/pullhead) @}
4+{@ if ($stats !== null) @}
5+ <p>{{ $stats['files'] }}個のファイル、<span class="gl-add">+{{ $stats['add'] }}</span> <span class="gl-del">-{{ $stats['del'] }}</span>{{ $stats['truncated'] ? '(多過ぎるので途中まで)' : '' }}</p>
6+{@ endif @}
7+{@ if ($canReview) @}
8+ <details class="gl-edit gl-reviewform"{{{ $pending > 0 ? ' open="open"' : '' }}}>
9+ <summary>レビューを出す{{ $pending > 0 ? '(下書きのコメント'.$pending.'件)' : '' }}</summary>
10+ <form method="post" action="{{ $filesUrl }}" class="gl-form">
11+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
12+ <textarea name="body" rows="4" placeholder="まとめ(Markdown)"></textarea>
13+ <label><input type="radio" name="state" value="commented" checked="checked" /> コメント</label>
14+{@ if ($canApprove) @}
15+ <label><input type="radio" name="state" value="approved" /> 承認</label>
16+ <label><input type="radio" name="state" value="changes" /> 変更を依頼</label>
17+{@ endif @}
18+ <button name="do" value="review">レビューを出す</button>
19+ </form>
20+ </details>
21+ <p class="gl-muted"><small>行番号を押すとその行にコメント出来ます。</small></p>
22+{@ endif @}
23+{@ include(gitler/diffs) @}
24+{@ include(common/footer) @}
@@ -0,0 +1,25 @@
1+ <div class="gl-issuehead">
2+ <h2>{{ $issue['title'] }} <span class="gl-muted">#{{ $issue['number'] }}</span></h2>
3+ <p>
4+ <span class="gl-statebadge gl-badge-{{ $merged ? 'merged' : $issue['state'] }}">{{ $merged ? '⛙ マージ済み' : ($issue['state'] === 'open' ? '● 開いている' : '✓ 閉じた') }}</span>
5+ <a href="{{ $who['url'] }}">{{ $who['name'] }}</a>が<code>{@ if ($headUrl !== '') @}<a href="{{ $headUrl }}">{{ $headLabel }}</a>{@ else @}{{ $headLabel }}{@ endif @}</code>から<code>{{ $pull['base'] }}</code>に{{ $commitCount }}個のコミットを{{ $merged ? 'マージしました' : 'マージしたい' }}
6+ </p>
7+{@ if (!empty($canEdit)) @}
8+ <details class="gl-edit">
9+ <summary>編集</summary>
10+ <form method="post" action="{{ $pullUrl }}" class="gl-form gl-issueform">
11+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
12+ <input type="text" name="title" value="{{ $issue['title'] }}" maxlength="200" required="required" />
13+{@ if (empty($issue['html'])) @}
14+ <textarea name="body" rows="12">{{ $issue['body'] }}</textarea>
15+{@ endif @}
16+ <button name="do" value="edit">保存</button>
17+ </form>
18+ </details>
19+{@ endif @}
20+ <nav class="gl-tabs gl-subtabs">
21+ <a href="{{ $pullUrl }}" class="{{ $tab === 'conversation' ? 'active' : '' }}">会話({{ count(array_filter($issue['comments'], fn($c) => empty($c['pending']))) }})</a>
22+ <a href="{{ $commitsTabUrl }}" class="{{ $tab === 'commits' ? 'active' : '' }}">コミット({{ $commitCount }})</a>
23+ <a href="{{ $filesTabUrl }}" class="{{ $tab === 'files' ? 'active' : '' }}">変更されたファイル</a>
24+ </nav>
25+ </div>
@@ -19,10 +19,30 @@
1919 {@ if ($meta['description'] !== '') @}
2020 <p class="gl-desc">{{ $meta['description'] }}</p>
2121 {@ endif @}
22+ <p class="gl-forkinfo">
23+{@ if ($forkOf !== '') @}
24+ フォーク元:<a href="/gitler/{{ $forkOf }}">{{ $forkOf }}</a>
25+{@ if ($parentCompare !== '') @}
26+ ・<a href="{{ $parentCompare }}">フォーク元にプルリクエストを作る</a>
27+{@ endif @}
28+{@ endif @}
29+{@ if ($forkCount > 0) @}
30+ <a href="{{ $repoUrl }}/forks">フォーク{{ $forkCount }}個</a>
31+{@ endif @}
32+{@ if ($canFork) @}
33+ <form method="post" action="{{ $repoUrl }}/fork" class="gl-inlineform">
34+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
35+ <button>フォークする</button>
36+ </form>
37+{@ endif @}
38+ </p>
2239 <nav class="gl-tabs">
2340 <a href="{{ $treeRoot }}" class="{{ in_array($gitView, ['gitler/tree', 'gitler/blob', 'gitler/blame', 'gitler/empty'], true) ? 'active' : '' }}">ファイル</a>
2441 {@ if ($issuesOn) @}
25- <a href="{{ $repoUrl }}/issues" class="{{ in_array($gitView, ['gitler/issues', 'gitler/issue', 'gitler/issuenew', 'gitler/labels', 'gitler/milestones'], true) ? 'active' : '' }}">イシュー({{ $openIssues }})</a>
42+ <a href="{{ $repoUrl }}/issues" class="{{ in_array($gitView, ['gitler/issues', 'gitler/issue', 'gitler/issuenew', 'gitler/labels', 'gitler/milestones'], true) && empty($isPull) ? 'active' : '' }}">イシュー({{ $openIssues }})</a>
43+{@ endif @}
44+{@ if ($pullsOn) @}
45+ <a href="{{ $repoUrl }}/pulls" class="{{ ($gitView === 'gitler/issues' && !empty($isPull)) || in_array($gitView, ['gitler/pull', 'gitler/pullcommits', 'gitler/pullfiles', 'gitler/compare'], true) ? 'active' : '' }}">プルリクエスト({{ $openPulls }})</a>
2646 {@ endif @}
2747 {@ if ($commitsUrl !== '') @}
2848 <a href="{{ $commitsUrl }}" class="{{ in_array($gitView, ['gitler/commits', 'gitler/commit'], true) ? 'active' : '' }}">コミット</a>
@@ -43,10 +43,11 @@
4343 <button name="do" value="addCollaborator">追加</button>
4444 </form>
4545
46- <h2 class="paragraph">イシューとFediverse</h2>
46+ <h2 class="paragraph">イシュー・プルリクエストとFediverse</h2>
4747 <form method="post" action="{{ $repoUrl }}/settings" class="gl-form">
4848 <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
4949 <label><input type="checkbox" name="issues" value="1"{{{ $meta['issues'] ? ' checked="checked"' : '' }}} /> イシューを使う</label>
50+ <label><input type="checkbox" name="pulls" value="1"{{{ $meta['pulls'] ? ' checked="checked"' : '' }}} /> プルリクエストを使う</label>
5051 {@ if ($fediAvailable) @}
5152 <label><input type="checkbox" name="federate" value="1"{{{ $meta['federate'] ? ' checked="checked"' : '' }}} /> 公開のリポジトリならFediverseに出す({{ $fediHandle }})</label>
5253 {@ endif @}
@@ -0,0 +1,170 @@
1+ <div class="gl-issue">
2+ <div class="gl-thread">
3+ <div class="gl-comment" id="c0">
4+ <div class="gl-comment-head">
5+{@ if ($who['icon'] !== '') @}
6+ <img src="{{ $who['icon'] }}" alt="" class="gl-av" />
7+{@ endif @}
8+ <a href="{{ $who['url'] }}"><b>{{ $who['name'] }}</b></a>{{{ $who['remote'] ? ' <span class="gl-badge">Fediverse</span>' : '' }}}
9+ <small>{{ $date }}{{ !empty($issue['edited']) ? '(編集済み)' : '' }}</small>
10+ </div>
11+ <div class="gl-comment-body">{{{ $bodyHtml }}}</div>
12+ </div>
13+{@ foreach ($timeline as $t) @}
14+{@ if ($t['kind'] === 'event') @}
15+ <div class="gl-event">{{{ $t['html'] }}} <small>{{ $t['date'] }}</small></div>
16+{@ elif ($t['kind'] === 'review') @}
17+ <div class="gl-comment gl-review gl-review-{{ $t['state'] }}" id="r{{ $t['id'] }}">
18+ <div class="gl-comment-head">
19+{@ if ($t['who']['icon'] !== '') @}
20+ <img src="{{ $t['who']['icon'] }}" alt="" class="gl-av" />
21+{@ endif @}
22+ <a href="{{ $t['who']['url'] }}"><b>{{ $t['who']['name'] }}</b></a>が{{ $t['stateText'] }}
23+ <small><a href="#r{{ $t['id'] }}">{{ $t['date'] }}</a></small>
24+ </div>
25+{@ if ($t['html'] !== '') @}
26+ <div class="gl-comment-body">{{{ $t['html'] }}}</div>
27+{@ endif @}
28+{@ foreach ($t['comments'] as $ic) @}
29+{@ include(gitler/inline) @}
30+{@ endforeach @}
31+ </div>
32+{@ elif ($t['kind'] === 'inline') @}
33+{$ $ic = $t['c'] $}
34+{@ include(gitler/inline) @}
35+{@ else @}
36+ <div class="gl-comment" id="c{{ $t['id'] }}">
37+ <div class="gl-comment-head">
38+{@ if ($t['who']['icon'] !== '') @}
39+ <img src="{{ $t['who']['icon'] }}" alt="" class="gl-av" />
40+{@ endif @}
41+ <a href="{{ $t['who']['url'] }}"><b>{{ $t['who']['name'] }}</b></a>
42+{@ if ($t['who']['remote']) @}
43+ <span class="gl-muted">{{ $t['who']['handle'] }}</span> <span class="gl-badge">Fediverse</span>
44+{@ endif @}
45+ <small><a href="#c{{ $t['id'] }}">{{ $t['date'] }}</a>{{ $t['edited'] ? '(編集済み)' : '' }}</small>
46+{@ if ($t['remote'] !== '') @}
47+ <small><a href="{{ $t['remote'] }}" rel="nofollow noopener noreferrer">元の投稿</a></small>
48+{@ endif @}
49+ </div>
50+ <div class="gl-comment-body">{{{ $t['html'] }}}</div>
51+{@ if ($t['canEdit'] || $t['canDelete'] || ($canAdmin && $t['actor'] !== '')) @}
52+ <div class="gl-row gl-comment-tools">
53+{@ if ($t['canEdit']) @}
54+ <details class="gl-edit">
55+ <summary>編集</summary>
56+ <form method="post" action="{{ $issueUrl }}" class="gl-form">
57+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
58+ <input type="hidden" name="id" value="{{ $t['id'] }}" />
59+ <textarea name="body" rows="6">{{ $t['body'] }}</textarea>
60+ <button name="do" value="editComment">保存</button>
61+ </form>
62+ </details>
63+{@ endif @}
64+{@ if ($t['canDelete']) @}
65+ <form method="post" action="{{ $issueUrl }}">
66+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
67+ <input type="hidden" name="id" value="{{ $t['id'] }}" />
68+ <button name="do" value="deleteComment" class="danger">削除</button>
69+ </form>
70+{@ endif @}
71+{@ if ($canAdmin && $t['actor'] !== '') @}
72+ <form method="post" action="{{ $issueUrl }}">
73+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
74+ <input type="hidden" name="actor" value="{{ $t['actor'] }}" />
75+ <button name="do" value="block" class="danger">この人をブロック</button>
76+ </form>
77+{@ endif @}
78+ </div>
79+{@ endif @}
80+ </div>
81+{@ endif @}
82+{@ endforeach @}
83+{@ if (isset($merge)) @}
84+{@ include(gitler/mergebox) @}
85+{@ endif @}
86+{@ if ($canIssue) @}
87+ <form method="post" action="{{ $issueUrl }}" class="gl-form gl-issueform">
88+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
89+ <textarea name="body" rows="6" placeholder="コメント(Markdown)"></textarea>
90+ <div class="gl-row">
91+ <button name="do" value="comment">コメント</button>
92+{@ if ($canClose) @}
93+{@ if ($issue['state'] === 'open') @}
94+ <button name="do" value="commentClose">閉じる</button>
95+{@ else @}
96+ <button name="do" value="commentReopen">開き直す</button>
97+{@ endif @}
98+{@ endif @}
99+ </div>
100+ <small>Markdownが使えます。「#3」で他のイシューにリンクします。コメントを書いて閉じると、コメントしてから閉じます。</small>
101+ </form>
102+{@ elif (!$user) @}
103+ <p class="paragraph"><a href="/login">ログイン</a>するとコメント出来ます。</p>
104+{@ endif @}
105+{@ if ($fedi) @}
106+ <p class="gl-muted gl-fedi">Fediverseから:Mastodon等で<code>{{ $fediUrl }}</code>を検索して返信するとコメントになります。<code>{{ $fediHandle }}</code>をフォローすると新しいイシューが届きます。</p>
107+{@ endif @}
108+ </div>
109+ <aside class="gl-side">
110+ <h3>ラベル</h3>
111+ <p>
112+{@ foreach ($issueLabels as $l) @}
113+ <a href="{{ $repoUrl }}/issues?label={{ $l['id'] }}" class="gl-label" style="background-color: {{ $l['color'] }}; color: {{ $l['fg'] }};" title="{{ $l['description'] }}">{{ $l['name'] }}</a>
114+{@ endforeach @}
115+{@ if (empty($issueLabels)) @}
116+ <span class="gl-muted">無し</span>
117+{@ endif @}
118+ </p>
119+ <h3>担当者</h3>
120+ <p>{{ !empty($issue['assignees']) ? implode('、', $issue['assignees']) : '無し' }}</p>
121+ <h3>マイルストーン</h3>
122+ <p>{{ $milestone !== '' ? $milestone : '無し' }}</p>
123+{@ if ($user) @}
124+ <h3>通知</h3>
125+ <form method="post" action="{{ $issueUrl }}">
126+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
127+ <p><small>{{ $subscribeNote }}</small></p>
128+ <button name="do" value="{{ $subscribed ? 'unsubscribe' : 'subscribe' }}">{{ $subscribed ? '通知を止める' : '通知を受け取る' }}</button>
129+ </form>
130+{@ endif @}
131+{@ if ($canTriage) @}
132+ <details class="gl-edit">
133+ <summary>変える</summary>
134+ <form method="post" action="{{ $issueUrl }}" class="gl-form">
135+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
136+{@ if (!empty($labels)) @}
137+ <fieldset class="gl-checks">
138+ <legend>ラベル</legend>
139+{@ foreach ($labels as $l) @}
140+ <label><input type="checkbox" name="labels[]" value="{{ $l['id'] }}"{{{ $l['checked'] ? ' checked="checked"' : '' }}} /> <span class="gl-label" style="background-color: {{ $l['color'] }}; color: {{ $l['fg'] }};">{{ $l['name'] }}</span></label>
141+{@ endforeach @}
142+ </fieldset>
143+{@ endif @}
144+ <fieldset class="gl-checks">
145+ <legend>担当者</legend>
146+{@ foreach ($people as $p) @}
147+ <label><input type="checkbox" name="assignees[]" value="{{ $p['name'] }}"{{{ $p['checked'] ? ' checked="checked"' : '' }}} /> {{ $p['name'] }}</label>
148+{@ endforeach @}
149+ </fieldset>
150+ <label>マイルストーン:
151+ <select name="milestone">
152+ <option value="0">無し</option>
153+{@ foreach ($milestones as $id => $m) @}
154+ <option value="{{ $id }}"{{{ $id === $issue['milestone'] ? ' selected="selected"' : '' }}}>{{ $m['title'] }}{{ $m['state'] === 'closed' ? '(終了)' : '' }}</option>
155+{@ endforeach @}
156+ </select>
157+ </label>
158+ <button name="do" value="triage">保存</button>
159+ </form>
160+ </details>
161+{@ endif @}
162+{@ if ($canAdmin) @}
163+ <form method="post" action="{{ $issueUrl }}" class="gl-delete">
164+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
165+ <label><input type="checkbox" name="confirm" value="1" required="required" /> 元に戻せません</label>
166+ <button name="do" value="delete" class="danger">{{ isset($pull) ? 'プルリクエスト' : 'イシュー' }}を削除</button>
167+ </form>
168+{@ endif @}
169+ </aside>
170+ </div>