master
ブランチ master
LittleBeast / src / Site / Controller / GitlerIssues.php
35.3 KB、584行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, 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
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Site\Controller;
39
40use Std\Lib\Activitypub;
41use Std\Lib\Fedi\Remote;
42use Std\Lib\Gitler\Federation;
43use Std\Lib\Gitler\Gitler as Hosting;
44use Std\Lib\Gitler\Issues;
45use Std\Lib\Gitler\Notify;
46
47/**
48 * Gitlerのイシュー、ラベル、マイルストーンのページ、ウォッチ、リポジトリのFediverse(アクター、受付ボックス等)
49 *
50 * Gitlerコントローラーで使う(リポジトリを開く等はGitlerの物)。
51 */
52trait GitlerIssues {
53 private ?Issues $issueDb = null;
54 /** 表示したユーザーのアバター */
55 private array $avatars = [];
56
57 //////// イシュー
58
59 /** …/issues?state=&label=&milestone=&assignee=&q=&sort=&page=(…/pullsはプルリクエスト) */
60 public function issues(array $params): void {
61 $pull = !empty($params['pull']);
62 $this->loadIssues($params, $pull);
63 $f = ['state' => in_array($_GET['state'] ?? '', ['closed', 'all'], true) ? $_GET['state'] : 'open'];
64 foreach (['label', 'milestone'] as $k) if (isset($_GET[$k]) && $_GET[$k] !== '') $f[$k] = (int)$_GET[$k];
65 foreach (['assignee', 'q', 'sort'] as $k) if (is_string($_GET[$k] ?? null) && $_GET[$k] !== '') $f[$k] = trim($_GET[$k]);
66 $all = $this->issueDb->search(['state' => 'all', 'pull' => $pull] + $f);
67 $counts = ['open' => 0, 'closed' => 0];
68 foreach ($all as $s) $counts[$s['state']]++;
69 $list = $f['state'] === 'all' ? $all : array_values(array_filter($all, fn($s) => $s['state'] === $f['state']));
70 $page = max(1, (int)($_GET['page'] ?? 1));
71 $pages = max(1, (int)ceil(count($list) / self::PER_PAGE));
72 $labels = $this->issueDb->labels();
73 $milestones = $this->issueDb->milestones();
74 $listUrl = $this->repoUrl().($pull ? '/pulls' : '/issues');
75 $query = fn(array $change) => $listUrl.'?'.http_build_query(array_filter($change + $f + ['page' => null], fn($v) => $v !== null && $v !== ''));
76 $this->renderRepo('gitler/issues', $pull ? 'プルリクエスト' : 'イシュー', [
77 'isPull' => $pull, 'listUrl' => $listUrl, 'newUrl' => $this->repoUrl().($pull ? '/compare' : '/issues/new'),
78 'agitHelp' => $pull ? 'git push origin HEAD:refs/for/'.$this->defaultBranch().'/トピック' : '',
79 'list' => array_map(fn($s) => $this->presentSummary($s, $labels, $milestones), array_slice($list, ($page - 1) * self::PER_PAGE, self::PER_PAGE)),
80 'counts' => $counts, 'f' => $f + ['label' => '', 'milestone' => '', 'assignee' => '', 'q' => '', 'sort' => 'created'],
81 'openUrl' => $query(['state' => 'open']), 'closedUrl' => $query(['state' => 'closed']),
82 'prevUrl' => $page > 1 ? $query(['page' => $page - 1]) : '', 'nextUrl' => $page < $pages ? $query(['page' => $page + 1]) : '',
83 'labels' => $this->presentLabels($labels), 'milestones' => $milestones, 'people' => $this->assignable(), 'canIssue' => $pull ? $this->canPull() : $this->canIssue(),
84 ]);
85 }
86
87 /** …/issues/new */
88 public function newIssue(array $params): void {
89 $this->loadIssues($params);
90 if (!$this->user) $this->redirect('/login');
91 if ($this->meta['archived']) $this->message('アーカイブされたリポジトリです(読むだけ)。', $this->repoUrl().'/issues');
92 if (!$this->canIssue()) $this->message('イシューを作る権限が有りません。', $this->repoUrl().'/issues');
93 $in = ['title' => '', 'body' => '', 'labels' => [], 'assignees' => [], 'milestone' => 0];
94 $error = '';
95 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
96 $in = ['title' => (string)($_POST['title'] ?? ''), 'body' => str_replace("\r\n", "\n", (string)($_POST['body'] ?? '')),
97 'labels' => (array)($_POST['labels'] ?? []), 'assignees' => (array)($_POST['assignees'] ?? []), 'milestone' => (int)($_POST['milestone'] ?? 0)];
98 if (!$this->csrfOk()) {
99 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
100 } else {
101 $opt = $this->access >= Hosting::WRITE ? ['labels' => $in['labels'], 'assignees' => array_intersect($in['assignees'], $this->assignable()), 'milestone' => $in['milestone']] : [];
102 $res = $this->issueDb->create($this->me(), $in['title'], $in['body'], $opt);
103 if ($res->isSuccess) {
104 Federation::issueOpened($this->owner, $this->name, $this->meta, $res->data['issue']);
105 Notify::issueOpened($this->owner, $this->name, $this->meta, $this->issueDb, $res->data['issue']);
106 $this->redirect($this->repoUrl().'/issues/'.$res->data['number']);
107 }
108 $error = (string)$res->message;
109 }
110 }
111 $this->renderRepo('gitler/issuenew', '新しいイシュー', [
112 'in' => $in, 'error' => $error, 'labels' => $this->presentLabels($this->issueDb->labels(), array_map('intval', $in['labels'])),
113 'milestones' => $this->issueDb->milestones(), 'people' => $this->assignable(),
114 ]);
115 }
116
117 /** …/issues/{n}(ActivityPubではNote)、…/pulls/{n}(プルリクエストの会話) */
118 public function issue(array $params): void {
119 $n = (int)($params['n'] ?? 0);
120 if ($this->wantsActivity()) {
121 [$owner, $name, $issue] = $this->apIssue($params);
122 $this->apJson(['@context' => Federation::CONTEXT] + Federation::issueNote($owner, $name, $issue));
123 }
124 $this->loadIssues($params, null);
125 $issue = $this->issueDb->get($n);
126 if ($issue === null) $this->notFound();
127 // イシューとプルリクエストのURLが違う
128 if (isset($issue['pull']) !== !empty($params['pull'])) $this->redirect($this->issuePage($issue));
129 if (!(isset($issue['pull']) ? $this->meta['pulls'] : $this->meta['issues'])) $this->notFound();
130 $error = '';
131 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
132 $error = $this->csrfOk() ? $this->issueAction($issue) : 'セッションの有効期限が切れました。もう一度お試し下さい。';
133 $issue = $this->issueDb->get($n);
134 if ($issue === null) $this->redirect($this->repoUrl().'/issues');
135 }
136 $pull = isset($issue['pull']);
137 header('Vary: Accept');
138 $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username;
139 $archived = $this->meta['archived'];
140 $labels = $this->issueDb->labels();
141 $milestones = $this->issueDb->milestones();
142 $timeline = [];
143 $replies = [];
144 foreach ($issue['comments'] as $c) if (isset($c['path']) && $c['replyTo'] !== null && empty($c['pending'])) $replies[$c['replyTo']][] = $c;
145 $byReview = [];
146 foreach ($issue['comments'] as $c) {
147 // 行へのコメント:レビューの物はレビューに、他はそれだけ(返信はスレッドに、下書きは出さない)
148 if (isset($c['path'])) {
149 if (!empty($c['pending']) || $c['replyTo'] !== null) continue;
150 $inline = $this->presentInline($c, $replies[$c['id']] ?? []) + ['outdated' => $c['commit'] !== $issue['pull']['headId']];
151 if ($c['review'] !== null) $byReview[$c['review']][] = $inline;
152 else $timeline[] = ['kind' => 'inline', 'at' => $c['created'], 'c' => $inline];
153 continue;
154 }
155 $mine = $this->user && ($c['author']['user'] ?? null) === $this->user->username;
156 $timeline[] = ['kind' => 'comment', 'at' => $c['created'], 'id' => $c['id'], 'who' => $this->who($c['author']), 'html' => $this->bodyHtml($c['body'], !empty($c['html'])),
157 'date' => date('Y-m-d H:i', $c['created']), 'edited' => $c['updated'] > $c['created'], 'body' => $c['body'], 'canEdit' => !$archived && $mine && empty($c['html']),
158 'canDelete' => !$archived && ($mine || $this->access >= Hosting::ADMIN), 'actor' => $c['author']['actor'] ?? '',
159 'remote' => preg_match('#^https?://#i', (string)($c['remoteId'] ?? '')) ? $c['remoteId'] : ''];
160 }
161 foreach ($issue['events'] as $e) $timeline[] = ['kind' => 'event', 'at' => $e['at'], 'html' => $this->eventHtml($e, $labels, $milestones), 'date' => date('Y-m-d H:i', $e['at'])];
162 $states = ['approved' => '承認しました', 'changes' => '変更を依頼しました', 'commented' => 'レビューしました'];
163 foreach ($issue['reviews'] ?? [] as $r) {
164 $timeline[] = ['kind' => 'review', 'at' => $r['at'], 'id' => $r['id'], 'who' => $this->who($r['author']), 'state' => $r['state'], 'stateText' => $states[$r['state']] ?? '',
165 'html' => trim($r['body']) !== '' ? $this->bodyHtml($r['body'], false) : '', 'date' => date('Y-m-d H:i', $r['at']), 'comments' => $byReview[$r['id']] ?? []];
166 }
167 // 同じ時刻なら出来事(閉じた、マージした等)を後に
168 usort($timeline, fn($a, $b) => [$a['at'], $a['kind'] === 'event'] <=> [$b['at'], $b['kind'] === 'event']);
169 $fedi = Federation::enabled($this->meta) && !$this->meta['archived'];
170 // 通知:受け取っている(true)、止めた(false)、ウォッチで受け取る、受け取っていない
171 $sub = $this->user ? ($issue['subscribers'][$this->user->username] ?? null) : null;
172 $watching = $this->user && Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) === Notify::WATCHING;
173 $this->renderRepo($pull ? 'gitler/pull' : 'gitler/issue', '#'.$n.' '.$issue['title'], ($pull ? $this->pullVars($issue) : []) + [
174 'issue' => $issue, 'who' => $this->who($issue['author']), 'bodyHtml' => $this->bodyHtml($issue['body'], !empty($issue['html'])),
175 'date' => date('Y-m-d H:i', $issue['created']), 'timeline' => $timeline, 'error' => $error,
176 'canIssue' => $pull ? $this->canReview() : $this->canIssue(), 'canEdit' => !$archived && (($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE),
177 'canClose' => !$archived && ($isAuthor || $this->access >= Hosting::WRITE) && empty($issue['pull']['merged']), 'canTriage' => !$archived && $this->access >= Hosting::WRITE,
178 'canAdmin' => !$archived && $this->access >= Hosting::ADMIN,
179 'labels' => $this->presentLabels($labels, $issue['labels']), 'issueLabels' => $this->presentLabels(array_intersect_key($labels, array_flip($issue['labels']))),
180 'milestones' => $milestones, 'milestone' => $issue['milestone'] !== null ? ($milestones[$issue['milestone']]['title'] ?? '') : '',
181 'people' => array_map(fn($p) => ['name' => $p, 'checked' => in_array($p, $issue['assignees'], true)], array_unique(array_merge($this->assignable(), $issue['assignees']))),
182 'issueUrl' => $this->issuePage($issue), 'fedi' => $fedi, 'canReview' => $pull && $this->canReview(),
183 'fediHandle' => $fedi ? '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host() : '',
184 'fediUrl' => $fedi ? Federation::issueUrl($this->owner, $this->name, $n) : '',
185 'subscribed' => $sub === true || ($sub === null && $watching),
186 'subscribeNote' => $sub === true ? '通知を受け取っています。' : ($sub === false ? '通知を止めています。' : ($watching ? 'リポジトリをウォッチしているので通知を受け取ります。' : '通知を受け取っていません。')),
187 ]);
188 }
189
190 /** …/issues/{n}/ticket(ForgeFedのTicket。ブラウザはイシューへ) */
191 public function issueTicket(array $params): void {
192 if (!$this->wantsActivity()) $this->redirect('/gitler/'.($params['owner'] ?? '').'/'.rawurlencode((string)($params['repo'] ?? '')).'/issues/'.(int)($params['n'] ?? 0));
193 [$owner, $name, $issue] = $this->apIssue($params);
194 $this->apJson(Federation::ticket($owner, $name, $issue));
195 }
196
197 /** …/issues/{n}/comments/{c}(ローカルのコメントのNote。ブラウザはコメントへ) */
198 public function issueComment(array $params): void {
199 $c = (int)($params['c'] ?? 0);
200 if (!$this->wantsActivity()) $this->redirect('/gitler/'.($params['owner'] ?? '').'/'.rawurlencode((string)($params['repo'] ?? '')).'/issues/'.(int)($params['n'] ?? 0).'#c'.$c);
201 [$owner, $name, $issue] = $this->apIssue($params);
202 foreach ($issue['comments'] as $x) {
203 if ($x['id'] === $c && !isset($x['author']['actor'])) $this->apJson(['@context' => Federation::CONTEXT] + Federation::commentNote($owner, $name, $issue, $x));
204 }
205 $this->apJson(['error' => '見つかりません'], 404);
206 }
207
208 /** イシューのページのPOST(エラーのメッセージ、成功したらリダイレクト) */
209 private function issueAction(array $issue): string {
210 $n = $issue['number'];
211 $url = $this->issuePage($issue);
212 $do = (string)($_POST['do'] ?? '');
213 if (isset($issue['pull']) && in_array($do, ['merge', 'deleteBranch', 'inline', 'inlinePending', 'reply', 'review'], true)) return $this->pullAction($issue, $do);
214 $me = $this->me();
215 $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username;
216 $canClose = $isAuthor || $this->access >= Hosting::WRITE;
217 $body = str_replace("\r\n", "\n", (string)($_POST['body'] ?? ''));
218 if (!$this->user) return 'ログインして下さい。';
219
220 if ($do === 'subscribe' || $do === 'unsubscribe') {
221 $this->issueDb->subscribe($n, [(string)$this->user->username], $do === 'subscribe');
222 $this->redirect($url);
223 }
224 if ($this->meta['archived']) return 'アーカイブされたリポジトリです(読むだけ)。';
225
226 if (in_array($do, ['comment', 'commentClose', 'commentReopen', 'close', 'reopen'], true)) {
227 if (!(isset($issue['pull']) ? $this->canReview() : $this->canIssue())) return 'コメントする権限が有りません。';
228 if (!empty($issue['pull']['merged']) && $do !== 'comment') return 'マージしたプルリクエストは開き直せません。';
229 $anchor = '';
230 if ($do === 'comment' || trim($body) !== '') {
231 $res = $this->issueDb->comment($n, $me, $body);
232 if (!$res->isSuccess) return (string)$res->message;
233 Federation::commentAdded($this->owner, $this->name, $this->meta, $res->data['issue'], $res->data['comment']);
234 Notify::commented($this->owner, $this->name, $this->meta, $this->issueDb, $res->data['issue'], $res->data['comment']);
235 $anchor = '#c'.$res->data['comment']['id'];
236 }
237 if ($do !== 'comment' && $canClose) {
238 $new = $this->issueDb->setState($n, in_array($do, ['reopen', 'commentReopen'], true), $me);
239 if ($new !== null) {
240 Federation::stateChanged($this->owner, $this->name, $this->meta, $new);
241 Notify::stateChanged($this->owner, $this->name, $this->meta, $new, $me);
242 }
243 }
244 $this->redirect($url.$anchor);
245 }
246 if ($do === 'edit') {
247 if (!(($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE)) return '編集する権限が有りません。';
248 $res = $this->issueDb->edit($n, $me, (string)($_POST['title'] ?? ''), empty($issue['html']) ? $body : $issue['body']);
249 if (!$res->isSuccess) return (string)$res->message;
250 Federation::issueEdited($this->owner, $this->name, $this->meta, $res->data['issue']);
251 $this->redirect($url);
252 }
253 if ($do === 'triage') {
254 if ($this->access < Hosting::WRITE) return '権限が有りません。';
255 $people = array_merge($this->assignable(), $issue['assignees']);
256 $new = $this->issueDb->triage($n, $me, (array)($_POST['labels'] ?? []), array_values(array_intersect((array)($_POST['assignees'] ?? []), $people)), (int)($_POST['milestone'] ?? 0));
257 $added = $new !== null ? array_values(array_diff($new['assignees'], $issue['assignees'])) : [];
258 if (!empty($added)) Notify::assigned($this->owner, $this->name, $this->meta, $new, $added, $me);
259 $this->redirect($url);
260 }
261 if ($do === 'editComment' || $do === 'deleteComment') {
262 $id = (int)($_POST['id'] ?? 0);
263 $c = null;
264 foreach ($issue['comments'] as $x) if ($x['id'] === $id) $c = $x;
265 if ($c === null) return 'コメントが有りません。';
266 $mine = ($c['author']['user'] ?? null) === $this->user->username;
267 if ($do === 'editComment') {
268 if (!$mine || !empty($c['html'])) return '編集する権限が有りません。';
269 $new = $this->issueDb->editComment($n, $id, $body);
270 if ($new === null) return 'コメントを入力して下さい。';
271 Federation::commentAdded($this->owner, $this->name, $this->meta, $this->issueDb->get($n) ?? $issue, $new, 'Update');
272 $this->redirect($url.'#c'.$id);
273 }
274 if (!$mine && $this->access < Hosting::ADMIN) return '削除する権限が有りません。';
275 $this->issueDb->editComment($n, $id, null);
276 Federation::commentDeleted($this->owner, $this->name, $this->meta, $issue, $c);
277 $this->redirect($url);
278 }
279 if ($do === 'delete') {
280 if ($this->access < Hosting::ADMIN) return '削除する権限が有りません。';
281 if (!isset($_POST['confirm'])) return '確認にチェックを入れて下さい。';
282 $this->issueDb->delete($n);
283 Federation::issueDeleted($this->owner, $this->name, $this->meta, $issue);
284 if (isset($issue['pull'])) {
285 try {
286 $this->repo->refs->update(\Std\Lib\Gitler\Pulls::ref($n), null);
287 } catch (\Std\Lib\Git\GitException) {
288 }
289 }
290 $this->redirect($this->repoUrl().(isset($issue['pull']) ? '/pulls' : '/issues'));
291 }
292 if ($do === 'block') {
293 if ($this->access < Hosting::ADMIN) return 'ブロックする権限が有りません。';
294 $res = Federation::block($this->owner, $this->name, (string)($_POST['actor'] ?? ''));
295 return $res->isSuccess ? '' : (string)$res->message;
296 }
297 return '';
298 }
299
300 //////// ウォッチ
301
302 /** …/watch(POST:state=watching、participating、ignoring) */
303 public function watch(array $params): void {
304 $this->load($params);
305 if (!$this->user) $this->redirect('/login');
306 if ($_SERVER['REQUEST_METHOD'] === 'POST' && $this->csrfOk()) Notify::setWatch($this->owner, $this->name, (string)$this->user->username, (string)($_POST['state'] ?? ''));
307 $back = (string)($_POST['back'] ?? '');
308 $this->redirect(str_starts_with($back, $this->repoUrl()) ? $back : $this->repoUrl());
309 }
310
311 //////// ラベルとマイルストーン
312
313 /** …/labels */
314 public function labels(array $params): void {
315 $this->loadIssues($params, null);
316 $error = '';
317 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
318 if (!$this->csrfOk()) {
319 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
320 } else if ($this->access < Hosting::WRITE || $this->meta['archived']) {
321 $error = $this->meta['archived'] ? 'アーカイブされたリポジトリです(読むだけ)。' : '権限が有りません。';
322 } else if (($_POST['do'] ?? '') === 'delete') {
323 $this->issueDb->deleteLabel((int)($_POST['id'] ?? 0));
324 $this->redirect($this->repoUrl().'/labels');
325 } else {
326 $id = (int)($_POST['id'] ?? 0);
327 $res = $this->issueDb->saveLabel($id > 0 ? $id : null, (string)($_POST['name'] ?? ''), (string)($_POST['color'] ?? ''), (string)($_POST['description'] ?? ''));
328 if ($res->isSuccess) $this->redirect($this->repoUrl().'/labels');
329 $error = (string)$res->message;
330 }
331 }
332 $counts = [];
333 foreach ($this->issueDb->search(['state' => 'open']) as $s) foreach ($s['labels'] as $l) $counts[$l] = ($counts[$l] ?? 0) + 1;
334 $list = [];
335 foreach ($this->presentLabels($this->issueDb->labels()) as $l) $list[] = $l + ['open' => $counts[$l['id']] ?? 0, 'url' => $this->repoUrl().'/issues?label='.$l['id']];
336 $this->renderRepo('gitler/labels', 'ラベル', ['list' => $list, 'error' => $error, 'canTriage' => $this->access >= Hosting::WRITE && !$this->meta['archived']]);
337 }
338
339 /** …/milestones */
340 public function milestones(array $params): void {
341 $this->loadIssues($params, null);
342 $error = '';
343 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
344 $do = (string)($_POST['do'] ?? '');
345 $id = (int)($_POST['id'] ?? 0);
346 $all = $this->issueDb->milestones();
347 if (!$this->csrfOk()) {
348 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
349 } else if ($this->access < Hosting::WRITE || $this->meta['archived']) {
350 $error = $this->meta['archived'] ? 'アーカイブされたリポジトリです(読むだけ)。' : '権限が有りません。';
351 } else if ($do === 'delete') {
352 $this->issueDb->deleteMilestone($id);
353 $this->redirect($this->repoUrl().'/milestones');
354 } else if (($do === 'close' || $do === 'reopen') && isset($all[$id])) {
355 $m = $all[$id];
356 $this->issueDb->saveMilestone($id, $m['title'], $m['description'], $m['due'], $do === 'close' ? 'closed' : 'open');
357 $this->redirect($this->repoUrl().'/milestones'.($do === 'close' ? '?state=closed' : ''));
358 } else {
359 $old = $all[$id] ?? null;
360 $res = $this->issueDb->saveMilestone($old ? $id : null, (string)($_POST['title'] ?? ''), (string)($_POST['description'] ?? ''), trim((string)($_POST['due'] ?? '')), $old['state'] ?? 'open');
361 if ($res->isSuccess) $this->redirect($this->repoUrl().'/milestones');
362 $error = (string)$res->message;
363 }
364 }
365 $state = ($_GET['state'] ?? '') === 'closed' ? 'closed' : 'open';
366 $stats = [];
367 foreach ($this->issueDb->search(['state' => 'all']) as $s) if ($s['milestone'] !== null) $stats[$s['milestone']][$s['state']] = ($stats[$s['milestone']][$s['state']] ?? 0) + 1;
368 $list = [];
369 $counts = ['open' => 0, 'closed' => 0];
370 foreach ($this->issueDb->milestones() as $id => $m) {
371 $counts[$m['state']]++;
372 if ($m['state'] !== $state) continue;
373 $open = $stats[$id]['open'] ?? 0;
374 $closed = $stats[$id]['closed'] ?? 0;
375 $list[] = $m + ['id' => $id, 'open' => $open, 'closed' => $closed, 'percent' => $open + $closed > 0 ? (int)floor($closed * 100 / ($open + $closed)) : 0,
376 'overdue' => $m['due'] !== '' && $m['state'] === 'open' && $m['due'] < date('Y-m-d'),
377 'html' => $m['description'] !== '' ? Issues::render($m['description'], $this->repoUrl().'/issues') : '',
378 'url' => $this->repoUrl().'/issues?state=all&milestone='.$id];
379 }
380 usort($list, fn($a, $b) => [$a['due'] === '' ? '9999' : $a['due'], $a['id']] <=> [$b['due'] === '' ? '9999' : $b['due'], $b['id']]);
381 $this->renderRepo('gitler/milestones', 'マイルストーン', ['list' => $list, 'error' => $error, 'state' => $state, 'counts' => $counts,
382 'canTriage' => $this->access >= Hosting::WRITE && !$this->meta['archived']]);
383 }
384
385 //////// ActivityPub(リポジトリのアクター)
386
387 /** アクターのJSON(…/gitler/{owner}/{repo}をActivityPubで取得) */
388 private function apActor(array $params): never {
389 [$owner, $name, $meta] = $this->apRepo($params);
390 $this->owner = $owner;
391 $this->name = $name;
392 $this->apJson(Federation::document($owner, $name, $meta, $this->cloneUrls()['https']));
393 }
394
395 /** …/inbox */
396 public function inbox(array $params): void {
397 if ($_SERVER['REQUEST_METHOD'] !== 'POST') $this->apJson(['error' => 'POSTだけです'], 405);
398 [$owner, $name] = $this->apRepo($params);
399 $max = 262144;
400 $input = file_get_contents('php://input', false, null, 0, $max + 1);
401 if ($input === false || strlen($input) > $max) $this->apJson(['error' => '大き過ぎます'], 413);
402 $activity = json_decode($input, true);
403 if (!is_array($activity) || !is_string($activity['type'] ?? null)) $this->apJson(['error' => '不正なアクティビティ'], 400);
404 if (!(new Activitypub())->verifyInboxSignature($input, $activity)) {
405 // 削除されたアカウントは鍵を取得出来ないので、Deleteは受け流す
406 $this->apJson($activity['type'] === 'Delete' ? ['status' => '無視しました'] : ['error' => '署名の検証に失敗'], $activity['type'] === 'Delete' ? 202 : 401);
407 }
408 logger(\LogType::ActivityPub, "Gitler({$owner}/{$name})の受付に入れた:".$input);
409 [$code, $body] = Federation::receive($owner, $name, $activity);
410 $this->apJson($body, $code);
411 }
412
413 /** …/outbox(最近のイシュー20件) */
414 public function outbox(array $params): void {
415 [$owner, $name] = $this->apRepo($params);
416 $issues = Issues::open($owner, $name);
417 $items = [];
418 foreach (array_slice($issues->search(['state' => 'all']), 0, 20) as $s) {
419 $issue = $issues->get($s['number']);
420 if ($issue === null) continue;
421 $note = Federation::issueNote($owner, $name, $issue);
422 $items[] = ['id' => $note['id'].'#create', 'type' => 'Create', 'actor' => $note['attributedTo'], 'published' => $note['published'], 'to' => $note['to'], 'cc' => $note['cc'], 'object' => $note];
423 }
424 $url = Federation::actorUrl($owner, $name).'/outbox';
425 $this->apJson(['@context' => Federation::CONTEXT, 'id' => $url, 'type' => 'OrderedCollection', 'totalItems' => count($issues->index()['issues']), 'orderedItems' => $items]);
426 }
427
428 /** …/followers(数だけ) */
429 public function followers(array $params): void {
430 [$owner, $name] = $this->apRepo($params);
431 $this->apJson(['@context' => 'https://www.w3.org/ns/activitystreams', 'id' => Federation::actorUrl($owner, $name).'/followers', 'type' => 'OrderedCollection',
432 'totalItems' => count(Federation::followers($owner, $name))]);
433 }
434
435 /** Fediverseに出ているリポジトリ([所有者, 名前, meta]、無ければ404) */
436 private function apRepo(array $params): array {
437 $owner = (string)($params['owner'] ?? '');
438 $name = (string)($params['repo'] ?? '');
439 if (!Hosting::validOwner($owner) || !Hosting::validName($name) || !Hosting::exists($owner, $name)) $this->apJson(['error' => '見つかりません'], 404);
440 $meta = Hosting::meta($owner, $name);
441 if (!Federation::enabled($meta)) $this->apJson(['error' => '見つかりません'], 404);
442 return [$owner, $name, $meta];
443 }
444
445 /** ActivityPubで取得するイシュー([所有者, 名前, イシュー]) */
446 private function apIssue(array $params): array {
447 [$owner, $name] = $this->apRepo($params);
448 $issue = Issues::open($owner, $name)->get((int)($params['n'] ?? 0));
449 if ($issue === null) $this->apJson(['error' => '見つかりません'], 404);
450 return [$owner, $name, $issue];
451 }
452
453 private function wantsActivity(): bool {
454 $accept = strtolower((string)($_SERVER['HTTP_ACCEPT'] ?? ''));
455 return str_contains($accept, 'application/activity+json') || str_contains($accept, 'application/ld+json');
456 }
457
458 private function apJson(array $data, int $code = 200): never {
459 http_response_code($code);
460 header('Content-Type: application/activity+json; charset=utf-8');
461 header('Vary: Accept');
462 echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
463 exit;
464 }
465
466 //////// プッシュ
467
468 /** プッシュの後:コミットのメッセージの「fixes #3」等でイシューを閉じる */
469 public static function afterPush(string $owner, string $name, array $done): void {
470 try {
471 $meta = Hosting::meta($owner, $name);
472 if (!$meta['issues']) return;
473 $issues = Issues::open($owner, $name);
474 $head = $issues->repo->headRef() ?? 'refs/heads/'.Hosting::config()['branch'];
475 foreach ($issues->processPush($done, $head) as $n) {
476 $issue = $issues->get($n);
477 if ($issue === null) continue;
478 Federation::stateChanged($owner, $name, $meta, $issue);
479 Notify::stateChanged($owner, $name, $meta, $issue, end($issue['events'])['by'] ?? []);
480 }
481 } catch (\Throwable $e) {
482 // プッシュ自体は終わっている
483 @error_log('Gitler: '.$e->getMessage());
484 }
485 }
486
487 //////// 共通
488
489 /** @param bool|null $pull プルリクエスト(nullならイシューかプルリクエストのどちらかが使えれば良い) */
490 private function loadIssues(array $params, ?bool $pull = false): void {
491 $this->load($params);
492 $on = $pull === null ? $this->meta['issues'] || $this->meta['pulls'] : ($pull ? $this->meta['pulls'] : $this->meta['issues']);
493 if (!$on) $this->notFound();
494 $this->issueDb = new Issues($this->repo);
495 }
496
497 /** イシュー又はプルリクエストのページ */
498 private function issuePage(array $issue): string {
499 return $this->repoUrl().(isset($issue['pull']) ? '/pulls/' : '/issues/').$issue['number'];
500 }
501
502 /** イシューを作ってコメント出来るか */
503 private function canIssue(): bool {
504 return $this->user !== null && $this->meta['issues'] && !$this->meta['archived'] && Hosting::userCan($this->user, 'issues');
505 }
506
507 private function me(): array {
508 return ['user' => (string)$this->user->username];
509 }
510
511 /** 担当者に出来る人(所有者と共同作業者) */
512 private function assignable(): array {
513 return array_values(array_unique(array_merge([$this->owner], array_map('strval', array_keys($this->meta['collaborators'])))));
514 }
515
516 /** 書いた者の表示(name、url、icon、remote) */
517 private function who(array $a): array {
518 if (isset($a['user'])) {
519 $u = (string)$a['user'];
520 if (!array_key_exists($u, $this->avatars)) {
521 $x = Hosting::user($u);
522 $this->avatars[$u] = $x ? avatar_url($x) : '';
523 }
524 return ['name' => $u, 'url' => '/gitler/'.$u, 'icon' => $this->avatars[$u], 'remote' => false, 'handle' => ''];
525 }
526 if (isset($a['actor'])) {
527 return ['name' => Federation::authorName($a), 'url' => (string)($a['url'] ?? $a['actor']), 'icon' => Remote::imageUrl((string)($a['icon'] ?? '')), 'remote' => true,
528 'handle' => (string)($a['handle'] ?? '')];
529 }
530 return ['name' => (string)($a['name'] ?? ''), 'url' => isset($a['commit']) ? $this->repoUrl().'/commit/'.$a['commit'] : '', 'icon' => '', 'remote' => false, 'handle' => ''];
531 }
532
533 private function bodyHtml(string $body, bool $html): string {
534 if ($html) return $body;
535 return trim($body) === '' ? '<p class="gl-muted">(説明は有りません)</p>' : Issues::render($body, $this->repoUrl().'/issues');
536 }
537
538 /** ラベルの表示(id、name、color、fg(文字の色)、description、checked) */
539 private function presentLabels(array $labels, array $checked = []): array {
540 $out = [];
541 foreach ($labels as $id => $l) {
542 $rgb = sscanf($l['color'], '#%02x%02x%02x');
543 $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)];
544 }
545 usort($out, fn($a, $b) => strcasecmp($a['name'], $b['name']));
546 return $out;
547 }
548
549 private function presentSummary(array $s, array $labels, array $milestones): array {
550 $who = $this->who($s['author']);
551 return ['number' => $s['number'], 'title' => $s['title'], 'url' => $this->repoUrl().(empty($s['pull']) ? '/issues/' : '/pulls/').$s['number'],
552 'state' => !empty($s['merged']) ? 'merged' : $s['state'],
553 'labels' => $this->presentLabels(array_intersect_key($labels, array_flip($s['labels']))), 'author' => $who['name'],
554 'date' => date('Y-m-d', $s['created']), 'closedDate' => $s['closed'] ? date('Y-m-d', $s['closed']) : '', 'comments' => $s['comments'],
555 'milestone' => $s['milestone'] !== null ? ($milestones[$s['milestone']]['title'] ?? '') : '', 'assignees' => $s['assignees']];
556 }
557
558 /** 出来事の文(HTML) */
559 private function eventHtml(array $e, array $labels, array $milestones): string {
560 $h = fn(string $s) => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
561 $who = $this->who($e['by']);
562 $by = $who['url'] !== '' ? '<a href="'.$h($who['url']).'">'.$h($who['name']).'</a>' : '<b>'.$h($who['name']).'</b>';
563 $commit = fn(string $id) => '<a href="'.$h($this->repoUrl().'/commit/'.$id).'"><code>'.$h(substr($id, 0, 7)).'</code></a>';
564 $chip = function(int $id) use ($labels, $h): string {
565 $l = $this->presentLabels(isset($labels[$id]) ? [$id => $labels[$id]] : [])[0] ?? null;
566 return $l ? '<span class="gl-label" style="background-color: '.$h($l['color']).'; color: '.$l['fg'].';">'.$h($l['name']).'</span>' : '<s>?</s>';
567 };
568 $list = fn(array $xs, callable $f) => implode('、', array_map($f, $xs));
569 return match ($e['type']) {
570 'closed' => isset($e['commit']) ? 'コミット'.$commit($e['commit']).'('.$by.')で閉じました'
571 : (isset($e['pull']) ? $by.'がプルリクエスト<a href="'.$h($this->repoUrl().'/pulls/'.$e['pull']).'">#'.(int)$e['pull'].'</a>のマージで閉じました' : $by.'が閉じました'),
572 'pushed' => $by.'がコミットを'.(int)$e['count'].'個プッシュしました('.$commit($e['from']).' → '.$commit($e['to']).')',
573 'merged' => $by.'が「'.$h($e['base']).'」にマージしました('.$commit($e['commit']).($e['method'] !== 'manual' ? '、'.['merge' => 'マージコミット', 'squash' => 'スカッシュ', 'rebase' => 'リベース'][$e['method']] : '').')',
574 'branchDeleted' => $by.'がブランチ「'.$h($e['branch']).'」を削除しました',
575 'reopened' => $by.'が開き直しました',
576 'title' => $by.'が題名を「'.$h($e['from']).'」から「'.$h($e['to']).'」に変えました',
577 'labels' => $by.'が'.trim(($e['add'] ? 'ラベル'.$list($e['add'], $chip).'を付け' : '').($e['add'] && $e['remove'] ? '、' : '').($e['remove'] ? 'ラベル'.$list($e['remove'], $chip).'を外し' : '')).'ました',
578 'assignees' => $by.'が'.($e['add'] ? '担当者に'.$h(implode('、', $e['add'])).'を追加し' : '').($e['add'] && $e['remove'] ? '、' : '').($e['remove'] ? $h(implode('、', $e['remove'])).'を担当者から外し' : '').'ました',
579 'milestone' => $e['to'] !== null ? $by.'がマイルストーンを「'.$h($milestones[$e['to']]['title'] ?? '?').'」にしました' : $by.'がマイルストーンを外しました',
580 'commit' => $by.'がコミット'.$commit($e['commit']).'「'.$h($e['subject']).'」('.$h($e['branch']).')で言及しました',
581 default => $h($e['type']),
582 };
583 }
584}