イシュートラッカー

守矢諏訪子 <suwako@076.moe>、2026-11-13 15:29
コミット b501fc94bbe372a1cec31a6e8e0d961c5739c0ad ・親 35614ab ・ファイルを見る

21個のファイル、+2652 -16

変更 README-JP.md
@@ -302,6 +302,8 @@
302302 鍵毎に `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 から)で同じ行を書けます。
303303 * リポジトリを整理する為に Cron のジョブ `gitlergc` を追加するか(例:`php cron.php add "@daily" gitlergc`)、`php gitler.php gc` を実行して下さい。
304304 * リポジトリのページには 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 が必要です。
305307
306308 ### TLS 1.3・1.2(php_openssl は不要)
307309 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 @@
300300 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.
301301 * Add the Cron job `gitlergc` (for example `php cron.php add "@daily" gitlergc`) to pack the repositories, or run `php gitler.php gc`.
302302 * 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.
303305
304306 ### TLS 1.3 and 1.2 (no php_openssl)
305307 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 @@
5656 use Site\Controller\Gitler as GitlerController;
5757 use Std\Lib\Git\Repository;
5858 use Std\Lib\Git\Ssh;
59+use Std\Lib\Gitler\Federation;
5960 use Std\Lib\Gitler\Gitler;
6061 use Std\Lib\Gitler\Keys;
6162
@@ -109,13 +110,17 @@
109110 fwrite(STDERR, "こんにちは、{$name}さん!Gitlerにはシェルが有りません。\n");
110111 exit(1);
111112 }
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 {
113117 // 「所有者/名前.git」又は「所有者/名前」(前に「gitler/」が有っても良い)
114118 if (!preg_match('#^(?:gitler/)?([A-Za-z0-9]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$#', $path, $m)) return null;
115119 $access = Gitler::access($user, $m[1], $m[2]);
116120 if ($access < ($write ? Gitler::WRITE : Gitler::READ)) return null;
121+ [$owner, $repoName] = [$m[1], $m[2]];
117122 return Gitler::open($m[1], $m[2]);
118- }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions()]));
123+ }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions($owner, $repoName)]));
119124
120125 case 'gc':
121126 $res = Gitler::gcAll();
@@ -384,6 +384,217 @@
384384 vertical-align: middle;
385385 }
386386
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+
387598 button.danger {
388599 border-color: #ee4030;
389600 color: #ee4030;
変更 route.php
@@ -169,6 +169,19 @@
169169 $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/git-upload-pack', Gitler::class.'@git', ['action' => 'git-upload-pack']);
170170 $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/git-receive-pack', Gitler::class.'@git', ['action' => 'git-receive-pack']);
171171 $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+ }
172185 foreach (['tree', 'blob', 'raw', 'blame', 'commits', 'branches', 'tags', 'archive', 'settings'] as $page) {
173186 $routes[] = Route::add('GET', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page);
174187 }
@@ -59,7 +59,8 @@
5959 // マルチユーザーでは「resource」でユーザーを探す(ブログのアクター以外)
6060 $resource = is_string($_GET['resource'] ?? null) ? $_GET['resource'] : '';
6161 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);
6364 if ($jrd === null) $this->json(404, ['error' => '見つかりません'], 'application/jrd+json');
6465 $this->json(200, $jrd, 'application/jrd+json');
6566 }
@@ -47,7 +47,9 @@
4747 use Std\Lib\Git\Repository;
4848 use Std\Lib\Git\Tree;
4949 use Std\Lib\Gitler\Archive;
50+use Std\Lib\Gitler\Federation;
5051 use Std\Lib\Gitler\Gitler as Hosting;
52+use Std\Lib\Gitler\Issues;
5153 use Std\Lib\Gitler\Keys;
5254 use Std\Lib\Gitler\Languages;
5355 use Std\Lib\Markdown;
@@ -59,10 +61,11 @@
5961 * Gitler(/gitler):Gitのリポジトリのホスティング。JavaScriptなし
6062 *
6163 * リポジトリのページはクエリでリビジョンとパスを渡す(例:/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。
6365 */
6466 class Gitler {
6567 use Mods;
68+ use GitlerIssues;
6669
6770 /** 1ページのコミットの数 */
6871 private const PER_PAGE = 30;
@@ -184,6 +187,9 @@
184187 public function repo(array $params): void {
185188 // ブラウザで「.git」を付けて開いた場合
186189 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');
187193 $this->load($params);
188194 if ($this->repo->isEmpty()) {
189195 // SSHの鍵を登録していればSSH、無ければHTTPS
@@ -475,6 +481,14 @@
475481 Hosting::delete($this->owner, $this->name);
476482 $this->redirect('/gitler/'.$this->owner);
477483 }
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;
478492 } else if ($do === 'gc') {
479493 try {
480494 $r = \Std\Lib\Git\Maintenance::gc($this->repo);
@@ -491,6 +505,9 @@
491505 'branches' => array_map(fn($n) => substr($n, 11), array_keys($this->repo->refs->all('refs/heads/'))),
492506 'currentBranch' => $this->defaultBranch(),
493507 '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),
494511 ]);
495512 }
496513
@@ -521,14 +538,20 @@
521538 exit;
522539 }
523540 $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)]);
525542 exit;
526543 }
527544
528- /** プッシュの制限(ブランチとタグだけ) */
529- public static function receiveOptions(): array {
545+ /**
546+ * プッシュの制限(ブランチとタグだけ)と、プッシュの後のイシューの処理
547+ * SSHではリポジトリが後で決まるので、所有者と名前は参照で受け取る
548+ */
549+ public static function receiveOptions(string &$owner, string &$name): array {
530550 return [
531551 '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+ },
532555 ];
533556 }
534557
@@ -773,6 +796,7 @@
773796 'commitsUrl' => $ref !== '' ? $this->url('commits', $ref, '') : '',
774797 'zipUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=zip' : '',
775798 '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,
776800 ]);
777801 }
778802
@@ -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 @@
5555 /**
5656 * アクティビティを送る(ローカルの宛先はすぐに処理し、リモートは待ち行列に入れる)
5757 *
58- * @param string $from 送るユーザー
58+ * @param string $from 送るユーザー(「gitler:所有者/名前」はGitlerのリポジトリ)
5959 * @param array $activity アクティビティ
6060 * @param array $inboxes 受付ボックスのURL
6161 * @return void
@@ -197,18 +197,20 @@
197197 /**
198198 * 署名して送る
199199 *
200- * @param string $from 送るユーザー(空ならブログのアクター)
200+ * @param string $from 送るユーザー(「gitler:所有者/名前」はGitlerのリポジトリ)
201201 * @param string $inbox 受付ボックス
202202 * @param array $activity アクティビティ
203203 * @return bool 2xxならtrue
204204 */
205205 protected static function send(string $from, string $inbox, array $activity): bool {
206206 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)];
208210 if ($key === null) return false;
209211
210212 $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);
212214 if ($headers === null) return false;
213215
214216 $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 @@
123123 /**
124124 * リポジトリの情報
125125 *
126- * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch
126+ * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、
127+ * issues(イシューを使う)、federate(公開ならFediverseに出す)
127128 */
128129 public static function meta(string $owner, string $name): array {
129130 $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true);
130131 $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];
133134 $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : [];
134135 return $j;
135136 }
@@ -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 @@
4141
4242 use Std\Lib\Tester;
4343 use Std\Lib\Auth;
44+use Std\Lib\Fedi\Fedi;
45+use Std\Lib\Fedi\Remote;
4446 use Std\Lib\Git\Commit;
4547 use Std\Lib\Git\Person;
4648 use Std\Lib\Git\Repository;
4749 use Std\Lib\Git\Tree;
4850 use Std\Lib\Gitler\Archive;
51+use Std\Lib\Gitler\Federation;
4952 use Std\Lib\Gitler\Gitler;
53+use Std\Lib\Gitler\Issues;
5054 use Std\Lib\Gitler\Keys;
5155 use Std\Lib\Gitler\Languages;
5256 use Roles;
@@ -400,6 +404,260 @@
400404 $test->assertSame('その他', $p[7]['name']);
401405 $test->assertSame([], Languages::present([]));
402406 });
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&amp;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 &amp; 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;
403661 });
404662
405663 $test->describe('Gitler(HTTPのパスワード)', function($test) use ($base): void {
変更 util.php
@@ -293,8 +293,11 @@
293293 }
294294
295295 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+ });
298301 // 90日より古いリモートの投稿とキャッシュを消す(1日1回)
299302 $cron->register('apcleanup', fn() => \Std\Lib\Fedi\Timeline::cleanup());
300303 }
@@ -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 @@
66 {@ endif @}
77 <nav class="gl-tabs">
88 <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 @}
912 {@ if ($commitsUrl !== '') @}
1013 <a href="{{ $commitsUrl }}" class="{{ in_array($gitView, ['gitler/commits', 'gitler/commit'], true) ? 'active' : '' }}">コミット</a>
1114 <a href="{{ $repoUrl }}/branches" class="{{ $gitView === 'gitler/branches' ? 'active' : '' }}">ブランチ({{ count($branches) }})</a>
@@ -43,6 +43,37 @@
4343 <button name="do" value="addCollaborator">追加</button>
4444 </form>
4545
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+
4677 <h2 class="paragraph">整理</h2>
4778 <form method="post" action="{{ $repoUrl }}/settings" class="gl-row">
4879 <input type="hidden" name="csrf_token" value="{{ $csrf }}" />