watching、participating、ignoring): * watching 新しいイシューと全部のイシューの動き(所有者と共同作業者の既定) * participating 通知を受け取るイシューだけ(他の人の既定) * ignoring 何も受け取らない * イシュー毎の受け取り(subscribers):作った人、コメントした人、担当者、メンションされた人。止める事も出来る。 * メールはKeysの設定(既定は受け取る)で、cronのnotifyが送る。自分の操作は通知しない。 */ final class Notify { public const WATCHING = 'watching'; public const PARTICIPATING = 'participating'; public const IGNORING = 'ignoring'; /** 「@ユーザー」(「@user@host」と「@所有者.名前」は除く) */ public const MENTION_RE = '/(?path.'/gitler/watch.json'; $repo->mkdir(dirname($file)); $lock = @fopen($file.'.lock', 'c'); if ($lock) flock($lock, LOCK_EX); try { $list = self::watchList($owner, $name); $list[$user] = $state; $repo->writeFile($file, (string)json_encode($list, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } finally { if ($lock) { flock($lock, LOCK_UN); fclose($lock); } } } /** ウォッチしている人(既定の所有者と共同作業者を含む) */ public static function watchers(string $owner, string $name, array $meta): array { $out = []; foreach (array_unique(array_merge([$owner], array_map('strval', array_keys($meta['collaborators'])), array_keys(self::watchList($owner, $name)))) as $u) { if (self::watchState($owner, $name, (string)$u, $meta) === self::WATCHING) $out[] = (string)$u; } return $out; } //////// イシュー /** Markdownの本文の「@ユーザー」(コードの中は除く) */ public static function mentions(string $text): array { $text = (string)preg_replace(['/^(```|~~~).*?^\1/ms', '/`[^`\n]*`/'], '', $text); preg_match_all(self::MENTION_RE, $text, $m); return array_values(array_unique($m[1])); } public static function issueOpened(string $owner, string $name, array $meta, Issues $db, array $issue): void { $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $issue['body'], !empty($issue['html'])); $to = array_fill_keys(self::watchers($owner, $name, $meta), 'opened') + array_fill_keys($issue['assignees'], 'assigned'); foreach ($mentioned as $u) $to[$u] = 'mention'; self::send($owner, $name, $meta, $db->get($issue['number']) ?? $issue, $issue['author'], $to, $issue['body'], !empty($issue['html']), ''); } public static function commented(string $owner, string $name, array $meta, Issues $db, array $issue, array $c): void { $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $c['body'], !empty($c['html'])); $issue = $db->get($issue['number']) ?? $issue; $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), 'comment'); foreach ($mentioned as $u) $to[$u] = 'mention'; self::send($owner, $name, $meta, $issue, $c['author'], $to, $c['body'], !empty($c['html']), '#c'.$c['id']); } /** 閉じた・開き直した($byはコミット等も) */ public static function stateChanged(string $owner, string $name, array $meta, array $issue, array $by): void { $type = $issue['state'] === 'closed' ? (isset($by['commit']) ? 'closedByCommit' : 'closed') : 'reopened'; $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), $type); self::send($owner, $name, $meta, $issue, $by, $to, '', false, ''); } /** プルリクエストをマージした */ public static function merged(string $owner, string $name, array $meta, array $issue, array $by): void { $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), 'merged'); self::send($owner, $name, $meta, $issue, $by, $to, '', false, ''); } /** プルリクエストのレビュー(state:approved、changes、commented) */ public static function reviewed(string $owner, string $name, array $meta, Issues $db, array $issue, array $review): void { $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $review['body'], false); $issue = $db->get($issue['number']) ?? $issue; $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), $review['state']); foreach ($mentioned as $u) $to[$u] = 'mention'; self::send($owner, $name, $meta, $issue, $review['author'], $to, $review['body'], false, '#r'.$review['id']); } /** 担当者に追加した */ public static function assigned(string $owner, string $name, array $meta, array $issue, array $users, array $by): void { self::send($owner, $name, $meta, $issue, $by, array_fill_keys($users, 'assigned'), '', false, ''); } //////// 送る /** * 通知を追加し、メールを待ち行列に入れる(SSHのプッシュではリポジトリに溜める) * * @param array|null $mail to、subject、body、name */ public static function deliver(string $user, array $record, ?array $mail): void { if ($user === '' || empty($record)) return; Notifications::add($user, $record); if (is_array($mail)) Notifications::queueMail((string)$mail['to'], (string)$mail['subject'], (string)$mail['body'], (string)($mail['name'] ?? '')); } /** * @param array $to ユーザー名 => 種類(opened、comment、mention、closed、closedByCommit、reopened、assigned、merged、 * approved・changes・commented(レビュー)) * @param string $anchor コメントの「#c5」等 */ private static function send(string $owner, string $name, array $meta, array $issue, array $by, array $to, string $body, bool $html, string $anchor): void { if (empty($to) || $meta['archived']) return; $n = $issue['number']; $repoName = "{$owner}/{$name}"; $title = "{$repoName}#{$n}「{$issue['title']}」"; $commit = isset($by['commit']) ? substr((string)$by['commit'], 0, 7) : ''; $kind = isset($issue['pull']) ? 'プルリクエスト' : 'イシュー'; $path = '/gitler/'.$owner.'/'.rawurlencode($name).(isset($issue['pull']) ? '/pulls/' : '/issues/').$n.$anchor; $excerpt = $body !== '' ? Html::text($html ? $body : Issues::render($body, '/gitler/'.$owner.'/'.rawurlencode($name).'/issues')) : ''; // Markdownの整形の空白を除く $excerpt = trim((string)preg_replace(['/[ \t]*\n[ \t]*/', '/\n{3,}/'], ["\n", "\n\n"], $excerpt)); $from = isset($by['user']) ? ['username' => (string)$by['user']] : (isset($by['actor']) ? $by : ['name' => (string)($by['name'] ?? '')]); $fromName = Federation::authorName($by); $base = rtrim((string)(SITEINFO['url'] ?? ''), '/') ?: \Std\Lib\Fedi\Fedi::base(); foreach ($to as $user => $type) { $user = (string)$user; if ($user === ($by['user'] ?? null)) continue; // 止めた人(メンションと担当は届ける)、無視している人、読めない人には送らない if (($issue['subscribers'][$user] ?? null) === false && !in_array($type, ['mention', 'assigned'], true)) continue; if (self::watchState($owner, $name, $user, $meta) === self::IGNORING) continue; $u = Gitler::user($user); if ($u === null || Gitler::access($u, $owner, $name) < Gitler::READ) continue; $label = match ($type) { 'opened' => "が{$repoName}に{$kind}#{$n}「{$issue['title']}」を作りました", 'merged' => "が{$title}をマージしました", 'approved' => "が{$title}を承認しました", 'changes' => "が{$title}に変更を依頼しました", 'commented' => "が{$title}をレビューしました", 'comment' => "が{$title}にコメントしました", 'mention' => "が{$title}であなたをメンションしました", 'closed' => "が{$title}を閉じました", 'closedByCommit' => "のコミット{$commit}が{$title}を閉じました", 'reopened' => "が{$title}を開き直しました", 'assigned' => "が{$title}の担当者にあなたを追加しました", default => '', }; $record = ['type' => 'gitler', 'from' => $from, 'label' => $label, 'page' => $path, 'excerpt' => mb_strimwidth((string)preg_replace('/\s+/u', ' ', $excerpt), 0, 160, '…')]; $mail = null; if (($u->email ?? '') !== '' && Keys::load($user)['email']) { $mail = ['to' => (string)$u->email, 'name' => $user, 'subject' => "[{$repoName}] {$issue['title']} (#{$n})", 'body' => $fromName.$label."\n\n".($excerpt !== '' ? mb_strimwidth($excerpt, 0, 4000, '…')."\n\n" : '').$base.$path."\n\n--\n" ."このリポジトリの通知(ウォッチ):{$base}/gitler/{$owner}/".rawurlencode($name)."\nメールの通知を止める:{$base}/gitler/-/keys\n"]; } if (Gitler::$deferred) { Gitler::defer($owner, $name, ['kind' => 'notify', 'user' => $user, 'record' => $record, 'mail' => $mail]); } else { self::deliver($user, $record, $mail); } } } /** 本文でメンションされた、リポジトリを読めるユーザー(通知を受け取る様にする) */ private static function mentioned(string $owner, string $name, Issues $db, int $n, string $body, bool $html): array { $out = []; foreach (self::mentions($html ? Html::text($body) : $body) as $u) { $user = Gitler::user($u); if ($user !== null && Gitler::access($user, $owner, $name) >= Gitler::READ) $out[] = (string)$user->username; } if (!empty($out)) $db->subscribe($n, $out, true); return $out; } /** イシューの通知を受け取る人 */ private static function subscribed(array $issue): array { return array_map('strval', array_keys(array_filter($issue['subscribers'] ?? [], fn($on) => $on === true))); } private static function watchList(string $owner, string $name): array { $j = json_decode((string)@file_get_contents(Gitler::path($owner, $name).'/gitler/watch.json'), true); return is_array($j) ? $j : []; } }