プルリク
守矢諏訪子 <suwako@076.moe>、2026-11-13 16:05
コミット 8379199f1a57b49e6d51abde59ca29bf5de18c00
・親 0748c3b
・ファイルを見る
31個のファイル、+2153 -215
変更
README-JP.md
| @@ -306,6 +306,7 @@ | ||
| 306 | 306 | * Fediverse(マルチユーザーの ActivityPub が有効な場合):公開のリポジトリは `@所有者.名前@ホスト` のアカウントになり、Mastodon 等からフォローするとイシューとコメントが届き、返信するとコメント、リポジトリをメンションした投稿は新しいイシューになります。ForgeFed の `Offer{Ticket}` と `Resolve` にも対応します(イシューの Ticket は `…/issues/3/ticket`)。リポジトリの設定でオフにしたり、アカウントやドメインをブロック出来ます(サイト全体のドメインのブロックも効きます)。配信には cron の `apdeliver` を使い、SSH のプッシュ(`git` ユーザー)で閉じたイシューは `gitler/spool/` に溜めて cron の `notify` が送ります。鍵の作成に GMP が必要です。 |
| 307 | 307 | * 通知:所有者と共同作業者は最初からリポジトリをウォッチし(新しいイシューと全部の動き)、他の人は作った・コメントした・担当の・メンション(`@ユーザー`)されたイシューの動きを受け取ります。リポジトリのページの「通知」でウォッチ・参加しているイシューだけ・無視を、イシューのページで受け取る・止めるを選べます。通知はサイトの「通知」に届き、`MAILER_ENABLED` ならメールでも届きます(`/gitler/-/keys` で止められます)。メールと SSH のプッシュの通知は cron の `notify` が送るので追加して下さい:`php cron.php add "* * * * *" notify`。 |
| 308 | 308 | * アーカイブ:リポジトリの設定でアーカイブすると読むだけになります(プッシュ、イシュー・コメント・ラベル・マイルストーンの変更、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`)。リポジトリ毎に設定で無効に出来ます。 | |
| 309 | 310 | |
| 310 | 311 | ### TLS 1.3・1.2(php_openssl は不要) |
| 311 | 312 | 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 @@ | ||
| 304 | 304 | * 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. |
| 305 | 305 | * 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`. |
| 306 | 306 | * 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. | |
| 307 | 308 | |
| 308 | 309 | ### TLS 1.3 and 1.2 (no php_openssl) |
| 309 | 310 | 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 @@ | ||
| 112 | 112 | // gitユーザーはdata/に書けないので、通知とFediverseへの配信はリポジトリに溜める(cronのnotifyが送る) |
| 113 | 113 | Gitler::$deferred = true; |
| 114 | 114 | $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 { | |
| 116 | 117 | // 「所有者/名前.git」又は「所有者/名前」(前に「gitler/」が有っても良い) |
| 117 | 118 | if (!preg_match('#^(?:gitler/)?([A-Za-z0-9]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$#', $path, $m)) return null; |
| 118 | 119 | $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']) { | |
| 121 | 125 | fwrite(STDERR, "アーカイブされたリポジトリにはプッシュ出来ません。\n"); |
| 122 | 126 | return null; |
| 123 | 127 | } |
| 124 | - [$owner, $repoName] = [$m[1], $m[2]]; | |
| 128 | + [$owner, $repoName, $repoAccess] = [$m[1], $m[2], $access]; | |
| 125 | 129 | 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)])); | |
| 127 | 131 | |
| 128 | 132 | case 'gc': |
| 129 | 133 | $res = Gitler::gcAll(); |
| @@ -612,6 +612,111 @@ | ||
| 612 | 612 | border-color: #2c980c; |
| 613 | 613 | } |
| 614 | 614 | |
| 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 | + | |
| 615 | 720 | .gl-archived { |
| 616 | 721 | border-left: 4px solid #f1e05a; |
| 617 | 722 | padding: 2px 8px; |
変更
route.php
| @@ -176,10 +176,17 @@ | ||
| 176 | 176 | $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/issues/{n}/comments/{c}', Gitler::class.'@issueComment'); |
| 177 | 177 | $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/issues/{n}', Gitler::class.'@issue'); |
| 178 | 178 | $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) { | |
| 180 | 187 | $routes[] = Route::add('GET', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page); |
| 181 | 188 | } |
| 182 | - foreach (['labels', 'milestones', 'inbox', 'watch'] as $page) { | |
| 189 | + foreach (['labels', 'milestones', 'inbox', 'watch', 'compare', 'fork'] as $page) { | |
| 183 | 190 | $routes[] = Route::add('POST', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page); |
| 184 | 191 | } |
| 185 | 192 | foreach (['tree', 'blob', 'raw', 'blame', 'commits', 'branches', 'tags', 'archive', 'settings'] as $page) { |
| @@ -52,6 +52,7 @@ | ||
| 52 | 52 | use Std\Lib\Gitler\Issues; |
| 53 | 53 | use Std\Lib\Gitler\Keys; |
| 54 | 54 | use Std\Lib\Gitler\Notify; |
| 55 | +use Std\Lib\Gitler\Pulls; | |
| 55 | 56 | use Std\Lib\Gitler\Languages; |
| 56 | 57 | use Std\Lib\Markdown; |
| 57 | 58 | use Std\Lib\Syntax; |
| @@ -62,11 +63,12 @@ | ||
| 62 | 63 | * Gitler(/gitler):Gitのリポジトリのホスティング。JavaScriptなし |
| 63 | 64 | * |
| 64 | 65 | * リポジトリのページはクエリでリビジョンとパスを渡す(例:/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。 | |
| 66 | 67 | */ |
| 67 | 68 | class Gitler { |
| 68 | 69 | use Mods; |
| 69 | 70 | use GitlerIssues; |
| 71 | + use GitlerPulls; | |
| 70 | 72 | |
| 71 | 73 | /** 1ページのコミットの数 */ |
| 72 | 74 | private const PER_PAGE = 30; |
| @@ -486,6 +488,7 @@ | ||
| 486 | 488 | } |
| 487 | 489 | } else if ($do === 'issues') { |
| 488 | 490 | $meta['issues'] = isset($_POST['issues']); |
| 491 | + $meta['pulls'] = isset($_POST['pulls']); | |
| 489 | 492 | $meta['federate'] = isset($_POST['federate']); |
| 490 | 493 | Hosting::saveMeta($this->owner, $this->name, $meta); |
| 491 | 494 | $message = '保存しました。'; |
| @@ -534,7 +537,10 @@ | ||
| 534 | 537 | $user ??= $this->user; |
| 535 | 538 | $exists = Hosting::validOwner($owner) && Hosting::validName($name) && Hosting::exists($owner, $name); |
| 536 | 539 | $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; | |
| 538 | 544 | if ($write && $access >= Hosting::WRITE && Hosting::meta($owner, $name)['archived']) { |
| 539 | 545 | http_response_code(403); |
| 540 | 546 | echo "アーカイブされたリポジトリにはプッシュ出来ません。\n"; |
| @@ -553,17 +559,25 @@ | ||
| 553 | 559 | exit; |
| 554 | 560 | } |
| 555 | 561 | $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)]); | |
| 557 | 563 | exit; |
| 558 | 564 | } |
| 559 | 565 | |
| 560 | 566 | /** |
| 561 | - * プッシュの制限(ブランチとタグだけ)と、プッシュの後のイシューの処理 | |
| 562 | - * SSHではリポジトリが後で決まるので、所有者と名前は参照で受け取る | |
| 567 | + * プッシュの制限(書けるならブランチとタグ、AGitのrefs/for/)と、プッシュの後のプルリクエストとイシューの処理 | |
| 568 | + * SSHではリポジトリが後で決まるので、所有者、名前、権限は参照で受け取る | |
| 563 | 569 | */ |
| 564 | - public static function receiveOptions(string &$owner, string &$name): array { | |
| 570 | + public static function receiveOptions(string &$owner, string &$name, ?\stdClass $user, int &$access): array { | |
| 565 | 571 | 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 | + }, | |
| 567 | 581 | 'after' => function(array $done) use (&$owner, &$name): void { |
| 568 | 582 | if ($owner !== '' && $name !== '') self::afterPush($owner, $name, $done); |
| 569 | 583 | }, |
| @@ -813,8 +827,21 @@ | ||
| 813 | 827 | 'tgzUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=tar.gz' : '', |
| 814 | 828 | 'issuesOn' => $this->meta['issues'], 'openIssues' => $this->meta['issues'] ? (new Issues($this->repo))->counts()['open'] : 0, |
| 815 | 829 | '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), | |
| 816 | 834 | 'watch' => $this->user ? Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) : '', |
| 817 | 835 | ]); |
| 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}"); | |
| 818 | 845 | } |
| 819 | 846 | |
| 820 | 847 | private function render(string $view, string $title, array $vars): void { |
| @@ -56,13 +56,14 @@ | ||
| 56 | 56 | |
| 57 | 57 | //////// イシュー |
| 58 | 58 | |
| 59 | - /** …/issues?state=&label=&milestone=&assignee=&q=&sort=&page= */ | |
| 59 | + /** …/issues?state=&label=&milestone=&assignee=&q=&sort=&page=(…/pullsはプルリクエスト) */ | |
| 60 | 60 | public function issues(array $params): void { |
| 61 | - $this->loadIssues($params); | |
| 61 | + $pull = !empty($params['pull']); | |
| 62 | + $this->loadIssues($params, $pull); | |
| 62 | 63 | $f = ['state' => in_array($_GET['state'] ?? '', ['closed', 'all'], true) ? $_GET['state'] : 'open']; |
| 63 | 64 | foreach (['label', 'milestone'] as $k) if (isset($_GET[$k]) && $_GET[$k] !== '') $f[$k] = (int)$_GET[$k]; |
| 64 | 65 | 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); | |
| 66 | 67 | $counts = ['open' => 0, 'closed' => 0]; |
| 67 | 68 | foreach ($all as $s) $counts[$s['state']]++; |
| 68 | 69 | $list = $f['state'] === 'all' ? $all : array_values(array_filter($all, fn($s) => $s['state'] === $f['state'])); |
| @@ -70,13 +71,16 @@ | ||
| 70 | 71 | $pages = max(1, (int)ceil(count($list) / self::PER_PAGE)); |
| 71 | 72 | $labels = $this->issueDb->labels(); |
| 72 | 73 | $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().'/トピック' : '', | |
| 75 | 79 | 'list' => array_map(fn($s) => $this->presentSummary($s, $labels, $milestones), array_slice($list, ($page - 1) * self::PER_PAGE, self::PER_PAGE)), |
| 76 | 80 | 'counts' => $counts, 'f' => $f + ['label' => '', 'milestone' => '', 'assignee' => '', 'q' => '', 'sort' => 'created'], |
| 77 | 81 | 'openUrl' => $query(['state' => 'open']), 'closedUrl' => $query(['state' => 'closed']), |
| 78 | 82 | '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(), | |
| 80 | 84 | ]); |
| 81 | 85 | } |
| 82 | 86 | |
| @@ -110,29 +114,44 @@ | ||
| 110 | 114 | ]); |
| 111 | 115 | } |
| 112 | 116 | |
| 113 | - /** …/issues/{n}(ActivityPubではNote) */ | |
| 117 | + /** …/issues/{n}(ActivityPubではNote)、…/pulls/{n}(プルリクエストの会話) */ | |
| 114 | 118 | public function issue(array $params): void { |
| 115 | 119 | $n = (int)($params['n'] ?? 0); |
| 116 | 120 | if ($this->wantsActivity()) { |
| 117 | 121 | [$owner, $name, $issue] = $this->apIssue($params); |
| 118 | 122 | $this->apJson(['@context' => Federation::CONTEXT] + Federation::issueNote($owner, $name, $issue)); |
| 119 | 123 | } |
| 120 | - $this->loadIssues($params); | |
| 124 | + $this->loadIssues($params, null); | |
| 121 | 125 | $issue = $this->issueDb->get($n); |
| 122 | 126 | 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(); | |
| 123 | 130 | $error = ''; |
| 124 | 131 | if ($_SERVER['REQUEST_METHOD'] === 'POST') { |
| 125 | 132 | $error = $this->csrfOk() ? $this->issueAction($issue) : 'セッションの有効期限が切れました。もう一度お試し下さい。'; |
| 126 | 133 | $issue = $this->issueDb->get($n); |
| 127 | 134 | if ($issue === null) $this->redirect($this->repoUrl().'/issues'); |
| 128 | 135 | } |
| 136 | + $pull = isset($issue['pull']); | |
| 129 | 137 | header('Vary: Accept'); |
| 130 | 138 | $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username; |
| 131 | 139 | $archived = $this->meta['archived']; |
| 132 | 140 | $labels = $this->issueDb->labels(); |
| 133 | 141 | $milestones = $this->issueDb->milestones(); |
| 134 | 142 | $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 = []; | |
| 135 | 146 | 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 | + } | |
| 136 | 155 | $mine = $this->user && ($c['author']['user'] ?? null) === $this->user->username; |
| 137 | 156 | $timeline[] = ['kind' => 'comment', 'at' => $c['created'], 'id' => $c['id'], 'who' => $this->who($c['author']), 'html' => $this->bodyHtml($c['body'], !empty($c['html'])), |
| 138 | 157 | '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 @@ | ||
| 140 | 159 | 'remote' => preg_match('#^https?://#i', (string)($c['remoteId'] ?? '')) ? $c['remoteId'] : '']; |
| 141 | 160 | } |
| 142 | 161 | 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']); | |
| 144 | 169 | $fedi = Federation::enabled($this->meta) && !$this->meta['archived']; |
| 145 | 170 | // 通知:受け取っている(true)、止めた(false)、ウォッチで受け取る、受け取っていない |
| 146 | 171 | $sub = $this->user ? ($issue['subscribers'][$this->user->username] ?? null) : null; |
| 147 | 172 | $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) : []) + [ | |
| 149 | 174 | 'issue' => $issue, 'who' => $this->who($issue['author']), 'bodyHtml' => $this->bodyHtml($issue['body'], !empty($issue['html'])), |
| 150 | 175 | '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, | |
| 153 | 178 | 'canAdmin' => !$archived && $this->access >= Hosting::ADMIN, |
| 154 | 179 | 'labels' => $this->presentLabels($labels, $issue['labels']), 'issueLabels' => $this->presentLabels(array_intersect_key($labels, array_flip($issue['labels']))), |
| 155 | 180 | 'milestones' => $milestones, 'milestone' => $issue['milestone'] !== null ? ($milestones[$issue['milestone']]['title'] ?? '') : '', |
| 156 | 181 | '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(), | |
| 158 | 183 | 'fediHandle' => $fedi ? '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host() : '', |
| 159 | 184 | 'fediUrl' => $fedi ? Federation::issueUrl($this->owner, $this->name, $n) : '', |
| 160 | 185 | 'subscribed' => $sub === true || ($sub === null && $watching), |
| @@ -183,8 +208,9 @@ | ||
| 183 | 208 | /** イシューのページのPOST(エラーのメッセージ、成功したらリダイレクト) */ |
| 184 | 209 | private function issueAction(array $issue): string { |
| 185 | 210 | $n = $issue['number']; |
| 186 | - $url = $this->repoUrl().'/issues/'.$n; | |
| 211 | + $url = $this->issuePage($issue); | |
| 187 | 212 | $do = (string)($_POST['do'] ?? ''); |
| 213 | + if (isset($issue['pull']) && in_array($do, ['merge', 'deleteBranch', 'inline', 'inlinePending', 'reply', 'review'], true)) return $this->pullAction($issue, $do); | |
| 188 | 214 | $me = $this->me(); |
| 189 | 215 | $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username; |
| 190 | 216 | $canClose = $isAuthor || $this->access >= Hosting::WRITE; |
| @@ -198,7 +224,8 @@ | ||
| 198 | 224 | if ($this->meta['archived']) return 'アーカイブされたリポジトリです(読むだけ)。'; |
| 199 | 225 | |
| 200 | 226 | 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 'マージしたプルリクエストは開き直せません。'; | |
| 202 | 229 | $anchor = ''; |
| 203 | 230 | if ($do === 'comment' || trim($body) !== '') { |
| 204 | 231 | $res = $this->issueDb->comment($n, $me, $body); |
| @@ -254,7 +281,13 @@ | ||
| 254 | 281 | if (!isset($_POST['confirm'])) return '確認にチェックを入れて下さい。'; |
| 255 | 282 | $this->issueDb->delete($n); |
| 256 | 283 | 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')); | |
| 258 | 291 | } |
| 259 | 292 | if ($do === 'block') { |
| 260 | 293 | if ($this->access < Hosting::ADMIN) return 'ブロックする権限が有りません。'; |
| @@ -279,7 +312,7 @@ | ||
| 279 | 312 | |
| 280 | 313 | /** …/labels */ |
| 281 | 314 | public function labels(array $params): void { |
| 282 | - $this->loadIssues($params); | |
| 315 | + $this->loadIssues($params, null); | |
| 283 | 316 | $error = ''; |
| 284 | 317 | if ($_SERVER['REQUEST_METHOD'] === 'POST') { |
| 285 | 318 | if (!$this->csrfOk()) { |
| @@ -305,7 +338,7 @@ | ||
| 305 | 338 | |
| 306 | 339 | /** …/milestones */ |
| 307 | 340 | public function milestones(array $params): void { |
| 308 | - $this->loadIssues($params); | |
| 341 | + $this->loadIssues($params, null); | |
| 309 | 342 | $error = ''; |
| 310 | 343 | if ($_SERVER['REQUEST_METHOD'] === 'POST') { |
| 311 | 344 | $do = (string)($_POST['do'] ?? ''); |
| @@ -453,12 +486,19 @@ | ||
| 453 | 486 | |
| 454 | 487 | //////// 共通 |
| 455 | 488 | |
| 456 | - private function loadIssues(array $params): void { | |
| 489 | + /** @param bool|null $pull プルリクエスト(nullならイシューかプルリクエストのどちらかが使えれば良い) */ | |
| 490 | + private function loadIssues(array $params, ?bool $pull = false): void { | |
| 457 | 491 | $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(); | |
| 459 | 494 | $this->issueDb = new Issues($this->repo); |
| 460 | 495 | } |
| 461 | 496 | |
| 497 | + /** イシュー又はプルリクエストのページ */ | |
| 498 | + private function issuePage(array $issue): string { | |
| 499 | + return $this->repoUrl().(isset($issue['pull']) ? '/pulls/' : '/issues/').$issue['number']; | |
| 500 | + } | |
| 501 | + | |
| 462 | 502 | /** イシューを作ってコメント出来るか */ |
| 463 | 503 | private function canIssue(): bool { |
| 464 | 504 | return $this->user !== null && $this->meta['issues'] && !$this->meta['archived'] && Hosting::userCan($this->user, 'issues'); |
| @@ -508,7 +548,8 @@ | ||
| 508 | 548 | |
| 509 | 549 | private function presentSummary(array $s, array $labels, array $milestones): array { |
| 510 | 550 | $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'], | |
| 512 | 553 | 'labels' => $this->presentLabels(array_intersect_key($labels, array_flip($s['labels']))), 'author' => $who['name'], |
| 513 | 554 | 'date' => date('Y-m-d', $s['created']), 'closedDate' => $s['closed'] ? date('Y-m-d', $s['closed']) : '', 'comments' => $s['comments'], |
| 514 | 555 | 'milestone' => $s['milestone'] !== null ? ($milestones[$s['milestone']]['title'] ?? '') : '', 'assignees' => $s['assignees']]; |
| @@ -526,7 +567,11 @@ | ||
| 526 | 567 | }; |
| 527 | 568 | $list = fn(array $xs, callable $f) => implode('、', array_map($f, $xs)); |
| 528 | 569 | 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']).'」を削除しました', | |
| 530 | 575 | 'reopened' => $by.'が開き直しました', |
| 531 | 576 | 'title' => $by.'が題名を「'.$h($e['from']).'」から「'.$h($e['to']).'」に変えました', |
| 532 | 577 | '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 @@ | ||
| 53 | 53 | * @param array $opt agent、unpackLimit(これより少ないオブジェクトはルーズにする、既定100)、 |
| 54 | 54 | * check(fn(array $commands): ?string、全部の更新の前、エラーのメッセージで全部拒否)、 |
| 55 | 55 | * 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]、結果を送る前。返した行はクライアントに表示する)、 | |
| 57 | 59 | * denyNonFastForwards、denyDeletes(既定はconfigのreceive.*) |
| 58 | 60 | */ |
| 59 | 61 | public function __construct(private Repository $repo, private array $opt = []) { |
| @@ -146,6 +148,15 @@ | ||
| 146 | 148 | foreach ($commands as $i => $c) $commands[$i]['error'] ??= 'atomic push failed'; |
| 147 | 149 | } |
| 148 | 150 | $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 | + } | |
| 149 | 160 | } |
| 150 | 161 | |
| 151 | 162 | if ($report) { |
| @@ -196,7 +207,9 @@ | ||
| 196 | 207 | /** 参照を更新する(古い値が変わっていれば失敗) */ |
| 197 | 208 | private function apply(array $commands, bool $atomic): array { |
| 198 | 209 | $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)); | |
| 200 | 213 | if (empty($ok)) return $commands; |
| 201 | 214 | $tx = fn(array $c) => [$c['ref'], $c['new'] === $zero ? null : $c['new'], $c['old']]; |
| 202 | 215 | if ($atomic) { |
| @@ -208,7 +221,7 @@ | ||
| 208 | 221 | return $commands; |
| 209 | 222 | } |
| 210 | 223 | foreach ($commands as $i => $c) { |
| 211 | - if ($c['error'] !== null) continue; | |
| 224 | + if ($c['error'] !== null || $skip($c)) continue; | |
| 212 | 225 | try { |
| 213 | 226 | $this->repo->refs->transaction([$tx($c)]); |
| 214 | 227 | } catch (GitException $e) { |
| @@ -91,6 +91,11 @@ | ||
| 91 | 91 | return self::actorUrl($owner, $name).'/issues/'.$n; |
| 92 | 92 | } |
| 93 | 93 | |
| 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 | + | |
| 94 | 99 | /** 「所有者.名前」(ユーザー名には「.」が無いので重ならない) */ |
| 95 | 100 | public static function handle(string $owner, string $name): string { |
| 96 | 101 | return $owner.'.'.$name; |
| @@ -264,11 +269,14 @@ | ||
| 264 | 269 | $url = self::issueUrl($owner, $name, $issue['number']); |
| 265 | 270 | $repo = self::actorUrl($owner, $name); |
| 266 | 271 | $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>' | |
| 269 | 277 | .self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html'])); |
| 270 | 278 | $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, | |
| 272 | 280 | 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], self::participants($owner, $name, $issue)), |
| 273 | 281 | 'published' => Fedi::now($issue['created']), 'content' => $content, 'mediaType' => 'text/html', |
| 274 | 282 | 'tag' => self::mentionTags(self::participants($owner, $name, $issue)), |
| @@ -284,7 +292,7 @@ | ||
| 284 | 292 | '@context' => self::CONTEXT, 'id' => "{$url}/ticket", 'type' => 'Ticket', 'attributedTo' => self::authorActor($owner, $name, $issue['author']), |
| 285 | 293 | 'context' => self::actorUrl($owner, $name), 'name' => $issue['title'], 'summary' => Html::esc($issue['title']), |
| 286 | 294 | '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), | |
| 288 | 296 | ]; |
| 289 | 297 | if (empty($issue['html'])) $t['source'] = ['content' => $issue['body'], 'mediaType' => 'text/markdown']; |
| 290 | 298 | return $t; |
| @@ -297,7 +305,7 @@ | ||
| 297 | 305 | $people = self::participants($owner, $name, $issue); |
| 298 | 306 | $note = [ |
| 299 | 307 | '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), | |
| 301 | 309 | 'published' => Fedi::now($c['created']), 'mediaType' => 'text/html', 'tag' => self::mentionTags($people), |
| 302 | 310 | 'content' => '<p><a href="'.Html::esc($url).'">#'.$issue['number'].'</a> '.Html::esc(self::authorName($c['author'])).':</p>' |
| 303 | 311 | .self::bodyHtml($owner, $name, $c['body'], !empty($c['html'])), |
| @@ -130,13 +130,15 @@ | ||
| 130 | 130 | * リポジトリの情報 |
| 131 | 131 | * |
| 132 | 132 | * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、 |
| 133 | - * issues(イシューを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ) | |
| 133 | + * issues(イシューを使う)、pulls(プルリクエストを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ)、 | |
| 134 | + * forkOf(フォーク元の「所有者/名前」又は空) | |
| 134 | 135 | */ |
| 135 | 136 | public static function meta(string $owner, string $name): array { |
| 136 | 137 | $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true); |
| 137 | 138 | $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']; | |
| 140 | 142 | $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : []; |
| 141 | 143 | return $j; |
| 142 | 144 | } |
| @@ -230,6 +232,62 @@ | ||
| 230 | 232 | return Result::Success("リポジトリ「{$owner}/{$name}」を作りました。", ['owner' => $owner, 'name' => $name]); |
| 231 | 233 | } |
| 232 | 234 | |
| 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 | + | |
| 233 | 291 | /** 名前を変える */ |
| 234 | 292 | public static function rename(string $owner, string $name, string $new): Result { |
| 235 | 293 | if (!self::validName($new)) return Result::Error('不正なリポジトリの名前です。'); |
| @@ -248,7 +306,8 @@ | ||
| 248 | 306 | if ($f === '.' || $f === '..') continue; |
| 249 | 307 | $p = "{$d}/{$f}"; |
| 250 | 308 | if (is_dir($p) && !is_link($p)) $rm($p); |
| 251 | - else { | |
| 309 | + // 読むだけのファイル(Windows)だけ権限を変える(フォークのパックはハードリンクで元と共有) | |
| 310 | + else if (!@unlink($p)) { | |
| 252 | 311 | @chmod($p, 0666); |
| 253 | 312 | @unlink($p); |
| 254 | 313 | } |
| @@ -52,7 +52,8 @@ | ||
| 52 | 52 | * |
| 53 | 53 | * 書く者:local({user})、remote({actor, name, handle, icon, url}、Fediverse)、commit({commit, name})。 |
| 54 | 54 | * 本文はMarkdown(htmlがtrueならリモートのHTML、受け取った時に安全にした物)。 |
| 55 | - * 番号は後でプルリクエストと共有する。 | |
| 55 | + * プルリクエストも同じ番号のイシュー(pull:Pullsを参照)。インラインのコメントはpath・side・line・commit等を持ち、 | |
| 56 | + * レビュー(reviews:id、author、state(commented、approved、changes)、body、at、commit)に属する。 | |
| 56 | 57 | */ |
| 57 | 58 | final class Issues { |
| 58 | 59 | public const MAX_TITLE = 200; |
| @@ -84,23 +85,27 @@ | ||
| 84 | 85 | } |
| 85 | 86 | |
| 86 | 87 | /** 開いている・閉じたイシューの数 */ |
| 87 | - public function counts(): array { | |
| 88 | + /** 開いている・閉じた数($pullならプルリクエスト) */ | |
| 89 | + public function counts(bool $pull = false): array { | |
| 88 | 90 | $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']++; | |
| 90 | 92 | return $c; |
| 91 | 93 | } |
| 92 | 94 | |
| 93 | 95 | /** |
| 94 | 96 | * 探す |
| 95 | 97 | * |
| 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なら両方) | |
| 97 | 100 | * @return array 概要の配列(新しい順) |
| 98 | 101 | */ |
| 99 | 102 | public function search(array $f = []): array { |
| 100 | 103 | $state = $f['state'] ?? 'open'; |
| 101 | 104 | $q = mb_strtolower(trim((string)($f['q'] ?? ''))); |
| 102 | 105 | $out = []; |
| 106 | + $pull = array_key_exists('pull', $f) ? $f['pull'] : false; | |
| 103 | 107 | foreach ($this->index()['issues'] as $s) { |
| 108 | + if ($pull !== null && !empty($s['pull']) !== $pull) continue; | |
| 104 | 109 | if ($state !== 'all' && $s['state'] !== $state) continue; |
| 105 | 110 | if (isset($f['label']) && !in_array((int)$f['label'], $s['labels'], true)) continue; |
| 106 | 111 | if (isset($f['milestone']) && (int)($s['milestone'] ?? 0) !== (int)$f['milestone']) continue; |
| @@ -132,7 +137,7 @@ | ||
| 132 | 137 | * イシューを作る |
| 133 | 138 | * |
| 134 | 139 | * @param array $author 書いた者 |
| 135 | - * @param array $opt html(本文がHTML)、remoteId、labels、assignees、milestone | |
| 140 | + * @param array $opt html(本文がHTML)、remoteId、labels、assignees、milestone、pull(プルリクエストの情報) | |
| 136 | 141 | */ |
| 137 | 142 | public function create(array $author, string $title, string $body, array $opt = []): Result { |
| 138 | 143 | $title = self::cleanTitle($title); |
| @@ -149,6 +154,7 @@ | ||
| 149 | 154 | 'assignees' => array_values(array_unique(array_map('strval', $opt['assignees'] ?? []))), 'milestone' => $this->validMilestone($opt['milestone'] ?? null), |
| 150 | 155 | 'remoteId' => $opt['remoteId'] ?? null, 'comments' => [], 'events' => [], 'seq' => 1, 'subscribers' => [], |
| 151 | 156 | ]; |
| 157 | + if (isset($opt['pull'])) $issue += ['pull' => $opt['pull'], 'reviews' => []]; | |
| 152 | 158 | // 作った人と担当者は通知を受け取る |
| 153 | 159 | foreach (array_merge(isset($author['user']) ? [$author['user']] : [], $issue['assignees']) as $u) $issue['subscribers'][$u] = true; |
| 154 | 160 | $idx['next'] = $n + 1; |
| @@ -218,6 +224,67 @@ | ||
| 218 | 224 | return Result::Success('コメントしました。', ['issue' => $issue, 'comment' => $added]); |
| 219 | 225 | } |
| 220 | 226 | |
| 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 | + | |
| 221 | 288 | /** コメントを変える(nullなら削除) */ |
| 222 | 289 | public function editComment(int $n, int $id, ?string $body): ?array { |
| 223 | 290 | if ($body !== null && (trim($body) === '' || strlen($body) > self::MAX_BODY)) return null; |
| @@ -330,10 +397,11 @@ | ||
| 330 | 397 | $closes = array_map('intval', $cm[1]); |
| 331 | 398 | $by = ['commit' => $id, 'name' => $c->author?->name ?? '']; |
| 332 | 399 | 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']); | |
| 334 | 401 | $didClose = false; |
| 335 | 402 | $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; | |
| 337 | 405 | foreach ($i['events'] as $e) if ($e['type'] === 'commit' && $e['commit'] === $id) $seen = true; |
| 338 | 406 | if (!$seen) $i['events'][] = ['type' => 'commit', 'by' => $by, 'at' => time(), 'commit' => $id, 'subject' => $c->subject(), 'branch' => substr($ref, 11)]; |
| 339 | 407 | $didClose = $close && $i['state'] === 'open'; |
| @@ -496,7 +564,8 @@ | ||
| 496 | 564 | $idx['issues'][(string)$issue['number']] = [ |
| 497 | 565 | 'number' => $issue['number'], 'title' => $issue['title'], 'state' => $issue['state'], 'author' => $issue['author'], 'authorKey' => self::authorKey($issue['author']), |
| 498 | 566 | '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']), | |
| 500 | 569 | ]; |
| 501 | 570 | $this->repo->mkdir($this->dir.'/issues'); |
| 502 | 571 | $this->writeJson($this->file($issue['number']), $issue); |
| @@ -125,6 +125,21 @@ | ||
| 125 | 125 | self::send($owner, $name, $meta, $issue, $by, $to, '', false, ''); |
| 126 | 126 | } |
| 127 | 127 | |
| 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 | + | |
| 128 | 143 | /** 担当者に追加した */ |
| 129 | 144 | public static function assigned(string $owner, string $name, array $meta, array $issue, array $users, array $by): void { |
| 130 | 145 | self::send($owner, $name, $meta, $issue, $by, array_fill_keys($users, 'assigned'), '', false, ''); |
| @@ -144,7 +159,8 @@ | ||
| 144 | 159 | } |
| 145 | 160 | |
| 146 | 161 | /** |
| 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(レビュー)) | |
| 148 | 164 | * @param string $anchor コメントの「#c5」等 |
| 149 | 165 | */ |
| 150 | 166 | 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 @@ | ||
| 153 | 169 | $repoName = "{$owner}/{$name}"; |
| 154 | 170 | $title = "{$repoName}#{$n}「{$issue['title']}」"; |
| 155 | 171 | $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; | |
| 157 | 174 | $excerpt = $body !== '' ? Html::text($html ? $body : Issues::render($body, '/gitler/'.$owner.'/'.rawurlencode($name).'/issues')) : ''; |
| 158 | 175 | // Markdownの整形の空白を除く |
| 159 | 176 | $excerpt = trim((string)preg_replace(['/[ \t]*\n[ \t]*/', '/\n{3,}/'], ["\n", "\n\n"], $excerpt)); |
| @@ -169,7 +186,11 @@ | ||
| 169 | 186 | $u = Gitler::user($user); |
| 170 | 187 | if ($u === null || Gitler::access($u, $owner, $name) < Gitler::READ) continue; |
| 171 | 188 | $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}をレビューしました", | |
| 173 | 194 | 'comment' => "が{$title}にコメントしました", |
| 174 | 195 | 'mention' => "が{$title}であなたをメンションしました", |
| 175 | 196 | '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 @@ | ||
| 47 | 47 | use Std\Lib\Git\Diff; |
| 48 | 48 | use Std\Lib\Git\GitException; |
| 49 | 49 | use Std\Lib\Git\Maintenance; |
| 50 | +use Std\Lib\Git\Merge; | |
| 50 | 51 | use Std\Lib\Git\PackIndexer; |
| 51 | 52 | use Std\Lib\Git\PackWriter; |
| 52 | 53 | use Std\Lib\Git\Person; |
| @@ -542,6 +543,14 @@ | ||
| 542 | 543 | $test->assertTrue($r[0] !== 'unpack ok'); |
| 543 | 544 | $test->assertSame('ng refs/heads/broken unpacker error', $r[1]); |
| 544 | 545 | $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); | |
| 545 | 554 | }); |
| 546 | 555 | |
| 547 | 556 | $test->it('SSHのコマンドを解析するはず', function($test): void { |
| @@ -550,6 +559,49 @@ | ||
| 550 | 559 | $test->assertSame(['git-upload-pack', "it's.git"], Ssh::parse("git upload-pack 'it'\\''s.git'")); |
| 551 | 560 | $test->assertSame(['git-upload-pack', 'a/b.git'], Ssh::parse('git-upload-pack ~/a/b.git')); |
| 552 | 561 | 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)); | |
| 553 | 605 | }); |
| 554 | 606 | }); |
| 555 | 607 |
| @@ -53,6 +53,7 @@ | ||
| 53 | 53 | use Std\Lib\Gitler\Issues; |
| 54 | 54 | use Std\Lib\Gitler\Keys; |
| 55 | 55 | use Std\Lib\Gitler\Notify; |
| 56 | +use Std\Lib\Gitler\Pulls; | |
| 56 | 57 | use Std\Lib\Notifications; |
| 57 | 58 | use Std\Lib\Gitler\Languages; |
| 58 | 59 | use Roles; |
| @@ -758,6 +759,162 @@ | ||
| 758 | 759 | $test->assertSame('アーカイブされています', $res[1]['status']); |
| 759 | 760 | } |
| 760 | 761 | $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']]); | |
| 761 | 918 | }); |
| 762 | 919 | Notifications::$dataDir = null; |
| 763 | 920 | }); |
| @@ -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 @@ | ||
| 20 | 20 | </details> |
| 21 | 21 | {@ endif @} |
| 22 | 22 | </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) @} | |
| 171 | 24 | {@ include(common/footer) @} |
| @@ -9,11 +9,11 @@ | ||
| 9 | 9 | <a href="{{ $repoUrl }}/labels">ラベル</a> |
| 10 | 10 | <a href="{{ $repoUrl }}/milestones">マイルストーン</a> |
| 11 | 11 | {@ if ($canIssue) @} |
| 12 | - <a href="{{ $repoUrl }}/issues/new" class="gl-button">+新しいイシュー</a> | |
| 12 | + <a href="{{ $newUrl }}" class="gl-button">+{{ $isPull ? '新しいプルリクエスト' : '新しいイシュー' }}</a> | |
| 13 | 13 | {@ endif @} |
| 14 | 14 | </span> |
| 15 | 15 | </div> |
| 16 | - <form method="get" action="{{ $repoUrl }}/issues" class="gl-row gl-filter"> | |
| 16 | + <form method="get" action="{{ $listUrl }}" class="gl-row gl-filter"> | |
| 17 | 17 | <input type="hidden" name="state" value="{{ $f['state'] }}" /> |
| 18 | 18 | <input type="text" name="q" value="{{ $f['q'] }}" placeholder="題名・番号" /> |
| 19 | 19 | <select name="label"> |
| @@ -42,23 +42,26 @@ | ||
| 42 | 42 | <button>絞り込む</button> |
| 43 | 43 | </form> |
| 44 | 44 | {@ if (empty($list)) @} |
| 45 | - <p class="paragraph">イシューは有りません。</p> | |
| 45 | + <p class="paragraph">{{ $isPull ? 'プルリクエスト' : 'イシュー' }}は有りません。</p> | |
| 46 | 46 | {@ endif @} |
| 47 | 47 | <ul class="gl-issues"> |
| 48 | 48 | {@ foreach ($list as $i) @} |
| 49 | 49 | <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> | |
| 51 | 51 | <a href="{{ $i['url'] }}"><b>{{ $i['title'] }}</b></a> |
| 52 | 52 | {@ 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> | |
| 54 | 54 | {@ endforeach @} |
| 55 | 55 | {@ if ($i['comments'] > 0) @} |
| 56 | 56 | <span class="gl-count">💬 {{ $i['comments'] }}</span> |
| 57 | 57 | {@ 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> | |
| 59 | 59 | </li> |
| 60 | 60 | {@ endforeach @} |
| 61 | 61 | </ul> |
| 62 | +{@ if ($agitHelp !== '') @} | |
| 63 | + <p class="gl-muted gl-fedi">フォークしないでプルリクエストを作る(AGit):<code>{{ $agitHelp }}</code>。同じトピックにもう一度プッシュすると更新します。</p> | |
| 64 | +{@ endif @} | |
| 62 | 65 | <p class="gl-pager"> |
| 63 | 66 | {@ if ($prevUrl !== '') @} |
| 64 | 67 | <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 @@ | ||
| 19 | 19 | {@ if ($meta['description'] !== '') @} |
| 20 | 20 | <p class="gl-desc">{{ $meta['description'] }}</p> |
| 21 | 21 | {@ 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> | |
| 22 | 39 | <nav class="gl-tabs"> |
| 23 | 40 | <a href="{{ $treeRoot }}" class="{{ in_array($gitView, ['gitler/tree', 'gitler/blob', 'gitler/blame', 'gitler/empty'], true) ? 'active' : '' }}">ファイル</a> |
| 24 | 41 | {@ 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> | |
| 26 | 46 | {@ endif @} |
| 27 | 47 | {@ if ($commitsUrl !== '') @} |
| 28 | 48 | <a href="{{ $commitsUrl }}" class="{{ in_array($gitView, ['gitler/commits', 'gitler/commit'], true) ? 'active' : '' }}">コミット</a> |
| @@ -43,10 +43,11 @@ | ||
| 43 | 43 | <button name="do" value="addCollaborator">追加</button> |
| 44 | 44 | </form> |
| 45 | 45 | |
| 46 | - <h2 class="paragraph">イシューとFediverse</h2> | |
| 46 | + <h2 class="paragraph">イシュー・プルリクエストとFediverse</h2> | |
| 47 | 47 | <form method="post" action="{{ $repoUrl }}/settings" class="gl-form"> |
| 48 | 48 | <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> |
| 49 | 49 | <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> | |
| 50 | 51 | {@ if ($fediAvailable) @} |
| 51 | 52 | <label><input type="checkbox" name="federate" value="1"{{{ $meta['federate'] ? ' checked="checked"' : '' }}} /> 公開のリポジトリならFediverseに出す({{ $fediHandle }})</label> |
| 52 | 53 | {@ 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> |