イシュートラッカー
守矢諏訪子 <suwako@076.moe>、2026-11-13 15:29
コミット b501fc94bbe372a1cec31a6e8e0d961c5739c0ad
・親 35614ab
・ファイルを見る
21個のファイル、+2652 -16
変更
README-JP.md
| @@ -302,6 +302,8 @@ | ||
| 302 | 302 | 鍵毎に `gitler.php serve <ユーザー>` を実行するので、ユーザーが出来るのは `git clone`・`git push`(`git@example.com:user/repo.git`)だけです。`AuthorizedKeysCommand` を使わない場合は、`php gitler.php authorized-keys > /var/www/git/.ssh/authorized_keys`(例えば cron から)で同じ行を書けます。 |
| 303 | 303 | * リポジトリを整理する為に Cron のジョブ `gitlergc` を追加するか(例:`php cron.php add "@daily" gitlergc`)、`php gitler.php gc` を実行して下さい。 |
| 304 | 304 | * リポジトリのページには GitHub の様に言語の割合が出ます(バイト数で、ファイル名・拡張子・`#!` で決めます)。数えるのはプログラミングとマークアップの言語だけで、データ(JSON、YAML 等)、文章(Markdown 等)、外部のコード(`vendor/`、`node_modules/` 等)、ドキュメント(`docs/`、`examples/` 等)、生成されたファイルは数えません。ルートの `.gitattributes` の `linguist-language`、`linguist-vendored`、`linguist-generated`、`linguist-documentation`、`linguist-detectable` で変えられます(例:`*.h linguist-language=C++`)。結果はリポジトリの中の `gitler-languages.json` にツリー毎に保存し、一覧には既定のブランチの一番多い言語が出ます。 |
| 305 | +* イシュー:ラベル、担当者、マイルストーン、Markdown の本文とコメント(「#3」は他のイシューへのリンク)。リポジトリを読めて「Gitのイシュー」のロールの有るユーザーが作ってコメント出来、書き込み権限の有る人がラベル等を付けて閉じます(作った人も閉じられます)。既定のブランチにプッシュしたコミットのメッセージの「fixes #3」「closes #3」「resolves #3」でイシューを閉じ、「#3」はイシューに記録します。設定でリポジトリ毎に無効に出来ます。データはリポジトリの中の `gitler/` です。 | |
| 306 | +* Fediverse(マルチユーザーの ActivityPub が有効な場合):公開のリポジトリは `@所有者.名前@ホスト` のアカウントになり、Mastodon 等からフォローするとイシューとコメントが届き、返信するとコメント、リポジトリをメンションした投稿は新しいイシューになります。ForgeFed の `Offer{Ticket}` と `Resolve` にも対応します(イシューの Ticket は `…/issues/3/ticket`)。リポジトリの設定でオフにしたり、アカウントやドメインをブロック出来ます(サイト全体のドメインのブロックも効きます)。配信には cron の `apdeliver` を使い、SSH のプッシュ(`git` ユーザー)で閉じたイシューは `gitler/spool/` に溜めて `apdeliver` が送ります。鍵の作成に GMP が必要です。 | |
| 305 | 307 | |
| 306 | 308 | ### TLS 1.3・1.2(php_openssl は不要) |
| 307 | 309 | 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
| @@ -300,6 +300,8 @@ | ||
| 300 | 300 | Each key runs `gitler.php serve <user>`, so the user only gets `git clone`/`git push` (`git@example.com:user/repo.git`). Without `AuthorizedKeysCommand`, `php gitler.php authorized-keys > /var/www/git/.ssh/authorized_keys` (for example from cron) writes the same lines. |
| 301 | 301 | * Add the Cron job `gitlergc` (for example `php cron.php add "@daily" gitlergc`) to pack the repositories, or run `php gitler.php gc`. |
| 302 | 302 | * The repository page shows the share of each language like GitHub (by bytes; file names, extensions and `#!`). Only programming and markup languages count: data (JSON, YAML…), prose (Markdown…), vendored code (`vendor/`, `node_modules/`…), documentation (`docs/`, `examples/`…) and generated files are left out. Change it with `linguist-language`, `linguist-vendored`, `linguist-generated`, `linguist-documentation` and `linguist-detectable` in the root `.gitattributes` (for example `*.h linguist-language=C++`). Results are stored per tree in `gitler-languages.json` inside the repository, and lists show the main language of the default branch. |
| 303 | +* Issues: labels, assignees, milestones, Markdown bodies and comments (`#3` links to another issue). Users who can read the repository and have the "Git issues" role open issues and comment; people with write access label and close them (authors can close their own). Commit messages pushed to the default branch close issues with `fixes #3`, `closes #3` or `resolves #3`, and any `#3` is recorded on the issue. Issues can be turned off per repository in its settings. The data lives in `gitler/` inside the repository. | |
| 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 `apdeliver`. Generating the keys needs GMP. | |
| 303 | 305 | |
| 304 | 306 | ### TLS 1.3 and 1.2 (no php_openssl) |
| 305 | 307 | 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
| @@ -56,6 +56,7 @@ | ||
| 56 | 56 | use Site\Controller\Gitler as GitlerController; |
| 57 | 57 | use Std\Lib\Git\Repository; |
| 58 | 58 | use Std\Lib\Git\Ssh; |
| 59 | +use Std\Lib\Gitler\Federation; | |
| 59 | 60 | use Std\Lib\Gitler\Gitler; |
| 60 | 61 | use Std\Lib\Gitler\Keys; |
| 61 | 62 | |
| @@ -109,13 +110,17 @@ | ||
| 109 | 110 | fwrite(STDERR, "こんにちは、{$name}さん!Gitlerにはシェルが有りません。\n"); |
| 110 | 111 | exit(1); |
| 111 | 112 | } |
| 112 | - exit(Ssh::handle($command, function(string $path, bool $write) use ($user): ?Repository { | |
| 113 | + // gitユーザーはdata/apに書けないので、Fediverseへの配信はリポジトリに溜める(cronのapdeliverが送る) | |
| 114 | + Federation::$spool = true; | |
| 115 | + $owner = $repoName = ''; | |
| 116 | + exit(Ssh::handle($command, function(string $path, bool $write) use ($user, &$owner, &$repoName): ?Repository { | |
| 113 | 117 | // 「所有者/名前.git」又は「所有者/名前」(前に「gitler/」が有っても良い) |
| 114 | 118 | if (!preg_match('#^(?:gitler/)?([A-Za-z0-9]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$#', $path, $m)) return null; |
| 115 | 119 | $access = Gitler::access($user, $m[1], $m[2]); |
| 116 | 120 | if ($access < ($write ? Gitler::WRITE : Gitler::READ)) return null; |
| 121 | + [$owner, $repoName] = [$m[1], $m[2]]; | |
| 117 | 122 | return Gitler::open($m[1], $m[2]); |
| 118 | - }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions()])); | |
| 123 | + }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions($owner, $repoName)])); | |
| 119 | 124 | |
| 120 | 125 | case 'gc': |
| 121 | 126 | $res = Gitler::gcAll(); |
| @@ -384,6 +384,217 @@ | ||
| 384 | 384 | vertical-align: middle; |
| 385 | 385 | } |
| 386 | 386 | |
| 387 | +.gl-issuebar { | |
| 388 | + display: flex; | |
| 389 | + flex-wrap: wrap; | |
| 390 | + justify-content: space-between; | |
| 391 | + align-items: center; | |
| 392 | + gap: 8px; | |
| 393 | + margin: 8px 0; | |
| 394 | +} | |
| 395 | + | |
| 396 | +.gl-states a { | |
| 397 | + padding: 2px 8px; | |
| 398 | + text-decoration: none; | |
| 399 | +} | |
| 400 | + | |
| 401 | +.gl-states a.active { | |
| 402 | + border: 1px solid #ea79d8; | |
| 403 | + border-radius: 10px; | |
| 404 | +} | |
| 405 | + | |
| 406 | +.gl-button { | |
| 407 | + border: 1px solid #ea79d8; | |
| 408 | + border-radius: 2px; | |
| 409 | + padding: 2px 8px; | |
| 410 | + text-decoration: none; | |
| 411 | +} | |
| 412 | + | |
| 413 | +.gl-filter input[type="text"] { | |
| 414 | + width: 12em; | |
| 415 | +} | |
| 416 | + | |
| 417 | +.gl-issues { | |
| 418 | + list-style: none; | |
| 419 | + padding: 0; | |
| 420 | +} | |
| 421 | + | |
| 422 | +.gl-issues li { | |
| 423 | + border-bottom: 1px solid #550f75; | |
| 424 | + padding: 8px 0; | |
| 425 | +} | |
| 426 | + | |
| 427 | +.gl-issues li small { | |
| 428 | + display: block; | |
| 429 | + margin-top: 2px; | |
| 430 | +} | |
| 431 | + | |
| 432 | +.gl-state-open { | |
| 433 | + color: #2c980c; | |
| 434 | +} | |
| 435 | + | |
| 436 | +.gl-state-closed { | |
| 437 | + color: #c016c6; | |
| 438 | +} | |
| 439 | + | |
| 440 | +.gl-label { | |
| 441 | + display: inline-block; | |
| 442 | + font-size: 0.8em; | |
| 443 | + padding: 0 8px; | |
| 444 | + border-radius: 10px; | |
| 445 | + text-decoration: none; | |
| 446 | + vertical-align: middle; | |
| 447 | +} | |
| 448 | + | |
| 449 | +.gl-count { | |
| 450 | + font-size: 0.85em; | |
| 451 | + margin-left: 4px; | |
| 452 | +} | |
| 453 | + | |
| 454 | +.gl-muted { | |
| 455 | + opacity: 0.7; | |
| 456 | +} | |
| 457 | + | |
| 458 | +.gl-pager { | |
| 459 | + display: flex; | |
| 460 | + gap: 16px; | |
| 461 | +} | |
| 462 | + | |
| 463 | +.gl-issuehead h2 { | |
| 464 | + margin: 8px 0 4px; | |
| 465 | + word-break: break-word; | |
| 466 | +} | |
| 467 | + | |
| 468 | +.gl-statebadge { | |
| 469 | + display: inline-block; | |
| 470 | + padding: 0 10px; | |
| 471 | + border-radius: 10px; | |
| 472 | + color: #ffffff; | |
| 473 | +} | |
| 474 | + | |
| 475 | +.gl-badge-open { | |
| 476 | + background-color: #2c980c; | |
| 477 | +} | |
| 478 | + | |
| 479 | +.gl-badge-closed { | |
| 480 | + background-color: #c016c6; | |
| 481 | +} | |
| 482 | + | |
| 483 | +.gl-issue { | |
| 484 | + display: flex; | |
| 485 | + flex-wrap: wrap; | |
| 486 | + gap: 16px; | |
| 487 | + align-items: flex-start; | |
| 488 | +} | |
| 489 | + | |
| 490 | +.gl-thread { | |
| 491 | + flex: 1 1 420px; | |
| 492 | + min-width: 0; | |
| 493 | +} | |
| 494 | + | |
| 495 | +.gl-side { | |
| 496 | + flex: 0 1 220px; | |
| 497 | +} | |
| 498 | + | |
| 499 | +.gl-side h3 { | |
| 500 | + font-size: 0.95em; | |
| 501 | + margin: 8px 0 2px; | |
| 502 | + border-bottom: 1px solid #550f75; | |
| 503 | +} | |
| 504 | + | |
| 505 | +.gl-side p { | |
| 506 | + margin: 4px 0; | |
| 507 | +} | |
| 508 | + | |
| 509 | +.gl-comment { | |
| 510 | + border: 1px solid #746c75; | |
| 511 | + border-radius: 2px; | |
| 512 | + margin: 8px 0; | |
| 513 | +} | |
| 514 | + | |
| 515 | +.gl-comment-head { | |
| 516 | + display: flex; | |
| 517 | + flex-wrap: wrap; | |
| 518 | + align-items: center; | |
| 519 | + gap: 6px; | |
| 520 | + padding: 4px 8px; | |
| 521 | + border-bottom: 1px solid #746c75; | |
| 522 | + background-color: rgba(85, 15, 117, 0.35); | |
| 523 | +} | |
| 524 | + | |
| 525 | +.gl-comment-body { | |
| 526 | + padding: 4px 8px; | |
| 527 | + overflow-x: auto; | |
| 528 | + word-break: break-word; | |
| 529 | +} | |
| 530 | + | |
| 531 | +.gl-comment-tools { | |
| 532 | + padding: 0 8px 4px; | |
| 533 | +} | |
| 534 | + | |
| 535 | +.gl-comment-tools form { | |
| 536 | + display: inline; | |
| 537 | +} | |
| 538 | + | |
| 539 | +.gl-av { | |
| 540 | + width: 20px; | |
| 541 | + height: 20px; | |
| 542 | + max-width: none; | |
| 543 | + border-radius: 2px; | |
| 544 | + object-fit: cover; | |
| 545 | +} | |
| 546 | + | |
| 547 | +.gl-event { | |
| 548 | + margin: 4px 0 4px 16px; | |
| 549 | + padding-left: 8px; | |
| 550 | + border-left: 2px solid #746c75; | |
| 551 | + font-size: 0.9em; | |
| 552 | +} | |
| 553 | + | |
| 554 | +.gl-issueform input[type="text"], .gl-issueform textarea, .gl-thread textarea { | |
| 555 | + width: 100%; | |
| 556 | + box-sizing: border-box; | |
| 557 | +} | |
| 558 | + | |
| 559 | +.gl-checks { | |
| 560 | + display: flex; | |
| 561 | + flex-wrap: wrap; | |
| 562 | + gap: 4px 12px; | |
| 563 | + border: 1px solid #746c75; | |
| 564 | + border-radius: 2px; | |
| 565 | +} | |
| 566 | + | |
| 567 | +.gl-edit summary { | |
| 568 | + cursor: pointer; | |
| 569 | +} | |
| 570 | + | |
| 571 | +.gl-fedi code { | |
| 572 | + word-break: break-all; | |
| 573 | +} | |
| 574 | + | |
| 575 | +.gl-progress { | |
| 576 | + height: 8px; | |
| 577 | + max-width: 400px; | |
| 578 | + margin: 4px 0; | |
| 579 | + border: 1px solid #746c75; | |
| 580 | + border-radius: 4px; | |
| 581 | + overflow: hidden; | |
| 582 | +} | |
| 583 | + | |
| 584 | +.gl-progress span { | |
| 585 | + display: block; | |
| 586 | + height: 100%; | |
| 587 | + background-color: #2c980c; | |
| 588 | +} | |
| 589 | + | |
| 590 | +.gl-overdue { | |
| 591 | + color: #ee4030; | |
| 592 | +} | |
| 593 | + | |
| 594 | +.gl-delete { | |
| 595 | + margin-top: 16px; | |
| 596 | +} | |
| 597 | + | |
| 387 | 598 | button.danger { |
| 388 | 599 | border-color: #ee4030; |
| 389 | 600 | color: #ee4030; |
変更
route.php
| @@ -169,6 +169,19 @@ | ||
| 169 | 169 | $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/git-upload-pack', Gitler::class.'@git', ['action' => 'git-upload-pack']); |
| 170 | 170 | $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/git-receive-pack', Gitler::class.'@git', ['action' => 'git-receive-pack']); |
| 171 | 171 | $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/commit/{id}', Gitler::class.'@commit'); |
| 172 | + // イシューとFediverse(inbox等) | |
| 173 | + $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/issues/new', Gitler::class.'@newIssue'); | |
| 174 | + $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/issues/new', Gitler::class.'@newIssue'); | |
| 175 | + $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/issues/{n}/ticket', Gitler::class.'@issueTicket'); | |
| 176 | + $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/issues/{n}/comments/{c}', Gitler::class.'@issueComment'); | |
| 177 | + $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/issues/{n}', Gitler::class.'@issue'); | |
| 178 | + $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/issues/{n}', Gitler::class.'@issue'); | |
| 179 | + foreach (['issues', 'labels', 'milestones', 'outbox', 'followers'] as $page) { | |
| 180 | + $routes[] = Route::add('GET', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page); | |
| 181 | + } | |
| 182 | + foreach (['labels', 'milestones', 'inbox'] as $page) { | |
| 183 | + $routes[] = Route::add('POST', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page); | |
| 184 | + } | |
| 172 | 185 | foreach (['tree', 'blob', 'raw', 'blame', 'commits', 'branches', 'tags', 'archive', 'settings'] as $page) { |
| 173 | 186 | $routes[] = Route::add('GET', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page); |
| 174 | 187 | } |
| @@ -59,7 +59,8 @@ | ||
| 59 | 59 | // マルチユーザーでは「resource」でユーザーを探す(ブログのアクター以外) |
| 60 | 60 | $resource = is_string($_GET['resource'] ?? null) ? $_GET['resource'] : ''; |
| 61 | 61 | if (Fedi::enabled() && $resource !== '' && !$this->isBlogResource($resource)) { |
| 62 | - $jrd = Actor::webfinger($resource); | |
| 62 | + // Gitlerのリポジトリ(acct:所有者.名前@ホスト) | |
| 63 | + $jrd = Actor::webfinger($resource) ?? (\Std\Lib\Gitler\Gitler::enabled() ? \Std\Lib\Gitler\Federation::webfinger($resource) : null); | |
| 63 | 64 | if ($jrd === null) $this->json(404, ['error' => '見つかりません'], 'application/jrd+json'); |
| 64 | 65 | $this->json(200, $jrd, 'application/jrd+json'); |
| 65 | 66 | } |
| @@ -47,7 +47,9 @@ | ||
| 47 | 47 | use Std\Lib\Git\Repository; |
| 48 | 48 | use Std\Lib\Git\Tree; |
| 49 | 49 | use Std\Lib\Gitler\Archive; |
| 50 | +use Std\Lib\Gitler\Federation; | |
| 50 | 51 | use Std\Lib\Gitler\Gitler as Hosting; |
| 52 | +use Std\Lib\Gitler\Issues; | |
| 51 | 53 | use Std\Lib\Gitler\Keys; |
| 52 | 54 | use Std\Lib\Gitler\Languages; |
| 53 | 55 | use Std\Lib\Markdown; |
| @@ -59,10 +61,11 @@ | ||
| 59 | 61 | * Gitler(/gitler):Gitのリポジトリのホスティング。JavaScriptなし |
| 60 | 62 | * |
| 61 | 63 | * リポジトリのページはクエリでリビジョンとパスを渡す(例:/gitler/user/repo/blob?ref=master&path=src/a.php)。 |
| 62 | - * git clone https://…/gitler/user/repo.git と SSH(gitler.php)に対応。 | |
| 64 | + * git clone https://…/gitler/user/repo.git と SSH(gitler.php)に対応。イシュー等はGitlerIssues。 | |
| 63 | 65 | */ |
| 64 | 66 | class Gitler { |
| 65 | 67 | use Mods; |
| 68 | + use GitlerIssues; | |
| 66 | 69 | |
| 67 | 70 | /** 1ページのコミットの数 */ |
| 68 | 71 | private const PER_PAGE = 30; |
| @@ -184,6 +187,9 @@ | ||
| 184 | 187 | public function repo(array $params): void { |
| 185 | 188 | // ブラウザで「.git」を付けて開いた場合 |
| 186 | 189 | if (str_ends_with((string)($params['repo'] ?? ''), '.git')) $this->redirect('/gitler/'.$params['owner'].'/'.rawurlencode(substr($params['repo'], 0, -4))); |
| 190 | + // Fediverseからはリポジトリのアクター | |
| 191 | + if ($this->wantsActivity()) $this->apActor($params); | |
| 192 | + header('Vary: Accept'); | |
| 187 | 193 | $this->load($params); |
| 188 | 194 | if ($this->repo->isEmpty()) { |
| 189 | 195 | // SSHの鍵を登録していればSSH、無ければHTTPS |
| @@ -475,6 +481,14 @@ | ||
| 475 | 481 | Hosting::delete($this->owner, $this->name); |
| 476 | 482 | $this->redirect('/gitler/'.$this->owner); |
| 477 | 483 | } |
| 484 | + } else if ($do === 'issues') { | |
| 485 | + $meta['issues'] = isset($_POST['issues']); | |
| 486 | + $meta['federate'] = isset($_POST['federate']); | |
| 487 | + Hosting::saveMeta($this->owner, $this->name, $meta); | |
| 488 | + $message = '保存しました。'; | |
| 489 | + } else if ($do === 'block' || $do === 'unblock') { | |
| 490 | + $res = Federation::block($this->owner, $this->name, (string)($_POST['target'] ?? ''), $do === 'block'); | |
| 491 | + $res->isSuccess ? $message = (string)$res->message : $error = (string)$res->message; | |
| 478 | 492 | } else if ($do === 'gc') { |
| 479 | 493 | try { |
| 480 | 494 | $r = \Std\Lib\Git\Maintenance::gc($this->repo); |
| @@ -491,6 +505,9 @@ | ||
| 491 | 505 | 'branches' => array_map(fn($n) => substr($n, 11), array_keys($this->repo->refs->all('refs/heads/'))), |
| 492 | 506 | 'currentBranch' => $this->defaultBranch(), |
| 493 | 507 | 'collaborators' => $this->meta['collaborators'], |
| 508 | + 'fediAvailable' => \Std\Lib\Fedi\Fedi::enabled(), 'fediOn' => Federation::enabled($this->meta), | |
| 509 | + 'fediHandle' => '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host(), | |
| 510 | + 'followerCount' => count(Federation::followers($this->owner, $this->name)), 'blocks' => Federation::blocks($this->owner, $this->name), | |
| 494 | 511 | ]); |
| 495 | 512 | } |
| 496 | 513 | |
| @@ -521,14 +538,20 @@ | ||
| 521 | 538 | exit; |
| 522 | 539 | } |
| 523 | 540 | $repo = Hosting::open($owner, $name); |
| 524 | - Http::handle($repo, $action, ['push' => $write, 'agent' => 'Gitler', 'receive' => self::receiveOptions()]); | |
| 541 | + Http::handle($repo, $action, ['push' => $write, 'agent' => 'Gitler', 'receive' => self::receiveOptions($owner, $name)]); | |
| 525 | 542 | exit; |
| 526 | 543 | } |
| 527 | 544 | |
| 528 | - /** プッシュの制限(ブランチとタグだけ) */ | |
| 529 | - public static function receiveOptions(): array { | |
| 545 | + /** | |
| 546 | + * プッシュの制限(ブランチとタグだけ)と、プッシュの後のイシューの処理 | |
| 547 | + * SSHではリポジトリが後で決まるので、所有者と名前は参照で受け取る | |
| 548 | + */ | |
| 549 | + public static function receiveOptions(string &$owner, string &$name): array { | |
| 530 | 550 | return [ |
| 531 | 551 | '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', |
| 552 | + 'after' => function(array $done) use (&$owner, &$name): void { | |
| 553 | + if ($owner !== '' && $name !== '') self::afterPush($owner, $name, $done); | |
| 554 | + }, | |
| 532 | 555 | ]; |
| 533 | 556 | } |
| 534 | 557 | |
| @@ -773,6 +796,7 @@ | ||
| 773 | 796 | 'commitsUrl' => $ref !== '' ? $this->url('commits', $ref, '') : '', |
| 774 | 797 | 'zipUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=zip' : '', |
| 775 | 798 | 'tgzUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=tar.gz' : '', |
| 799 | + 'issuesOn' => $this->meta['issues'], 'openIssues' => $this->meta['issues'] ? (new Issues($this->repo))->counts()['open'] : 0, | |
| 776 | 800 | ]); |
| 777 | 801 | } |
| 778 | 802 |
| @@ -0,0 +1,503 @@ | ||
| 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\Activitypub; | |
| 41 | +use Std\Lib\Fedi\Remote; | |
| 42 | +use Std\Lib\Gitler\Federation; | |
| 43 | +use Std\Lib\Gitler\Gitler as Hosting; | |
| 44 | +use Std\Lib\Gitler\Issues; | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Gitlerのイシュー、ラベル、マイルストーンのページと、リポジトリのFediverse(アクター、受付ボックス等) | |
| 48 | + * | |
| 49 | + * Gitlerコントローラーで使う(リポジトリを開く等はGitlerの物)。 | |
| 50 | + */ | |
| 51 | +trait GitlerIssues { | |
| 52 | + private ?Issues $issueDb = null; | |
| 53 | + /** 表示したユーザーのアバター */ | |
| 54 | + private array $avatars = []; | |
| 55 | + | |
| 56 | + //////// イシュー | |
| 57 | + | |
| 58 | + /** …/issues?state=&label=&milestone=&assignee=&q=&sort=&page= */ | |
| 59 | + public function issues(array $params): void { | |
| 60 | + $this->loadIssues($params); | |
| 61 | + $f = ['state' => in_array($_GET['state'] ?? '', ['closed', 'all'], true) ? $_GET['state'] : 'open']; | |
| 62 | + foreach (['label', 'milestone'] as $k) if (isset($_GET[$k]) && $_GET[$k] !== '') $f[$k] = (int)$_GET[$k]; | |
| 63 | + foreach (['assignee', 'q', 'sort'] as $k) if (is_string($_GET[$k] ?? null) && $_GET[$k] !== '') $f[$k] = trim($_GET[$k]); | |
| 64 | + $all = $this->issueDb->search(['state' => 'all'] + $f); | |
| 65 | + $counts = ['open' => 0, 'closed' => 0]; | |
| 66 | + foreach ($all as $s) $counts[$s['state']]++; | |
| 67 | + $list = $f['state'] === 'all' ? $all : array_values(array_filter($all, fn($s) => $s['state'] === $f['state'])); | |
| 68 | + $page = max(1, (int)($_GET['page'] ?? 1)); | |
| 69 | + $pages = max(1, (int)ceil(count($list) / self::PER_PAGE)); | |
| 70 | + $labels = $this->issueDb->labels(); | |
| 71 | + $milestones = $this->issueDb->milestones(); | |
| 72 | + $query = fn(array $change) => $this->repoUrl().'/issues?'.http_build_query(array_filter($change + $f + ['page' => null], fn($v) => $v !== null && $v !== '')); | |
| 73 | + $this->renderRepo('gitler/issues', 'イシュー', [ | |
| 74 | + 'list' => array_map(fn($s) => $this->presentSummary($s, $labels, $milestones), array_slice($list, ($page - 1) * self::PER_PAGE, self::PER_PAGE)), | |
| 75 | + 'counts' => $counts, 'f' => $f + ['label' => '', 'milestone' => '', 'assignee' => '', 'q' => '', 'sort' => 'created'], | |
| 76 | + 'openUrl' => $query(['state' => 'open']), 'closedUrl' => $query(['state' => 'closed']), | |
| 77 | + 'prevUrl' => $page > 1 ? $query(['page' => $page - 1]) : '', 'nextUrl' => $page < $pages ? $query(['page' => $page + 1]) : '', | |
| 78 | + 'labels' => $this->presentLabels($labels), 'milestones' => $milestones, 'people' => $this->assignable(), 'canIssue' => $this->canIssue(), | |
| 79 | + ]); | |
| 80 | + } | |
| 81 | + | |
| 82 | + /** …/issues/new */ | |
| 83 | + public function newIssue(array $params): void { | |
| 84 | + $this->loadIssues($params); | |
| 85 | + if (!$this->user) $this->redirect('/login'); | |
| 86 | + if (!$this->canIssue()) $this->message('イシューを作る権限が有りません。', $this->repoUrl().'/issues'); | |
| 87 | + $in = ['title' => '', 'body' => '', 'labels' => [], 'assignees' => [], 'milestone' => 0]; | |
| 88 | + $error = ''; | |
| 89 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 90 | + $in = ['title' => (string)($_POST['title'] ?? ''), 'body' => str_replace("\r\n", "\n", (string)($_POST['body'] ?? '')), | |
| 91 | + 'labels' => (array)($_POST['labels'] ?? []), 'assignees' => (array)($_POST['assignees'] ?? []), 'milestone' => (int)($_POST['milestone'] ?? 0)]; | |
| 92 | + if (!$this->csrfOk()) { | |
| 93 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 94 | + } else { | |
| 95 | + $opt = $this->access >= Hosting::WRITE ? ['labels' => $in['labels'], 'assignees' => array_intersect($in['assignees'], $this->assignable()), 'milestone' => $in['milestone']] : []; | |
| 96 | + $res = $this->issueDb->create($this->me(), $in['title'], $in['body'], $opt); | |
| 97 | + if ($res->isSuccess) { | |
| 98 | + Federation::issueOpened($this->owner, $this->name, $this->meta, $res->data['issue']); | |
| 99 | + $this->redirect($this->repoUrl().'/issues/'.$res->data['number']); | |
| 100 | + } | |
| 101 | + $error = (string)$res->message; | |
| 102 | + } | |
| 103 | + } | |
| 104 | + $this->renderRepo('gitler/issuenew', '新しいイシュー', [ | |
| 105 | + 'in' => $in, 'error' => $error, 'labels' => $this->presentLabels($this->issueDb->labels(), array_map('intval', $in['labels'])), | |
| 106 | + 'milestones' => $this->issueDb->milestones(), 'people' => $this->assignable(), | |
| 107 | + ]); | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** …/issues/{n}(ActivityPubではNote) */ | |
| 111 | + public function issue(array $params): void { | |
| 112 | + $n = (int)($params['n'] ?? 0); | |
| 113 | + if ($this->wantsActivity()) { | |
| 114 | + [$owner, $name, $issue] = $this->apIssue($params); | |
| 115 | + $this->apJson(['@context' => Federation::CONTEXT] + Federation::issueNote($owner, $name, $issue)); | |
| 116 | + } | |
| 117 | + $this->loadIssues($params); | |
| 118 | + $issue = $this->issueDb->get($n); | |
| 119 | + if ($issue === null) $this->notFound(); | |
| 120 | + $error = ''; | |
| 121 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 122 | + $error = $this->csrfOk() ? $this->issueAction($issue) : 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 123 | + $issue = $this->issueDb->get($n); | |
| 124 | + if ($issue === null) $this->redirect($this->repoUrl().'/issues'); | |
| 125 | + } | |
| 126 | + header('Vary: Accept'); | |
| 127 | + $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username; | |
| 128 | + $labels = $this->issueDb->labels(); | |
| 129 | + $milestones = $this->issueDb->milestones(); | |
| 130 | + $timeline = []; | |
| 131 | + foreach ($issue['comments'] as $c) { | |
| 132 | + $mine = $this->user && ($c['author']['user'] ?? null) === $this->user->username; | |
| 133 | + $timeline[] = ['kind' => 'comment', 'at' => $c['created'], 'id' => $c['id'], 'who' => $this->who($c['author']), 'html' => $this->bodyHtml($c['body'], !empty($c['html'])), | |
| 134 | + 'date' => date('Y-m-d H:i', $c['created']), 'edited' => $c['updated'] > $c['created'], 'body' => $c['body'], 'canEdit' => $mine && empty($c['html']), | |
| 135 | + 'canDelete' => $mine || $this->access >= Hosting::ADMIN, 'actor' => $c['author']['actor'] ?? '', | |
| 136 | + 'remote' => preg_match('#^https?://#i', (string)($c['remoteId'] ?? '')) ? $c['remoteId'] : '']; | |
| 137 | + } | |
| 138 | + 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'])]; | |
| 139 | + usort($timeline, fn($a, $b) => $a['at'] <=> $b['at']); | |
| 140 | + $fedi = Federation::enabled($this->meta); | |
| 141 | + $this->renderRepo('gitler/issue', '#'.$n.' '.$issue['title'], [ | |
| 142 | + 'issue' => $issue, 'who' => $this->who($issue['author']), 'bodyHtml' => $this->bodyHtml($issue['body'], !empty($issue['html'])), | |
| 143 | + 'date' => date('Y-m-d H:i', $issue['created']), 'timeline' => $timeline, 'error' => $error, | |
| 144 | + 'canIssue' => $this->canIssue(), 'canEdit' => ($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE, 'canClose' => $isAuthor || $this->access >= Hosting::WRITE, | |
| 145 | + 'canTriage' => $this->access >= Hosting::WRITE, 'canAdmin' => $this->access >= Hosting::ADMIN, | |
| 146 | + 'labels' => $this->presentLabels($labels, $issue['labels']), 'issueLabels' => $this->presentLabels(array_intersect_key($labels, array_flip($issue['labels']))), | |
| 147 | + 'milestones' => $milestones, 'milestone' => $issue['milestone'] !== null ? ($milestones[$issue['milestone']]['title'] ?? '') : '', | |
| 148 | + 'people' => array_map(fn($p) => ['name' => $p, 'checked' => in_array($p, $issue['assignees'], true)], array_unique(array_merge($this->assignable(), $issue['assignees']))), | |
| 149 | + 'issueUrl' => $this->repoUrl().'/issues/'.$n, 'fedi' => $fedi, | |
| 150 | + 'fediHandle' => $fedi ? '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host() : '', | |
| 151 | + 'fediUrl' => $fedi ? Federation::issueUrl($this->owner, $this->name, $n) : '', | |
| 152 | + ]); | |
| 153 | + } | |
| 154 | + | |
| 155 | + /** …/issues/{n}/ticket(ForgeFedのTicket。ブラウザはイシューへ) */ | |
| 156 | + public function issueTicket(array $params): void { | |
| 157 | + if (!$this->wantsActivity()) $this->redirect('/gitler/'.($params['owner'] ?? '').'/'.rawurlencode((string)($params['repo'] ?? '')).'/issues/'.(int)($params['n'] ?? 0)); | |
| 158 | + [$owner, $name, $issue] = $this->apIssue($params); | |
| 159 | + $this->apJson(Federation::ticket($owner, $name, $issue)); | |
| 160 | + } | |
| 161 | + | |
| 162 | + /** …/issues/{n}/comments/{c}(ローカルのコメントのNote。ブラウザはコメントへ) */ | |
| 163 | + public function issueComment(array $params): void { | |
| 164 | + $c = (int)($params['c'] ?? 0); | |
| 165 | + if (!$this->wantsActivity()) $this->redirect('/gitler/'.($params['owner'] ?? '').'/'.rawurlencode((string)($params['repo'] ?? '')).'/issues/'.(int)($params['n'] ?? 0).'#c'.$c); | |
| 166 | + [$owner, $name, $issue] = $this->apIssue($params); | |
| 167 | + foreach ($issue['comments'] as $x) { | |
| 168 | + if ($x['id'] === $c && !isset($x['author']['actor'])) $this->apJson(['@context' => Federation::CONTEXT] + Federation::commentNote($owner, $name, $issue, $x)); | |
| 169 | + } | |
| 170 | + $this->apJson(['error' => '見つかりません'], 404); | |
| 171 | + } | |
| 172 | + | |
| 173 | + /** イシューのページのPOST(エラーのメッセージ、成功したらリダイレクト) */ | |
| 174 | + private function issueAction(array $issue): string { | |
| 175 | + $n = $issue['number']; | |
| 176 | + $url = $this->repoUrl().'/issues/'.$n; | |
| 177 | + $do = (string)($_POST['do'] ?? ''); | |
| 178 | + $me = $this->me(); | |
| 179 | + $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username; | |
| 180 | + $canClose = $isAuthor || $this->access >= Hosting::WRITE; | |
| 181 | + $body = str_replace("\r\n", "\n", (string)($_POST['body'] ?? '')); | |
| 182 | + if (!$this->user) return 'ログインして下さい。'; | |
| 183 | + | |
| 184 | + if (in_array($do, ['comment', 'commentClose', 'commentReopen', 'close', 'reopen'], true)) { | |
| 185 | + if (!$this->canIssue()) return 'コメントする権限が有りません。'; | |
| 186 | + $anchor = ''; | |
| 187 | + if ($do === 'comment' || trim($body) !== '') { | |
| 188 | + $res = $this->issueDb->comment($n, $me, $body); | |
| 189 | + if (!$res->isSuccess) return (string)$res->message; | |
| 190 | + Federation::commentAdded($this->owner, $this->name, $this->meta, $res->data['issue'], $res->data['comment']); | |
| 191 | + $anchor = '#c'.$res->data['comment']['id']; | |
| 192 | + } | |
| 193 | + if ($do !== 'comment' && $canClose) { | |
| 194 | + $new = $this->issueDb->setState($n, in_array($do, ['reopen', 'commentReopen'], true), $me); | |
| 195 | + if ($new !== null) Federation::stateChanged($this->owner, $this->name, $this->meta, $new); | |
| 196 | + } | |
| 197 | + $this->redirect($url.$anchor); | |
| 198 | + } | |
| 199 | + if ($do === 'edit') { | |
| 200 | + if (!(($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE)) return '編集する権限が有りません。'; | |
| 201 | + $res = $this->issueDb->edit($n, $me, (string)($_POST['title'] ?? ''), empty($issue['html']) ? $body : $issue['body']); | |
| 202 | + if (!$res->isSuccess) return (string)$res->message; | |
| 203 | + Federation::issueEdited($this->owner, $this->name, $this->meta, $res->data['issue']); | |
| 204 | + $this->redirect($url); | |
| 205 | + } | |
| 206 | + if ($do === 'triage') { | |
| 207 | + if ($this->access < Hosting::WRITE) return '権限が有りません。'; | |
| 208 | + $people = array_merge($this->assignable(), $issue['assignees']); | |
| 209 | + $this->issueDb->triage($n, $me, (array)($_POST['labels'] ?? []), array_values(array_intersect((array)($_POST['assignees'] ?? []), $people)), (int)($_POST['milestone'] ?? 0)); | |
| 210 | + $this->redirect($url); | |
| 211 | + } | |
| 212 | + if ($do === 'editComment' || $do === 'deleteComment') { | |
| 213 | + $id = (int)($_POST['id'] ?? 0); | |
| 214 | + $c = null; | |
| 215 | + foreach ($issue['comments'] as $x) if ($x['id'] === $id) $c = $x; | |
| 216 | + if ($c === null) return 'コメントが有りません。'; | |
| 217 | + $mine = ($c['author']['user'] ?? null) === $this->user->username; | |
| 218 | + if ($do === 'editComment') { | |
| 219 | + if (!$mine || !empty($c['html'])) return '編集する権限が有りません。'; | |
| 220 | + $new = $this->issueDb->editComment($n, $id, $body); | |
| 221 | + if ($new === null) return 'コメントを入力して下さい。'; | |
| 222 | + Federation::commentAdded($this->owner, $this->name, $this->meta, $this->issueDb->get($n) ?? $issue, $new, 'Update'); | |
| 223 | + $this->redirect($url.'#c'.$id); | |
| 224 | + } | |
| 225 | + if (!$mine && $this->access < Hosting::ADMIN) return '削除する権限が有りません。'; | |
| 226 | + $this->issueDb->editComment($n, $id, null); | |
| 227 | + Federation::commentDeleted($this->owner, $this->name, $this->meta, $issue, $c); | |
| 228 | + $this->redirect($url); | |
| 229 | + } | |
| 230 | + if ($do === 'delete') { | |
| 231 | + if ($this->access < Hosting::ADMIN) return '削除する権限が有りません。'; | |
| 232 | + if (!isset($_POST['confirm'])) return '確認にチェックを入れて下さい。'; | |
| 233 | + $this->issueDb->delete($n); | |
| 234 | + Federation::issueDeleted($this->owner, $this->name, $this->meta, $issue); | |
| 235 | + $this->redirect($this->repoUrl().'/issues'); | |
| 236 | + } | |
| 237 | + if ($do === 'block') { | |
| 238 | + if ($this->access < Hosting::ADMIN) return 'ブロックする権限が有りません。'; | |
| 239 | + $res = Federation::block($this->owner, $this->name, (string)($_POST['actor'] ?? '')); | |
| 240 | + return $res->isSuccess ? '' : (string)$res->message; | |
| 241 | + } | |
| 242 | + return ''; | |
| 243 | + } | |
| 244 | + | |
| 245 | + //////// ラベルとマイルストーン | |
| 246 | + | |
| 247 | + /** …/labels */ | |
| 248 | + public function labels(array $params): void { | |
| 249 | + $this->loadIssues($params); | |
| 250 | + $error = ''; | |
| 251 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 252 | + if (!$this->csrfOk()) { | |
| 253 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 254 | + } else if ($this->access < Hosting::WRITE) { | |
| 255 | + $error = '権限が有りません。'; | |
| 256 | + } else if (($_POST['do'] ?? '') === 'delete') { | |
| 257 | + $this->issueDb->deleteLabel((int)($_POST['id'] ?? 0)); | |
| 258 | + $this->redirect($this->repoUrl().'/labels'); | |
| 259 | + } else { | |
| 260 | + $id = (int)($_POST['id'] ?? 0); | |
| 261 | + $res = $this->issueDb->saveLabel($id > 0 ? $id : null, (string)($_POST['name'] ?? ''), (string)($_POST['color'] ?? ''), (string)($_POST['description'] ?? '')); | |
| 262 | + if ($res->isSuccess) $this->redirect($this->repoUrl().'/labels'); | |
| 263 | + $error = (string)$res->message; | |
| 264 | + } | |
| 265 | + } | |
| 266 | + $counts = []; | |
| 267 | + foreach ($this->issueDb->search(['state' => 'open']) as $s) foreach ($s['labels'] as $l) $counts[$l] = ($counts[$l] ?? 0) + 1; | |
| 268 | + $list = []; | |
| 269 | + foreach ($this->presentLabels($this->issueDb->labels()) as $l) $list[] = $l + ['open' => $counts[$l['id']] ?? 0, 'url' => $this->repoUrl().'/issues?label='.$l['id']]; | |
| 270 | + $this->renderRepo('gitler/labels', 'ラベル', ['list' => $list, 'error' => $error, 'canTriage' => $this->access >= Hosting::WRITE]); | |
| 271 | + } | |
| 272 | + | |
| 273 | + /** …/milestones */ | |
| 274 | + public function milestones(array $params): void { | |
| 275 | + $this->loadIssues($params); | |
| 276 | + $error = ''; | |
| 277 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 278 | + $do = (string)($_POST['do'] ?? ''); | |
| 279 | + $id = (int)($_POST['id'] ?? 0); | |
| 280 | + $all = $this->issueDb->milestones(); | |
| 281 | + if (!$this->csrfOk()) { | |
| 282 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 283 | + } else if ($this->access < Hosting::WRITE) { | |
| 284 | + $error = '権限が有りません。'; | |
| 285 | + } else if ($do === 'delete') { | |
| 286 | + $this->issueDb->deleteMilestone($id); | |
| 287 | + $this->redirect($this->repoUrl().'/milestones'); | |
| 288 | + } else if (($do === 'close' || $do === 'reopen') && isset($all[$id])) { | |
| 289 | + $m = $all[$id]; | |
| 290 | + $this->issueDb->saveMilestone($id, $m['title'], $m['description'], $m['due'], $do === 'close' ? 'closed' : 'open'); | |
| 291 | + $this->redirect($this->repoUrl().'/milestones'.($do === 'close' ? '?state=closed' : '')); | |
| 292 | + } else { | |
| 293 | + $old = $all[$id] ?? null; | |
| 294 | + $res = $this->issueDb->saveMilestone($old ? $id : null, (string)($_POST['title'] ?? ''), (string)($_POST['description'] ?? ''), trim((string)($_POST['due'] ?? '')), $old['state'] ?? 'open'); | |
| 295 | + if ($res->isSuccess) $this->redirect($this->repoUrl().'/milestones'); | |
| 296 | + $error = (string)$res->message; | |
| 297 | + } | |
| 298 | + } | |
| 299 | + $state = ($_GET['state'] ?? '') === 'closed' ? 'closed' : 'open'; | |
| 300 | + $stats = []; | |
| 301 | + foreach ($this->issueDb->search(['state' => 'all']) as $s) if ($s['milestone'] !== null) $stats[$s['milestone']][$s['state']] = ($stats[$s['milestone']][$s['state']] ?? 0) + 1; | |
| 302 | + $list = []; | |
| 303 | + $counts = ['open' => 0, 'closed' => 0]; | |
| 304 | + foreach ($this->issueDb->milestones() as $id => $m) { | |
| 305 | + $counts[$m['state']]++; | |
| 306 | + if ($m['state'] !== $state) continue; | |
| 307 | + $open = $stats[$id]['open'] ?? 0; | |
| 308 | + $closed = $stats[$id]['closed'] ?? 0; | |
| 309 | + $list[] = $m + ['id' => $id, 'open' => $open, 'closed' => $closed, 'percent' => $open + $closed > 0 ? (int)floor($closed * 100 / ($open + $closed)) : 0, | |
| 310 | + 'overdue' => $m['due'] !== '' && $m['state'] === 'open' && $m['due'] < date('Y-m-d'), | |
| 311 | + 'html' => $m['description'] !== '' ? Issues::render($m['description'], $this->repoUrl().'/issues') : '', | |
| 312 | + 'url' => $this->repoUrl().'/issues?state=all&milestone='.$id]; | |
| 313 | + } | |
| 314 | + usort($list, fn($a, $b) => [$a['due'] === '' ? '9999' : $a['due'], $a['id']] <=> [$b['due'] === '' ? '9999' : $b['due'], $b['id']]); | |
| 315 | + $this->renderRepo('gitler/milestones', 'マイルストーン', ['list' => $list, 'error' => $error, 'state' => $state, 'counts' => $counts, 'canTriage' => $this->access >= Hosting::WRITE]); | |
| 316 | + } | |
| 317 | + | |
| 318 | + //////// ActivityPub(リポジトリのアクター) | |
| 319 | + | |
| 320 | + /** アクターのJSON(…/gitler/{owner}/{repo}をActivityPubで取得) */ | |
| 321 | + private function apActor(array $params): never { | |
| 322 | + [$owner, $name, $meta] = $this->apRepo($params); | |
| 323 | + $this->owner = $owner; | |
| 324 | + $this->name = $name; | |
| 325 | + $this->apJson(Federation::document($owner, $name, $meta, $this->cloneUrls()['https'])); | |
| 326 | + } | |
| 327 | + | |
| 328 | + /** …/inbox */ | |
| 329 | + public function inbox(array $params): void { | |
| 330 | + if ($_SERVER['REQUEST_METHOD'] !== 'POST') $this->apJson(['error' => 'POSTだけです'], 405); | |
| 331 | + [$owner, $name] = $this->apRepo($params); | |
| 332 | + $max = 262144; | |
| 333 | + $input = file_get_contents('php://input', false, null, 0, $max + 1); | |
| 334 | + if ($input === false || strlen($input) > $max) $this->apJson(['error' => '大き過ぎます'], 413); | |
| 335 | + $activity = json_decode($input, true); | |
| 336 | + if (!is_array($activity) || !is_string($activity['type'] ?? null)) $this->apJson(['error' => '不正なアクティビティ'], 400); | |
| 337 | + if (!(new Activitypub())->verifyInboxSignature($input, $activity)) { | |
| 338 | + // 削除されたアカウントは鍵を取得出来ないので、Deleteは受け流す | |
| 339 | + $this->apJson($activity['type'] === 'Delete' ? ['status' => '無視しました'] : ['error' => '署名の検証に失敗'], $activity['type'] === 'Delete' ? 202 : 401); | |
| 340 | + } | |
| 341 | + logger(\LogType::ActivityPub, "Gitler({$owner}/{$name})の受付に入れた:".$input); | |
| 342 | + [$code, $body] = Federation::receive($owner, $name, $activity); | |
| 343 | + $this->apJson($body, $code); | |
| 344 | + } | |
| 345 | + | |
| 346 | + /** …/outbox(最近のイシュー20件) */ | |
| 347 | + public function outbox(array $params): void { | |
| 348 | + [$owner, $name] = $this->apRepo($params); | |
| 349 | + $issues = Issues::open($owner, $name); | |
| 350 | + $items = []; | |
| 351 | + foreach (array_slice($issues->search(['state' => 'all']), 0, 20) as $s) { | |
| 352 | + $issue = $issues->get($s['number']); | |
| 353 | + if ($issue === null) continue; | |
| 354 | + $note = Federation::issueNote($owner, $name, $issue); | |
| 355 | + $items[] = ['id' => $note['id'].'#create', 'type' => 'Create', 'actor' => $note['attributedTo'], 'published' => $note['published'], 'to' => $note['to'], 'cc' => $note['cc'], 'object' => $note]; | |
| 356 | + } | |
| 357 | + $url = Federation::actorUrl($owner, $name).'/outbox'; | |
| 358 | + $this->apJson(['@context' => Federation::CONTEXT, 'id' => $url, 'type' => 'OrderedCollection', 'totalItems' => count($issues->index()['issues']), 'orderedItems' => $items]); | |
| 359 | + } | |
| 360 | + | |
| 361 | + /** …/followers(数だけ) */ | |
| 362 | + public function followers(array $params): void { | |
| 363 | + [$owner, $name] = $this->apRepo($params); | |
| 364 | + $this->apJson(['@context' => 'https://www.w3.org/ns/activitystreams', 'id' => Federation::actorUrl($owner, $name).'/followers', 'type' => 'OrderedCollection', | |
| 365 | + 'totalItems' => count(Federation::followers($owner, $name))]); | |
| 366 | + } | |
| 367 | + | |
| 368 | + /** Fediverseに出ているリポジトリ([所有者, 名前, meta]、無ければ404) */ | |
| 369 | + private function apRepo(array $params): array { | |
| 370 | + $owner = (string)($params['owner'] ?? ''); | |
| 371 | + $name = (string)($params['repo'] ?? ''); | |
| 372 | + if (!Hosting::validOwner($owner) || !Hosting::validName($name) || !Hosting::exists($owner, $name)) $this->apJson(['error' => '見つかりません'], 404); | |
| 373 | + $meta = Hosting::meta($owner, $name); | |
| 374 | + if (!Federation::enabled($meta)) $this->apJson(['error' => '見つかりません'], 404); | |
| 375 | + return [$owner, $name, $meta]; | |
| 376 | + } | |
| 377 | + | |
| 378 | + /** ActivityPubで取得するイシュー([所有者, 名前, イシュー]) */ | |
| 379 | + private function apIssue(array $params): array { | |
| 380 | + [$owner, $name] = $this->apRepo($params); | |
| 381 | + $issue = Issues::open($owner, $name)->get((int)($params['n'] ?? 0)); | |
| 382 | + if ($issue === null) $this->apJson(['error' => '見つかりません'], 404); | |
| 383 | + return [$owner, $name, $issue]; | |
| 384 | + } | |
| 385 | + | |
| 386 | + private function wantsActivity(): bool { | |
| 387 | + $accept = strtolower((string)($_SERVER['HTTP_ACCEPT'] ?? '')); | |
| 388 | + return str_contains($accept, 'application/activity+json') || str_contains($accept, 'application/ld+json'); | |
| 389 | + } | |
| 390 | + | |
| 391 | + private function apJson(array $data, int $code = 200): never { | |
| 392 | + http_response_code($code); | |
| 393 | + header('Content-Type: application/activity+json; charset=utf-8'); | |
| 394 | + header('Vary: Accept'); | |
| 395 | + echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); | |
| 396 | + exit; | |
| 397 | + } | |
| 398 | + | |
| 399 | + //////// プッシュ | |
| 400 | + | |
| 401 | + /** プッシュの後:コミットのメッセージの「fixes #3」等でイシューを閉じる */ | |
| 402 | + public static function afterPush(string $owner, string $name, array $done): void { | |
| 403 | + try { | |
| 404 | + $meta = Hosting::meta($owner, $name); | |
| 405 | + if (!$meta['issues']) return; | |
| 406 | + $issues = Issues::open($owner, $name); | |
| 407 | + $head = $issues->repo->headRef() ?? 'refs/heads/'.Hosting::config()['branch']; | |
| 408 | + foreach ($issues->processPush($done, $head) as $n) { | |
| 409 | + $issue = $issues->get($n); | |
| 410 | + if ($issue !== null) Federation::stateChanged($owner, $name, $meta, $issue); | |
| 411 | + } | |
| 412 | + } catch (\Throwable $e) { | |
| 413 | + // プッシュ自体は終わっている | |
| 414 | + @error_log('Gitler: '.$e->getMessage()); | |
| 415 | + } | |
| 416 | + } | |
| 417 | + | |
| 418 | + //////// 共通 | |
| 419 | + | |
| 420 | + private function loadIssues(array $params): void { | |
| 421 | + $this->load($params); | |
| 422 | + if (!$this->meta['issues']) $this->notFound(); | |
| 423 | + $this->issueDb = new Issues($this->repo); | |
| 424 | + } | |
| 425 | + | |
| 426 | + /** イシューを作ってコメント出来るか */ | |
| 427 | + private function canIssue(): bool { | |
| 428 | + return $this->user !== null && $this->meta['issues'] && Hosting::userCan($this->user, 'issues'); | |
| 429 | + } | |
| 430 | + | |
| 431 | + private function me(): array { | |
| 432 | + return ['user' => (string)$this->user->username]; | |
| 433 | + } | |
| 434 | + | |
| 435 | + /** 担当者に出来る人(所有者と共同作業者) */ | |
| 436 | + private function assignable(): array { | |
| 437 | + return array_values(array_unique(array_merge([$this->owner], array_map('strval', array_keys($this->meta['collaborators']))))); | |
| 438 | + } | |
| 439 | + | |
| 440 | + /** 書いた者の表示(name、url、icon、remote) */ | |
| 441 | + private function who(array $a): array { | |
| 442 | + if (isset($a['user'])) { | |
| 443 | + $u = (string)$a['user']; | |
| 444 | + if (!array_key_exists($u, $this->avatars)) { | |
| 445 | + $x = Hosting::user($u); | |
| 446 | + $this->avatars[$u] = $x ? avatar_url($x) : ''; | |
| 447 | + } | |
| 448 | + return ['name' => $u, 'url' => '/gitler/'.$u, 'icon' => $this->avatars[$u], 'remote' => false, 'handle' => '']; | |
| 449 | + } | |
| 450 | + if (isset($a['actor'])) { | |
| 451 | + return ['name' => Federation::authorName($a), 'url' => (string)($a['url'] ?? $a['actor']), 'icon' => Remote::imageUrl((string)($a['icon'] ?? '')), 'remote' => true, | |
| 452 | + 'handle' => (string)($a['handle'] ?? '')]; | |
| 453 | + } | |
| 454 | + return ['name' => (string)($a['name'] ?? ''), 'url' => isset($a['commit']) ? $this->repoUrl().'/commit/'.$a['commit'] : '', 'icon' => '', 'remote' => false, 'handle' => '']; | |
| 455 | + } | |
| 456 | + | |
| 457 | + private function bodyHtml(string $body, bool $html): string { | |
| 458 | + if ($html) return $body; | |
| 459 | + return trim($body) === '' ? '<p class="gl-muted">(説明は有りません)</p>' : Issues::render($body, $this->repoUrl().'/issues'); | |
| 460 | + } | |
| 461 | + | |
| 462 | + /** ラベルの表示(id、name、color、fg(文字の色)、description、checked) */ | |
| 463 | + private function presentLabels(array $labels, array $checked = []): array { | |
| 464 | + $out = []; | |
| 465 | + foreach ($labels as $id => $l) { | |
| 466 | + $rgb = sscanf($l['color'], '#%02x%02x%02x'); | |
| 467 | + $out[] = $l + ['id' => $id, 'fg' => (0.299 * $rgb[0] + 0.587 * $rgb[1] + 0.114 * $rgb[2]) > 150 ? '#000000' : '#ffffff', 'checked' => in_array($id, $checked, true)]; | |
| 468 | + } | |
| 469 | + usort($out, fn($a, $b) => strcasecmp($a['name'], $b['name'])); | |
| 470 | + return $out; | |
| 471 | + } | |
| 472 | + | |
| 473 | + private function presentSummary(array $s, array $labels, array $milestones): array { | |
| 474 | + $who = $this->who($s['author']); | |
| 475 | + return ['number' => $s['number'], 'title' => $s['title'], 'url' => $this->repoUrl().'/issues/'.$s['number'], 'state' => $s['state'], | |
| 476 | + 'labels' => $this->presentLabels(array_intersect_key($labels, array_flip($s['labels']))), 'author' => $who['name'], | |
| 477 | + 'date' => date('Y-m-d', $s['created']), 'closedDate' => $s['closed'] ? date('Y-m-d', $s['closed']) : '', 'comments' => $s['comments'], | |
| 478 | + 'milestone' => $s['milestone'] !== null ? ($milestones[$s['milestone']]['title'] ?? '') : '', 'assignees' => $s['assignees']]; | |
| 479 | + } | |
| 480 | + | |
| 481 | + /** 出来事の文(HTML) */ | |
| 482 | + private function eventHtml(array $e, array $labels, array $milestones): string { | |
| 483 | + $h = fn(string $s) => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); | |
| 484 | + $who = $this->who($e['by']); | |
| 485 | + $by = $who['url'] !== '' ? '<a href="'.$h($who['url']).'">'.$h($who['name']).'</a>' : '<b>'.$h($who['name']).'</b>'; | |
| 486 | + $commit = fn(string $id) => '<a href="'.$h($this->repoUrl().'/commit/'.$id).'"><code>'.$h(substr($id, 0, 7)).'</code></a>'; | |
| 487 | + $chip = function(int $id) use ($labels, $h): string { | |
| 488 | + $l = $this->presentLabels(isset($labels[$id]) ? [$id => $labels[$id]] : [])[0] ?? null; | |
| 489 | + return $l ? '<span class="gl-label" style="background-color: '.$h($l['color']).'; color: '.$l['fg'].';">'.$h($l['name']).'</span>' : '<s>?</s>'; | |
| 490 | + }; | |
| 491 | + $list = fn(array $xs, callable $f) => implode('、', array_map($f, $xs)); | |
| 492 | + return match ($e['type']) { | |
| 493 | + 'closed' => isset($e['commit']) ? 'コミット'.$commit($e['commit']).'('.$by.')で閉じました' : $by.'が閉じました', | |
| 494 | + 'reopened' => $by.'が開き直しました', | |
| 495 | + 'title' => $by.'が題名を「'.$h($e['from']).'」から「'.$h($e['to']).'」に変えました', | |
| 496 | + 'labels' => $by.'が'.trim(($e['add'] ? 'ラベル'.$list($e['add'], $chip).'を付け' : '').($e['add'] && $e['remove'] ? '、' : '').($e['remove'] ? 'ラベル'.$list($e['remove'], $chip).'を外し' : '')).'ました', | |
| 497 | + 'assignees' => $by.'が'.($e['add'] ? '担当者に'.$h(implode('、', $e['add'])).'を追加し' : '').($e['add'] && $e['remove'] ? '、' : '').($e['remove'] ? $h(implode('、', $e['remove'])).'を担当者から外し' : '').'ました', | |
| 498 | + 'milestone' => $e['to'] !== null ? $by.'がマイルストーンを「'.$h($milestones[$e['to']]['title'] ?? '?').'」にしました' : $by.'がマイルストーンを外しました', | |
| 499 | + 'commit' => $by.'がコミット'.$commit($e['commit']).'「'.$h($e['subject']).'」('.$h($e['branch']).')で言及しました', | |
| 500 | + default => $h($e['type']), | |
| 501 | + }; | |
| 502 | + } | |
| 503 | +} |
| @@ -55,7 +55,7 @@ | ||
| 55 | 55 | /** |
| 56 | 56 | * アクティビティを送る(ローカルの宛先はすぐに処理し、リモートは待ち行列に入れる) |
| 57 | 57 | * |
| 58 | - * @param string $from 送るユーザー | |
| 58 | + * @param string $from 送るユーザー(「gitler:所有者/名前」はGitlerのリポジトリ) | |
| 59 | 59 | * @param array $activity アクティビティ |
| 60 | 60 | * @param array $inboxes 受付ボックスのURL |
| 61 | 61 | * @return void |
| @@ -197,18 +197,20 @@ | ||
| 197 | 197 | /** |
| 198 | 198 | * 署名して送る |
| 199 | 199 | * |
| 200 | - * @param string $from 送るユーザー(空ならブログのアクター) | |
| 200 | + * @param string $from 送るユーザー(「gitler:所有者/名前」はGitlerのリポジトリ) | |
| 201 | 201 | * @param string $inbox 受付ボックス |
| 202 | 202 | * @param array $activity アクティビティ |
| 203 | 203 | * @return bool 2xxならtrue |
| 204 | 204 | */ |
| 205 | 205 | protected static function send(string $from, string $inbox, array $activity): bool { |
| 206 | 206 | if (!Remote::isSafeUrl($inbox)) return false; |
| 207 | - $key = Actor::privateKey($from); | |
| 207 | + // Gitlerのリポジトリ(「gitler:所有者/名前」)はリポジトリの鍵 | |
| 208 | + [$key, $keyId] = str_starts_with($from, 'gitler:') ? (\Std\Lib\Gitler\Federation::signer(substr($from, 7)) ?? [null, '']) | |
| 209 | + : [Actor::privateKey($from), Actor::keyId($from)]; | |
| 208 | 210 | if ($key === null) return false; |
| 209 | 211 | |
| 210 | 212 | $body = json_encode($activity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); |
| 211 | - $headers = (new Activitypub())->signRequest($inbox, $body, $key, 'post', Actor::keyId($from)); | |
| 213 | + $headers = (new Activitypub())->signRequest($inbox, $body, $key, 'post', $keyId); | |
| 212 | 214 | if ($headers === null) return false; |
| 213 | 215 | |
| 214 | 216 | $curl = new Curl($inbox); |
| @@ -0,0 +1,685 @@ | ||
| 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\Crypto\Rsa; | |
| 41 | +use Std\Lib\Fedi\Actor; | |
| 42 | +use Std\Lib\Fedi\Delivery; | |
| 43 | +use Std\Lib\Fedi\Fedi; | |
| 44 | +use Std\Lib\Fedi\Html; | |
| 45 | +use Std\Lib\Fedi\Remote; | |
| 46 | + | |
| 47 | +/** | |
| 48 | + * GitlerのFediverse(ActivityPub、ForgeFed) | |
| 49 | + * | |
| 50 | + * 公開のリポジトリは「@所有者.名前@ホスト」のアクター(ID:/gitler/所有者/名前)で、フォロー出来る。 | |
| 51 | + * イシューとコメントはNoteとしてフォロワーに届く(Mastodon等で読めて、返信するとコメントになる)。 | |
| 52 | + * リポジトリをメンションした投稿は新しいイシューになる。ForgeFedのOffer{Ticket}、Resolveにも対応。 | |
| 53 | + * | |
| 54 | + * /gitler/o/r アクター(ForgeFedのcloneUri、ticketsTrackedBy付き) | |
| 55 | + * /gitler/o/r/issues/3 イシューのNote | |
| 56 | + * /gitler/o/r/issues/3/ticket ForgeFedのTicket | |
| 57 | + * /gitler/o/r/issues/3/comments/5 コメントのNote | |
| 58 | + * | |
| 59 | + * 鍵、フォロワー、ブロックはリポジトリの中のgitler/。配信はFediのDelivery(cronのapdeliver)。 | |
| 60 | + * SSHのプッシュ(gitユーザー)はdata/apに書けないので、gitler/spool/に溜めてapdeliverが送る。 | |
| 61 | + */ | |
| 62 | +final class Federation { | |
| 63 | + public const CONTEXT = [ | |
| 64 | + 'https://www.w3.org/ns/activitystreams', | |
| 65 | + 'https://w3id.org/security/v1', | |
| 66 | + [ | |
| 67 | + 'forgefed' => 'https://forgefed.org/ns#', | |
| 68 | + 'Repository' => 'forgefed:Repository', | |
| 69 | + 'Ticket' => 'forgefed:Ticket', | |
| 70 | + 'Resolve' => 'forgefed:Resolve', | |
| 71 | + 'isResolved' => 'forgefed:isResolved', | |
| 72 | + 'cloneUri' => ['@id' => 'forgefed:cloneUri', '@type' => '@id'], | |
| 73 | + 'ticketsTrackedBy' => ['@id' => 'forgefed:ticketsTrackedBy', '@type' => '@id'], | |
| 74 | + 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', | |
| 75 | + 'toot' => 'http://joinmastodon.org/ns#', | |
| 76 | + 'discoverable' => 'toot:discoverable', | |
| 77 | + ], | |
| 78 | + ]; | |
| 79 | + | |
| 80 | + /** SSHのプッシュでは配信をリポジトリに溜める */ | |
| 81 | + public static bool $spool = false; | |
| 82 | + | |
| 83 | + /** このリポジトリがFediverseに出るか */ | |
| 84 | + public static function enabled(array $meta): bool { | |
| 85 | + return Fedi::enabled() && empty($meta['private']) && ($meta['issues'] ?? true) && ($meta['federate'] ?? true); | |
| 86 | + } | |
| 87 | + | |
| 88 | + public static function actorUrl(string $owner, string $name): string { | |
| 89 | + return Fedi::base().'/gitler/'.$owner.'/'.rawurlencode($name); | |
| 90 | + } | |
| 91 | + | |
| 92 | + public static function issueUrl(string $owner, string $name, int $n): string { | |
| 93 | + return self::actorUrl($owner, $name).'/issues/'.$n; | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** 「所有者.名前」(ユーザー名には「.」が無いので重ならない) */ | |
| 97 | + public static function handle(string $owner, string $name): string { | |
| 98 | + return $owner.'.'.$name; | |
| 99 | + } | |
| 100 | + | |
| 101 | + //////// アクター | |
| 102 | + | |
| 103 | + /** | |
| 104 | + * WebFinger(acct:所有者.名前@ホスト又はアクターのURL) | |
| 105 | + * | |
| 106 | + * @return array|null JRD、このリポジトリでなければnull | |
| 107 | + */ | |
| 108 | + public static function webfinger(string $resource): ?array { | |
| 109 | + $found = null; | |
| 110 | + if (preg_match('/^(?:acct:)?@?([A-Za-z0-9]+)\.([A-Za-z0-9._-]+)@(.+)$/', $resource, $m) && strtolower($m[3]) === strtolower(Fedi::host())) { | |
| 111 | + $found = self::find($m[1], $m[2]); | |
| 112 | + } else if (preg_match('#^'.preg_quote(Fedi::base(), '#').'/gitler/([A-Za-z0-9]+)/([^/?\#]+)$#', $resource, $m)) { | |
| 113 | + $found = self::find($m[1], rawurldecode($m[2])); | |
| 114 | + } | |
| 115 | + if ($found === null) return null; | |
| 116 | + [$owner, $name] = $found; | |
| 117 | + $url = self::actorUrl($owner, $name); | |
| 118 | + return [ | |
| 119 | + 'subject' => 'acct:'.self::handle($owner, $name).'@'.Fedi::host(), | |
| 120 | + 'aliases' => [$url], | |
| 121 | + 'links' => [ | |
| 122 | + ['rel' => 'self', 'type' => 'application/activity+json', 'href' => $url], | |
| 123 | + ['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => $url], | |
| 124 | + ], | |
| 125 | + ]; | |
| 126 | + } | |
| 127 | + | |
| 128 | + /** 大文字・小文字を区別せずにFediverseに出るリポジトリを探す([所有者, 名前]) */ | |
| 129 | + public static function find(string $owner, string $name): ?array { | |
| 130 | + foreach (Gitler::list() as $r) { | |
| 131 | + if (strcasecmp($r['owner'], $owner) === 0 && strcasecmp($r['name'], $name) === 0) return self::enabled($r['meta']) ? [$r['owner'], $r['name']] : null; | |
| 132 | + } | |
| 133 | + return null; | |
| 134 | + } | |
| 135 | + | |
| 136 | + /** アクターのJSON-LD */ | |
| 137 | + public static function document(string $owner, string $name, array $meta, string $cloneUrl): array { | |
| 138 | + $url = self::actorUrl($owner, $name); | |
| 139 | + $desc = (string)($meta['description'] ?? ''); | |
| 140 | + $doc = [ | |
| 141 | + '@context' => self::CONTEXT, | |
| 142 | + 'id' => $url, | |
| 143 | + // Mastodon等がフォロー出来る種類(ForgeFedのRepositoryはアカウントとして扱われない) | |
| 144 | + 'type' => 'Service', | |
| 145 | + 'preferredUsername' => self::handle($owner, $name), | |
| 146 | + 'name' => "{$owner}/{$name}", | |
| 147 | + 'summary' => ($desc !== '' ? '<p>'.Html::esc($desc).'</p>' : '').'<p>Gitlerのリポジトリ。フォローするとイシューが届き、返信するとコメント出来ます。メンションすると新しいイシューになります。</p>', | |
| 148 | + 'url' => $url, | |
| 149 | + 'inbox' => "{$url}/inbox", | |
| 150 | + 'outbox' => "{$url}/outbox", | |
| 151 | + 'followers' => "{$url}/followers", | |
| 152 | + 'manuallyApprovesFollowers' => false, | |
| 153 | + 'discoverable' => true, | |
| 154 | + 'published' => Fedi::now((int)($meta['created'] ?? 0) ?: time()), | |
| 155 | + 'cloneUri' => $cloneUrl, | |
| 156 | + 'ticketsTrackedBy' => $url, | |
| 157 | + ]; | |
| 158 | + $u = Gitler::user($owner); | |
| 159 | + if ($u !== null) { | |
| 160 | + $avatar = avatar_url($u); | |
| 161 | + $doc['icon'] = ['type' => 'Image', 'url' => preg_match('#^https?://#i', $avatar) ? $avatar : Fedi::base().$avatar]; | |
| 162 | + } | |
| 163 | + $key = self::key($owner, $name); | |
| 164 | + if ($key !== null) $doc['publicKey'] = ['id' => "{$url}#main-key", 'owner' => $url, 'publicKeyPem' => $key->publicPem()]; | |
| 165 | + return $doc; | |
| 166 | + } | |
| 167 | + | |
| 168 | + /** リポジトリの秘密鍵(無ければ作る、GMPが必要) */ | |
| 169 | + public static function key(string $owner, string $name): ?Rsa { | |
| 170 | + if (!function_exists('gmp_init') || !Gitler::exists($owner, $name)) return null; | |
| 171 | + $repo = Gitler::open($owner, $name); | |
| 172 | + $file = $repo->path.'/gitler/private.pem'; | |
| 173 | + if (!is_file($file)) { | |
| 174 | + $repo->mkdir(dirname($file)); | |
| 175 | + $lock = @fopen($file.'.lock', 'c'); | |
| 176 | + if ($lock) flock($lock, LOCK_EX); | |
| 177 | + try { | |
| 178 | + if (!is_file($file)) { | |
| 179 | + $key = Rsa::generate(2048); | |
| 180 | + $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; | |
| 181 | + file_put_contents($tmp, $key->toPem()); | |
| 182 | + @chmod($tmp, 0600); | |
| 183 | + rename($tmp, $file); | |
| 184 | + } | |
| 185 | + } finally { | |
| 186 | + if ($lock) { | |
| 187 | + flock($lock, LOCK_UN); | |
| 188 | + fclose($lock); | |
| 189 | + } | |
| 190 | + @unlink($file.'.lock'); | |
| 191 | + } | |
| 192 | + } | |
| 193 | + try { | |
| 194 | + $key = Rsa::fromPem((string)@file_get_contents($file)); | |
| 195 | + return $key->isPrivate() ? $key : null; | |
| 196 | + } catch (\InvalidArgumentException) { | |
| 197 | + return null; | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 201 | + /** | |
| 202 | + * 配信の署名(Deliveryの「gitler:所有者/名前」) | |
| 203 | + * | |
| 204 | + * @return array|null [秘密鍵, keyId] | |
| 205 | + */ | |
| 206 | + public static function signer(string $repo): ?array { | |
| 207 | + [$owner, $name] = array_pad(explode('/', $repo, 2), 2, ''); | |
| 208 | + if (!Gitler::validOwner($owner) || !Gitler::validName($name)) return null; | |
| 209 | + $key = self::key($owner, $name); | |
| 210 | + return $key === null ? null : [$key, self::actorUrl($owner, $name).'#main-key']; | |
| 211 | + } | |
| 212 | + | |
| 213 | + //////// フォロワーとブロック | |
| 214 | + | |
| 215 | + /** フォロワー(アクターのURL => inbox、sharedInbox、since) */ | |
| 216 | + public static function followers(string $owner, string $name): array { | |
| 217 | + return self::read($owner, $name, 'followers.json'); | |
| 218 | + } | |
| 219 | + | |
| 220 | + /** ブロック(actors、domains) */ | |
| 221 | + public static function blocks(string $owner, string $name): array { | |
| 222 | + return self::read($owner, $name, 'blocks.json') + ['actors' => [], 'domains' => []]; | |
| 223 | + } | |
| 224 | + | |
| 225 | + public static function isBlocked(string $owner, string $name, string $actor): bool { | |
| 226 | + if (Actor::isDomainBlocked($actor)) return true; | |
| 227 | + $b = self::blocks($owner, $name); | |
| 228 | + $d = Fedi::domainOf($actor); | |
| 229 | + if (in_array($actor, $b['actors'], true)) return true; | |
| 230 | + foreach ($b['domains'] as $x) if ($d === $x || str_ends_with($d, '.'.$x)) return true; | |
| 231 | + return false; | |
| 232 | + } | |
| 233 | + | |
| 234 | + /** アクター(URL、@user@host)又はドメインをブロックする・外す */ | |
| 235 | + public static function block(string $owner, string $name, string $target, bool $block = true): \Result { | |
| 236 | + $target = trim($target); | |
| 237 | + $isDomain = false; | |
| 238 | + if (preg_match('/^@?([^@\s\/]+)@([^@\s\/]+)$/', $target, $m)) { | |
| 239 | + $target = Remote::webfinger($m[1], $m[2]) ?? ''; | |
| 240 | + if ($target === '') return \Result::Error('アクターが見つかりません。'); | |
| 241 | + } else if (preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $target)) { | |
| 242 | + $isDomain = true; | |
| 243 | + } else if (!preg_match('#^https?://#i', $target)) { | |
| 244 | + return \Result::Error('「@ユーザー@ホスト」、アクターのURL又はドメインを入力して下さい。'); | |
| 245 | + } | |
| 246 | + self::updateJson($owner, $name, 'blocks.json', function(array $b) use ($target, $isDomain, $block): array { | |
| 247 | + $b += ['actors' => [], 'domains' => []]; | |
| 248 | + $key = $isDomain ? 'domains' : 'actors'; | |
| 249 | + $v = $isDomain ? strtolower($target) : $target; | |
| 250 | + $b[$key] = array_values(array_diff($b[$key], [$v])); | |
| 251 | + if ($block) $b[$key][] = $v; | |
| 252 | + return $b; | |
| 253 | + }); | |
| 254 | + if ($block && !$isDomain) self::updateJson($owner, $name, 'followers.json', function(array $l) use ($target): array { | |
| 255 | + unset($l[$target]); | |
| 256 | + return $l; | |
| 257 | + }); | |
| 258 | + return \Result::Success($block ? 'ブロックしました。' : 'ブロックを外しました。'); | |
| 259 | + } | |
| 260 | + | |
| 261 | + //////// オブジェクト | |
| 262 | + | |
| 263 | + /** イシューのNote(フォロワーのタイムラインに出る物) */ | |
| 264 | + public static function issueNote(string $owner, string $name, array $issue): array { | |
| 265 | + $url = self::issueUrl($owner, $name, $issue['number']); | |
| 266 | + $repo = self::actorUrl($owner, $name); | |
| 267 | + $closed = $issue['state'] === 'closed'; | |
| 268 | + $content = '<p>'.($closed ? '【解決済み】' : '').'<a href="'.Html::esc($url).'">#'.$issue['number'].' '.Html::esc($issue['title']).'</a></p>' | |
| 269 | + .'<p>'.Html::esc(self::authorName($issue['author'])).'さんが'.Html::esc("{$owner}/{$name}").'にイシューを作りました。</p>' | |
| 270 | + .self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html'])); | |
| 271 | + $note = [ | |
| 272 | + 'id' => $url, 'type' => 'Note', 'attributedTo' => $repo, 'context' => "{$url}/ticket", 'url' => $url, | |
| 273 | + 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], self::participants($owner, $name, $issue)), | |
| 274 | + 'published' => Fedi::now($issue['created']), 'content' => $content, 'mediaType' => 'text/html', | |
| 275 | + 'tag' => self::mentionTags(self::participants($owner, $name, $issue)), | |
| 276 | + ]; | |
| 277 | + if (!empty($issue['edited']) || $closed) $note['updated'] = Fedi::now($issue['updated']); | |
| 278 | + return $note; | |
| 279 | + } | |
| 280 | + | |
| 281 | + /** ForgeFedのTicket */ | |
| 282 | + public static function ticket(string $owner, string $name, array $issue): array { | |
| 283 | + $url = self::issueUrl($owner, $name, $issue['number']); | |
| 284 | + $t = [ | |
| 285 | + '@context' => self::CONTEXT, 'id' => "{$url}/ticket", 'type' => 'Ticket', 'attributedTo' => self::authorActor($owner, $name, $issue['author']), | |
| 286 | + 'context' => self::actorUrl($owner, $name), 'name' => $issue['title'], 'summary' => Html::esc($issue['title']), | |
| 287 | + 'content' => self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html'])), 'mediaType' => 'text/html', | |
| 288 | + 'published' => Fedi::now($issue['created']), 'updated' => Fedi::now($issue['updated']), 'isResolved' => $issue['state'] === 'closed', 'url' => $url, | |
| 289 | + ]; | |
| 290 | + if (empty($issue['html'])) $t['source'] = ['content' => $issue['body'], 'mediaType' => 'text/markdown']; | |
| 291 | + return $t; | |
| 292 | + } | |
| 293 | + | |
| 294 | + /** コメントのNote */ | |
| 295 | + public static function commentNote(string $owner, string $name, array $issue, array $c): array { | |
| 296 | + $url = self::issueUrl($owner, $name, $issue['number']); | |
| 297 | + $repo = self::actorUrl($owner, $name); | |
| 298 | + $people = self::participants($owner, $name, $issue); | |
| 299 | + $note = [ | |
| 300 | + 'id' => "{$url}/comments/{$c['id']}", 'type' => 'Note', 'attributedTo' => $repo, 'inReplyTo' => $url, 'context' => "{$url}/ticket", | |
| 301 | + 'url' => "{$url}#c{$c['id']}", 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], $people), | |
| 302 | + 'published' => Fedi::now($c['created']), 'mediaType' => 'text/html', 'tag' => self::mentionTags($people), | |
| 303 | + 'content' => '<p><a href="'.Html::esc($url).'">#'.$issue['number'].'</a> '.Html::esc(self::authorName($c['author'])).':</p>' | |
| 304 | + .self::bodyHtml($owner, $name, $c['body'], !empty($c['html'])), | |
| 305 | + ]; | |
| 306 | + if ($c['updated'] > $c['created']) $note['updated'] = Fedi::now($c['updated']); | |
| 307 | + return $note; | |
| 308 | + } | |
| 309 | + | |
| 310 | + //////// 送る | |
| 311 | + | |
| 312 | + public static function issueOpened(string $owner, string $name, array $meta, array $issue): void { | |
| 313 | + if (!self::enabled($meta)) return; | |
| 314 | + self::send($owner, $name, self::activity($owner, $name, 'Create', self::issueNote($owner, $name, $issue)), self::participants($owner, $name, $issue)); | |
| 315 | + } | |
| 316 | + | |
| 317 | + public static function issueEdited(string $owner, string $name, array $meta, array $issue): void { | |
| 318 | + if (!self::enabled($meta)) return; | |
| 319 | + self::send($owner, $name, self::activity($owner, $name, 'Update', self::issueNote($owner, $name, $issue)), self::participants($owner, $name, $issue)); | |
| 320 | + } | |
| 321 | + | |
| 322 | + /** 閉じた・開いた(ForgeFedのResolve、MastodonにはNoteの更新) */ | |
| 323 | + public static function stateChanged(string $owner, string $name, array $meta, array $issue): void { | |
| 324 | + if (!self::enabled($meta)) return; | |
| 325 | + $people = self::participants($owner, $name, $issue); | |
| 326 | + $ticket = self::issueUrl($owner, $name, $issue['number']).'/ticket'; | |
| 327 | + $resolve = self::activity($owner, $name, 'Resolve', $ticket); | |
| 328 | + if ($issue['state'] !== 'closed') $resolve = self::activity($owner, $name, 'Undo', array_diff_key($resolve, ['@context' => true])); | |
| 329 | + self::send($owner, $name, $resolve, $people); | |
| 330 | + self::send($owner, $name, self::activity($owner, $name, 'Update', self::issueNote($owner, $name, $issue)), $people); | |
| 331 | + } | |
| 332 | + | |
| 333 | + public static function issueDeleted(string $owner, string $name, array $meta, array $issue): void { | |
| 334 | + if (!self::enabled($meta)) return; | |
| 335 | + $id = self::issueUrl($owner, $name, $issue['number']); | |
| 336 | + self::send($owner, $name, self::activity($owner, $name, 'Delete', ['id' => $id, 'type' => 'Tombstone']), self::participants($owner, $name, $issue)); | |
| 337 | + } | |
| 338 | + | |
| 339 | + /** ローカルのコメント(リモートのコメントは書いた人が送っている) */ | |
| 340 | + public static function commentAdded(string $owner, string $name, array $meta, array $issue, array $c, string $type = 'Create'): void { | |
| 341 | + if (!self::enabled($meta) || isset($c['author']['actor'])) return; | |
| 342 | + self::send($owner, $name, self::activity($owner, $name, $type, self::commentNote($owner, $name, $issue, $c)), self::participants($owner, $name, $issue)); | |
| 343 | + } | |
| 344 | + | |
| 345 | + public static function commentDeleted(string $owner, string $name, array $meta, array $issue, array $c): void { | |
| 346 | + if (!self::enabled($meta) || isset($c['author']['actor'])) return; | |
| 347 | + $id = self::issueUrl($owner, $name, $issue['number']).'/comments/'.$c['id']; | |
| 348 | + self::send($owner, $name, self::activity($owner, $name, 'Delete', ['id' => $id, 'type' => 'Tombstone']), self::participants($owner, $name, $issue)); | |
| 349 | + } | |
| 350 | + | |
| 351 | + /** 溜めた配信を送る(cronのapdeliver) */ | |
| 352 | + public static function flush(): int { | |
| 353 | + $n = 0; | |
| 354 | + foreach (Gitler::list() as $r) { | |
| 355 | + $dir = Gitler::path($r['owner'], $r['name']).'/gitler/spool'; | |
| 356 | + foreach (glob($dir.'/*.json') ?: [] as $f) { | |
| 357 | + $job = json_decode((string)@file_get_contents($f), true); | |
| 358 | + @unlink($f); | |
| 359 | + if (!is_array($job) || !is_array($job['activity'] ?? null) || !self::enabled($r['meta'])) continue; | |
| 360 | + self::send($r['owner'], $r['name'], $job['activity'], (array)($job['extra'] ?? []), true); | |
| 361 | + $n++; | |
| 362 | + } | |
| 363 | + } | |
| 364 | + return $n; | |
| 365 | + } | |
| 366 | + | |
| 367 | + //////// 受け取る | |
| 368 | + | |
| 369 | + /** | |
| 370 | + * リポジトリの受付ボックスに届いたアクティビティ(署名は検証済み) | |
| 371 | + * | |
| 372 | + * @return array [HTTPステータス, 本文] | |
| 373 | + */ | |
| 374 | + public static function receive(string $owner, string $name, array $a): array { | |
| 375 | + $meta = Gitler::meta($owner, $name); | |
| 376 | + if (!self::enabled($meta)) return [404, ['error' => '見つかりません']]; | |
| 377 | + $actor = Fedi::id($a['actor'] ?? null); | |
| 378 | + if ($actor === null || Fedi::isLocal($actor)) return [403, ['error' => '不正なactor']]; | |
| 379 | + if (self::isBlocked($owner, $name, $actor)) return [202, ['status' => '無視しました']]; | |
| 380 | + try { | |
| 381 | + return match ((string)($a['type'] ?? '')) { | |
| 382 | + 'Follow' => self::onFollow($owner, $name, $a, $actor), | |
| 383 | + 'Undo' => self::onUndo($owner, $name, $meta, $a, $actor), | |
| 384 | + 'Create' => self::onCreate($owner, $name, $meta, $a, $actor), | |
| 385 | + 'Offer' => self::onOffer($owner, $name, $meta, $a, $actor), | |
| 386 | + 'Update' => self::onUpdate($owner, $name, $a, $actor), | |
| 387 | + 'Delete' => self::onDelete($owner, $name, $a, $actor), | |
| 388 | + 'Resolve' => self::onResolve($owner, $name, $meta, Fedi::id($a['object'] ?? null), $actor, false), | |
| 389 | + default => [202, ['status' => '未対応なアクティビティタイプ']], | |
| 390 | + }; | |
| 391 | + } catch (\Throwable $e) { | |
| 392 | + logger(\LogType::ActivityPub, "Gitlerの受付ボックスの処理に失敗({$owner}/{$name}):".$e->getMessage()); | |
| 393 | + return [500, ['error' => '処理に失敗']]; | |
| 394 | + } | |
| 395 | + } | |
| 396 | + | |
| 397 | + private static function onFollow(string $owner, string $name, array $a, string $actor): array { | |
| 398 | + if (Fedi::id($a['object'] ?? null) !== self::actorUrl($owner, $name)) return [202, ['status' => '無視しました']]; | |
| 399 | + $doc = Remote::actor($actor); | |
| 400 | + $inbox = $doc ? Remote::inbox($doc, false) : null; | |
| 401 | + if ($inbox === null) return [202, ['status' => '無視しました']]; | |
| 402 | + self::updateJson($owner, $name, 'followers.json', fn(array $l) => [$actor => ['inbox' => $inbox, 'sharedInbox' => Remote::inbox($doc, true), 'since' => time()]] + $l); | |
| 403 | + $me = self::actorUrl($owner, $name); | |
| 404 | + Delivery::enqueue('gitler:'.$owner.'/'.$name, ['@context' => self::CONTEXT, 'id' => "{$me}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $me, 'object' => $a], [$inbox]); | |
| 405 | + return [202, ['status' => 'OK']]; | |
| 406 | + } | |
| 407 | + | |
| 408 | + private static function onUndo(string $owner, string $name, array $meta, array $a, string $actor): array { | |
| 409 | + $inner = $a['object'] ?? null; | |
| 410 | + if (!is_array($inner) || Fedi::id($inner['actor'] ?? null) !== $actor) return [202, ['status' => '無視しました']]; | |
| 411 | + if (($inner['type'] ?? '') === 'Follow') { | |
| 412 | + self::updateJson($owner, $name, 'followers.json', function(array $l) use ($actor): array { | |
| 413 | + unset($l[$actor]); | |
| 414 | + return $l; | |
| 415 | + }); | |
| 416 | + return [202, ['status' => 'OK']]; | |
| 417 | + } | |
| 418 | + if (($inner['type'] ?? '') === 'Resolve') return self::onResolve($owner, $name, $meta, Fedi::id($inner['object'] ?? null), $actor, true); | |
| 419 | + return [202, ['status' => '無視しました']]; | |
| 420 | + } | |
| 421 | + | |
| 422 | + private static function onCreate(string $owner, string $name, array $meta, array $a, string $actor): array { | |
| 423 | + $obj = $a['object'] ?? null; | |
| 424 | + if (is_string($obj)) $obj = Remote::object($obj); | |
| 425 | + if (!is_array($obj) || !self::isType($obj, ['Note', 'Article', 'Page', 'Question'])) return [202, ['status' => '無視しました']]; | |
| 426 | + $id = Fedi::id($obj['id'] ?? null); | |
| 427 | + if (Fedi::id($obj['attributedTo'] ?? null) !== $actor || $id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) return [403, ['error' => '作者が違います']]; | |
| 428 | + $issues = Issues::open($owner, $name); | |
| 429 | + $author = self::remoteAuthor($actor); | |
| 430 | + $html = self::cleanHtml($owner, $name, (string)($obj['content'] ?? '')); | |
| 431 | + $parent = Fedi::id($obj['inReplyTo'] ?? null); | |
| 432 | + | |
| 433 | + // イシューかコメントへの返信はコメント | |
| 434 | + if ($parent !== null) { | |
| 435 | + $n = self::issueOf($owner, $name, $issues, $parent); | |
| 436 | + if ($n === null) return [202, ['status' => '無視しました']]; | |
| 437 | + if ($html !== '') $issues->comment($n, $author, $html, ['html' => true, 'remoteId' => $id]); | |
| 438 | + return [202, ['status' => 'OK']]; | |
| 439 | + } | |
| 440 | + | |
| 441 | + // リポジトリへのメンションは新しいイシュー(題名は名前又は最初の行) | |
| 442 | + $mentioned = false; | |
| 443 | + foreach ((array)($obj['tag'] ?? []) as $t) if (is_array($t) && ($t['type'] ?? '') === 'Mention' && ($t['href'] ?? '') === self::actorUrl($owner, $name)) $mentioned = true; | |
| 444 | + if (!$mentioned) return [202, ['status' => '無視しました']]; | |
| 445 | + $title = is_string($obj['name'] ?? null) ? $obj['name'] : ''; | |
| 446 | + if ($title === '') { | |
| 447 | + $text = trim(preg_replace('/@'.preg_quote(self::handle($owner, $name), '/').'(@[A-Za-z0-9.-]+)?/i', '', Html::text($html))); | |
| 448 | + $title = trim(strtok($text, "\n") ?: ''); | |
| 449 | + if (mb_strlen($title) > 80) $title = mb_substr($title, 0, 79).'…'; | |
| 450 | + } | |
| 451 | + if ($title === '') return [202, ['status' => '無視しました']]; | |
| 452 | + $res = $issues->create($author, $title, $html, ['html' => true, 'remoteId' => $id]); | |
| 453 | + if ($res->isSuccess) self::issueOpened($owner, $name, $meta, $res->data['issue']); | |
| 454 | + return [202, ['status' => 'OK']]; | |
| 455 | + } | |
| 456 | + | |
| 457 | + /** ForgeFed:Offer{Ticket}でイシューを作り、Acceptを返す */ | |
| 458 | + private static function onOffer(string $owner, string $name, array $meta, array $a, string $actor): array { | |
| 459 | + $t = $a['object'] ?? null; | |
| 460 | + $repo = self::actorUrl($owner, $name); | |
| 461 | + if (!is_array($t) || !self::isType($t, ['Ticket'])) return [202, ['status' => '無視しました']]; | |
| 462 | + if (Fedi::id($a['target'] ?? $t['context'] ?? null) !== $repo) return [202, ['status' => '無視しました']]; | |
| 463 | + if (isset($t['attributedTo']) && Fedi::id($t['attributedTo']) !== $actor) return [403, ['error' => '作者が違います']]; | |
| 464 | + $title = html_entity_decode(strip_tags((string)($t['name'] ?? $t['summary'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 465 | + $src = is_array($t['source'] ?? null) ? $t['source'] : []; | |
| 466 | + $markdown = ($src['mediaType'] ?? '') === 'text/markdown' && is_string($src['content'] ?? null); | |
| 467 | + $body = $markdown ? $src['content'] : self::cleanHtml($owner, $name, (string)($t['content'] ?? '')); | |
| 468 | + $remoteId = Fedi::id($t['id'] ?? null) ?? Fedi::id($a['id'] ?? null); | |
| 469 | + $issues = Issues::open($owner, $name); | |
| 470 | + $res = $issues->create(self::remoteAuthor($actor), $title, $body, ['html' => !$markdown, 'remoteId' => $remoteId]); | |
| 471 | + $n = (int)($res->data['number'] ?? 0); | |
| 472 | + if ($n > 0) { | |
| 473 | + $doc = Remote::actor($actor); | |
| 474 | + $inbox = $doc ? Remote::inbox($doc, false) : null; | |
| 475 | + if ($inbox !== null) { | |
| 476 | + Delivery::enqueue('gitler:'.$owner.'/'.$name, ['@context' => self::CONTEXT, 'id' => "{$repo}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $repo, | |
| 477 | + 'object' => $a, 'result' => self::issueUrl($owner, $name, $n).'/ticket'], [$inbox]); | |
| 478 | + } | |
| 479 | + if ($res->isSuccess) self::issueOpened($owner, $name, $meta, $res->data['issue']); | |
| 480 | + } | |
| 481 | + return [202, ['status' => 'OK']]; | |
| 482 | + } | |
| 483 | + | |
| 484 | + private static function onUpdate(string $owner, string $name, array $a, string $actor): array { | |
| 485 | + $obj = $a['object'] ?? null; | |
| 486 | + if (!is_array($obj)) return [202, ['status' => '無視しました']]; | |
| 487 | + if (Remote::isActor($obj)) { | |
| 488 | + if (($obj['id'] ?? null) === $actor) Remote::cacheActor($obj); | |
| 489 | + return [202, ['status' => 'OK']]; | |
| 490 | + } | |
| 491 | + $id = Fedi::id($obj['id'] ?? null); | |
| 492 | + $issues = Issues::open($owner, $name); | |
| 493 | + $found = $id !== null ? $issues->findRemote($id) : null; | |
| 494 | + if ($found === null) return [202, ['status' => '無視しました']]; | |
| 495 | + [$n, $cid] = $found; | |
| 496 | + $html = self::cleanHtml($owner, $name, (string)($obj['content'] ?? '')); | |
| 497 | + $issues->update($n, function(array $i) use ($cid, $actor, $html): ?array { | |
| 498 | + if ($cid === null) { | |
| 499 | + if (($i['author']['actor'] ?? null) !== $actor || empty($i['html'])) return null; | |
| 500 | + $i['body'] = $html; | |
| 501 | + $i['edited'] = time(); | |
| 502 | + return $i; | |
| 503 | + } | |
| 504 | + foreach ($i['comments'] as $k => $c) { | |
| 505 | + if ($c['id'] !== $cid || ($c['author']['actor'] ?? null) !== $actor) continue; | |
| 506 | + $i['comments'][$k]['body'] = $html; | |
| 507 | + $i['comments'][$k]['updated'] = time(); | |
| 508 | + return $i; | |
| 509 | + } | |
| 510 | + return null; | |
| 511 | + }); | |
| 512 | + return [202, ['status' => 'OK']]; | |
| 513 | + } | |
| 514 | + | |
| 515 | + private static function onDelete(string $owner, string $name, array $a, string $actor): array { | |
| 516 | + $id = Fedi::id($a['object'] ?? null); | |
| 517 | + if ($id === $actor) { | |
| 518 | + // アカウントの削除 | |
| 519 | + self::updateJson($owner, $name, 'followers.json', function(array $l) use ($actor): array { | |
| 520 | + unset($l[$actor]); | |
| 521 | + return $l; | |
| 522 | + }); | |
| 523 | + return [202, ['status' => 'OK']]; | |
| 524 | + } | |
| 525 | + $issues = Issues::open($owner, $name); | |
| 526 | + $found = $id !== null ? $issues->findRemote($id) : null; | |
| 527 | + if ($found === null) return [202, ['status' => '無視しました']]; | |
| 528 | + [$n, $cid] = $found; | |
| 529 | + $issues->update($n, function(array $i) use ($cid, $actor): ?array { | |
| 530 | + if ($cid === null) { | |
| 531 | + // イシューは残し、本文だけ消す | |
| 532 | + if (($i['author']['actor'] ?? null) !== $actor) return null; | |
| 533 | + $i['body'] = '<p>(削除されました)</p>'; | |
| 534 | + $i['html'] = true; | |
| 535 | + return $i; | |
| 536 | + } | |
| 537 | + foreach ($i['comments'] as $k => $c) { | |
| 538 | + if ($c['id'] !== $cid || ($c['author']['actor'] ?? null) !== $actor) continue; | |
| 539 | + array_splice($i['comments'], $k, 1); | |
| 540 | + return $i; | |
| 541 | + } | |
| 542 | + return null; | |
| 543 | + }); | |
| 544 | + return [202, ['status' => 'OK']]; | |
| 545 | + } | |
| 546 | + | |
| 547 | + /** ForgeFedのResolve(イシューを作ったリモートの人だけが閉じられる) */ | |
| 548 | + private static function onResolve(string $owner, string $name, array $meta, ?string $object, string $actor, bool $undo): array { | |
| 549 | + $issues = Issues::open($owner, $name); | |
| 550 | + $n = $object !== null ? self::issueOf($owner, $name, $issues, $object) : null; | |
| 551 | + $issue = $n !== null ? $issues->get($n) : null; | |
| 552 | + if ($issue === null || ($issue['author']['actor'] ?? null) !== $actor) return [202, ['status' => '無視しました']]; | |
| 553 | + $new = $issues->setState($n, $undo, self::remoteAuthor($actor)); | |
| 554 | + if ($new !== null) self::stateChanged($owner, $name, $meta, $new); | |
| 555 | + return [202, ['status' => 'OK']]; | |
| 556 | + } | |
| 557 | + | |
| 558 | + //////// 内部 | |
| 559 | + | |
| 560 | + /** 書いた者の表示名 */ | |
| 561 | + public static function authorName(array $a): string { | |
| 562 | + if (isset($a['user'])) return (string)$a['user']; | |
| 563 | + if (isset($a['actor'])) return (string)(($a['name'] ?? '') !== '' ? $a['name'] : ($a['handle'] ?? $a['actor'])); | |
| 564 | + return (string)($a['name'] ?? ''); | |
| 565 | + } | |
| 566 | + | |
| 567 | + /** リモートの書いた者(アクターを取得して名前とアイコンを残す) */ | |
| 568 | + public static function remoteAuthor(string $actor): array { | |
| 569 | + $doc = Remote::actor($actor) ?? []; | |
| 570 | + $icon = $doc['icon'] ?? null; | |
| 571 | + if (is_array($icon) && array_is_list($icon)) $icon = $icon[0] ?? null; | |
| 572 | + $url = Fedi::id($doc['url'] ?? null) ?? $actor; | |
| 573 | + return ['actor' => $actor, 'name' => mb_substr(strip_tags((string)($doc['name'] ?? '')), 0, 100), 'handle' => $doc ? Remote::handle($doc) : $actor, | |
| 574 | + 'icon' => is_array($icon) && is_string($icon['url'] ?? null) ? $icon['url'] : '', 'url' => preg_match('#^https?://#i', $url) ? $url : $actor]; | |
| 575 | + } | |
| 576 | + | |
| 577 | + /** ForgeFedのattributedTo(Fediverseを使えるローカルのユーザーはそのアクター) */ | |
| 578 | + private static function authorActor(string $owner, string $name, array $a): string { | |
| 579 | + if (isset($a['actor'])) return (string)$a['actor']; | |
| 580 | + if (isset($a['user']) && Fedi::localActor((string)$a['user']) !== null) return Fedi::actorUrl((string)$a['user']); | |
| 581 | + return self::actorUrl($owner, $name); | |
| 582 | + } | |
| 583 | + | |
| 584 | + /** イシューに関わったリモートの人(作った人とコメントした人) */ | |
| 585 | + private static function participants(string $owner, string $name, array $issue): array { | |
| 586 | + $out = []; | |
| 587 | + foreach (array_merge([$issue['author']], array_column($issue['comments'], 'author')) as $a) { | |
| 588 | + if (isset($a['actor']) && !self::isBlocked($owner, $name, $a['actor'])) $out[$a['actor']] = true; | |
| 589 | + } | |
| 590 | + return array_keys($out); | |
| 591 | + } | |
| 592 | + | |
| 593 | + private static function mentionTags(array $actors): array { | |
| 594 | + $tags = []; | |
| 595 | + foreach ($actors as $url) { | |
| 596 | + $doc = Remote::cachedActor($url); | |
| 597 | + $tags[] = ['type' => 'Mention', 'href' => $url, 'name' => $doc ? Remote::handle($doc) : $url]; | |
| 598 | + } | |
| 599 | + return $tags; | |
| 600 | + } | |
| 601 | + | |
| 602 | + /** 本文のHTML(Markdownは描画して安全に、リンクは絶対URLに) */ | |
| 603 | + private static function bodyHtml(string $owner, string $name, string $body, bool $html): string { | |
| 604 | + if ($html) return $body; | |
| 605 | + $issues = self::actorUrl($owner, $name).'/issues'; | |
| 606 | + $out = Issues::render($body, $issues); | |
| 607 | + $out = (string)preg_replace('/\shref="\/(?!\/)/', ' href="'.Fedi::base().'/', $out); | |
| 608 | + // 整形の改行と空の段落を外す(Mastodon等で空行にならない様に) | |
| 609 | + $out = (string)preg_replace(['~>\s*\n\s*<~', '~(<p>|<li>)\s+~', '~\s+(</p>|</li>)~'], ['><', '$1', '$1'], Html::sanitize($out)); | |
| 610 | + return trim((string)preg_replace('~<p></p>~', '', $out)); | |
| 611 | + } | |
| 612 | + | |
| 613 | + /** 受け取ったHTMLを安全にして、リポジトリへのメンションを外す */ | |
| 614 | + private static function cleanHtml(string $owner, string $name, string $html): string { | |
| 615 | + $html = Html::sanitize($html); | |
| 616 | + $url = preg_quote(Html::esc(self::actorUrl($owner, $name)), '~'); | |
| 617 | + $html = (string)preg_replace(['~<span class="h-card">\s*<a href="'.$url.'"[^>]*>.*?</a>\s*</span>\s*~s', '~<a href="'.$url.'"[^>]*>.*?</a>\s*~s'], '', $html); | |
| 618 | + return trim((string)preg_replace('~<p>\s*</p>~', '', $html)); | |
| 619 | + } | |
| 620 | + | |
| 621 | + /** オブジェクトのIDがこのリポジトリのどのイシューか */ | |
| 622 | + private static function issueOf(string $owner, string $name, Issues $issues, string $id): ?int { | |
| 623 | + $base = self::actorUrl($owner, $name).'/issues/'; | |
| 624 | + if (str_starts_with($id, $base) && preg_match('#^(\d+)(?:/ticket|/comments/\d+)?$#', substr($id, strlen($base)), $m)) { | |
| 625 | + return $issues->get((int)$m[1]) !== null ? (int)$m[1] : null; | |
| 626 | + } | |
| 627 | + return $issues->findRemote($id)[0] ?? null; | |
| 628 | + } | |
| 629 | + | |
| 630 | + private static function isType(array $obj, array $types): bool { | |
| 631 | + foreach ((array)($obj['type'] ?? []) as $t) if (in_array($t, $types, true)) return true; | |
| 632 | + return false; | |
| 633 | + } | |
| 634 | + | |
| 635 | + private static function activity(string $owner, string $name, string $type, array|string $object): array { | |
| 636 | + $me = self::actorUrl($owner, $name); | |
| 637 | + $obj = is_array($object) ? $object : null; | |
| 638 | + return ['@context' => self::CONTEXT, 'id' => "{$me}#activities/".Fedi::newId(), 'type' => $type, 'actor' => $me, | |
| 639 | + 'to' => $obj['to'] ?? [Fedi::PUBLIC], 'cc' => $obj['cc'] ?? ["{$me}/followers"], 'object' => $object]; | |
| 640 | + } | |
| 641 | + | |
| 642 | + /** | |
| 643 | + * フォロワーと関わった人に送る(SSHのプッシュではリポジトリに溜める) | |
| 644 | + */ | |
| 645 | + private static function send(string $owner, string $name, array $activity, array $extra, bool $now = false): void { | |
| 646 | + if (self::$spool && !$now) { | |
| 647 | + $repo = Gitler::open($owner, $name); | |
| 648 | + $repo->mkdir($repo->path.'/gitler/spool'); | |
| 649 | + $repo->writeFile($repo->path.'/gitler/spool/'.Fedi::newId().'.json', (string)json_encode(['activity' => $activity, 'extra' => $extra], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); | |
| 650 | + return; | |
| 651 | + } | |
| 652 | + $inboxes = []; | |
| 653 | + foreach (self::followers($owner, $name) as $f) { | |
| 654 | + $inbox = $f['sharedInbox'] ?? $f['inbox'] ?? null; | |
| 655 | + if (is_string($inbox)) $inboxes[] = $inbox; | |
| 656 | + } | |
| 657 | + foreach ($extra as $actor) { | |
| 658 | + $doc = Remote::actor($actor); | |
| 659 | + $inbox = $doc ? Remote::inbox($doc) : null; | |
| 660 | + if ($inbox !== null) $inboxes[] = $inbox; | |
| 661 | + } | |
| 662 | + if (!empty($inboxes)) Delivery::enqueue('gitler:'.$owner.'/'.$name, $activity, $inboxes); | |
| 663 | + } | |
| 664 | + | |
| 665 | + private static function read(string $owner, string $name, string $file): array { | |
| 666 | + $j = json_decode((string)@file_get_contents(Gitler::path($owner, $name).'/gitler/'.$file), true); | |
| 667 | + return is_array($j) ? $j : []; | |
| 668 | + } | |
| 669 | + | |
| 670 | + private static function updateJson(string $owner, string $name, string $file, callable $fn): void { | |
| 671 | + $repo = Gitler::open($owner, $name); | |
| 672 | + $path = $repo->path.'/gitler/'.$file; | |
| 673 | + $repo->mkdir(dirname($path)); | |
| 674 | + $lock = @fopen($path.'.lock', 'c'); | |
| 675 | + if ($lock) flock($lock, LOCK_EX); | |
| 676 | + try { | |
| 677 | + $repo->writeFile($path, (string)json_encode($fn(self::read($owner, $name, $file)), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); | |
| 678 | + } finally { | |
| 679 | + if ($lock) { | |
| 680 | + flock($lock, LOCK_UN); | |
| 681 | + fclose($lock); | |
| 682 | + } | |
| 683 | + } | |
| 684 | + } | |
| 685 | +} |
| @@ -123,13 +123,14 @@ | ||
| 123 | 123 | /** |
| 124 | 124 | * リポジトリの情報 |
| 125 | 125 | * |
| 126 | - * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch | |
| 126 | + * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、 | |
| 127 | + * issues(イシューを使う)、federate(公開ならFediverseに出す) | |
| 127 | 128 | */ |
| 128 | 129 | public static function meta(string $owner, string $name): array { |
| 129 | 130 | $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true); |
| 130 | 131 | $j = is_array($j) ? $j : []; |
| 131 | - $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => []]; | |
| 132 | - $j['private'] = (bool)$j['private']; | |
| 132 | + $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => [], 'issues' => true, 'federate' => true]; | |
| 133 | + foreach (['private', 'issues', 'federate'] as $k) $j[$k] = (bool)$j[$k]; | |
| 133 | 134 | $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : []; |
| 134 | 135 | return $j; |
| 135 | 136 | } |
| @@ -0,0 +1,517 @@ | ||
| 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\Git\GitException; | |
| 41 | +use Std\Lib\Git\Repository; | |
| 42 | +use Std\Lib\Git\Walk; | |
| 43 | +use Std\Lib\Markdown; | |
| 44 | +use Result; | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Gitlerのイシュー(リポジトリの中のgitler/) | |
| 48 | + * | |
| 49 | + * issues.json 索引(次の番号、イシュー毎の概要、リモートのID => [番号, コメント]) | |
| 50 | + * issues/{番号}.json イシュー(本文、コメント、出来事) | |
| 51 | + * labels.json、milestones.json | |
| 52 | + * | |
| 53 | + * 書く者:local({user})、remote({actor, name, handle, icon, url}、Fediverse)、commit({commit, name})。 | |
| 54 | + * 本文はMarkdown(htmlがtrueならリモートのHTML、受け取った時に安全にした物)。 | |
| 55 | + * 番号は後でプルリクエストと共有する。 | |
| 56 | + */ | |
| 57 | +final class Issues { | |
| 58 | + public const MAX_TITLE = 200; | |
| 59 | + public const MAX_BODY = 65536; | |
| 60 | + /** プッシュで見るコミットの最大数 */ | |
| 61 | + private const MAX_PUSH_COMMITS = 500; | |
| 62 | + /** 「fixes #3」等(英語のGitHubと同じ言葉) */ | |
| 63 | + private const CLOSE_RE = '/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/i'; | |
| 64 | + | |
| 65 | + private string $dir; | |
| 66 | + /** ロックの深さ(同じプロセスで2回ロックしない) */ | |
| 67 | + private int $depth = 0; | |
| 68 | + | |
| 69 | + public function __construct(public readonly Repository $repo) { | |
| 70 | + $this->dir = $repo->path.'/gitler'; | |
| 71 | + } | |
| 72 | + | |
| 73 | + public static function open(string $owner, string $name): self { | |
| 74 | + return new self(Gitler::open($owner, $name)); | |
| 75 | + } | |
| 76 | + | |
| 77 | + //////// 読む | |
| 78 | + | |
| 79 | + /** 索引(next、issues:番号 => 概要、remote:リモートのID => [番号, コメントのID]) */ | |
| 80 | + public function index(): array { | |
| 81 | + $j = json_decode((string)@file_get_contents($this->dir.'/issues.json'), true); | |
| 82 | + $j = is_array($j) ? $j : []; | |
| 83 | + return ['next' => max(1, (int)($j['next'] ?? 1)), 'issues' => is_array($j['issues'] ?? null) ? $j['issues'] : [], 'remote' => is_array($j['remote'] ?? null) ? $j['remote'] : []]; | |
| 84 | + } | |
| 85 | + | |
| 86 | + /** 開いている・閉じたイシューの数 */ | |
| 87 | + public function counts(): array { | |
| 88 | + $c = ['open' => 0, 'closed' => 0]; | |
| 89 | + foreach ($this->index()['issues'] as $s) $c[$s['state'] === 'closed' ? 'closed' : 'open']++; | |
| 90 | + return $c; | |
| 91 | + } | |
| 92 | + | |
| 93 | + /** | |
| 94 | + * 探す | |
| 95 | + * | |
| 96 | + * @param array $f state(open、closed、all)、label(ID)、milestone(ID、0は無し)、assignee、author、q、sort(created、updated、comments) | |
| 97 | + * @return array 概要の配列(新しい順) | |
| 98 | + */ | |
| 99 | + public function search(array $f = []): array { | |
| 100 | + $state = $f['state'] ?? 'open'; | |
| 101 | + $q = mb_strtolower(trim((string)($f['q'] ?? ''))); | |
| 102 | + $out = []; | |
| 103 | + foreach ($this->index()['issues'] as $s) { | |
| 104 | + if ($state !== 'all' && $s['state'] !== $state) continue; | |
| 105 | + if (isset($f['label']) && !in_array((int)$f['label'], $s['labels'], true)) continue; | |
| 106 | + if (isset($f['milestone']) && (int)($s['milestone'] ?? 0) !== (int)$f['milestone']) continue; | |
| 107 | + if (isset($f['assignee']) && !in_array($f['assignee'], $s['assignees'], true)) continue; | |
| 108 | + if (isset($f['author']) && $s['authorKey'] !== $f['author']) continue; | |
| 109 | + if ($q !== '' && !str_contains(mb_strtolower($s['title']), $q) && ltrim($q, '#') !== (string)$s['number']) continue; | |
| 110 | + $out[] = $s; | |
| 111 | + } | |
| 112 | + $key = match ($f['sort'] ?? 'created') { 'updated' => 'updated', 'comments' => 'comments', default => 'number' }; | |
| 113 | + usort($out, fn($a, $b) => $b[$key] <=> $a[$key] ?: $b['number'] <=> $a['number']); | |
| 114 | + return $out; | |
| 115 | + } | |
| 116 | + | |
| 117 | + public function get(int $n): ?array { | |
| 118 | + if ($n < 1) return null; | |
| 119 | + $j = json_decode((string)@file_get_contents($this->file($n)), true); | |
| 120 | + return is_array($j) && ($j['number'] ?? 0) === $n ? $j : null; | |
| 121 | + } | |
| 122 | + | |
| 123 | + /** リモートのIDのイシューとコメント([番号, コメントのID又はnull]) */ | |
| 124 | + public function findRemote(string $id): ?array { | |
| 125 | + $r = $this->index()['remote'][$id] ?? null; | |
| 126 | + return is_array($r) ? [(int)$r[0], $r[1] === null ? null : (int)$r[1]] : null; | |
| 127 | + } | |
| 128 | + | |
| 129 | + //////// 書く | |
| 130 | + | |
| 131 | + /** | |
| 132 | + * イシューを作る | |
| 133 | + * | |
| 134 | + * @param array $author 書いた者 | |
| 135 | + * @param array $opt html(本文がHTML)、remoteId、labels、assignees、milestone | |
| 136 | + */ | |
| 137 | + public function create(array $author, string $title, string $body, array $opt = []): Result { | |
| 138 | + $title = self::cleanTitle($title); | |
| 139 | + if ($title === '') return Result::Error('題名を入力して下さい。'); | |
| 140 | + if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。'); | |
| 141 | + return $this->locked(function() use ($author, $title, $body, $opt): Result { | |
| 142 | + $idx = $this->index(); | |
| 143 | + if (isset($opt['remoteId']) && isset($idx['remote'][$opt['remoteId']])) return Result::Error('既に有ります。', ['number' => (int)$idx['remote'][$opt['remoteId']][0]]); | |
| 144 | + $n = $idx['next']; | |
| 145 | + $now = time(); | |
| 146 | + $issue = [ | |
| 147 | + 'number' => $n, 'title' => $title, 'body' => $body, 'html' => !empty($opt['html']), 'author' => $author, 'state' => 'open', | |
| 148 | + 'created' => $now, 'updated' => $now, 'closed' => null, 'labels' => $this->validLabels($opt['labels'] ?? []), | |
| 149 | + 'assignees' => array_values(array_unique(array_map('strval', $opt['assignees'] ?? []))), 'milestone' => $this->validMilestone($opt['milestone'] ?? null), | |
| 150 | + 'remoteId' => $opt['remoteId'] ?? null, 'comments' => [], 'events' => [], 'seq' => 1, | |
| 151 | + ]; | |
| 152 | + $idx['next'] = $n + 1; | |
| 153 | + if ($issue['remoteId'] !== null) $idx['remote'][$issue['remoteId']] = [$n, null]; | |
| 154 | + $this->save($issue, $idx); | |
| 155 | + return Result::Success('イシューを作りました。', ['number' => $n, 'issue' => $issue]); | |
| 156 | + }); | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** | |
| 160 | + * イシューを変える($fnはイシューを受け取って新しいイシューを返す、nullなら何もしない) | |
| 161 | + * | |
| 162 | + * @return array|null 新しいイシュー | |
| 163 | + */ | |
| 164 | + public function update(int $n, callable $fn): ?array { | |
| 165 | + return $this->locked(function() use ($n, $fn): ?array { | |
| 166 | + $issue = $this->get($n); | |
| 167 | + if ($issue === null) return null; | |
| 168 | + $new = $fn($issue); | |
| 169 | + if (!is_array($new)) return null; | |
| 170 | + $new['updated'] = time(); | |
| 171 | + $idx = $this->index(); | |
| 172 | + // リモートのIDの索引を作り直す | |
| 173 | + foreach ($idx['remote'] as $id => [$num]) if ((int)$num === $n) unset($idx['remote'][$id]); | |
| 174 | + if (($new['remoteId'] ?? null) !== null) $idx['remote'][$new['remoteId']] = [$n, null]; | |
| 175 | + foreach ($new['comments'] as $c) if (($c['remoteId'] ?? null) !== null) $idx['remote'][$c['remoteId']] = [$n, $c['id']]; | |
| 176 | + $this->save($new, $idx); | |
| 177 | + return $new; | |
| 178 | + }); | |
| 179 | + } | |
| 180 | + | |
| 181 | + /** 題名と本文を変える */ | |
| 182 | + public function edit(int $n, array $by, string $title, string $body): Result { | |
| 183 | + $title = self::cleanTitle($title); | |
| 184 | + if ($title === '') return Result::Error('題名を入力して下さい。'); | |
| 185 | + if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。'); | |
| 186 | + $issue = $this->update($n, function(array $i) use ($by, $title, $body): array { | |
| 187 | + if ($i['title'] !== $title) $i['events'][] = ['type' => 'title', 'by' => $by, 'at' => time(), 'from' => $i['title'], 'to' => $title]; | |
| 188 | + $i['title'] = $title; | |
| 189 | + $i['body'] = $body; | |
| 190 | + $i['edited'] = time(); | |
| 191 | + return $i; | |
| 192 | + }); | |
| 193 | + return $issue ? Result::Success('保存しました。', ['issue' => $issue]) : Result::Error('イシューが有りません。'); | |
| 194 | + } | |
| 195 | + | |
| 196 | + /** | |
| 197 | + * コメントを追加する | |
| 198 | + * | |
| 199 | + * @param array $opt html、remoteId | |
| 200 | + */ | |
| 201 | + public function comment(int $n, array $author, string $body, array $opt = []): Result { | |
| 202 | + if (trim($body) === '') return Result::Error('コメントを入力して下さい。'); | |
| 203 | + if (strlen($body) > self::MAX_BODY) return Result::Error('コメントが長過ぎます。'); | |
| 204 | + $added = null; | |
| 205 | + $issue = $this->update($n, function(array $i) use ($author, $body, $opt, &$added): ?array { | |
| 206 | + if (isset($opt['remoteId'])) foreach ($i['comments'] as $c) if (($c['remoteId'] ?? null) === $opt['remoteId']) return null; | |
| 207 | + $added = ['id' => (int)$i['seq'], 'author' => $author, 'body' => $body, 'html' => !empty($opt['html']), 'created' => time(), 'updated' => time(), | |
| 208 | + 'remoteId' => $opt['remoteId'] ?? null]; | |
| 209 | + $i['seq'] = (int)$i['seq'] + 1; | |
| 210 | + $i['comments'][] = $added; | |
| 211 | + return $i; | |
| 212 | + }); | |
| 213 | + if ($issue === null || $added === null) return Result::Error('コメント出来ません。'); | |
| 214 | + return Result::Success('コメントしました。', ['issue' => $issue, 'comment' => $added]); | |
| 215 | + } | |
| 216 | + | |
| 217 | + /** コメントを変える(nullなら削除) */ | |
| 218 | + public function editComment(int $n, int $id, ?string $body): ?array { | |
| 219 | + if ($body !== null && (trim($body) === '' || strlen($body) > self::MAX_BODY)) return null; | |
| 220 | + $found = null; | |
| 221 | + $this->update($n, function(array $i) use ($id, $body, &$found): ?array { | |
| 222 | + foreach ($i['comments'] as $k => $c) { | |
| 223 | + if ($c['id'] !== $id) continue; | |
| 224 | + $found = $c; | |
| 225 | + if ($body === null) { | |
| 226 | + array_splice($i['comments'], $k, 1); | |
| 227 | + } else { | |
| 228 | + $i['comments'][$k]['body'] = $body; | |
| 229 | + $i['comments'][$k]['updated'] = time(); | |
| 230 | + $found = $i['comments'][$k]; | |
| 231 | + } | |
| 232 | + return $i; | |
| 233 | + } | |
| 234 | + return null; | |
| 235 | + }); | |
| 236 | + return $found; | |
| 237 | + } | |
| 238 | + | |
| 239 | + /** 閉じる・開く(変わらなければnull) */ | |
| 240 | + public function setState(int $n, bool $open, array $by, array $extra = []): ?array { | |
| 241 | + return $this->update($n, function(array $i) use ($open, $by, $extra): ?array { | |
| 242 | + if (($i['state'] === 'open') === $open) return null; | |
| 243 | + $i['state'] = $open ? 'open' : 'closed'; | |
| 244 | + $i['closed'] = $open ? null : time(); | |
| 245 | + $i['events'][] = ['type' => $open ? 'reopened' : 'closed', 'by' => $by, 'at' => time()] + $extra; | |
| 246 | + return $i; | |
| 247 | + }); | |
| 248 | + } | |
| 249 | + | |
| 250 | + /** ラベル・担当者・マイルストーン(書き込み権限の有る人) */ | |
| 251 | + public function triage(int $n, array $by, ?array $labels, ?array $assignees, int|false|null $milestone): ?array { | |
| 252 | + return $this->update($n, function(array $i) use ($by, $labels, $assignees, $milestone): array { | |
| 253 | + $at = time(); | |
| 254 | + if ($labels !== null) { | |
| 255 | + $labels = $this->validLabels($labels); | |
| 256 | + $add = array_values(array_diff($labels, $i['labels'])); | |
| 257 | + $remove = array_values(array_diff($i['labels'], $labels)); | |
| 258 | + if ($add || $remove) $i['events'][] = ['type' => 'labels', 'by' => $by, 'at' => $at, 'add' => $add, 'remove' => $remove]; | |
| 259 | + $i['labels'] = $labels; | |
| 260 | + } | |
| 261 | + if ($assignees !== null) { | |
| 262 | + $assignees = array_values(array_unique(array_map('strval', $assignees))); | |
| 263 | + $add = array_values(array_diff($assignees, $i['assignees'])); | |
| 264 | + $remove = array_values(array_diff($i['assignees'], $assignees)); | |
| 265 | + if ($add || $remove) $i['events'][] = ['type' => 'assignees', 'by' => $by, 'at' => $at, 'add' => $add, 'remove' => $remove]; | |
| 266 | + $i['assignees'] = $assignees; | |
| 267 | + } | |
| 268 | + if ($milestone !== false) { | |
| 269 | + $m = $this->validMilestone($milestone); | |
| 270 | + if ($m !== $i['milestone']) $i['events'][] = ['type' => 'milestone', 'by' => $by, 'at' => $at, 'from' => $i['milestone'], 'to' => $m]; | |
| 271 | + $i['milestone'] = $m; | |
| 272 | + } | |
| 273 | + return $i; | |
| 274 | + }); | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** イシューを消す(番号は使い回さない) */ | |
| 278 | + public function delete(int $n): bool { | |
| 279 | + return $this->locked(function() use ($n): bool { | |
| 280 | + if ($this->get($n) === null) return false; | |
| 281 | + $idx = $this->index(); | |
| 282 | + unset($idx['issues'][(string)$n]); | |
| 283 | + foreach ($idx['remote'] as $id => [$num]) if ((int)$num === $n) unset($idx['remote'][$id]); | |
| 284 | + $this->writeJson($this->dir.'/issues.json', $idx); | |
| 285 | + @unlink($this->file($n)); | |
| 286 | + return true; | |
| 287 | + }); | |
| 288 | + } | |
| 289 | + | |
| 290 | + //////// プッシュ | |
| 291 | + | |
| 292 | + /** | |
| 293 | + * プッシュしたコミットのメッセージの「#3」をイシューに記録し、既定のブランチなら「fixes #3」等で閉じる | |
| 294 | + * | |
| 295 | + * @param array $updates [参照, 前, 後]の配列 | |
| 296 | + * @param string $default 既定のブランチ(refs/heads/…) | |
| 297 | + * @return array 閉じたイシューの番号 | |
| 298 | + */ | |
| 299 | + public function processPush(array $updates, string $default): array { | |
| 300 | + $zero = $this->repo->zeroId(); | |
| 301 | + $closed = []; | |
| 302 | + if (empty($this->index()['issues'])) return []; | |
| 303 | + foreach ($updates as [$ref, $old, $new]) { | |
| 304 | + if (!str_starts_with($ref, 'refs/heads/') || $new === $zero) continue; | |
| 305 | + // 新しいブランチは他のブランチに無いコミットだけ | |
| 306 | + $haves = $old !== $zero ? [$old] : array_values(array_filter($this->repo->refs->all('refs/heads/'), fn($id, $r) => $r !== $ref, ARRAY_FILTER_USE_BOTH)); | |
| 307 | + try { | |
| 308 | + [$ids] = (new Walk($this->repo))->commitsBetween([$new], $haves); | |
| 309 | + } catch (GitException) { | |
| 310 | + continue; | |
| 311 | + } | |
| 312 | + // 古い順に | |
| 313 | + foreach (array_reverse(array_slice($ids, 0, self::MAX_PUSH_COMMITS)) as $id) { | |
| 314 | + $c = $this->repo->commit($id); | |
| 315 | + if (!preg_match_all('/(?<![\w&\/])#(\d+)\b/', $c->message, $m)) continue; | |
| 316 | + preg_match_all(self::CLOSE_RE, $c->message, $cm); | |
| 317 | + $closes = array_map('intval', $cm[1]); | |
| 318 | + $by = ['commit' => $id, 'name' => $c->author?->name ?? '']; | |
| 319 | + foreach (array_unique(array_map('intval', $m[1])) as $n) { | |
| 320 | + $close = $ref === $default && in_array($n, $closes, true); | |
| 321 | + $didClose = false; | |
| 322 | + $this->update($n, function(array $i) use ($id, $c, $by, $close, $ref, &$didClose): ?array { | |
| 323 | + $seen = false; | |
| 324 | + foreach ($i['events'] as $e) if ($e['type'] === 'commit' && $e['commit'] === $id) $seen = true; | |
| 325 | + if (!$seen) $i['events'][] = ['type' => 'commit', 'by' => $by, 'at' => time(), 'commit' => $id, 'subject' => $c->subject(), 'branch' => substr($ref, 11)]; | |
| 326 | + $didClose = $close && $i['state'] === 'open'; | |
| 327 | + if ($didClose) { | |
| 328 | + $i['state'] = 'closed'; | |
| 329 | + $i['closed'] = time(); | |
| 330 | + $i['events'][] = ['type' => 'closed', 'by' => $by, 'at' => time(), 'commit' => $id]; | |
| 331 | + } | |
| 332 | + return $seen && !$didClose ? null : $i; | |
| 333 | + }); | |
| 334 | + if ($didClose) $closed[$n] = true; | |
| 335 | + } | |
| 336 | + } | |
| 337 | + } | |
| 338 | + return array_keys($closed); | |
| 339 | + } | |
| 340 | + | |
| 341 | + //////// ラベルとマイルストーン | |
| 342 | + | |
| 343 | + /** ラベル(ID => name、color、description) */ | |
| 344 | + public function labels(): array { | |
| 345 | + return $this->readList('labels.json', 'labels'); | |
| 346 | + } | |
| 347 | + | |
| 348 | + public function saveLabel(?int $id, string $name, string $color, string $description): Result { | |
| 349 | + $name = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $name)); | |
| 350 | + if ($name === '' || mb_strlen($name) > 50) return Result::Error('ラベルの名前は1〜50文字です。'); | |
| 351 | + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) return Result::Error('色が不正です。'); | |
| 352 | + return $this->locked(function() use ($id, $name, $color, $description): Result { | |
| 353 | + $l = $this->readList('labels.json', 'labels', true); | |
| 354 | + foreach ($l['labels'] as $k => $x) if ((int)$k !== $id && mb_strtolower($x['name']) === mb_strtolower($name)) return Result::Error("ラベル「{$name}」は既に有ります。"); | |
| 355 | + if ($id !== null && !isset($l['labels'][$id])) return Result::Error('ラベルが有りません。'); | |
| 356 | + $id ??= $l['next']++; | |
| 357 | + $l['labels'][$id] = ['name' => $name, 'color' => strtolower($color), 'description' => mb_substr(trim($description), 0, 200)]; | |
| 358 | + $this->writeJson($this->dir.'/labels.json', $l); | |
| 359 | + return Result::Success('保存しました。', ['id' => $id]); | |
| 360 | + }); | |
| 361 | + } | |
| 362 | + | |
| 363 | + public function deleteLabel(int $id): void { | |
| 364 | + $this->locked(function() use ($id): void { | |
| 365 | + $l = $this->readList('labels.json', 'labels', true); | |
| 366 | + unset($l['labels'][$id]); | |
| 367 | + $this->writeJson($this->dir.'/labels.json', $l); | |
| 368 | + }); | |
| 369 | + foreach ($this->search(['state' => 'all', 'label' => $id]) as $s) { | |
| 370 | + $this->update($s['number'], function(array $i) use ($id): array { | |
| 371 | + $i['labels'] = array_values(array_diff($i['labels'], [$id])); | |
| 372 | + return $i; | |
| 373 | + }); | |
| 374 | + } | |
| 375 | + } | |
| 376 | + | |
| 377 | + /** マイルストーン(ID => title、description、due(Y-m-d又は空)、state) */ | |
| 378 | + public function milestones(): array { | |
| 379 | + return $this->readList('milestones.json', 'milestones'); | |
| 380 | + } | |
| 381 | + | |
| 382 | + public function saveMilestone(?int $id, string $title, string $description, string $due, string $state = 'open'): Result { | |
| 383 | + $title = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $title)); | |
| 384 | + if ($title === '' || mb_strlen($title) > 100) return Result::Error('マイルストーンの名前は1〜100文字です。'); | |
| 385 | + if ($due !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $due) || !checkdate((int)substr($due, 5, 2), (int)substr($due, 8, 2), (int)substr($due, 0, 4)))) return Result::Error('期日が不正です。'); | |
| 386 | + return $this->locked(function() use ($id, $title, $description, $due, $state): Result { | |
| 387 | + $l = $this->readList('milestones.json', 'milestones', true); | |
| 388 | + if ($id !== null && !isset($l['milestones'][$id])) return Result::Error('マイルストーンが有りません。'); | |
| 389 | + $id ??= $l['next']++; | |
| 390 | + $l['milestones'][$id] = ['title' => $title, 'description' => mb_substr(trim($description), 0, 2000), 'due' => $due, 'state' => $state === 'closed' ? 'closed' : 'open']; | |
| 391 | + $this->writeJson($this->dir.'/milestones.json', $l); | |
| 392 | + return Result::Success('保存しました。', ['id' => $id]); | |
| 393 | + }); | |
| 394 | + } | |
| 395 | + | |
| 396 | + public function deleteMilestone(int $id): void { | |
| 397 | + $this->locked(function() use ($id): void { | |
| 398 | + $l = $this->readList('milestones.json', 'milestones', true); | |
| 399 | + unset($l['milestones'][$id]); | |
| 400 | + $this->writeJson($this->dir.'/milestones.json', $l); | |
| 401 | + }); | |
| 402 | + foreach ($this->search(['state' => 'all', 'milestone' => $id]) as $s) { | |
| 403 | + $this->update($s['number'], function(array $i): array { | |
| 404 | + $i['milestone'] = null; | |
| 405 | + return $i; | |
| 406 | + }); | |
| 407 | + } | |
| 408 | + } | |
| 409 | + | |
| 410 | + //////// 表示 | |
| 411 | + | |
| 412 | + /** | |
| 413 | + * MarkdownをHTMLにする(安全なリンクだけ、「#3」をイシューへのリンクに) | |
| 414 | + * | |
| 415 | + * @param string $issuesUrl イシューの一覧のURL(…/issues) | |
| 416 | + */ | |
| 417 | + public static function render(string $text, string $issuesUrl): string { | |
| 418 | + $html = Markdown::fromString($text)->parse(); | |
| 419 | + $html = (string)preg_replace_callback('/\s(href|src)="([^"]*)"/i', function(array $m): string { | |
| 420 | + $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); | |
| 421 | + return preg_match('~^(https?:|mailto:|#|/(?!/))~i', $url) ? " {$m[1]}=\"{$m[2]}\"" : " {$m[1]}=\"#\""; | |
| 422 | + }, $html); | |
| 423 | + return self::linkify($html, $issuesUrl); | |
| 424 | + } | |
| 425 | + | |
| 426 | + /** HTMLの文字の部分(a、code、preの外)の「#3」とURLをリンクにする */ | |
| 427 | + public static function linkify(string $html, string $issuesUrl): string { | |
| 428 | + $parts = preg_split('/(<[^>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE); | |
| 429 | + $depth = 0; | |
| 430 | + foreach ($parts as $k => $p) { | |
| 431 | + if ($p === '') continue; | |
| 432 | + if ($p[0] === '<') { | |
| 433 | + if (preg_match('~^<(/?)(a|code|pre)\b~i', $p, $m)) $depth = max(0, $depth + ($m[1] === '/' ? -1 : 1)); | |
| 434 | + continue; | |
| 435 | + } | |
| 436 | + if ($depth > 0) continue; | |
| 437 | + $parts[$k] = (string)preg_replace_callback('~(https?://[^\s<>"]+[^\s<>".,;:!?)\]」。、])|(?<![\w&#/])#(\d+)\b~u', function(array $m) use ($issuesUrl): string { | |
| 438 | + if (($m[2] ?? '') !== '') return '<a href="'.htmlspecialchars($issuesUrl, ENT_QUOTES, 'UTF-8').'/'.$m[2].'">#'.$m[2].'</a>'; | |
| 439 | + return '<a href="'.$m[1].'" rel="nofollow noopener noreferrer">'.$m[1].'</a>'; | |
| 440 | + }, $p); | |
| 441 | + } | |
| 442 | + return implode('', $parts); | |
| 443 | + } | |
| 444 | + | |
| 445 | + /** 書いた者の比較用のキー(ローカルはユーザー名、リモートはアクターのURL) */ | |
| 446 | + public static function authorKey(array $a): string { | |
| 447 | + return (string)($a['user'] ?? $a['actor'] ?? ('commit:'.($a['commit'] ?? ''))); | |
| 448 | + } | |
| 449 | + | |
| 450 | + //////// 内部 | |
| 451 | + | |
| 452 | + private function file(int $n): string { | |
| 453 | + return $this->dir.'/issues/'.$n.'.json'; | |
| 454 | + } | |
| 455 | + | |
| 456 | + private static function cleanTitle(string $t): string { | |
| 457 | + return mb_substr(trim(preg_replace('/[\x00-\x1f\x7f]+/', ' ', $t)), 0, self::MAX_TITLE); | |
| 458 | + } | |
| 459 | + | |
| 460 | + private function validLabels(array $ids): array { | |
| 461 | + $all = $this->labels(); | |
| 462 | + return array_values(array_unique(array_filter(array_map('intval', $ids), fn($id) => isset($all[$id])))); | |
| 463 | + } | |
| 464 | + | |
| 465 | + private function validMilestone(mixed $id): ?int { | |
| 466 | + $id = (int)$id; | |
| 467 | + return $id > 0 && isset($this->milestones()[$id]) ? $id : null; | |
| 468 | + } | |
| 469 | + | |
| 470 | + /** ID => 物($withNextなら[next, 名前 => ID => 物]) */ | |
| 471 | + private function readList(string $file, string $key, bool $withNext = false): array { | |
| 472 | + $j = json_decode((string)@file_get_contents($this->dir.'/'.$file), true); | |
| 473 | + $list = []; | |
| 474 | + foreach ((array)($j[$key] ?? []) as $id => $x) if (is_array($x)) $list[(int)$id] = $x; | |
| 475 | + return $withNext ? ['next' => max(1, (int)($j['next'] ?? 1), ...array_map(fn($id) => $id + 1, array_keys($list))), $key => $list] : $list; | |
| 476 | + } | |
| 477 | + | |
| 478 | + /** イシューと索引を書く */ | |
| 479 | + private function save(array $issue, array $idx): void { | |
| 480 | + $last = $issue['created']; | |
| 481 | + foreach ($issue['comments'] as $c) $last = max($last, $c['created']); | |
| 482 | + $idx['issues'][(string)$issue['number']] = [ | |
| 483 | + 'number' => $issue['number'], 'title' => $issue['title'], 'state' => $issue['state'], 'author' => $issue['author'], 'authorKey' => self::authorKey($issue['author']), | |
| 484 | + 'created' => $issue['created'], 'updated' => $issue['updated'], 'closed' => $issue['closed'], 'labels' => $issue['labels'], 'assignees' => $issue['assignees'], | |
| 485 | + 'milestone' => $issue['milestone'], 'comments' => count($issue['comments']), 'last' => $last, | |
| 486 | + ]; | |
| 487 | + $this->repo->mkdir($this->dir.'/issues'); | |
| 488 | + $this->writeJson($this->file($issue['number']), $issue); | |
| 489 | + $this->writeJson($this->dir.'/issues.json', $idx); | |
| 490 | + } | |
| 491 | + | |
| 492 | + private function writeJson(string $file, array $data): void { | |
| 493 | + $this->repo->mkdir(dirname($file)); | |
| 494 | + $this->repo->writeFile($file, (string)json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); | |
| 495 | + } | |
| 496 | + | |
| 497 | + /** gitler/の中を書く間はロックする(Webとgitユーザーの両方から書けるロックファイル) */ | |
| 498 | + private function locked(callable $fn): mixed { | |
| 499 | + if ($this->depth > 0) return $fn(); | |
| 500 | + $this->repo->mkdir($this->dir); | |
| 501 | + $file = $this->dir.'/issues.lock'; | |
| 502 | + $new = !is_file($file); | |
| 503 | + $lock = @fopen($file, 'c'); | |
| 504 | + if ($lock && $new) $this->repo->chmodFile($file); | |
| 505 | + if ($lock) flock($lock, LOCK_EX); | |
| 506 | + $this->depth++; | |
| 507 | + try { | |
| 508 | + return $fn(); | |
| 509 | + } finally { | |
| 510 | + $this->depth--; | |
| 511 | + if ($lock) { | |
| 512 | + flock($lock, LOCK_UN); | |
| 513 | + fclose($lock); | |
| 514 | + } | |
| 515 | + } | |
| 516 | + } | |
| 517 | +} |
| @@ -41,12 +41,16 @@ | ||
| 41 | 41 | |
| 42 | 42 | use Std\Lib\Tester; |
| 43 | 43 | use Std\Lib\Auth; |
| 44 | +use Std\Lib\Fedi\Fedi; | |
| 45 | +use Std\Lib\Fedi\Remote; | |
| 44 | 46 | use Std\Lib\Git\Commit; |
| 45 | 47 | use Std\Lib\Git\Person; |
| 46 | 48 | use Std\Lib\Git\Repository; |
| 47 | 49 | use Std\Lib\Git\Tree; |
| 48 | 50 | use Std\Lib\Gitler\Archive; |
| 51 | +use Std\Lib\Gitler\Federation; | |
| 49 | 52 | use Std\Lib\Gitler\Gitler; |
| 53 | +use Std\Lib\Gitler\Issues; | |
| 50 | 54 | use Std\Lib\Gitler\Keys; |
| 51 | 55 | use Std\Lib\Gitler\Languages; |
| 52 | 56 | use Roles; |
| @@ -400,6 +404,260 @@ | ||
| 400 | 404 | $test->assertSame('その他', $p[7]['name']); |
| 401 | 405 | $test->assertSame([], Languages::present([])); |
| 402 | 406 | }); |
| 407 | +}); | |
| 408 | + | |
| 409 | +$test->describe('Gitler(イシュー)', function($test) use ($owner): void { | |
| 410 | + Gitler::create($owner, 'issues1'); | |
| 411 | + $db = Issues::open('owner001', 'issues1'); | |
| 412 | + $me = ['user' => 'owner001']; | |
| 413 | + | |
| 414 | + $test->it('イシューとコメントを作り、探せるはず', function($test) use ($db, $me): void { | |
| 415 | + $test->assertFalse($db->create($me, " \n", 'x')->isSuccess, '題名が無い'); | |
| 416 | + $r1 = $db->create($me, "落ちる\nバグ", "手順:\n1. 開く"); | |
| 417 | + $test->assertTrue($r1->isSuccess); | |
| 418 | + $test->assertSame(1, $r1->data['number']); | |
| 419 | + $test->assertSame('落ちる バグ', $r1->data['issue']['title'], '改行は空白'); | |
| 420 | + $test->assertSame(2, $db->create(['user' => 'other001'], '機能の要望', '')->data['number']); | |
| 421 | + $c = $db->comment(1, ['user' => 'other001'], '再現しました'); | |
| 422 | + $test->assertTrue($c->isSuccess); | |
| 423 | + $test->assertSame(1, $c->data['comment']['id']); | |
| 424 | + $test->assertFalse($db->comment(1, $me, ' ')->isSuccess); | |
| 425 | + $test->assertFalse($db->comment(99, $me, 'x')->isSuccess); | |
| 426 | + $test->assertSame(['open' => 2, 'closed' => 0], $db->counts()); | |
| 427 | + $test->assertSame([2, 1], array_column($db->search(), 'number')); | |
| 428 | + $test->assertSame([1], array_column($db->search(['q' => '落ちる']), 'number')); | |
| 429 | + $test->assertSame([1], array_column($db->search(['q' => '#1']), 'number')); | |
| 430 | + $test->assertSame([2], array_column($db->search(['author' => 'other001']), 'number')); | |
| 431 | + $test->assertSame([1, 2], array_column($db->search(['sort' => 'comments']), 'number')); | |
| 432 | + $test->assertSame(1, $db->index()['issues'][1]['comments']); | |
| 433 | + }); | |
| 434 | + | |
| 435 | + $test->it('編集・閉じる・ラベル・担当者・マイルストーンが記録されるはず', function($test) use ($db, $me): void { | |
| 436 | + $test->assertTrue($db->edit(1, $me, '起動すると落ちる', '新しい本文')->isSuccess); | |
| 437 | + $bug = $db->saveLabel(null, 'バグ', '#ee4030', '壊れている')->data['id']; | |
| 438 | + $test->assertFalse($db->saveLabel(null, 'ばぐ', 'red', '')->isSuccess, '色'); | |
| 439 | + $test->assertFalse($db->saveLabel(null, 'バグ', '#000000', '')->isSuccess, '同じ名前'); | |
| 440 | + $ms = $db->saveMilestone(null, 'v1.0', '最初', '2026-12-31')->data['id']; | |
| 441 | + $test->assertFalse($db->saveMilestone(null, 'x', '', '2026-02-30')->isSuccess, '不正な日付'); | |
| 442 | + $db->triage(1, $me, [$bug, 999], ['owner001'], $ms); | |
| 443 | + $i = $db->get(1); | |
| 444 | + $test->assertSame([$bug], $i['labels'], '無いラベルは付かない'); | |
| 445 | + $test->assertSame(['owner001'], $i['assignees']); | |
| 446 | + $test->assertSame($ms, $i['milestone']); | |
| 447 | + $test->assertSame(['title', 'labels', 'assignees', 'milestone'], array_column($i['events'], 'type')); | |
| 448 | + $test->assertNotNull($db->setState(1, false, $me)); | |
| 449 | + $test->assertNull($db->setState(1, false, $me), '閉じたイシューは閉じない'); | |
| 450 | + $test->assertSame(['open' => 1, 'closed' => 1], $db->counts()); | |
| 451 | + $test->assertSame([1], array_column($db->search(['state' => 'closed', 'label' => $bug, 'milestone' => $ms, 'assignee' => 'owner001']), 'number')); | |
| 452 | + $db->deleteLabel($bug); | |
| 453 | + $db->deleteMilestone($ms); | |
| 454 | + $i = $db->get(1); | |
| 455 | + $test->assertSame([], $i['labels']); | |
| 456 | + $test->assertNull($i['milestone']); | |
| 457 | + $test->assertSame('新しい本文', $i['body']); | |
| 458 | + $test->assertSame('再現しました(編集)', $db->editComment(1, 1, '再現しました(編集)')['body']); | |
| 459 | + $test->assertNotNull($db->editComment(1, 1, null)); | |
| 460 | + $test->assertSame([], $db->get(1)['comments']); | |
| 461 | + $test->assertTrue($db->delete(2)); | |
| 462 | + $test->assertNull($db->get(2)); | |
| 463 | + $test->assertSame(3, $db->create($me, '次', '')->data['number'], '番号は使い回さない'); | |
| 464 | + }); | |
| 465 | + | |
| 466 | + $test->it('プッシュしたコミットでイシューに言及し、既定のブランチの「fixes #N」で閉じるはず', function($test) use ($db, $me): void { | |
| 467 | + $repo = $db->repo; | |
| 468 | + $db->setState(1, true, $me); | |
| 469 | + $p = new Person('諏訪子', 's@x', 1700000000, '+0900'); | |
| 470 | + $c1 = $repo->writeCommit(Commit::create($repo->updateTree(null, ['a' => [Tree::FILE, "1\n"]]), [], $p, $p, "作業中 #3\n")); | |
| 471 | + $repo->refs->update('refs/heads/topic', $c1); | |
| 472 | + $test->assertSame([], $db->processPush([['refs/heads/topic', $repo->zeroId(), $c1]], 'refs/heads/master')); | |
| 473 | + $test->assertSame('open', $db->get(3)['state'], '他のブランチでは閉じない'); | |
| 474 | + $c2 = $repo->writeCommit(Commit::create($repo->updateTree(null, ['a' => [Tree::FILE, "2\n"]]), [$c1], $p, $p, "直した\n\nFixes #1, closes #3 (see #99)\n")); | |
| 475 | + $repo->refs->update('refs/heads/master', $c2); | |
| 476 | + $test->assertSame([1, 3], $db->processPush([['refs/heads/master', $repo->zeroId(), $c2]], 'refs/heads/master')); | |
| 477 | + $i = $db->get(3); | |
| 478 | + $test->assertSame('closed', $i['state']); | |
| 479 | + $types = array_column($i['events'], 'type'); | |
| 480 | + $test->assertSame(['commit', 'commit', 'closed'], $types); | |
| 481 | + $test->assertSame($c2, end($i['events'])['commit']); | |
| 482 | + $test->assertSame([], $db->processPush([['refs/heads/master', $c1, $c2]], 'refs/heads/master'), '2回目は何もしない'); | |
| 483 | + }); | |
| 484 | + | |
| 485 | + $test->it('Markdownを安全にして「#3」とURLをリンクにするはず', function($test): void { | |
| 486 | + $h = Issues::render("[x](javascript:alert(1)) <script>alert(1)</script> #3 https://example.com/a?b=1&c=2\n\n`#4` [#5](https://x.example)", '/gitler/o/r/issues'); | |
| 487 | + $test->assertFalse(str_contains($h, 'javascript:')); | |
| 488 | + $test->assertFalse(str_contains($h, '<script')); | |
| 489 | + $test->assertTrue(str_contains($h, '<a href="/gitler/o/r/issues/3">#3</a>')); | |
| 490 | + $test->assertTrue(str_contains($h, '<a href="https://example.com/a?b=1&c=2"')); | |
| 491 | + $test->assertTrue(str_contains($h, '<code>#4</code>'), 'コードの中はそのまま'); | |
| 492 | + $test->assertFalse(str_contains($h, 'issues/5'), 'リンクの中はそのまま'); | |
| 493 | + }); | |
| 494 | +}); | |
| 495 | + | |
| 496 | +$test->describe('Gitler(Fediverse)', function($test) use ($base, $owner): void { | |
| 497 | + if (!Fedi::enabled()) { | |
| 498 | + $test->skipAll('マルチユーザーのFediverse(ACTIVITYPUB_MULTIUSER)が無効'); | |
| 499 | + return; | |
| 500 | + } | |
| 501 | + Fedi::$dataDir = "{$base}/ap"; | |
| 502 | + Fedi::$baseUrl = 'https://lb.test'; | |
| 503 | + Gitler::create($owner, 'fedi1', ['description' => 'テスト']); | |
| 504 | + Gitler::create($owner, 'hidden', ['private' => true]); | |
| 505 | + $meta = Gitler::meta('owner001', 'fedi1'); | |
| 506 | + $repoUrl = 'https://lb.test/gitler/owner001/fedi1'; | |
| 507 | + // リモートのアクター(取得しない様にキャッシュに入れる) | |
| 508 | + $alice = 'https://8.8.8.8/users/alice'; | |
| 509 | + $bob = 'https://9.9.9.9/users/bob'; | |
| 510 | + foreach ([$alice, $bob] as $a) { | |
| 511 | + Remote::cacheActor(['id' => $a, 'type' => 'Person', 'preferredUsername' => basename($a), 'name' => ucfirst(basename($a)), 'inbox' => "{$a}/inbox", | |
| 512 | + 'endpoints' => ['sharedInbox' => dirname($a, 2).'/inbox'], 'url' => dirname($a, 2).'/@'.basename($a)]); | |
| 513 | + } | |
| 514 | + $queue = function(): array { | |
| 515 | + $out = []; | |
| 516 | + foreach (glob(Fedi::path('queue/').'*.json') ?: [] as $f) { | |
| 517 | + $j = json_decode((string)file_get_contents($f), true); | |
| 518 | + $out[] = [$j['from'], $j['inbox'], $j['activity']['type'], $j['activity']]; | |
| 519 | + unlink($f); | |
| 520 | + } | |
| 521 | + return $out; | |
| 522 | + }; | |
| 523 | + | |
| 524 | + $test->it('WebFingerとアクターを返すはず(非公開は無し)', function($test) use ($meta, $repoUrl): void { | |
| 525 | + $test->assertSame('acct:owner001.fedi1@lb.test', Federation::webfinger('acct:owner001.fedi1@lb.test')['subject']); | |
| 526 | + $test->assertSame($repoUrl, Federation::webfinger('acct:OWNER001.FEDI1@LB.TEST')['links'][0]['href'], '大文字・小文字'); | |
| 527 | + $test->assertSame('acct:owner001.fedi1@lb.test', Federation::webfinger($repoUrl)['subject']); | |
| 528 | + $test->assertNull(Federation::webfinger('acct:owner001.hidden@lb.test')); | |
| 529 | + $test->assertNull(Federation::webfinger('acct:owner001.fedi1@other.example')); | |
| 530 | + $doc = Federation::document('owner001', 'fedi1', $meta, 'https://lb.test/gitler/owner001/fedi1.git'); | |
| 531 | + $test->assertSame('Service', $doc['type']); | |
| 532 | + $test->assertSame('owner001.fedi1', $doc['preferredUsername']); | |
| 533 | + $test->assertSame("{$repoUrl}/inbox", $doc['inbox']); | |
| 534 | + $test->assertSame($repoUrl, $doc['ticketsTrackedBy']); | |
| 535 | + }); | |
| 536 | + | |
| 537 | + $test->it('フォローを受け付けてAcceptを返すはず', function($test) use ($alice, $repoUrl, $queue): void { | |
| 538 | + $queue(); | |
| 539 | + [$code] = Federation::receive('owner001', 'fedi1', ['id' => "{$alice}#f1", 'type' => 'Follow', 'actor' => $alice, 'object' => $repoUrl]); | |
| 540 | + $test->assertSame(202, $code); | |
| 541 | + $test->assertTrue(isset(Federation::followers('owner001', 'fedi1')[$alice])); | |
| 542 | + $q = $queue(); | |
| 543 | + $test->assertSame([['gitler:owner001/fedi1', "{$alice}/inbox", 'Accept']], array_map(fn($x) => array_slice($x, 0, 3), $q)); | |
| 544 | + $test->assertSame(403, Federation::receive('owner001', 'fedi1', ['type' => 'Follow', 'actor' => $repoUrl, 'object' => $repoUrl])[0], 'ローカルを名乗る'); | |
| 545 | + $test->assertSame(404, Federation::receive('owner001', 'hidden', ['type' => 'Follow', 'actor' => $alice, 'object' => $repoUrl])[0], '非公開'); | |
| 546 | + }); | |
| 547 | + | |
| 548 | + $test->it('メンションでイシューを作り、返信をコメントにするはず', function($test) use ($alice, $bob, $repoUrl, $queue): void { | |
| 549 | + $mention = '<span class="h-card"><a href="'.$repoUrl.'" class="u-url mention">@<span>owner001.fedi1</span></a></span>'; | |
| 550 | + $note = ['id' => 'https://8.8.8.8/notes/1', 'type' => 'Note', 'attributedTo' => $alice, 'content' => "<p>{$mention} 起動しない<br />ログ:<script>x</script>何も出ない</p>", | |
| 551 | + 'tag' => [['type' => 'Mention', 'href' => $repoUrl]]]; | |
| 552 | + Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => $note]); | |
| 553 | + $db = Issues::open('owner001', 'fedi1'); | |
| 554 | + $i = $db->get(1); | |
| 555 | + $test->assertSame('起動しない', $i['title']); | |
| 556 | + $test->assertSame($alice, $i['author']['actor']); | |
| 557 | + $test->assertSame('Alice', $i['author']['name']); | |
| 558 | + $test->assertSame('@alice@8.8.8.8', $i['author']['handle']); | |
| 559 | + $test->assertFalse(str_contains($i['body'], 'script')); | |
| 560 | + $test->assertFalse(str_contains($i['body'], 'owner001.fedi1'), 'メンションは外す'); | |
| 561 | + $q = $queue(); | |
| 562 | + $test->assertSame(['Create'], array_values(array_unique(array_column($q, 2)))); | |
| 563 | + $test->assertSame('https://lb.test/gitler/owner001/fedi1/issues/1', $q[0][3]['object']['id']); | |
| 564 | + $test->assertSame([$alice], array_column($q[0][3]['object']['tag'], 'href'), '作った人をメンション'); | |
| 565 | + // 同じ投稿は2回作らない | |
| 566 | + Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => $note]); | |
| 567 | + $test->assertSame(1, count($db->index()['issues'])); | |
| 568 | + // メンションの無い投稿は無視 | |
| 569 | + Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => ['id' => 'https://8.8.8.8/notes/2', 'type' => 'Note', 'attributedTo' => $alice, 'content' => '<p>関係無い</p>']]); | |
| 570 | + $test->assertSame(1, count($db->index()['issues'])); | |
| 571 | + // 返信 | |
| 572 | + $reply = ['id' => 'https://9.9.9.9/notes/9', 'type' => 'Note', 'attributedTo' => $bob, 'inReplyTo' => "{$repoUrl}/issues/1", 'content' => "<p>{$mention} 私もです</p>"]; | |
| 573 | + Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $bob, 'object' => $reply]); | |
| 574 | + Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $bob, 'object' => $reply]); | |
| 575 | + $c = $db->get(1)['comments']; | |
| 576 | + $test->assertSame(1, count($c)); | |
| 577 | + $test->assertSame('<p>私もです</p>', $c[0]['body']); | |
| 578 | + // 返信への返信も同じイシュー | |
| 579 | + Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => ['id' => 'https://8.8.8.8/notes/3', 'type' => 'Note', 'attributedTo' => $alice, | |
| 580 | + 'inReplyTo' => $reply['id'], 'content' => '<p>ありがとう</p>']]); | |
| 581 | + $test->assertSame(2, count($db->get(1)['comments'])); | |
| 582 | + // 他人の投稿を名乗る | |
| 583 | + $test->assertSame(403, Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $alice, 'object' => ['id' => 'https://9.9.9.9/notes/10', 'type' => 'Note', | |
| 584 | + 'attributedTo' => $bob, 'inReplyTo' => "{$repoUrl}/issues/1", 'content' => 'x']])[0]); | |
| 585 | + // 編集と削除は書いた人だけ | |
| 586 | + Federation::receive('owner001', 'fedi1', ['type' => 'Update', 'actor' => $alice, 'object' => $reply + ['content' => '<p>乗っ取り</p>']]); | |
| 587 | + $test->assertSame('<p>私もです</p>', $db->get(1)['comments'][0]['body']); | |
| 588 | + Federation::receive('owner001', 'fedi1', ['type' => 'Update', 'actor' => $bob, 'object' => ['content' => '<p>私もです(追記)</p>'] + $reply]); | |
| 589 | + $test->assertSame('<p>私もです(追記)</p>', $db->get(1)['comments'][0]['body']); | |
| 590 | + Federation::receive('owner001', 'fedi1', ['type' => 'Delete', 'actor' => $bob, 'object' => ['id' => $reply['id'], 'type' => 'Tombstone']]); | |
| 591 | + $test->assertSame(1, count($db->get(1)['comments'])); | |
| 592 | + $queue(); | |
| 593 | + }); | |
| 594 | + | |
| 595 | + $test->it('ローカルのコメントと閉じた事を送り、作った人のResolveで閉じるはず', function($test) use ($alice, $bob, $repoUrl, $queue): void { | |
| 596 | + $db = Issues::open('owner001', 'fedi1'); | |
| 597 | + $meta = Gitler::meta('owner001', 'fedi1'); | |
| 598 | + $res = $db->comment(1, ['user' => 'owner001'], "直します #1"); | |
| 599 | + Federation::commentAdded('owner001', 'fedi1', $meta, $res->data['issue'], $res->data['comment']); | |
| 600 | + $q = $queue(); | |
| 601 | + $test->assertSame('Create', $q[0][2]); | |
| 602 | + $obj = $q[0][3]['object']; | |
| 603 | + $test->assertSame("{$repoUrl}/issues/1/comments/".$res->data['comment']['id'], $obj['id']); | |
| 604 | + $test->assertSame("{$repoUrl}/issues/1", $obj['inReplyTo']); | |
| 605 | + $test->assertTrue(str_contains($obj['content'], 'href="https://lb.test/gitler/owner001/fedi1/issues/1"'), '絶対URL'); | |
| 606 | + $inboxes = array_unique(array_column($q, 1)); | |
| 607 | + sort($inboxes); | |
| 608 | + $test->assertSame(['https://8.8.8.8/inbox'], $inboxes, 'フォロワーと関わった人(共有の受付ボックス)'); | |
| 609 | + // Resolveは作った人だけ | |
| 610 | + Federation::receive('owner001', 'fedi1', ['type' => 'Resolve', 'actor' => $bob, 'object' => "{$repoUrl}/issues/1/ticket"]); | |
| 611 | + $test->assertSame('open', $db->get(1)['state']); | |
| 612 | + Federation::receive('owner001', 'fedi1', ['type' => 'Resolve', 'actor' => $alice, 'object' => "{$repoUrl}/issues/1/ticket"]); | |
| 613 | + $test->assertSame('closed', $db->get(1)['state']); | |
| 614 | + $types = array_column($queue(), 2); | |
| 615 | + $test->assertTrue(in_array('Resolve', $types, true) && in_array('Update', $types, true)); | |
| 616 | + Federation::receive('owner001', 'fedi1', ['type' => 'Undo', 'actor' => $alice, 'object' => ['type' => 'Resolve', 'actor' => $alice, 'object' => "{$repoUrl}/issues/1/ticket"]]); | |
| 617 | + $test->assertSame('open', $db->get(1)['state']); | |
| 618 | + $test->assertTrue(in_array('Undo', array_column($queue(), 2), true)); | |
| 619 | + }); | |
| 620 | + | |
| 621 | + $test->it('ForgeFedのOffer{Ticket}でイシューを作ってAcceptを返すはず', function($test) use ($bob, $repoUrl, $queue): void { | |
| 622 | + $offer = ['id' => 'https://9.9.9.9/offers/1', 'type' => 'Offer', 'actor' => $bob, 'target' => $repoUrl, | |
| 623 | + 'object' => ['type' => 'Ticket', 'attributedTo' => $bob, 'summary' => 'Tickets & more', 'content' => '<p>x</p>', | |
| 624 | + 'source' => ['mediaType' => 'text/markdown', 'content' => "**太字**\n"]]]; | |
| 625 | + Federation::receive('owner001', 'fedi1', $offer); | |
| 626 | + $i = Issues::open('owner001', 'fedi1')->get(2); | |
| 627 | + $test->assertSame('Tickets & more', $i['title']); | |
| 628 | + $test->assertSame("**太字**\n", $i['body']); | |
| 629 | + $test->assertFalse($i['html'], 'Markdownのまま'); | |
| 630 | + $accept = array_values(array_filter($queue(), fn($x) => $x[2] === 'Accept')); | |
| 631 | + $test->assertSame("{$repoUrl}/issues/2/ticket", $accept[0][3]['result']); | |
| 632 | + $t = Federation::ticket('owner001', 'fedi1', $i); | |
| 633 | + $test->assertSame('Ticket', $t['type']); | |
| 634 | + $test->assertSame($bob, $t['attributedTo']); | |
| 635 | + $test->assertFalse($t['isResolved']); | |
| 636 | + }); | |
| 637 | + | |
| 638 | + $test->it('ブロックした人は無視し、SSHのプッシュでは配信を溜めるはず', function($test) use ($bob, $repoUrl, $queue): void { | |
| 639 | + $test->assertTrue(Federation::block('owner001', 'fedi1', $bob)->isSuccess); | |
| 640 | + $test->assertTrue(Federation::block('owner001', 'fedi1', 'spam.example')->isSuccess); | |
| 641 | + $test->assertTrue(Federation::isBlocked('owner001', 'fedi1', 'https://a.spam.example/u/x')); | |
| 642 | + $before = count(Issues::open('owner001', 'fedi1')->get(1)['comments']); | |
| 643 | + Federation::receive('owner001', 'fedi1', ['type' => 'Create', 'actor' => $bob, 'object' => ['id' => 'https://9.9.9.9/notes/20', 'type' => 'Note', 'attributedTo' => $bob, | |
| 644 | + 'inReplyTo' => "{$repoUrl}/issues/1", 'content' => '<p>x</p>']]); | |
| 645 | + $test->assertSame($before, count(Issues::open('owner001', 'fedi1')->get(1)['comments'])); | |
| 646 | + Federation::block('owner001', 'fedi1', $bob, false); | |
| 647 | + $test->assertFalse(Federation::isBlocked('owner001', 'fedi1', $bob)); | |
| 648 | + $queue(); | |
| 649 | + Federation::$spool = true; | |
| 650 | + $issue = Issues::open('owner001', 'fedi1')->get(1); | |
| 651 | + Federation::stateChanged('owner001', 'fedi1', Gitler::meta('owner001', 'fedi1'), $issue); | |
| 652 | + Federation::$spool = false; | |
| 653 | + $test->assertSame([], $queue()); | |
| 654 | + $test->assertSame(2, count(glob(Gitler::path('owner001', 'fedi1').'/gitler/spool/*.json'))); | |
| 655 | + $test->assertSame(2, Federation::flush()); | |
| 656 | + $test->assertSame([], glob(Gitler::path('owner001', 'fedi1').'/gitler/spool/*.json')); | |
| 657 | + $test->assertTrue(count($queue()) > 0); | |
| 658 | + }); | |
| 659 | + Fedi::$dataDir = null; | |
| 660 | + Fedi::$baseUrl = null; | |
| 403 | 661 | }); |
| 404 | 662 | |
| 405 | 663 | $test->describe('Gitler(HTTPのパスワード)', function($test) use ($base): void { |
変更
util.php
| @@ -293,8 +293,11 @@ | ||
| 293 | 293 | } |
| 294 | 294 | |
| 295 | 295 | if (ACTIVITYPUB_MULTIUSER) { |
| 296 | - // マイクロブログの配信(毎分) | |
| 297 | - $cron->register('apdeliver', fn() => \Std\Lib\Fedi\Delivery::run()); | |
| 296 | + // マイクロブログとGitlerの配信(毎分。SSHのプッシュでGitlerのリポジトリに溜めた物も) | |
| 297 | + $cron->register('apdeliver', function(): \Result { | |
| 298 | + if (\Std\Lib\Gitler\Gitler::enabled()) \Std\Lib\Gitler\Federation::flush(); | |
| 299 | + return \Std\Lib\Fedi\Delivery::run(); | |
| 300 | + }); | |
| 298 | 301 | // 90日より古いリモートの投稿とキャッシュを消す(1日1回) |
| 299 | 302 | $cron->register('apcleanup', fn() => \Std\Lib\Fedi\Timeline::cleanup()); |
| 300 | 303 | } |
| @@ -0,0 +1,163 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(gitler/repohead) @} | |
| 3 | + <div class="gl-issuehead"> | |
| 4 | + <h2>{{ $issue['title'] }} <span class="gl-muted">#{{ $issue['number'] }}</span></h2> | |
| 5 | + <p> | |
| 6 | + <span class="gl-statebadge gl-badge-{{ $issue['state'] }}">{{ $issue['state'] === 'open' ? '● 開いている' : '✓ 閉じた' }}</span> | |
| 7 | + <a href="{{ $who['url'] }}">{{ $who['name'] }}</a>{{ $who['handle'] !== '' ? '('.$who['handle'].')' : '' }}が{{ $date }}に作成、コメント{{ count($issue['comments']) }}件 | |
| 8 | + </p> | |
| 9 | +{@ if ($canEdit) @} | |
| 10 | + <details class="gl-edit"> | |
| 11 | + <summary>編集</summary> | |
| 12 | + <form method="post" action="{{ $issueUrl }}" class="gl-form gl-issueform"> | |
| 13 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 14 | + <input type="text" name="title" value="{{ $issue['title'] }}" maxlength="200" required="required" /> | |
| 15 | +{@ if (empty($issue['html'])) @} | |
| 16 | + <textarea name="body" rows="12">{{ $issue['body'] }}</textarea> | |
| 17 | +{@ endif @} | |
| 18 | + <button name="do" value="edit">保存</button> | |
| 19 | + </form> | |
| 20 | + </details> | |
| 21 | +{@ endif @} | |
| 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 ($canTriage) @} | |
| 124 | + <details class="gl-edit"> | |
| 125 | + <summary>変える</summary> | |
| 126 | + <form method="post" action="{{ $issueUrl }}" class="gl-form"> | |
| 127 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 128 | +{@ if (!empty($labels)) @} | |
| 129 | + <fieldset class="gl-checks"> | |
| 130 | + <legend>ラベル</legend> | |
| 131 | +{@ foreach ($labels as $l) @} | |
| 132 | + <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> | |
| 133 | +{@ endforeach @} | |
| 134 | + </fieldset> | |
| 135 | +{@ endif @} | |
| 136 | + <fieldset class="gl-checks"> | |
| 137 | + <legend>担当者</legend> | |
| 138 | +{@ foreach ($people as $p) @} | |
| 139 | + <label><input type="checkbox" name="assignees[]" value="{{ $p['name'] }}"{{{ $p['checked'] ? ' checked="checked"' : '' }}} /> {{ $p['name'] }}</label> | |
| 140 | +{@ endforeach @} | |
| 141 | + </fieldset> | |
| 142 | + <label>マイルストーン: | |
| 143 | + <select name="milestone"> | |
| 144 | + <option value="0">無し</option> | |
| 145 | +{@ foreach ($milestones as $id => $m) @} | |
| 146 | + <option value="{{ $id }}"{{{ $id === $issue['milestone'] ? ' selected="selected"' : '' }}}>{{ $m['title'] }}{{ $m['state'] === 'closed' ? '(終了)' : '' }}</option> | |
| 147 | +{@ endforeach @} | |
| 148 | + </select> | |
| 149 | + </label> | |
| 150 | + <button name="do" value="triage">保存</button> | |
| 151 | + </form> | |
| 152 | + </details> | |
| 153 | +{@ endif @} | |
| 154 | +{@ if ($canAdmin) @} | |
| 155 | + <form method="post" action="{{ $issueUrl }}" class="gl-delete"> | |
| 156 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 157 | + <label><input type="checkbox" name="confirm" value="1" required="required" /> 元に戻せません</label> | |
| 158 | + <button name="do" value="delete" class="danger">イシューを削除</button> | |
| 159 | + </form> | |
| 160 | +{@ endif @} | |
| 161 | + </aside> | |
| 162 | + </div> | |
| 163 | +{@ include(common/footer) @} |
| @@ -0,0 +1,39 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(gitler/repohead) @} | |
| 3 | + <h2 class="paragraph">新しいイシュー</h2> | |
| 4 | + <form method="post" action="{{ $repoUrl }}/issues/new" class="gl-form gl-issueform"> | |
| 5 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 6 | + <input type="text" name="title" value="{{ $in['title'] }}" maxlength="200" placeholder="題名" required="required" /> | |
| 7 | + <textarea name="body" rows="12" placeholder="説明(Markdown)">{{ $in['body'] }}</textarea> | |
| 8 | + <small>Markdownが使えます。「#3」で他のイシューにリンクします。</small> | |
| 9 | +{@ if ($canWrite) @} | |
| 10 | +{@ if (!empty($labels)) @} | |
| 11 | + <fieldset class="gl-checks"> | |
| 12 | + <legend>ラベル</legend> | |
| 13 | +{@ foreach ($labels as $l) @} | |
| 14 | + <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> | |
| 15 | +{@ endforeach @} | |
| 16 | + </fieldset> | |
| 17 | +{@ endif @} | |
| 18 | + <fieldset class="gl-checks"> | |
| 19 | + <legend>担当者</legend> | |
| 20 | +{@ foreach ($people as $p) @} | |
| 21 | + <label><input type="checkbox" name="assignees[]" value="{{ $p }}"{{{ in_array($p, $in['assignees'], true) ? ' checked="checked"' : '' }}} /> {{ $p }}</label> | |
| 22 | +{@ endforeach @} | |
| 23 | + </fieldset> | |
| 24 | +{@ if (!empty($milestones)) @} | |
| 25 | + <label>マイルストーン: | |
| 26 | + <select name="milestone"> | |
| 27 | + <option value="0">無し</option> | |
| 28 | +{@ foreach ($milestones as $id => $m) @} | |
| 29 | +{@ if ($m['state'] === 'open') @} | |
| 30 | + <option value="{{ $id }}"{{{ $id === $in['milestone'] ? ' selected="selected"' : '' }}}>{{ $m['title'] }}</option> | |
| 31 | +{@ endif @} | |
| 32 | +{@ endforeach @} | |
| 33 | + </select> | |
| 34 | + </label> | |
| 35 | +{@ endif @} | |
| 36 | +{@ endif @} | |
| 37 | + <button>作る</button> | |
| 38 | + </form> | |
| 39 | +{@ include(common/footer) @} |
| @@ -0,0 +1,70 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(gitler/repohead) @} | |
| 3 | + <div class="gl-issuebar"> | |
| 4 | + <span class="gl-states"> | |
| 5 | + <a href="{{ $openUrl }}" class="{{ $f['state'] === 'open' ? 'active' : '' }}"><span class="gl-state-open">●</span> 開いている({{ $counts['open'] }})</a> | |
| 6 | + <a href="{{ $closedUrl }}" class="{{ $f['state'] === 'closed' ? 'active' : '' }}"><span class="gl-state-closed">✓</span> 閉じた({{ $counts['closed'] }})</a> | |
| 7 | + </span> | |
| 8 | + <span class="gl-links"> | |
| 9 | + <a href="{{ $repoUrl }}/labels">ラベル</a> | |
| 10 | + <a href="{{ $repoUrl }}/milestones">マイルストーン</a> | |
| 11 | +{@ if ($canIssue) @} | |
| 12 | + <a href="{{ $repoUrl }}/issues/new" class="gl-button">+新しいイシュー</a> | |
| 13 | +{@ endif @} | |
| 14 | + </span> | |
| 15 | + </div> | |
| 16 | + <form method="get" action="{{ $repoUrl }}/issues" class="gl-row gl-filter"> | |
| 17 | + <input type="hidden" name="state" value="{{ $f['state'] }}" /> | |
| 18 | + <input type="text" name="q" value="{{ $f['q'] }}" placeholder="題名・番号" /> | |
| 19 | + <select name="label"> | |
| 20 | + <option value="">ラベル:全部</option> | |
| 21 | +{@ foreach ($labels as $l) @} | |
| 22 | + <option value="{{ $l['id'] }}"{{{ (string)$l['id'] === (string)$f['label'] ? ' selected="selected"' : '' }}}>{{ $l['name'] }}</option> | |
| 23 | +{@ endforeach @} | |
| 24 | + </select> | |
| 25 | + <select name="milestone"> | |
| 26 | + <option value="">マイルストーン:全部</option> | |
| 27 | +{@ foreach ($milestones as $id => $m) @} | |
| 28 | + <option value="{{ $id }}"{{{ (string)$id === (string)$f['milestone'] ? ' selected="selected"' : '' }}}>{{ $m['title'] }}</option> | |
| 29 | +{@ endforeach @} | |
| 30 | + </select> | |
| 31 | + <select name="assignee"> | |
| 32 | + <option value="">担当者:全部</option> | |
| 33 | +{@ foreach ($people as $p) @} | |
| 34 | + <option value="{{ $p }}"{{{ $p === $f['assignee'] ? ' selected="selected"' : '' }}}>{{ $p }}</option> | |
| 35 | +{@ endforeach @} | |
| 36 | + </select> | |
| 37 | + <select name="sort"> | |
| 38 | + <option value="created"{{{ $f['sort'] === 'created' ? ' selected="selected"' : '' }}}>新しい順</option> | |
| 39 | + <option value="updated"{{{ $f['sort'] === 'updated' ? ' selected="selected"' : '' }}}>更新順</option> | |
| 40 | + <option value="comments"{{{ $f['sort'] === 'comments' ? ' selected="selected"' : '' }}}>コメントの数</option> | |
| 41 | + </select> | |
| 42 | + <button>絞り込む</button> | |
| 43 | + </form> | |
| 44 | +{@ if (empty($list)) @} | |
| 45 | + <p class="paragraph">イシューは有りません。</p> | |
| 46 | +{@ endif @} | |
| 47 | + <ul class="gl-issues"> | |
| 48 | +{@ foreach ($list as $i) @} | |
| 49 | + <li> | |
| 50 | + <span class="gl-state-{{ $i['state'] }}">{{ $i['state'] === 'open' ? '●' : '✓' }}</span> | |
| 51 | + <a href="{{ $i['url'] }}"><b>{{ $i['title'] }}</b></a> | |
| 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> | |
| 54 | +{@ endforeach @} | |
| 55 | +{@ if ($i['comments'] > 0) @} | |
| 56 | + <span class="gl-count">💬 {{ $i['comments'] }}</span> | |
| 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> | |
| 59 | + </li> | |
| 60 | +{@ endforeach @} | |
| 61 | + </ul> | |
| 62 | + <p class="gl-pager"> | |
| 63 | +{@ if ($prevUrl !== '') @} | |
| 64 | + <a href="{{ $prevUrl }}">← 前</a> | |
| 65 | +{@ endif @} | |
| 66 | +{@ if ($nextUrl !== '') @} | |
| 67 | + <a href="{{ $nextUrl }}">次 →</a> | |
| 68 | +{@ endif @} | |
| 69 | + </p> | |
| 70 | +{@ include(common/footer) @} |
| @@ -0,0 +1,44 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(gitler/repohead) @} | |
| 3 | + <div class="gl-issuebar"> | |
| 4 | + <span class="gl-states"><a href="{{ $repoUrl }}/issues">イシュー</a> <a href="{{ $repoUrl }}/labels" class="active">ラベル</a> <a href="{{ $repoUrl }}/milestones">マイルストーン</a></span> | |
| 5 | + </div> | |
| 6 | +{@ if ($canTriage) @} | |
| 7 | + <details class="gl-edit"> | |
| 8 | + <summary>+新しいラベル</summary> | |
| 9 | + <form method="post" action="{{ $repoUrl }}/labels" class="gl-row"> | |
| 10 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 11 | + <input type="text" name="name" maxlength="50" placeholder="名前" required="required" /> | |
| 12 | + <input type="color" name="color" value="#c016c6" /> | |
| 13 | + <input type="text" name="description" maxlength="200" placeholder="説明" /> | |
| 14 | + <button name="do" value="save">作る</button> | |
| 15 | + </form> | |
| 16 | + </details> | |
| 17 | +{@ endif @} | |
| 18 | +{@ if (empty($list)) @} | |
| 19 | + <p class="paragraph">ラベルは有りません。</p> | |
| 20 | +{@ endif @} | |
| 21 | + <ul class="gl-issues"> | |
| 22 | +{@ foreach ($list as $l) @} | |
| 23 | + <li> | |
| 24 | + <a href="{{ $l['url'] }}" class="gl-label" style="background-color: {{ $l['color'] }}; color: {{ $l['fg'] }};">{{ $l['name'] }}</a> | |
| 25 | + <span>{{ $l['description'] }}</span> | |
| 26 | + <small><a href="{{ $l['url'] }}">開いているイシュー{{ $l['open'] }}件</a></small> | |
| 27 | +{@ if ($canTriage) @} | |
| 28 | + <details class="gl-edit"> | |
| 29 | + <summary>編集</summary> | |
| 30 | + <form method="post" action="{{ $repoUrl }}/labels" class="gl-row"> | |
| 31 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 32 | + <input type="hidden" name="id" value="{{ $l['id'] }}" /> | |
| 33 | + <input type="text" name="name" value="{{ $l['name'] }}" maxlength="50" required="required" /> | |
| 34 | + <input type="color" name="color" value="{{ $l['color'] }}" /> | |
| 35 | + <input type="text" name="description" value="{{ $l['description'] }}" maxlength="200" /> | |
| 36 | + <button name="do" value="save">保存</button> | |
| 37 | + <button name="do" value="delete" class="danger">削除</button> | |
| 38 | + </form> | |
| 39 | + </details> | |
| 40 | +{@ endif @} | |
| 41 | + </li> | |
| 42 | +{@ endforeach @} | |
| 43 | + </ul> | |
| 44 | +{@ include(common/footer) @} |
| @@ -0,0 +1,59 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(gitler/repohead) @} | |
| 3 | + <div class="gl-issuebar"> | |
| 4 | + <span class="gl-states"> | |
| 5 | + <a href="{{ $repoUrl }}/milestones" class="{{ $state === 'open' ? 'active' : '' }}">進行中({{ $counts['open'] }})</a> | |
| 6 | + <a href="{{ $repoUrl }}/milestones?state=closed" class="{{ $state === 'closed' ? 'active' : '' }}">終了({{ $counts['closed'] }})</a> | |
| 7 | + </span> | |
| 8 | + <span class="gl-links"><a href="{{ $repoUrl }}/issues">イシュー</a> <a href="{{ $repoUrl }}/labels">ラベル</a></span> | |
| 9 | + </div> | |
| 10 | +{@ if ($canTriage) @} | |
| 11 | + <details class="gl-edit"> | |
| 12 | + <summary>+新しいマイルストーン</summary> | |
| 13 | + <form method="post" action="{{ $repoUrl }}/milestones" class="gl-form"> | |
| 14 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 15 | + <input type="text" name="title" maxlength="100" placeholder="名前" required="required" /> | |
| 16 | + <label>期日:<input type="date" name="due" /></label> | |
| 17 | + <textarea name="description" rows="4" placeholder="説明(Markdown)"></textarea> | |
| 18 | + <button name="do" value="save">作る</button> | |
| 19 | + </form> | |
| 20 | + </details> | |
| 21 | +{@ endif @} | |
| 22 | +{@ if (empty($list)) @} | |
| 23 | + <p class="paragraph">マイルストーンは有りません。</p> | |
| 24 | +{@ endif @} | |
| 25 | + <ul class="gl-issues"> | |
| 26 | +{@ foreach ($list as $m) @} | |
| 27 | + <li> | |
| 28 | + <a href="{{ $m['url'] }}"><b>🏁 {{ $m['title'] }}</b></a> | |
| 29 | + <small class="{{ $m['overdue'] ? 'gl-overdue' : '' }}">{{ $m['due'] !== '' ? '期日:'.$m['due'].($m['overdue'] ? '(過ぎています)' : '') : '期日無し' }}</small> | |
| 30 | + <div class="gl-progress"><span style="width: {{ $m['percent'] }}%;"></span></div> | |
| 31 | + <small>{{ $m['percent'] }}%完了 開いている{{ $m['open'] }}件、閉じた{{ $m['closed'] }}件</small> | |
| 32 | +{@ if ($m['html'] !== '') @} | |
| 33 | + <div class="gl-comment-body">{{{ $m['html'] }}}</div> | |
| 34 | +{@ endif @} | |
| 35 | +{@ if ($canTriage) @} | |
| 36 | + <div class="gl-row"> | |
| 37 | + <details class="gl-edit"> | |
| 38 | + <summary>編集</summary> | |
| 39 | + <form method="post" action="{{ $repoUrl }}/milestones" class="gl-form"> | |
| 40 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 41 | + <input type="hidden" name="id" value="{{ $m['id'] }}" /> | |
| 42 | + <input type="text" name="title" value="{{ $m['title'] }}" maxlength="100" required="required" /> | |
| 43 | + <label>期日:<input type="date" name="due" value="{{ $m['due'] }}" /></label> | |
| 44 | + <textarea name="description" rows="4">{{ $m['description'] }}</textarea> | |
| 45 | + <button name="do" value="save">保存</button> | |
| 46 | + </form> | |
| 47 | + </details> | |
| 48 | + <form method="post" action="{{ $repoUrl }}/milestones"> | |
| 49 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 50 | + <input type="hidden" name="id" value="{{ $m['id'] }}" /> | |
| 51 | + <button name="do" value="{{ $m['state'] === 'open' ? 'close' : 'reopen' }}">{{ $m['state'] === 'open' ? '終了にする' : '進行中に戻す' }}</button> | |
| 52 | + <button name="do" value="delete" class="danger">削除</button> | |
| 53 | + </form> | |
| 54 | + </div> | |
| 55 | +{@ endif @} | |
| 56 | + </li> | |
| 57 | +{@ endforeach @} | |
| 58 | + </ul> | |
| 59 | +{@ include(common/footer) @} |
| @@ -6,6 +6,9 @@ | ||
| 6 | 6 | {@ endif @} |
| 7 | 7 | <nav class="gl-tabs"> |
| 8 | 8 | <a href="{{ $treeRoot }}" class="{{ in_array($gitView, ['gitler/tree', 'gitler/blob', 'gitler/blame', 'gitler/empty'], true) ? 'active' : '' }}">ファイル</a> |
| 9 | +{@ if ($issuesOn) @} | |
| 10 | + <a href="{{ $repoUrl }}/issues" class="{{ in_array($gitView, ['gitler/issues', 'gitler/issue', 'gitler/issuenew', 'gitler/labels', 'gitler/milestones'], true) ? 'active' : '' }}">イシュー({{ $openIssues }})</a> | |
| 11 | +{@ endif @} | |
| 9 | 12 | {@ if ($commitsUrl !== '') @} |
| 10 | 13 | <a href="{{ $commitsUrl }}" class="{{ in_array($gitView, ['gitler/commits', 'gitler/commit'], true) ? 'active' : '' }}">コミット</a> |
| 11 | 14 | <a href="{{ $repoUrl }}/branches" class="{{ $gitView === 'gitler/branches' ? 'active' : '' }}">ブランチ({{ count($branches) }})</a> |
| @@ -43,6 +43,37 @@ | ||
| 43 | 43 | <button name="do" value="addCollaborator">追加</button> |
| 44 | 44 | </form> |
| 45 | 45 | |
| 46 | + <h2 class="paragraph">イシューとFediverse</h2> | |
| 47 | + <form method="post" action="{{ $repoUrl }}/settings" class="gl-form"> | |
| 48 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 49 | + <label><input type="checkbox" name="issues" value="1"{{{ $meta['issues'] ? ' checked="checked"' : '' }}} /> イシューを使う</label> | |
| 50 | +{@ if ($fediAvailable) @} | |
| 51 | + <label><input type="checkbox" name="federate" value="1"{{{ $meta['federate'] ? ' checked="checked"' : '' }}} /> 公開のリポジトリならFediverseに出す({{ $fediHandle }})</label> | |
| 52 | +{@ endif @} | |
| 53 | + <button name="do" value="issues">保存</button> | |
| 54 | + </form> | |
| 55 | +{@ if ($fediOn) @} | |
| 56 | + <p class="paragraph">Mastodon等から<code>{{ $fediHandle }}</code>をフォローするとイシューが届き、返信するとコメントになります。リポジトリをメンションした投稿は新しいイシューになります。フォロワー:{{ $followerCount }}人</p> | |
| 57 | + <h3 class="paragraph">ブロック</h3> | |
| 58 | + <ul class="gl-keys"> | |
| 59 | +{@ foreach (array_merge($blocks['actors'], $blocks['domains']) as $b) @} | |
| 60 | + <li> | |
| 61 | + {{ $b }} | |
| 62 | + <form method="post" action="{{ $repoUrl }}/settings"> | |
| 63 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 64 | + <input type="hidden" name="target" value="{{ $b }}" /> | |
| 65 | + <button name="do" value="unblock">外す</button> | |
| 66 | + </form> | |
| 67 | + </li> | |
| 68 | +{@ endforeach @} | |
| 69 | + </ul> | |
| 70 | + <form method="post" action="{{ $repoUrl }}/settings" class="gl-row"> | |
| 71 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 72 | + <input type="text" name="target" placeholder="@user@example.com又はexample.com" required="required" /> | |
| 73 | + <button name="do" value="block" class="danger">ブロック</button> | |
| 74 | + </form> | |
| 75 | +{@ endif @} | |
| 76 | + | |
| 46 | 77 | <h2 class="paragraph">整理</h2> |
| 47 | 78 | <form method="post" action="{{ $repoUrl }}/settings" class="gl-row"> |
| 48 | 79 | <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> |