loadIssues($params, $pull); $f = ['state' => in_array($_GET['state'] ?? '', ['closed', 'all'], true) ? $_GET['state'] : 'open']; foreach (['label', 'milestone'] as $k) if (isset($_GET[$k]) && $_GET[$k] !== '') $f[$k] = (int)$_GET[$k]; foreach (['assignee', 'q', 'sort'] as $k) if (is_string($_GET[$k] ?? null) && $_GET[$k] !== '') $f[$k] = trim($_GET[$k]); $all = $this->issueDb->search(['state' => 'all', 'pull' => $pull] + $f); $counts = ['open' => 0, 'closed' => 0]; foreach ($all as $s) $counts[$s['state']]++; $list = $f['state'] === 'all' ? $all : array_values(array_filter($all, fn($s) => $s['state'] === $f['state'])); $page = max(1, (int)($_GET['page'] ?? 1)); $pages = max(1, (int)ceil(count($list) / self::PER_PAGE)); $labels = $this->issueDb->labels(); $milestones = $this->issueDb->milestones(); $listUrl = $this->repoUrl().($pull ? '/pulls' : '/issues'); $query = fn(array $change) => $listUrl.'?'.http_build_query(array_filter($change + $f + ['page' => null], fn($v) => $v !== null && $v !== '')); $this->renderRepo('gitler/issues', $pull ? 'プルリクエスト' : 'イシュー', [ 'isPull' => $pull, 'listUrl' => $listUrl, 'newUrl' => $this->repoUrl().($pull ? '/compare' : '/issues/new'), 'agitHelp' => $pull ? 'git push origin HEAD:refs/for/'.$this->defaultBranch().'/トピック' : '', 'list' => array_map(fn($s) => $this->presentSummary($s, $labels, $milestones), array_slice($list, ($page - 1) * self::PER_PAGE, self::PER_PAGE)), 'counts' => $counts, 'f' => $f + ['label' => '', 'milestone' => '', 'assignee' => '', 'q' => '', 'sort' => 'created'], 'openUrl' => $query(['state' => 'open']), 'closedUrl' => $query(['state' => 'closed']), 'prevUrl' => $page > 1 ? $query(['page' => $page - 1]) : '', 'nextUrl' => $page < $pages ? $query(['page' => $page + 1]) : '', 'labels' => $this->presentLabels($labels), 'milestones' => $milestones, 'people' => $this->assignable(), 'canIssue' => $pull ? $this->canPull() : $this->canIssue(), ]); } /** …/issues/new */ public function newIssue(array $params): void { $this->loadIssues($params); if (!$this->user) $this->redirect('/login'); if ($this->meta['archived']) $this->message('アーカイブされたリポジトリです(読むだけ)。', $this->repoUrl().'/issues'); if (!$this->canIssue()) $this->message('イシューを作る権限が有りません。', $this->repoUrl().'/issues'); $in = ['title' => '', 'body' => '', 'labels' => [], 'assignees' => [], 'milestone' => 0]; $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $in = ['title' => (string)($_POST['title'] ?? ''), 'body' => str_replace("\r\n", "\n", (string)($_POST['body'] ?? '')), 'labels' => (array)($_POST['labels'] ?? []), 'assignees' => (array)($_POST['assignees'] ?? []), 'milestone' => (int)($_POST['milestone'] ?? 0)]; if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { $opt = $this->access >= Hosting::WRITE ? ['labels' => $in['labels'], 'assignees' => array_intersect($in['assignees'], $this->assignable()), 'milestone' => $in['milestone']] : []; $res = $this->issueDb->create($this->me(), $in['title'], $in['body'], $opt); if ($res->isSuccess) { Federation::issueOpened($this->owner, $this->name, $this->meta, $res->data['issue']); Notify::issueOpened($this->owner, $this->name, $this->meta, $this->issueDb, $res->data['issue']); $this->redirect($this->repoUrl().'/issues/'.$res->data['number']); } $error = (string)$res->message; } } $this->renderRepo('gitler/issuenew', '新しいイシュー', [ 'in' => $in, 'error' => $error, 'labels' => $this->presentLabels($this->issueDb->labels(), array_map('intval', $in['labels'])), 'milestones' => $this->issueDb->milestones(), 'people' => $this->assignable(), ]); } /** …/issues/{n}(ActivityPubではNote)、…/pulls/{n}(プルリクエストの会話) */ public function issue(array $params): void { $n = (int)($params['n'] ?? 0); if ($this->wantsActivity()) { [$owner, $name, $issue] = $this->apIssue($params); $this->apJson(['@context' => Federation::CONTEXT] + Federation::issueNote($owner, $name, $issue)); } $this->loadIssues($params, null); $issue = $this->issueDb->get($n); if ($issue === null) $this->notFound(); // イシューとプルリクエストのURLが違う if (isset($issue['pull']) !== !empty($params['pull'])) $this->redirect($this->issuePage($issue)); if (!(isset($issue['pull']) ? $this->meta['pulls'] : $this->meta['issues'])) $this->notFound(); $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $error = $this->csrfOk() ? $this->issueAction($issue) : 'セッションの有効期限が切れました。もう一度お試し下さい。'; $issue = $this->issueDb->get($n); if ($issue === null) $this->redirect($this->repoUrl().'/issues'); } $pull = isset($issue['pull']); header('Vary: Accept'); $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username; $archived = $this->meta['archived']; $labels = $this->issueDb->labels(); $milestones = $this->issueDb->milestones(); $timeline = []; $replies = []; foreach ($issue['comments'] as $c) if (isset($c['path']) && $c['replyTo'] !== null && empty($c['pending'])) $replies[$c['replyTo']][] = $c; $byReview = []; foreach ($issue['comments'] as $c) { // 行へのコメント:レビューの物はレビューに、他はそれだけ(返信はスレッドに、下書きは出さない) if (isset($c['path'])) { if (!empty($c['pending']) || $c['replyTo'] !== null) continue; $inline = $this->presentInline($c, $replies[$c['id']] ?? []) + ['outdated' => $c['commit'] !== $issue['pull']['headId']]; if ($c['review'] !== null) $byReview[$c['review']][] = $inline; else $timeline[] = ['kind' => 'inline', 'at' => $c['created'], 'c' => $inline]; continue; } $mine = $this->user && ($c['author']['user'] ?? null) === $this->user->username; $timeline[] = ['kind' => 'comment', 'at' => $c['created'], 'id' => $c['id'], 'who' => $this->who($c['author']), 'html' => $this->bodyHtml($c['body'], !empty($c['html'])), 'date' => date('Y-m-d H:i', $c['created']), 'edited' => $c['updated'] > $c['created'], 'body' => $c['body'], 'canEdit' => !$archived && $mine && empty($c['html']), 'canDelete' => !$archived && ($mine || $this->access >= Hosting::ADMIN), 'actor' => $c['author']['actor'] ?? '', 'remote' => preg_match('#^https?://#i', (string)($c['remoteId'] ?? '')) ? $c['remoteId'] : '']; } 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'])]; $states = ['approved' => '承認しました', 'changes' => '変更を依頼しました', 'commented' => 'レビューしました']; foreach ($issue['reviews'] ?? [] as $r) { $timeline[] = ['kind' => 'review', 'at' => $r['at'], 'id' => $r['id'], 'who' => $this->who($r['author']), 'state' => $r['state'], 'stateText' => $states[$r['state']] ?? '', 'html' => trim($r['body']) !== '' ? $this->bodyHtml($r['body'], false) : '', 'date' => date('Y-m-d H:i', $r['at']), 'comments' => $byReview[$r['id']] ?? []]; } // 同じ時刻なら出来事(閉じた、マージした等)を後に usort($timeline, fn($a, $b) => [$a['at'], $a['kind'] === 'event'] <=> [$b['at'], $b['kind'] === 'event']); $fedi = Federation::enabled($this->meta) && !$this->meta['archived']; // 通知:受け取っている(true)、止めた(false)、ウォッチで受け取る、受け取っていない $sub = $this->user ? ($issue['subscribers'][$this->user->username] ?? null) : null; $watching = $this->user && Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) === Notify::WATCHING; $this->renderRepo($pull ? 'gitler/pull' : 'gitler/issue', '#'.$n.' '.$issue['title'], ($pull ? $this->pullVars($issue) : []) + [ 'issue' => $issue, 'who' => $this->who($issue['author']), 'bodyHtml' => $this->bodyHtml($issue['body'], !empty($issue['html'])), 'date' => date('Y-m-d H:i', $issue['created']), 'timeline' => $timeline, 'error' => $error, 'canIssue' => $pull ? $this->canReview() : $this->canIssue(), 'canEdit' => !$archived && (($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE), 'canClose' => !$archived && ($isAuthor || $this->access >= Hosting::WRITE) && empty($issue['pull']['merged']), 'canTriage' => !$archived && $this->access >= Hosting::WRITE, 'canAdmin' => !$archived && $this->access >= Hosting::ADMIN, 'labels' => $this->presentLabels($labels, $issue['labels']), 'issueLabels' => $this->presentLabels(array_intersect_key($labels, array_flip($issue['labels']))), 'milestones' => $milestones, 'milestone' => $issue['milestone'] !== null ? ($milestones[$issue['milestone']]['title'] ?? '') : '', 'people' => array_map(fn($p) => ['name' => $p, 'checked' => in_array($p, $issue['assignees'], true)], array_unique(array_merge($this->assignable(), $issue['assignees']))), 'issueUrl' => $this->issuePage($issue), 'fedi' => $fedi, 'canReview' => $pull && $this->canReview(), 'fediHandle' => $fedi ? '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host() : '', 'fediUrl' => $fedi ? Federation::issueUrl($this->owner, $this->name, $n) : '', 'subscribed' => $sub === true || ($sub === null && $watching), 'subscribeNote' => $sub === true ? '通知を受け取っています。' : ($sub === false ? '通知を止めています。' : ($watching ? 'リポジトリをウォッチしているので通知を受け取ります。' : '通知を受け取っていません。')), ]); } /** …/issues/{n}/ticket(ForgeFedのTicket。ブラウザはイシューへ) */ public function issueTicket(array $params): void { if (!$this->wantsActivity()) $this->redirect('/gitler/'.($params['owner'] ?? '').'/'.rawurlencode((string)($params['repo'] ?? '')).'/issues/'.(int)($params['n'] ?? 0)); [$owner, $name, $issue] = $this->apIssue($params); $this->apJson(Federation::ticket($owner, $name, $issue)); } /** …/issues/{n}/comments/{c}(ローカルのコメントのNote。ブラウザはコメントへ) */ public function issueComment(array $params): void { $c = (int)($params['c'] ?? 0); if (!$this->wantsActivity()) $this->redirect('/gitler/'.($params['owner'] ?? '').'/'.rawurlencode((string)($params['repo'] ?? '')).'/issues/'.(int)($params['n'] ?? 0).'#c'.$c); [$owner, $name, $issue] = $this->apIssue($params); foreach ($issue['comments'] as $x) { if ($x['id'] === $c && !isset($x['author']['actor'])) $this->apJson(['@context' => Federation::CONTEXT] + Federation::commentNote($owner, $name, $issue, $x)); } $this->apJson(['error' => '見つかりません'], 404); } /** イシューのページのPOST(エラーのメッセージ、成功したらリダイレクト) */ private function issueAction(array $issue): string { $n = $issue['number']; $url = $this->issuePage($issue); $do = (string)($_POST['do'] ?? ''); if (isset($issue['pull']) && in_array($do, ['merge', 'deleteBranch', 'inline', 'inlinePending', 'reply', 'review'], true)) return $this->pullAction($issue, $do); $me = $this->me(); $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username; $canClose = $isAuthor || $this->access >= Hosting::WRITE; $body = str_replace("\r\n", "\n", (string)($_POST['body'] ?? '')); if (!$this->user) return 'ログインして下さい。'; if ($do === 'subscribe' || $do === 'unsubscribe') { $this->issueDb->subscribe($n, [(string)$this->user->username], $do === 'subscribe'); $this->redirect($url); } if ($this->meta['archived']) return 'アーカイブされたリポジトリです(読むだけ)。'; if (in_array($do, ['comment', 'commentClose', 'commentReopen', 'close', 'reopen'], true)) { if (!(isset($issue['pull']) ? $this->canReview() : $this->canIssue())) return 'コメントする権限が有りません。'; if (!empty($issue['pull']['merged']) && $do !== 'comment') return 'マージしたプルリクエストは開き直せません。'; $anchor = ''; if ($do === 'comment' || trim($body) !== '') { $res = $this->issueDb->comment($n, $me, $body); if (!$res->isSuccess) return (string)$res->message; Federation::commentAdded($this->owner, $this->name, $this->meta, $res->data['issue'], $res->data['comment']); Notify::commented($this->owner, $this->name, $this->meta, $this->issueDb, $res->data['issue'], $res->data['comment']); $anchor = '#c'.$res->data['comment']['id']; } if ($do !== 'comment' && $canClose) { $new = $this->issueDb->setState($n, in_array($do, ['reopen', 'commentReopen'], true), $me); if ($new !== null) { Federation::stateChanged($this->owner, $this->name, $this->meta, $new); Notify::stateChanged($this->owner, $this->name, $this->meta, $new, $me); } } $this->redirect($url.$anchor); } if ($do === 'edit') { if (!(($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE)) return '編集する権限が有りません。'; $res = $this->issueDb->edit($n, $me, (string)($_POST['title'] ?? ''), empty($issue['html']) ? $body : $issue['body']); if (!$res->isSuccess) return (string)$res->message; Federation::issueEdited($this->owner, $this->name, $this->meta, $res->data['issue']); $this->redirect($url); } if ($do === 'triage') { if ($this->access < Hosting::WRITE) return '権限が有りません。'; $people = array_merge($this->assignable(), $issue['assignees']); $new = $this->issueDb->triage($n, $me, (array)($_POST['labels'] ?? []), array_values(array_intersect((array)($_POST['assignees'] ?? []), $people)), (int)($_POST['milestone'] ?? 0)); $added = $new !== null ? array_values(array_diff($new['assignees'], $issue['assignees'])) : []; if (!empty($added)) Notify::assigned($this->owner, $this->name, $this->meta, $new, $added, $me); $this->redirect($url); } if ($do === 'editComment' || $do === 'deleteComment') { $id = (int)($_POST['id'] ?? 0); $c = null; foreach ($issue['comments'] as $x) if ($x['id'] === $id) $c = $x; if ($c === null) return 'コメントが有りません。'; $mine = ($c['author']['user'] ?? null) === $this->user->username; if ($do === 'editComment') { if (!$mine || !empty($c['html'])) return '編集する権限が有りません。'; $new = $this->issueDb->editComment($n, $id, $body); if ($new === null) return 'コメントを入力して下さい。'; Federation::commentAdded($this->owner, $this->name, $this->meta, $this->issueDb->get($n) ?? $issue, $new, 'Update'); $this->redirect($url.'#c'.$id); } if (!$mine && $this->access < Hosting::ADMIN) return '削除する権限が有りません。'; $this->issueDb->editComment($n, $id, null); Federation::commentDeleted($this->owner, $this->name, $this->meta, $issue, $c); $this->redirect($url); } if ($do === 'delete') { if ($this->access < Hosting::ADMIN) return '削除する権限が有りません。'; if (!isset($_POST['confirm'])) return '確認にチェックを入れて下さい。'; $this->issueDb->delete($n); Federation::issueDeleted($this->owner, $this->name, $this->meta, $issue); if (isset($issue['pull'])) { try { $this->repo->refs->update(\Std\Lib\Gitler\Pulls::ref($n), null); } catch (\Std\Lib\Git\GitException) { } } $this->redirect($this->repoUrl().(isset($issue['pull']) ? '/pulls' : '/issues')); } if ($do === 'block') { if ($this->access < Hosting::ADMIN) return 'ブロックする権限が有りません。'; $res = Federation::block($this->owner, $this->name, (string)($_POST['actor'] ?? '')); return $res->isSuccess ? '' : (string)$res->message; } return ''; } //////// ウォッチ /** …/watch(POST:state=watching、participating、ignoring) */ public function watch(array $params): void { $this->load($params); if (!$this->user) $this->redirect('/login'); if ($_SERVER['REQUEST_METHOD'] === 'POST' && $this->csrfOk()) Notify::setWatch($this->owner, $this->name, (string)$this->user->username, (string)($_POST['state'] ?? '')); $back = (string)($_POST['back'] ?? ''); $this->redirect(str_starts_with($back, $this->repoUrl()) ? $back : $this->repoUrl()); } //////// ラベルとマイルストーン /** …/labels */ public function labels(array $params): void { $this->loadIssues($params, null); $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else if ($this->access < Hosting::WRITE || $this->meta['archived']) { $error = $this->meta['archived'] ? 'アーカイブされたリポジトリです(読むだけ)。' : '権限が有りません。'; } else if (($_POST['do'] ?? '') === 'delete') { $this->issueDb->deleteLabel((int)($_POST['id'] ?? 0)); $this->redirect($this->repoUrl().'/labels'); } else { $id = (int)($_POST['id'] ?? 0); $res = $this->issueDb->saveLabel($id > 0 ? $id : null, (string)($_POST['name'] ?? ''), (string)($_POST['color'] ?? ''), (string)($_POST['description'] ?? '')); if ($res->isSuccess) $this->redirect($this->repoUrl().'/labels'); $error = (string)$res->message; } } $counts = []; foreach ($this->issueDb->search(['state' => 'open']) as $s) foreach ($s['labels'] as $l) $counts[$l] = ($counts[$l] ?? 0) + 1; $list = []; foreach ($this->presentLabels($this->issueDb->labels()) as $l) $list[] = $l + ['open' => $counts[$l['id']] ?? 0, 'url' => $this->repoUrl().'/issues?label='.$l['id']]; $this->renderRepo('gitler/labels', 'ラベル', ['list' => $list, 'error' => $error, 'canTriage' => $this->access >= Hosting::WRITE && !$this->meta['archived']]); } /** …/milestones */ public function milestones(array $params): void { $this->loadIssues($params, null); $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $do = (string)($_POST['do'] ?? ''); $id = (int)($_POST['id'] ?? 0); $all = $this->issueDb->milestones(); if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else if ($this->access < Hosting::WRITE || $this->meta['archived']) { $error = $this->meta['archived'] ? 'アーカイブされたリポジトリです(読むだけ)。' : '権限が有りません。'; } else if ($do === 'delete') { $this->issueDb->deleteMilestone($id); $this->redirect($this->repoUrl().'/milestones'); } else if (($do === 'close' || $do === 'reopen') && isset($all[$id])) { $m = $all[$id]; $this->issueDb->saveMilestone($id, $m['title'], $m['description'], $m['due'], $do === 'close' ? 'closed' : 'open'); $this->redirect($this->repoUrl().'/milestones'.($do === 'close' ? '?state=closed' : '')); } else { $old = $all[$id] ?? null; $res = $this->issueDb->saveMilestone($old ? $id : null, (string)($_POST['title'] ?? ''), (string)($_POST['description'] ?? ''), trim((string)($_POST['due'] ?? '')), $old['state'] ?? 'open'); if ($res->isSuccess) $this->redirect($this->repoUrl().'/milestones'); $error = (string)$res->message; } } $state = ($_GET['state'] ?? '') === 'closed' ? 'closed' : 'open'; $stats = []; foreach ($this->issueDb->search(['state' => 'all']) as $s) if ($s['milestone'] !== null) $stats[$s['milestone']][$s['state']] = ($stats[$s['milestone']][$s['state']] ?? 0) + 1; $list = []; $counts = ['open' => 0, 'closed' => 0]; foreach ($this->issueDb->milestones() as $id => $m) { $counts[$m['state']]++; if ($m['state'] !== $state) continue; $open = $stats[$id]['open'] ?? 0; $closed = $stats[$id]['closed'] ?? 0; $list[] = $m + ['id' => $id, 'open' => $open, 'closed' => $closed, 'percent' => $open + $closed > 0 ? (int)floor($closed * 100 / ($open + $closed)) : 0, 'overdue' => $m['due'] !== '' && $m['state'] === 'open' && $m['due'] < date('Y-m-d'), 'html' => $m['description'] !== '' ? Issues::render($m['description'], $this->repoUrl().'/issues') : '', 'url' => $this->repoUrl().'/issues?state=all&milestone='.$id]; } usort($list, fn($a, $b) => [$a['due'] === '' ? '9999' : $a['due'], $a['id']] <=> [$b['due'] === '' ? '9999' : $b['due'], $b['id']]); $this->renderRepo('gitler/milestones', 'マイルストーン', ['list' => $list, 'error' => $error, 'state' => $state, 'counts' => $counts, 'canTriage' => $this->access >= Hosting::WRITE && !$this->meta['archived']]); } //////// ActivityPub(リポジトリのアクター) /** アクターのJSON(…/gitler/{owner}/{repo}をActivityPubで取得) */ private function apActor(array $params): never { [$owner, $name, $meta] = $this->apRepo($params); $this->owner = $owner; $this->name = $name; $this->apJson(Federation::document($owner, $name, $meta, $this->cloneUrls()['https'])); } /** …/inbox */ public function inbox(array $params): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') $this->apJson(['error' => 'POSTだけです'], 405); [$owner, $name] = $this->apRepo($params); $max = 262144; $input = file_get_contents('php://input', false, null, 0, $max + 1); if ($input === false || strlen($input) > $max) $this->apJson(['error' => '大き過ぎます'], 413); $activity = json_decode($input, true); if (!is_array($activity) || !is_string($activity['type'] ?? null)) $this->apJson(['error' => '不正なアクティビティ'], 400); if (!(new Activitypub())->verifyInboxSignature($input, $activity)) { // 削除されたアカウントは鍵を取得出来ないので、Deleteは受け流す $this->apJson($activity['type'] === 'Delete' ? ['status' => '無視しました'] : ['error' => '署名の検証に失敗'], $activity['type'] === 'Delete' ? 202 : 401); } logger(\LogType::ActivityPub, "Gitler({$owner}/{$name})の受付に入れた:".$input); [$code, $body] = Federation::receive($owner, $name, $activity); $this->apJson($body, $code); } /** …/outbox(最近のイシュー20件) */ public function outbox(array $params): void { [$owner, $name] = $this->apRepo($params); $issues = Issues::open($owner, $name); $items = []; foreach (array_slice($issues->search(['state' => 'all']), 0, 20) as $s) { $issue = $issues->get($s['number']); if ($issue === null) continue; $note = Federation::issueNote($owner, $name, $issue); $items[] = ['id' => $note['id'].'#create', 'type' => 'Create', 'actor' => $note['attributedTo'], 'published' => $note['published'], 'to' => $note['to'], 'cc' => $note['cc'], 'object' => $note]; } $url = Federation::actorUrl($owner, $name).'/outbox'; $this->apJson(['@context' => Federation::CONTEXT, 'id' => $url, 'type' => 'OrderedCollection', 'totalItems' => count($issues->index()['issues']), 'orderedItems' => $items]); } /** …/followers(数だけ) */ public function followers(array $params): void { [$owner, $name] = $this->apRepo($params); $this->apJson(['@context' => 'https://www.w3.org/ns/activitystreams', 'id' => Federation::actorUrl($owner, $name).'/followers', 'type' => 'OrderedCollection', 'totalItems' => count(Federation::followers($owner, $name))]); } /** Fediverseに出ているリポジトリ([所有者, 名前, meta]、無ければ404) */ private function apRepo(array $params): array { $owner = (string)($params['owner'] ?? ''); $name = (string)($params['repo'] ?? ''); if (!Hosting::validOwner($owner) || !Hosting::validName($name) || !Hosting::exists($owner, $name)) $this->apJson(['error' => '見つかりません'], 404); $meta = Hosting::meta($owner, $name); if (!Federation::enabled($meta)) $this->apJson(['error' => '見つかりません'], 404); return [$owner, $name, $meta]; } /** ActivityPubで取得するイシュー([所有者, 名前, イシュー]) */ private function apIssue(array $params): array { [$owner, $name] = $this->apRepo($params); $issue = Issues::open($owner, $name)->get((int)($params['n'] ?? 0)); if ($issue === null) $this->apJson(['error' => '見つかりません'], 404); return [$owner, $name, $issue]; } private function wantsActivity(): bool { $accept = strtolower((string)($_SERVER['HTTP_ACCEPT'] ?? '')); return str_contains($accept, 'application/activity+json') || str_contains($accept, 'application/ld+json'); } private function apJson(array $data, int $code = 200): never { http_response_code($code); header('Content-Type: application/activity+json; charset=utf-8'); header('Vary: Accept'); echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; } //////// プッシュ /** プッシュの後:コミットのメッセージの「fixes #3」等でイシューを閉じる */ public static function afterPush(string $owner, string $name, array $done): void { try { $meta = Hosting::meta($owner, $name); if (!$meta['issues']) return; $issues = Issues::open($owner, $name); $head = $issues->repo->headRef() ?? 'refs/heads/'.Hosting::config()['branch']; foreach ($issues->processPush($done, $head) as $n) { $issue = $issues->get($n); if ($issue === null) continue; Federation::stateChanged($owner, $name, $meta, $issue); Notify::stateChanged($owner, $name, $meta, $issue, end($issue['events'])['by'] ?? []); } } catch (\Throwable $e) { // プッシュ自体は終わっている @error_log('Gitler: '.$e->getMessage()); } } //////// 共通 /** @param bool|null $pull プルリクエスト(nullならイシューかプルリクエストのどちらかが使えれば良い) */ private function loadIssues(array $params, ?bool $pull = false): void { $this->load($params); $on = $pull === null ? $this->meta['issues'] || $this->meta['pulls'] : ($pull ? $this->meta['pulls'] : $this->meta['issues']); if (!$on) $this->notFound(); $this->issueDb = new Issues($this->repo); } /** イシュー又はプルリクエストのページ */ private function issuePage(array $issue): string { return $this->repoUrl().(isset($issue['pull']) ? '/pulls/' : '/issues/').$issue['number']; } /** イシューを作ってコメント出来るか */ private function canIssue(): bool { return $this->user !== null && $this->meta['issues'] && !$this->meta['archived'] && Hosting::userCan($this->user, 'issues'); } private function me(): array { return ['user' => (string)$this->user->username]; } /** 担当者に出来る人(所有者と共同作業者) */ private function assignable(): array { return array_values(array_unique(array_merge([$this->owner], array_map('strval', array_keys($this->meta['collaborators']))))); } /** 書いた者の表示(name、url、icon、remote) */ private function who(array $a): array { if (isset($a['user'])) { $u = (string)$a['user']; if (!array_key_exists($u, $this->avatars)) { $x = Hosting::user($u); $this->avatars[$u] = $x ? avatar_url($x) : ''; } return ['name' => $u, 'url' => '/gitler/'.$u, 'icon' => $this->avatars[$u], 'remote' => false, 'handle' => '']; } if (isset($a['actor'])) { return ['name' => Federation::authorName($a), 'url' => (string)($a['url'] ?? $a['actor']), 'icon' => Remote::imageUrl((string)($a['icon'] ?? '')), 'remote' => true, 'handle' => (string)($a['handle'] ?? '')]; } return ['name' => (string)($a['name'] ?? ''), 'url' => isset($a['commit']) ? $this->repoUrl().'/commit/'.$a['commit'] : '', 'icon' => '', 'remote' => false, 'handle' => '']; } private function bodyHtml(string $body, bool $html): string { if ($html) return $body; return trim($body) === '' ? '
(説明は有りません)
' : Issues::render($body, $this->repoUrl().'/issues'); } /** ラベルの表示(id、name、color、fg(文字の色)、description、checked) */ private function presentLabels(array $labels, array $checked = []): array { $out = []; foreach ($labels as $id => $l) { $rgb = sscanf($l['color'], '#%02x%02x%02x'); $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)]; } usort($out, fn($a, $b) => strcasecmp($a['name'], $b['name'])); return $out; } private function presentSummary(array $s, array $labels, array $milestones): array { $who = $this->who($s['author']); return ['number' => $s['number'], 'title' => $s['title'], 'url' => $this->repoUrl().(empty($s['pull']) ? '/issues/' : '/pulls/').$s['number'], 'state' => !empty($s['merged']) ? 'merged' : $s['state'], 'labels' => $this->presentLabels(array_intersect_key($labels, array_flip($s['labels']))), 'author' => $who['name'], 'date' => date('Y-m-d', $s['created']), 'closedDate' => $s['closed'] ? date('Y-m-d', $s['closed']) : '', 'comments' => $s['comments'], 'milestone' => $s['milestone'] !== null ? ($milestones[$s['milestone']]['title'] ?? '') : '', 'assignees' => $s['assignees']]; } /** 出来事の文(HTML) */ private function eventHtml(array $e, array $labels, array $milestones): string { $h = fn(string $s) => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); $who = $this->who($e['by']); $by = $who['url'] !== '' ? ''.$h($who['name']).'' : ''.$h($who['name']).''; $commit = fn(string $id) => ''.$h(substr($id, 0, 7)).'';
$chip = function(int $id) use ($labels, $h): string {
$l = $this->presentLabels(isset($labels[$id]) ? [$id => $labels[$id]] : [])[0] ?? null;
return $l ? ''.$h($l['name']).'' : '