master
ブランチ master
LittleBeast / src / Std / Lib / Gitler / Notify.php
12.9 KB、237行 生 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 Std\Lib\Gitler;
39
40use Std\Lib\Fedi\Html;
41use 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 */
53final 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}