| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, and/or sell copies of the Software, subject to the following conditions: |
| 10 | |
| 11 | 1. **Origin Attribution**: |
| 12 | - You must not misrepresent the origin of the Software; you must not claim |
| 13 | you created the original Software. |
| 14 | - If the Software is used in a product, you must either: |
| 15 | a. Provide clear attribution in the product's documentation, user interface, |
| 16 | or other visible areas, **OR** |
| 17 | b. Pay the original developers a fee they specify in writing. |
| 18 | 2. **Usage Restriction**: |
| 19 | - The Software, or any derivative works, dependencies, or libraries |
| 20 | incorporating it, must not be used for censorship or to suppress freedom of |
| 21 | speech, expression, or creativity. Prohibited uses include, but are not |
| 22 | limited to: |
| 23 | - Censorship of so-called "hate speech", visuals, non-mainstream opinions, |
| 24 | ideas, or objective reality. |
| 25 | - Tools or systems designed to restrict access to information or |
| 26 | artistic works. |
| 27 | 3. **Notice Preservation**: |
| 28 | - This license and the above copyright notice must remain intact in all copies |
| 29 | of the source code. |
| 30 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Gitler; |
| 39 | |
| 40 | use Std\Lib\Fedi\Html; |
| 41 | use Std\Lib\Notifications; |
| 42 | |
| 43 | /** |
| 44 | * Gitlerの通知(サイトの「通知」とメール) |
| 45 | * |
| 46 | * リポジトリのウォッチ(gitler/watch.json:ユーザー名 => watching、participating、ignoring): |
| 47 | * watching 新しいイシューと全部のイシューの動き(所有者と共同作業者の既定) |
| 48 | * participating 通知を受け取るイシューだけ(他の人の既定) |
| 49 | * ignoring 何も受け取らない |
| 50 | * イシュー毎の受け取り(subscribers):作った人、コメントした人、担当者、メンションされた人。止める事も出来る。 |
| 51 | * メールはKeysの設定(既定は受け取る)で、cronのnotifyが送る。自分の操作は通知しない。 |
| 52 | */ |
| 53 | final class Notify { |
| 54 | public const WATCHING = 'watching'; |
| 55 | public const PARTICIPATING = 'participating'; |
| 56 | public const IGNORING = 'ignoring'; |
| 57 | /** 「@ユーザー」(「@user@host」と「@所有者.名前」は除く) */ |
| 58 | public const MENTION_RE = '/(?<![\w@\/&.])@([A-Za-z0-9]+)(?![\w@]|\.\w)/'; |
| 59 | |
| 60 | //////// ウォッチ |
| 61 | |
| 62 | public static function watchState(string $owner, string $name, string $user, ?array $meta = null): string { |
| 63 | $w = self::watchList($owner, $name)[$user] ?? null; |
| 64 | if (in_array($w, [self::WATCHING, self::PARTICIPATING, self::IGNORING], true)) return $w; |
| 65 | $meta ??= Gitler::meta($owner, $name); |
| 66 | return $user === $owner || isset($meta['collaborators'][$user]) ? self::WATCHING : self::PARTICIPATING; |
| 67 | } |
| 68 | |
| 69 | public static function setWatch(string $owner, string $name, string $user, string $state): void { |
| 70 | if (!in_array($state, [self::WATCHING, self::PARTICIPATING, self::IGNORING], true)) return; |
| 71 | $repo = Gitler::open($owner, $name); |
| 72 | $file = $repo->path.'/gitler/watch.json'; |
| 73 | $repo->mkdir(dirname($file)); |
| 74 | $lock = @fopen($file.'.lock', 'c'); |
| 75 | if ($lock) flock($lock, LOCK_EX); |
| 76 | try { |
| 77 | $list = self::watchList($owner, $name); |
| 78 | $list[$user] = $state; |
| 79 | $repo->writeFile($file, (string)json_encode($list, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
| 80 | } finally { |
| 81 | if ($lock) { |
| 82 | flock($lock, LOCK_UN); |
| 83 | fclose($lock); |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | /** ウォッチしている人(既定の所有者と共同作業者を含む) */ |
| 89 | public static function watchers(string $owner, string $name, array $meta): array { |
| 90 | $out = []; |
| 91 | foreach (array_unique(array_merge([$owner], array_map('strval', array_keys($meta['collaborators'])), array_keys(self::watchList($owner, $name)))) as $u) { |
| 92 | if (self::watchState($owner, $name, (string)$u, $meta) === self::WATCHING) $out[] = (string)$u; |
| 93 | } |
| 94 | return $out; |
| 95 | } |
| 96 | |
| 97 | //////// イシュー |
| 98 | |
| 99 | /** Markdownの本文の「@ユーザー」(コードの中は除く) */ |
| 100 | public static function mentions(string $text): array { |
| 101 | $text = (string)preg_replace(['/^(```|~~~).*?^\1/ms', '/`[^`\n]*`/'], '', $text); |
| 102 | preg_match_all(self::MENTION_RE, $text, $m); |
| 103 | return array_values(array_unique($m[1])); |
| 104 | } |
| 105 | |
| 106 | public static function issueOpened(string $owner, string $name, array $meta, Issues $db, array $issue): void { |
| 107 | $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $issue['body'], !empty($issue['html'])); |
| 108 | $to = array_fill_keys(self::watchers($owner, $name, $meta), 'opened') + array_fill_keys($issue['assignees'], 'assigned'); |
| 109 | foreach ($mentioned as $u) $to[$u] = 'mention'; |
| 110 | self::send($owner, $name, $meta, $db->get($issue['number']) ?? $issue, $issue['author'], $to, $issue['body'], !empty($issue['html']), ''); |
| 111 | } |
| 112 | |
| 113 | public static function commented(string $owner, string $name, array $meta, Issues $db, array $issue, array $c): void { |
| 114 | $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $c['body'], !empty($c['html'])); |
| 115 | $issue = $db->get($issue['number']) ?? $issue; |
| 116 | $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), 'comment'); |
| 117 | foreach ($mentioned as $u) $to[$u] = 'mention'; |
| 118 | self::send($owner, $name, $meta, $issue, $c['author'], $to, $c['body'], !empty($c['html']), '#c'.$c['id']); |
| 119 | } |
| 120 | |
| 121 | /** 閉じた・開き直した($byはコミット等も) */ |
| 122 | public static function stateChanged(string $owner, string $name, array $meta, array $issue, array $by): void { |
| 123 | $type = $issue['state'] === 'closed' ? (isset($by['commit']) ? 'closedByCommit' : 'closed') : 'reopened'; |
| 124 | $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), $type); |
| 125 | self::send($owner, $name, $meta, $issue, $by, $to, '', false, ''); |
| 126 | } |
| 127 | |
| 128 | /** プルリクエストをマージした */ |
| 129 | public static function merged(string $owner, string $name, array $meta, array $issue, array $by): void { |
| 130 | $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), 'merged'); |
| 131 | self::send($owner, $name, $meta, $issue, $by, $to, '', false, ''); |
| 132 | } |
| 133 | |
| 134 | /** プルリクエストのレビュー(state:approved、changes、commented) */ |
| 135 | public static function reviewed(string $owner, string $name, array $meta, Issues $db, array $issue, array $review): void { |
| 136 | $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $review['body'], false); |
| 137 | $issue = $db->get($issue['number']) ?? $issue; |
| 138 | $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), $review['state']); |
| 139 | foreach ($mentioned as $u) $to[$u] = 'mention'; |
| 140 | self::send($owner, $name, $meta, $issue, $review['author'], $to, $review['body'], false, '#r'.$review['id']); |
| 141 | } |
| 142 | |
| 143 | /** 担当者に追加した */ |
| 144 | public static function assigned(string $owner, string $name, array $meta, array $issue, array $users, array $by): void { |
| 145 | self::send($owner, $name, $meta, $issue, $by, array_fill_keys($users, 'assigned'), '', false, ''); |
| 146 | } |
| 147 | |
| 148 | //////// 送る |
| 149 | |
| 150 | /** |
| 151 | * 通知を追加し、メールを待ち行列に入れる(SSHのプッシュではリポジトリに溜める) |
| 152 | * |
| 153 | * @param array|null $mail to、subject、body、name |
| 154 | */ |
| 155 | public static function deliver(string $user, array $record, ?array $mail): void { |
| 156 | if ($user === '' || empty($record)) return; |
| 157 | Notifications::add($user, $record); |
| 158 | if (is_array($mail)) Notifications::queueMail((string)$mail['to'], (string)$mail['subject'], (string)$mail['body'], (string)($mail['name'] ?? '')); |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * @param array $to ユーザー名 => 種類(opened、comment、mention、closed、closedByCommit、reopened、assigned、merged、 |
| 163 | * approved・changes・commented(レビュー)) |
| 164 | * @param string $anchor コメントの「#c5」等 |
| 165 | */ |
| 166 | private static function send(string $owner, string $name, array $meta, array $issue, array $by, array $to, string $body, bool $html, string $anchor): void { |
| 167 | if (empty($to) || $meta['archived']) return; |
| 168 | $n = $issue['number']; |
| 169 | $repoName = "{$owner}/{$name}"; |
| 170 | $title = "{$repoName}#{$n}「{$issue['title']}」"; |
| 171 | $commit = isset($by['commit']) ? substr((string)$by['commit'], 0, 7) : ''; |
| 172 | $kind = isset($issue['pull']) ? 'プルリクエスト' : 'イシュー'; |
| 173 | $path = '/gitler/'.$owner.'/'.rawurlencode($name).(isset($issue['pull']) ? '/pulls/' : '/issues/').$n.$anchor; |
| 174 | $excerpt = $body !== '' ? Html::text($html ? $body : Issues::render($body, '/gitler/'.$owner.'/'.rawurlencode($name).'/issues')) : ''; |
| 175 | // Markdownの整形の空白を除く |
| 176 | $excerpt = trim((string)preg_replace(['/[ \t]*\n[ \t]*/', '/\n{3,}/'], ["\n", "\n\n"], $excerpt)); |
| 177 | $from = isset($by['user']) ? ['username' => (string)$by['user']] : (isset($by['actor']) ? $by : ['name' => (string)($by['name'] ?? '')]); |
| 178 | $fromName = Federation::authorName($by); |
| 179 | $base = rtrim((string)(SITEINFO['url'] ?? ''), '/') ?: \Std\Lib\Fedi\Fedi::base(); |
| 180 | foreach ($to as $user => $type) { |
| 181 | $user = (string)$user; |
| 182 | if ($user === ($by['user'] ?? null)) continue; |
| 183 | // 止めた人(メンションと担当は届ける)、無視している人、読めない人には送らない |
| 184 | if (($issue['subscribers'][$user] ?? null) === false && !in_array($type, ['mention', 'assigned'], true)) continue; |
| 185 | if (self::watchState($owner, $name, $user, $meta) === self::IGNORING) continue; |
| 186 | $u = Gitler::user($user); |
| 187 | if ($u === null || Gitler::access($u, $owner, $name) < Gitler::READ) continue; |
| 188 | $label = match ($type) { |
| 189 | 'opened' => "が{$repoName}に{$kind}#{$n}「{$issue['title']}」を作りました", |
| 190 | 'merged' => "が{$title}をマージしました", |
| 191 | 'approved' => "が{$title}を承認しました", |
| 192 | 'changes' => "が{$title}に変更を依頼しました", |
| 193 | 'commented' => "が{$title}をレビューしました", |
| 194 | 'comment' => "が{$title}にコメントしました", |
| 195 | 'mention' => "が{$title}であなたをメンションしました", |
| 196 | 'closed' => "が{$title}を閉じました", |
| 197 | 'closedByCommit' => "のコミット{$commit}が{$title}を閉じました", |
| 198 | 'reopened' => "が{$title}を開き直しました", |
| 199 | 'assigned' => "が{$title}の担当者にあなたを追加しました", |
| 200 | default => '', |
| 201 | }; |
| 202 | $record = ['type' => 'gitler', 'from' => $from, 'label' => $label, 'page' => $path, 'excerpt' => mb_strimwidth((string)preg_replace('/\s+/u', ' ', $excerpt), 0, 160, '…')]; |
| 203 | $mail = null; |
| 204 | if (($u->email ?? '') !== '' && Keys::load($user)['email']) { |
| 205 | $mail = ['to' => (string)$u->email, 'name' => $user, 'subject' => "[{$repoName}] {$issue['title']} (#{$n})", |
| 206 | 'body' => $fromName.$label."\n\n".($excerpt !== '' ? mb_strimwidth($excerpt, 0, 4000, '…')."\n\n" : '').$base.$path."\n\n--\n" |
| 207 | ."このリポジトリの通知(ウォッチ):{$base}/gitler/{$owner}/".rawurlencode($name)."\nメールの通知を止める:{$base}/gitler/-/keys\n"]; |
| 208 | } |
| 209 | if (Gitler::$deferred) { |
| 210 | Gitler::defer($owner, $name, ['kind' => 'notify', 'user' => $user, 'record' => $record, 'mail' => $mail]); |
| 211 | } else { |
| 212 | self::deliver($user, $record, $mail); |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | /** 本文でメンションされた、リポジトリを読めるユーザー(通知を受け取る様にする) */ |
| 218 | private static function mentioned(string $owner, string $name, Issues $db, int $n, string $body, bool $html): array { |
| 219 | $out = []; |
| 220 | foreach (self::mentions($html ? Html::text($body) : $body) as $u) { |
| 221 | $user = Gitler::user($u); |
| 222 | if ($user !== null && Gitler::access($user, $owner, $name) >= Gitler::READ) $out[] = (string)$user->username; |
| 223 | } |
| 224 | if (!empty($out)) $db->subscribe($n, $out, true); |
| 225 | return $out; |
| 226 | } |
| 227 | |
| 228 | /** イシューの通知を受け取る人 */ |
| 229 | private static function subscribed(array $issue): array { |
| 230 | return array_map('strval', array_keys(array_filter($issue['subscribers'] ?? [], fn($on) => $on === true))); |
| 231 | } |
| 232 | |
| 233 | private static function watchList(string $owner, string $name): array { |
| 234 | $j = json_decode((string)@file_get_contents(Gitler::path($owner, $name).'/gitler/watch.json'), true); |
| 235 | return is_array($j) ? $j : []; |
| 236 | } |
| 237 | } |