| 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\Git\GitException; |
| 41 | use Std\Lib\Git\Repository; |
| 42 | use Std\Lib\Git\Walk; |
| 43 | use Std\Lib\Markdown; |
| 44 | use Result; |
| 45 | |
| 46 | /** |
| 47 | * Gitlerのイシュー(リポジトリの中のgitler/) |
| 48 | * |
| 49 | * issues.json 索引(次の番号、イシュー毎の概要、リモートのID => [番号, コメント]) |
| 50 | * issues/{番号}.json イシュー(本文、コメント、出来事、通知を受け取るユーザー(subscribers:ユーザー名 => bool)) |
| 51 | * labels.json、milestones.json |
| 52 | * |
| 53 | * 書く者:local({user})、remote({actor, name, handle, icon, url}、Fediverse)、commit({commit, name})。 |
| 54 | * 本文はMarkdown(htmlがtrueならリモートのHTML、受け取った時に安全にした物)。 |
| 55 | * プルリクエストも同じ番号のイシュー(pull:Pullsを参照)。インラインのコメントはpath・side・line・commit等を持ち、 |
| 56 | * レビュー(reviews:id、author、state(commented、approved、changes)、body、at、commit)に属する。 |
| 57 | */ |
| 58 | final class Issues { |
| 59 | public const MAX_TITLE = 200; |
| 60 | public const MAX_BODY = 65536; |
| 61 | /** プッシュで見るコミットの最大数 */ |
| 62 | private const MAX_PUSH_COMMITS = 500; |
| 63 | /** 「fixes #3」等(英語のGitHubと同じ言葉) */ |
| 64 | private const CLOSE_RE = '/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/i'; |
| 65 | |
| 66 | private string $dir; |
| 67 | /** ロックの深さ(同じプロセスで2回ロックしない) */ |
| 68 | private int $depth = 0; |
| 69 | |
| 70 | public function __construct(public readonly Repository $repo) { |
| 71 | $this->dir = $repo->path.'/gitler'; |
| 72 | } |
| 73 | |
| 74 | public static function open(string $owner, string $name): self { |
| 75 | return new self(Gitler::open($owner, $name)); |
| 76 | } |
| 77 | |
| 78 | //////// 読む |
| 79 | |
| 80 | /** 索引(next、issues:番号 => 概要、remote:リモートのID => [番号, コメントのID]) */ |
| 81 | public function index(): array { |
| 82 | $j = json_decode((string)@file_get_contents($this->dir.'/issues.json'), true); |
| 83 | $j = is_array($j) ? $j : []; |
| 84 | return ['next' => max(1, (int)($j['next'] ?? 1)), 'issues' => is_array($j['issues'] ?? null) ? $j['issues'] : [], 'remote' => is_array($j['remote'] ?? null) ? $j['remote'] : []]; |
| 85 | } |
| 86 | |
| 87 | /** 開いている・閉じたイシューの数 */ |
| 88 | /** 開いている・閉じた数($pullならプルリクエスト) */ |
| 89 | public function counts(bool $pull = false): array { |
| 90 | $c = ['open' => 0, 'closed' => 0]; |
| 91 | foreach ($this->index()['issues'] as $s) if (!empty($s['pull']) === $pull) $c[$s['state'] === 'closed' ? 'closed' : 'open']++; |
| 92 | return $c; |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * 探す |
| 97 | * |
| 98 | * @param array $f state(open、closed、all)、label(ID)、milestone(ID、0は無し)、assignee、author、q、sort(created、updated、comments)、 |
| 99 | * pull(trueならプルリクエストだけ、falseならイシューだけ、既定はfalse、nullなら両方) |
| 100 | * @return array 概要の配列(新しい順) |
| 101 | */ |
| 102 | public function search(array $f = []): array { |
| 103 | $state = $f['state'] ?? 'open'; |
| 104 | $q = mb_strtolower(trim((string)($f['q'] ?? ''))); |
| 105 | $out = []; |
| 106 | $pull = array_key_exists('pull', $f) ? $f['pull'] : false; |
| 107 | foreach ($this->index()['issues'] as $s) { |
| 108 | if ($pull !== null && !empty($s['pull']) !== $pull) continue; |
| 109 | if ($state !== 'all' && $s['state'] !== $state) continue; |
| 110 | if (isset($f['label']) && !in_array((int)$f['label'], $s['labels'], true)) continue; |
| 111 | if (isset($f['milestone']) && (int)($s['milestone'] ?? 0) !== (int)$f['milestone']) continue; |
| 112 | if (isset($f['assignee']) && !in_array($f['assignee'], $s['assignees'], true)) continue; |
| 113 | if (isset($f['author']) && $s['authorKey'] !== $f['author']) continue; |
| 114 | if ($q !== '' && !str_contains(mb_strtolower($s['title']), $q) && ltrim($q, '#') !== (string)$s['number']) continue; |
| 115 | $out[] = $s; |
| 116 | } |
| 117 | $key = match ($f['sort'] ?? 'created') { 'updated' => 'updated', 'comments' => 'comments', default => 'number' }; |
| 118 | usort($out, fn($a, $b) => $b[$key] <=> $a[$key] ?: $b['number'] <=> $a['number']); |
| 119 | return $out; |
| 120 | } |
| 121 | |
| 122 | public function get(int $n): ?array { |
| 123 | if ($n < 1) return null; |
| 124 | $j = json_decode((string)@file_get_contents($this->file($n)), true); |
| 125 | return is_array($j) && ($j['number'] ?? 0) === $n ? $j : null; |
| 126 | } |
| 127 | |
| 128 | /** リモートのIDのイシューとコメント([番号, コメントのID又はnull]) */ |
| 129 | public function findRemote(string $id): ?array { |
| 130 | $r = $this->index()['remote'][$id] ?? null; |
| 131 | return is_array($r) ? [(int)$r[0], $r[1] === null ? null : (int)$r[1]] : null; |
| 132 | } |
| 133 | |
| 134 | //////// 書く |
| 135 | |
| 136 | /** |
| 137 | * イシューを作る |
| 138 | * |
| 139 | * @param array $author 書いた者 |
| 140 | * @param array $opt html(本文がHTML)、remoteId、labels、assignees、milestone、pull(プルリクエストの情報) |
| 141 | */ |
| 142 | public function create(array $author, string $title, string $body, array $opt = []): Result { |
| 143 | $title = self::cleanTitle($title); |
| 144 | if ($title === '') return Result::Error('題名を入力して下さい。'); |
| 145 | if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。'); |
| 146 | return $this->locked(function() use ($author, $title, $body, $opt): Result { |
| 147 | $idx = $this->index(); |
| 148 | if (isset($opt['remoteId']) && isset($idx['remote'][$opt['remoteId']])) return Result::Error('既に有ります。', ['number' => (int)$idx['remote'][$opt['remoteId']][0]]); |
| 149 | $n = $idx['next']; |
| 150 | $now = time(); |
| 151 | $issue = [ |
| 152 | 'number' => $n, 'title' => $title, 'body' => $body, 'html' => !empty($opt['html']), 'author' => $author, 'state' => 'open', |
| 153 | 'created' => $now, 'updated' => $now, 'closed' => null, 'labels' => $this->validLabels($opt['labels'] ?? []), |
| 154 | 'assignees' => array_values(array_unique(array_map('strval', $opt['assignees'] ?? []))), 'milestone' => $this->validMilestone($opt['milestone'] ?? null), |
| 155 | 'remoteId' => $opt['remoteId'] ?? null, 'comments' => [], 'events' => [], 'seq' => 1, 'subscribers' => [], |
| 156 | ]; |
| 157 | if (isset($opt['pull'])) $issue += ['pull' => $opt['pull'], 'reviews' => []]; |
| 158 | // 作った人と担当者は通知を受け取る |
| 159 | foreach (array_merge(isset($author['user']) ? [$author['user']] : [], $issue['assignees']) as $u) $issue['subscribers'][$u] = true; |
| 160 | $idx['next'] = $n + 1; |
| 161 | if ($issue['remoteId'] !== null) $idx['remote'][$issue['remoteId']] = [$n, null]; |
| 162 | $this->save($issue, $idx); |
| 163 | return Result::Success('イシューを作りました。', ['number' => $n, 'issue' => $issue]); |
| 164 | }); |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * イシューを変える($fnはイシューを受け取って新しいイシューを返す、nullなら何もしない) |
| 169 | * |
| 170 | * @return array|null 新しいイシュー |
| 171 | */ |
| 172 | public function update(int $n, callable $fn): ?array { |
| 173 | return $this->locked(function() use ($n, $fn): ?array { |
| 174 | $issue = $this->get($n); |
| 175 | if ($issue === null) return null; |
| 176 | $new = $fn($issue); |
| 177 | if (!is_array($new)) return null; |
| 178 | $new['updated'] = time(); |
| 179 | $idx = $this->index(); |
| 180 | // リモートのIDの索引を作り直す |
| 181 | foreach ($idx['remote'] as $id => [$num]) if ((int)$num === $n) unset($idx['remote'][$id]); |
| 182 | if (($new['remoteId'] ?? null) !== null) $idx['remote'][$new['remoteId']] = [$n, null]; |
| 183 | foreach ($new['comments'] as $c) if (($c['remoteId'] ?? null) !== null) $idx['remote'][$c['remoteId']] = [$n, $c['id']]; |
| 184 | $this->save($new, $idx); |
| 185 | return $new; |
| 186 | }); |
| 187 | } |
| 188 | |
| 189 | /** 題名と本文を変える */ |
| 190 | public function edit(int $n, array $by, string $title, string $body): Result { |
| 191 | $title = self::cleanTitle($title); |
| 192 | if ($title === '') return Result::Error('題名を入力して下さい。'); |
| 193 | if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。'); |
| 194 | $issue = $this->update($n, function(array $i) use ($by, $title, $body): array { |
| 195 | if ($i['title'] !== $title) $i['events'][] = ['type' => 'title', 'by' => $by, 'at' => time(), 'from' => $i['title'], 'to' => $title]; |
| 196 | $i['title'] = $title; |
| 197 | $i['body'] = $body; |
| 198 | $i['edited'] = time(); |
| 199 | return $i; |
| 200 | }); |
| 201 | return $issue ? Result::Success('保存しました。', ['issue' => $issue]) : Result::Error('イシューが有りません。'); |
| 202 | } |
| 203 | |
| 204 | /** |
| 205 | * コメントを追加する |
| 206 | * |
| 207 | * @param array $opt html、remoteId |
| 208 | */ |
| 209 | public function comment(int $n, array $author, string $body, array $opt = []): Result { |
| 210 | if (trim($body) === '') return Result::Error('コメントを入力して下さい。'); |
| 211 | if (strlen($body) > self::MAX_BODY) return Result::Error('コメントが長過ぎます。'); |
| 212 | $added = null; |
| 213 | $issue = $this->update($n, function(array $i) use ($author, $body, $opt, &$added): ?array { |
| 214 | if (isset($opt['remoteId'])) foreach ($i['comments'] as $c) if (($c['remoteId'] ?? null) === $opt['remoteId']) return null; |
| 215 | $added = ['id' => (int)$i['seq'], 'author' => $author, 'body' => $body, 'html' => !empty($opt['html']), 'created' => time(), 'updated' => time(), |
| 216 | 'remoteId' => $opt['remoteId'] ?? null]; |
| 217 | $i['seq'] = (int)$i['seq'] + 1; |
| 218 | $i['comments'][] = $added; |
| 219 | // コメントした人は通知を受け取る(止めていても) |
| 220 | if (isset($author['user'])) $i['subscribers'][$author['user']] = true; |
| 221 | return $i; |
| 222 | }); |
| 223 | if ($issue === null || $added === null) return Result::Error('コメント出来ません。'); |
| 224 | return Result::Success('コメントしました。', ['issue' => $issue, 'comment' => $added]); |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * プルリクエストの行へのコメント |
| 229 | * |
| 230 | * @param array $at path、side(old、new)、line、commit、hunk(表示用の前の行)、replyTo(スレッドの最初のコメント)、pending(レビューの下書き) |
| 231 | */ |
| 232 | public function inlineComment(int $n, array $author, string $body, array $at): Result { |
| 233 | if (trim($body) === '') return Result::Error('コメントを入力して下さい。'); |
| 234 | if (strlen($body) > self::MAX_BODY) return Result::Error('コメントが長過ぎます。'); |
| 235 | $added = null; |
| 236 | $issue = $this->update($n, function(array $i) use ($author, $body, $at, &$added): ?array { |
| 237 | if (!isset($i['pull'])) return null; |
| 238 | $root = null; |
| 239 | if (!empty($at['replyTo'])) { |
| 240 | foreach ($i['comments'] as $c) if ($c['id'] === (int)$at['replyTo'] && isset($c['path'])) $root = $c; |
| 241 | if ($root === null) return null; |
| 242 | } |
| 243 | $added = ['id' => (int)$i['seq'], 'author' => $author, 'body' => $body, 'html' => false, 'created' => time(), 'updated' => time(), 'remoteId' => null, |
| 244 | 'path' => (string)($root['path'] ?? $at['path']), 'side' => ($root['side'] ?? $at['side']) === 'old' ? 'old' : 'new', 'line' => (int)($root['line'] ?? $at['line']), |
| 245 | 'commit' => (string)($root['commit'] ?? $at['commit']), 'hunk' => $root !== null ? [] : array_values(array_map('strval', (array)($at['hunk'] ?? []))), |
| 246 | 'replyTo' => $root !== null ? $root['id'] : null, 'review' => null, 'pending' => !empty($at['pending'])]; |
| 247 | $i['seq'] = (int)$i['seq'] + 1; |
| 248 | $i['comments'][] = $added; |
| 249 | if (isset($author['user'])) $i['subscribers'][$author['user']] = true; |
| 250 | return $i; |
| 251 | }); |
| 252 | if ($issue === null || $added === null) return Result::Error('コメント出来ません。'); |
| 253 | return Result::Success('コメントしました。', ['issue' => $issue, 'comment' => $added]); |
| 254 | } |
| 255 | |
| 256 | /** |
| 257 | * レビューを出す(その人の下書きのコメントをレビューに入れる) |
| 258 | * |
| 259 | * @param string $state commented、approved、changes |
| 260 | */ |
| 261 | public function submitReview(int $n, array $author, string $state, string $body, string $commit): Result { |
| 262 | if (!in_array($state, ['commented', 'approved', 'changes'], true)) return Result::Error('不正なレビューです。'); |
| 263 | if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。'); |
| 264 | $review = null; |
| 265 | $key = self::authorKey($author); |
| 266 | $issue = $this->update($n, function(array $i) use ($author, $state, $body, $commit, $key, &$review): ?array { |
| 267 | if (!isset($i['pull'])) return null; |
| 268 | $pending = array_filter($i['comments'], fn($c) => !empty($c['pending']) && self::authorKey($c['author']) === $key); |
| 269 | if ($state === 'commented' && trim($body) === '' && empty($pending)) return null; |
| 270 | $id = (int)($i['rseq'] ?? 1); |
| 271 | $review = ['id' => $id, 'author' => $author, 'state' => $state, 'body' => $body, 'at' => time(), 'commit' => $commit]; |
| 272 | $i['rseq'] = $id + 1; |
| 273 | $i['reviews'][] = $review; |
| 274 | foreach ($i['comments'] as $k => $c) { |
| 275 | if (!empty($c['pending']) && self::authorKey($c['author']) === $key) { |
| 276 | $i['comments'][$k]['pending'] = false; |
| 277 | $i['comments'][$k]['review'] = $id; |
| 278 | $i['comments'][$k]['created'] = time(); |
| 279 | } |
| 280 | } |
| 281 | if (isset($author['user'])) $i['subscribers'][$author['user']] = true; |
| 282 | return $i; |
| 283 | }); |
| 284 | if ($issue === null || $review === null) return Result::Error('コメントを入力して下さい。'); |
| 285 | return Result::Success('レビューを出しました。', ['issue' => $issue, 'review' => $review]); |
| 286 | } |
| 287 | |
| 288 | /** コメントを変える(nullなら削除) */ |
| 289 | public function editComment(int $n, int $id, ?string $body): ?array { |
| 290 | if ($body !== null && (trim($body) === '' || strlen($body) > self::MAX_BODY)) return null; |
| 291 | $found = null; |
| 292 | $this->update($n, function(array $i) use ($id, $body, &$found): ?array { |
| 293 | foreach ($i['comments'] as $k => $c) { |
| 294 | if ($c['id'] !== $id) continue; |
| 295 | $found = $c; |
| 296 | if ($body === null) { |
| 297 | array_splice($i['comments'], $k, 1); |
| 298 | } else { |
| 299 | $i['comments'][$k]['body'] = $body; |
| 300 | $i['comments'][$k]['updated'] = time(); |
| 301 | $found = $i['comments'][$k]; |
| 302 | } |
| 303 | return $i; |
| 304 | } |
| 305 | return null; |
| 306 | }); |
| 307 | return $found; |
| 308 | } |
| 309 | |
| 310 | /** 閉じる・開く(変わらなければnull) */ |
| 311 | public function setState(int $n, bool $open, array $by, array $extra = []): ?array { |
| 312 | return $this->update($n, function(array $i) use ($open, $by, $extra): ?array { |
| 313 | if (($i['state'] === 'open') === $open) return null; |
| 314 | $i['state'] = $open ? 'open' : 'closed'; |
| 315 | $i['closed'] = $open ? null : time(); |
| 316 | $i['events'][] = ['type' => $open ? 'reopened' : 'closed', 'by' => $by, 'at' => time()] + $extra; |
| 317 | return $i; |
| 318 | }); |
| 319 | } |
| 320 | |
| 321 | /** ラベル・担当者・マイルストーン(書き込み権限の有る人) */ |
| 322 | public function triage(int $n, array $by, ?array $labels, ?array $assignees, int|false|null $milestone): ?array { |
| 323 | return $this->update($n, function(array $i) use ($by, $labels, $assignees, $milestone): array { |
| 324 | $at = time(); |
| 325 | if ($labels !== null) { |
| 326 | $labels = $this->validLabels($labels); |
| 327 | $add = array_values(array_diff($labels, $i['labels'])); |
| 328 | $remove = array_values(array_diff($i['labels'], $labels)); |
| 329 | if ($add || $remove) $i['events'][] = ['type' => 'labels', 'by' => $by, 'at' => $at, 'add' => $add, 'remove' => $remove]; |
| 330 | $i['labels'] = $labels; |
| 331 | } |
| 332 | if ($assignees !== null) { |
| 333 | $assignees = array_values(array_unique(array_map('strval', $assignees))); |
| 334 | $add = array_values(array_diff($assignees, $i['assignees'])); |
| 335 | $remove = array_values(array_diff($i['assignees'], $assignees)); |
| 336 | if ($add || $remove) $i['events'][] = ['type' => 'assignees', 'by' => $by, 'at' => $at, 'add' => $add, 'remove' => $remove]; |
| 337 | foreach ($add as $u) $i['subscribers'][$u] = true; |
| 338 | $i['assignees'] = $assignees; |
| 339 | } |
| 340 | if ($milestone !== false) { |
| 341 | $m = $this->validMilestone($milestone); |
| 342 | if ($m !== $i['milestone']) $i['events'][] = ['type' => 'milestone', 'by' => $by, 'at' => $at, 'from' => $i['milestone'], 'to' => $m]; |
| 343 | $i['milestone'] = $m; |
| 344 | } |
| 345 | return $i; |
| 346 | }); |
| 347 | } |
| 348 | |
| 349 | /** 通知を受け取る・止める(明示的に止めた人はtrueに戻るまで受け取らない) */ |
| 350 | public function subscribe(int $n, array $users, bool $on): ?array { |
| 351 | return $this->update($n, function(array $i) use ($users, $on): array { |
| 352 | foreach ($users as $u) $i['subscribers'][(string)$u] = $on; |
| 353 | return $i; |
| 354 | }); |
| 355 | } |
| 356 | |
| 357 | /** イシューを消す(番号は使い回さない) */ |
| 358 | public function delete(int $n): bool { |
| 359 | return $this->locked(function() use ($n): bool { |
| 360 | if ($this->get($n) === null) return false; |
| 361 | $idx = $this->index(); |
| 362 | unset($idx['issues'][(string)$n]); |
| 363 | foreach ($idx['remote'] as $id => [$num]) if ((int)$num === $n) unset($idx['remote'][$id]); |
| 364 | $this->writeJson($this->dir.'/issues.json', $idx); |
| 365 | @unlink($this->file($n)); |
| 366 | return true; |
| 367 | }); |
| 368 | } |
| 369 | |
| 370 | //////// プッシュ |
| 371 | |
| 372 | /** |
| 373 | * プッシュしたコミットのメッセージの「#3」をイシューに記録し、既定のブランチなら「fixes #3」等で閉じる |
| 374 | * |
| 375 | * @param array $updates [参照, 前, 後]の配列 |
| 376 | * @param string $default 既定のブランチ(refs/heads/…) |
| 377 | * @return array 閉じたイシューの番号 |
| 378 | */ |
| 379 | public function processPush(array $updates, string $default): array { |
| 380 | $zero = $this->repo->zeroId(); |
| 381 | $closed = []; |
| 382 | if (empty($this->index()['issues'])) return []; |
| 383 | foreach ($updates as [$ref, $old, $new]) { |
| 384 | if (!str_starts_with($ref, 'refs/heads/') || $new === $zero) continue; |
| 385 | // 新しいブランチは他のブランチに無いコミットだけ |
| 386 | $haves = $old !== $zero ? [$old] : array_values(array_filter($this->repo->refs->all('refs/heads/'), fn($id, $r) => $r !== $ref, ARRAY_FILTER_USE_BOTH)); |
| 387 | try { |
| 388 | [$ids] = (new Walk($this->repo))->commitsBetween([$new], $haves); |
| 389 | } catch (GitException) { |
| 390 | continue; |
| 391 | } |
| 392 | // 古い順に |
| 393 | foreach (array_reverse(array_slice($ids, 0, self::MAX_PUSH_COMMITS)) as $id) { |
| 394 | $c = $this->repo->commit($id); |
| 395 | if (!preg_match_all('/(?<![\w&\/])#(\d+)\b/', $c->message, $m)) continue; |
| 396 | preg_match_all(self::CLOSE_RE, $c->message, $cm); |
| 397 | $closes = array_map('intval', $cm[1]); |
| 398 | $by = ['commit' => $id, 'name' => $c->author?->name ?? '']; |
| 399 | foreach (array_unique(array_map('intval', $m[1])) as $n) { |
| 400 | $close = $ref === $default && in_array($n, $closes, true) && empty($this->index()['issues'][$n]['pull']); |
| 401 | $didClose = false; |
| 402 | $this->update($n, function(array $i) use ($id, $c, $by, $close, $ref, &$didClose): ?array { |
| 403 | // マージしたプルリクエスト自身のコミットは記録しない |
| 404 | $seen = ($i['pull']['merged']['commit'] ?? '') === $id; |
| 405 | foreach ($i['events'] as $e) if ($e['type'] === 'commit' && $e['commit'] === $id) $seen = true; |
| 406 | if (!$seen) $i['events'][] = ['type' => 'commit', 'by' => $by, 'at' => time(), 'commit' => $id, 'subject' => $c->subject(), 'branch' => substr($ref, 11)]; |
| 407 | $didClose = $close && $i['state'] === 'open'; |
| 408 | if ($didClose) { |
| 409 | $i['state'] = 'closed'; |
| 410 | $i['closed'] = time(); |
| 411 | $i['events'][] = ['type' => 'closed', 'by' => $by, 'at' => time(), 'commit' => $id]; |
| 412 | } |
| 413 | return $seen && !$didClose ? null : $i; |
| 414 | }); |
| 415 | if ($didClose) $closed[$n] = true; |
| 416 | } |
| 417 | } |
| 418 | } |
| 419 | return array_keys($closed); |
| 420 | } |
| 421 | |
| 422 | //////// ラベルとマイルストーン |
| 423 | |
| 424 | /** ラベル(ID => name、color、description) */ |
| 425 | public function labels(): array { |
| 426 | return $this->readList('labels.json', 'labels'); |
| 427 | } |
| 428 | |
| 429 | public function saveLabel(?int $id, string $name, string $color, string $description): Result { |
| 430 | $name = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $name)); |
| 431 | if ($name === '' || mb_strlen($name) > 50) return Result::Error('ラベルの名前は1〜50文字です。'); |
| 432 | if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) return Result::Error('色が不正です。'); |
| 433 | return $this->locked(function() use ($id, $name, $color, $description): Result { |
| 434 | $l = $this->readList('labels.json', 'labels', true); |
| 435 | foreach ($l['labels'] as $k => $x) if ((int)$k !== $id && mb_strtolower($x['name']) === mb_strtolower($name)) return Result::Error("ラベル「{$name}」は既に有ります。"); |
| 436 | if ($id !== null && !isset($l['labels'][$id])) return Result::Error('ラベルが有りません。'); |
| 437 | $id ??= $l['next']++; |
| 438 | $l['labels'][$id] = ['name' => $name, 'color' => strtolower($color), 'description' => mb_substr(trim($description), 0, 200)]; |
| 439 | $this->writeJson($this->dir.'/labels.json', $l); |
| 440 | return Result::Success('保存しました。', ['id' => $id]); |
| 441 | }); |
| 442 | } |
| 443 | |
| 444 | public function deleteLabel(int $id): void { |
| 445 | $this->locked(function() use ($id): void { |
| 446 | $l = $this->readList('labels.json', 'labels', true); |
| 447 | unset($l['labels'][$id]); |
| 448 | $this->writeJson($this->dir.'/labels.json', $l); |
| 449 | }); |
| 450 | foreach ($this->search(['state' => 'all', 'label' => $id]) as $s) { |
| 451 | $this->update($s['number'], function(array $i) use ($id): array { |
| 452 | $i['labels'] = array_values(array_diff($i['labels'], [$id])); |
| 453 | return $i; |
| 454 | }); |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | /** マイルストーン(ID => title、description、due(Y-m-d又は空)、state) */ |
| 459 | public function milestones(): array { |
| 460 | return $this->readList('milestones.json', 'milestones'); |
| 461 | } |
| 462 | |
| 463 | public function saveMilestone(?int $id, string $title, string $description, string $due, string $state = 'open'): Result { |
| 464 | $title = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $title)); |
| 465 | if ($title === '' || mb_strlen($title) > 100) return Result::Error('マイルストーンの名前は1〜100文字です。'); |
| 466 | if ($due !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $due) || !checkdate((int)substr($due, 5, 2), (int)substr($due, 8, 2), (int)substr($due, 0, 4)))) return Result::Error('期日が不正です。'); |
| 467 | return $this->locked(function() use ($id, $title, $description, $due, $state): Result { |
| 468 | $l = $this->readList('milestones.json', 'milestones', true); |
| 469 | if ($id !== null && !isset($l['milestones'][$id])) return Result::Error('マイルストーンが有りません。'); |
| 470 | $id ??= $l['next']++; |
| 471 | $l['milestones'][$id] = ['title' => $title, 'description' => mb_substr(trim($description), 0, 2000), 'due' => $due, 'state' => $state === 'closed' ? 'closed' : 'open']; |
| 472 | $this->writeJson($this->dir.'/milestones.json', $l); |
| 473 | return Result::Success('保存しました。', ['id' => $id]); |
| 474 | }); |
| 475 | } |
| 476 | |
| 477 | public function deleteMilestone(int $id): void { |
| 478 | $this->locked(function() use ($id): void { |
| 479 | $l = $this->readList('milestones.json', 'milestones', true); |
| 480 | unset($l['milestones'][$id]); |
| 481 | $this->writeJson($this->dir.'/milestones.json', $l); |
| 482 | }); |
| 483 | foreach ($this->search(['state' => 'all', 'milestone' => $id]) as $s) { |
| 484 | $this->update($s['number'], function(array $i): array { |
| 485 | $i['milestone'] = null; |
| 486 | return $i; |
| 487 | }); |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | //////// 表示 |
| 492 | |
| 493 | /** |
| 494 | * MarkdownをHTMLにする(安全なリンクだけ、「#3」をイシューへのリンクに) |
| 495 | * |
| 496 | * @param string $issuesUrl イシューの一覧のURL(…/issues) |
| 497 | */ |
| 498 | public static function render(string $text, string $issuesUrl): string { |
| 499 | $html = Markdown::fromString($text)->parse(); |
| 500 | $html = (string)preg_replace_callback('/\s(href|src)="([^"]*)"/i', function(array $m): string { |
| 501 | $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); |
| 502 | return preg_match('~^(https?:|mailto:|#|/(?!/))~i', $url) ? " {$m[1]}=\"{$m[2]}\"" : " {$m[1]}=\"#\""; |
| 503 | }, $html); |
| 504 | return self::linkify($html, $issuesUrl); |
| 505 | } |
| 506 | |
| 507 | /** HTMLの文字の部分(a、code、preの外)の「#3」、「@ユーザー」(居れば)とURLをリンクにする */ |
| 508 | public static function linkify(string $html, string $issuesUrl): string { |
| 509 | $parts = preg_split('/(<[^>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE); |
| 510 | $depth = 0; |
| 511 | foreach ($parts as $k => $p) { |
| 512 | if ($p === '') continue; |
| 513 | if ($p[0] === '<') { |
| 514 | if (preg_match('~^<(/?)(a|code|pre)\b~i', $p, $m)) $depth = max(0, $depth + ($m[1] === '/' ? -1 : 1)); |
| 515 | continue; |
| 516 | } |
| 517 | if ($depth > 0) continue; |
| 518 | $parts[$k] = (string)preg_replace_callback('~(https?://[^\s<>"]+[^\s<>".,;:!?)\]」。、])|(?<![\w&#/])#(\d+)\b|(?<![\w@/&.])@([A-Za-z0-9]+)(?![\w@]|\.\w)~u', function(array $m) use ($issuesUrl): string { |
| 519 | if (($m[3] ?? '') !== '') return Gitler::user($m[3]) !== null ? '<a href="/gitler/'.$m[3].'">@'.$m[3].'</a>' : $m[0]; |
| 520 | if (($m[2] ?? '') !== '') return '<a href="'.htmlspecialchars($issuesUrl, ENT_QUOTES, 'UTF-8').'/'.$m[2].'">#'.$m[2].'</a>'; |
| 521 | return '<a href="'.$m[1].'" rel="nofollow noopener noreferrer">'.$m[1].'</a>'; |
| 522 | }, $p); |
| 523 | } |
| 524 | return implode('', $parts); |
| 525 | } |
| 526 | |
| 527 | /** 書いた者の比較用のキー(ローカルはユーザー名、リモートはアクターのURL) */ |
| 528 | public static function authorKey(array $a): string { |
| 529 | return (string)($a['user'] ?? $a['actor'] ?? ('commit:'.($a['commit'] ?? ''))); |
| 530 | } |
| 531 | |
| 532 | //////// 内部 |
| 533 | |
| 534 | private function file(int $n): string { |
| 535 | return $this->dir.'/issues/'.$n.'.json'; |
| 536 | } |
| 537 | |
| 538 | private static function cleanTitle(string $t): string { |
| 539 | return mb_substr(trim(preg_replace('/[\x00-\x1f\x7f]+/', ' ', $t)), 0, self::MAX_TITLE); |
| 540 | } |
| 541 | |
| 542 | private function validLabels(array $ids): array { |
| 543 | $all = $this->labels(); |
| 544 | return array_values(array_unique(array_filter(array_map('intval', $ids), fn($id) => isset($all[$id])))); |
| 545 | } |
| 546 | |
| 547 | private function validMilestone(mixed $id): ?int { |
| 548 | $id = (int)$id; |
| 549 | return $id > 0 && isset($this->milestones()[$id]) ? $id : null; |
| 550 | } |
| 551 | |
| 552 | /** ID => 物($withNextなら[next, 名前 => ID => 物]) */ |
| 553 | private function readList(string $file, string $key, bool $withNext = false): array { |
| 554 | $j = json_decode((string)@file_get_contents($this->dir.'/'.$file), true); |
| 555 | $list = []; |
| 556 | foreach ((array)($j[$key] ?? []) as $id => $x) if (is_array($x)) $list[(int)$id] = $x; |
| 557 | return $withNext ? ['next' => max(1, (int)($j['next'] ?? 1), ...array_map(fn($id) => $id + 1, array_keys($list))), $key => $list] : $list; |
| 558 | } |
| 559 | |
| 560 | /** イシューと索引を書く */ |
| 561 | private function save(array $issue, array $idx): void { |
| 562 | $last = $issue['created']; |
| 563 | foreach ($issue['comments'] as $c) $last = max($last, $c['created']); |
| 564 | $idx['issues'][(string)$issue['number']] = [ |
| 565 | 'number' => $issue['number'], 'title' => $issue['title'], 'state' => $issue['state'], 'author' => $issue['author'], 'authorKey' => self::authorKey($issue['author']), |
| 566 | 'created' => $issue['created'], 'updated' => $issue['updated'], 'closed' => $issue['closed'], 'labels' => $issue['labels'], 'assignees' => $issue['assignees'], |
| 567 | 'milestone' => $issue['milestone'], 'comments' => count(array_filter($issue['comments'], fn($c) => empty($c['pending']))), 'last' => $last, |
| 568 | 'pull' => isset($issue['pull']), 'merged' => !empty($issue['pull']['merged']), |
| 569 | ]; |
| 570 | $this->repo->mkdir($this->dir.'/issues'); |
| 571 | $this->writeJson($this->file($issue['number']), $issue); |
| 572 | $this->writeJson($this->dir.'/issues.json', $idx); |
| 573 | } |
| 574 | |
| 575 | private function writeJson(string $file, array $data): void { |
| 576 | $this->repo->mkdir(dirname($file)); |
| 577 | $this->repo->writeFile($file, (string)json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
| 578 | } |
| 579 | |
| 580 | /** gitler/の中を書く間はロックする(Webとgitユーザーの両方から書けるロックファイル) */ |
| 581 | private function locked(callable $fn): mixed { |
| 582 | if ($this->depth > 0) return $fn(); |
| 583 | $this->repo->mkdir($this->dir); |
| 584 | $file = $this->dir.'/issues.lock'; |
| 585 | $new = !is_file($file); |
| 586 | $lock = @fopen($file, 'c'); |
| 587 | if ($lock && $new) $this->repo->chmodFile($file); |
| 588 | if ($lock) flock($lock, LOCK_EX); |
| 589 | $this->depth++; |
| 590 | try { |
| 591 | return $fn(); |
| 592 | } finally { |
| 593 | $this->depth--; |
| 594 | if ($lock) { |
| 595 | flock($lock, LOCK_UN); |
| 596 | fclose($lock); |
| 597 | } |
| 598 | } |
| 599 | } |
| 600 | } |