| 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 | use Std\Lib\Gitler\Notify; |
| 46 | |
| 47 | /** |
| 48 | * Gitlerのイシュー、ラベル、マイルストーンのページ、ウォッチ、リポジトリのFediverse(アクター、受付ボックス等) |
| 49 | * |
| 50 | * Gitlerコントローラーで使う(リポジトリを開く等はGitlerの物)。 |
| 51 | */ |
| 52 | trait 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 | } |