[番号, コメント]) * issues/{番号}.json イシュー(本文、コメント、出来事、通知を受け取るユーザー(subscribers:ユーザー名 => bool)) * labels.json、milestones.json * * 書く者:local({user})、remote({actor, name, handle, icon, url}、Fediverse)、commit({commit, name})。 * 本文はMarkdown(htmlがtrueならリモートのHTML、受け取った時に安全にした物)。 * プルリクエストも同じ番号のイシュー(pull:Pullsを参照)。インラインのコメントはpath・side・line・commit等を持ち、 * レビュー(reviews:id、author、state(commented、approved、changes)、body、at、commit)に属する。 */ final class Issues { public const MAX_TITLE = 200; public const MAX_BODY = 65536; /** プッシュで見るコミットの最大数 */ private const MAX_PUSH_COMMITS = 500; /** 「fixes #3」等(英語のGitHubと同じ言葉) */ private const CLOSE_RE = '/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/i'; private string $dir; /** ロックの深さ(同じプロセスで2回ロックしない) */ private int $depth = 0; public function __construct(public readonly Repository $repo) { $this->dir = $repo->path.'/gitler'; } public static function open(string $owner, string $name): self { return new self(Gitler::open($owner, $name)); } //////// 読む /** 索引(next、issues:番号 => 概要、remote:リモートのID => [番号, コメントのID]) */ public function index(): array { $j = json_decode((string)@file_get_contents($this->dir.'/issues.json'), true); $j = is_array($j) ? $j : []; return ['next' => max(1, (int)($j['next'] ?? 1)), 'issues' => is_array($j['issues'] ?? null) ? $j['issues'] : [], 'remote' => is_array($j['remote'] ?? null) ? $j['remote'] : []]; } /** 開いている・閉じたイシューの数 */ /** 開いている・閉じた数($pullならプルリクエスト) */ public function counts(bool $pull = false): array { $c = ['open' => 0, 'closed' => 0]; foreach ($this->index()['issues'] as $s) if (!empty($s['pull']) === $pull) $c[$s['state'] === 'closed' ? 'closed' : 'open']++; return $c; } /** * 探す * * @param array $f state(open、closed、all)、label(ID)、milestone(ID、0は無し)、assignee、author、q、sort(created、updated、comments)、 * pull(trueならプルリクエストだけ、falseならイシューだけ、既定はfalse、nullなら両方) * @return array 概要の配列(新しい順) */ public function search(array $f = []): array { $state = $f['state'] ?? 'open'; $q = mb_strtolower(trim((string)($f['q'] ?? ''))); $out = []; $pull = array_key_exists('pull', $f) ? $f['pull'] : false; foreach ($this->index()['issues'] as $s) { if ($pull !== null && !empty($s['pull']) !== $pull) continue; if ($state !== 'all' && $s['state'] !== $state) continue; if (isset($f['label']) && !in_array((int)$f['label'], $s['labels'], true)) continue; if (isset($f['milestone']) && (int)($s['milestone'] ?? 0) !== (int)$f['milestone']) continue; if (isset($f['assignee']) && !in_array($f['assignee'], $s['assignees'], true)) continue; if (isset($f['author']) && $s['authorKey'] !== $f['author']) continue; if ($q !== '' && !str_contains(mb_strtolower($s['title']), $q) && ltrim($q, '#') !== (string)$s['number']) continue; $out[] = $s; } $key = match ($f['sort'] ?? 'created') { 'updated' => 'updated', 'comments' => 'comments', default => 'number' }; usort($out, fn($a, $b) => $b[$key] <=> $a[$key] ?: $b['number'] <=> $a['number']); return $out; } public function get(int $n): ?array { if ($n < 1) return null; $j = json_decode((string)@file_get_contents($this->file($n)), true); return is_array($j) && ($j['number'] ?? 0) === $n ? $j : null; } /** リモートのIDのイシューとコメント([番号, コメントのID又はnull]) */ public function findRemote(string $id): ?array { $r = $this->index()['remote'][$id] ?? null; return is_array($r) ? [(int)$r[0], $r[1] === null ? null : (int)$r[1]] : null; } //////// 書く /** * イシューを作る * * @param array $author 書いた者 * @param array $opt html(本文がHTML)、remoteId、labels、assignees、milestone、pull(プルリクエストの情報) */ public function create(array $author, string $title, string $body, array $opt = []): Result { $title = self::cleanTitle($title); if ($title === '') return Result::Error('題名を入力して下さい。'); if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。'); return $this->locked(function() use ($author, $title, $body, $opt): Result { $idx = $this->index(); if (isset($opt['remoteId']) && isset($idx['remote'][$opt['remoteId']])) return Result::Error('既に有ります。', ['number' => (int)$idx['remote'][$opt['remoteId']][0]]); $n = $idx['next']; $now = time(); $issue = [ 'number' => $n, 'title' => $title, 'body' => $body, 'html' => !empty($opt['html']), 'author' => $author, 'state' => 'open', 'created' => $now, 'updated' => $now, 'closed' => null, 'labels' => $this->validLabels($opt['labels'] ?? []), 'assignees' => array_values(array_unique(array_map('strval', $opt['assignees'] ?? []))), 'milestone' => $this->validMilestone($opt['milestone'] ?? null), 'remoteId' => $opt['remoteId'] ?? null, 'comments' => [], 'events' => [], 'seq' => 1, 'subscribers' => [], ]; if (isset($opt['pull'])) $issue += ['pull' => $opt['pull'], 'reviews' => []]; // 作った人と担当者は通知を受け取る foreach (array_merge(isset($author['user']) ? [$author['user']] : [], $issue['assignees']) as $u) $issue['subscribers'][$u] = true; $idx['next'] = $n + 1; if ($issue['remoteId'] !== null) $idx['remote'][$issue['remoteId']] = [$n, null]; $this->save($issue, $idx); return Result::Success('イシューを作りました。', ['number' => $n, 'issue' => $issue]); }); } /** * イシューを変える($fnはイシューを受け取って新しいイシューを返す、nullなら何もしない) * * @return array|null 新しいイシュー */ public function update(int $n, callable $fn): ?array { return $this->locked(function() use ($n, $fn): ?array { $issue = $this->get($n); if ($issue === null) return null; $new = $fn($issue); if (!is_array($new)) return null; $new['updated'] = time(); $idx = $this->index(); // リモートのIDの索引を作り直す foreach ($idx['remote'] as $id => [$num]) if ((int)$num === $n) unset($idx['remote'][$id]); if (($new['remoteId'] ?? null) !== null) $idx['remote'][$new['remoteId']] = [$n, null]; foreach ($new['comments'] as $c) if (($c['remoteId'] ?? null) !== null) $idx['remote'][$c['remoteId']] = [$n, $c['id']]; $this->save($new, $idx); return $new; }); } /** 題名と本文を変える */ public function edit(int $n, array $by, string $title, string $body): Result { $title = self::cleanTitle($title); if ($title === '') return Result::Error('題名を入力して下さい。'); if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。'); $issue = $this->update($n, function(array $i) use ($by, $title, $body): array { if ($i['title'] !== $title) $i['events'][] = ['type' => 'title', 'by' => $by, 'at' => time(), 'from' => $i['title'], 'to' => $title]; $i['title'] = $title; $i['body'] = $body; $i['edited'] = time(); return $i; }); return $issue ? Result::Success('保存しました。', ['issue' => $issue]) : Result::Error('イシューが有りません。'); } /** * コメントを追加する * * @param array $opt html、remoteId */ public function comment(int $n, array $author, string $body, array $opt = []): Result { if (trim($body) === '') return Result::Error('コメントを入力して下さい。'); if (strlen($body) > self::MAX_BODY) return Result::Error('コメントが長過ぎます。'); $added = null; $issue = $this->update($n, function(array $i) use ($author, $body, $opt, &$added): ?array { if (isset($opt['remoteId'])) foreach ($i['comments'] as $c) if (($c['remoteId'] ?? null) === $opt['remoteId']) return null; $added = ['id' => (int)$i['seq'], 'author' => $author, 'body' => $body, 'html' => !empty($opt['html']), 'created' => time(), 'updated' => time(), 'remoteId' => $opt['remoteId'] ?? null]; $i['seq'] = (int)$i['seq'] + 1; $i['comments'][] = $added; // コメントした人は通知を受け取る(止めていても) if (isset($author['user'])) $i['subscribers'][$author['user']] = true; return $i; }); if ($issue === null || $added === null) return Result::Error('コメント出来ません。'); return Result::Success('コメントしました。', ['issue' => $issue, 'comment' => $added]); } /** * プルリクエストの行へのコメント * * @param array $at path、side(old、new)、line、commit、hunk(表示用の前の行)、replyTo(スレッドの最初のコメント)、pending(レビューの下書き) */ public function inlineComment(int $n, array $author, string $body, array $at): Result { if (trim($body) === '') return Result::Error('コメントを入力して下さい。'); if (strlen($body) > self::MAX_BODY) return Result::Error('コメントが長過ぎます。'); $added = null; $issue = $this->update($n, function(array $i) use ($author, $body, $at, &$added): ?array { if (!isset($i['pull'])) return null; $root = null; if (!empty($at['replyTo'])) { foreach ($i['comments'] as $c) if ($c['id'] === (int)$at['replyTo'] && isset($c['path'])) $root = $c; if ($root === null) return null; } $added = ['id' => (int)$i['seq'], 'author' => $author, 'body' => $body, 'html' => false, 'created' => time(), 'updated' => time(), 'remoteId' => null, 'path' => (string)($root['path'] ?? $at['path']), 'side' => ($root['side'] ?? $at['side']) === 'old' ? 'old' : 'new', 'line' => (int)($root['line'] ?? $at['line']), 'commit' => (string)($root['commit'] ?? $at['commit']), 'hunk' => $root !== null ? [] : array_values(array_map('strval', (array)($at['hunk'] ?? []))), 'replyTo' => $root !== null ? $root['id'] : null, 'review' => null, 'pending' => !empty($at['pending'])]; $i['seq'] = (int)$i['seq'] + 1; $i['comments'][] = $added; if (isset($author['user'])) $i['subscribers'][$author['user']] = true; return $i; }); if ($issue === null || $added === null) return Result::Error('コメント出来ません。'); return Result::Success('コメントしました。', ['issue' => $issue, 'comment' => $added]); } /** * レビューを出す(その人の下書きのコメントをレビューに入れる) * * @param string $state commented、approved、changes */ public function submitReview(int $n, array $author, string $state, string $body, string $commit): Result { if (!in_array($state, ['commented', 'approved', 'changes'], true)) return Result::Error('不正なレビューです。'); if (strlen($body) > self::MAX_BODY) return Result::Error('本文が長過ぎます。'); $review = null; $key = self::authorKey($author); $issue = $this->update($n, function(array $i) use ($author, $state, $body, $commit, $key, &$review): ?array { if (!isset($i['pull'])) return null; $pending = array_filter($i['comments'], fn($c) => !empty($c['pending']) && self::authorKey($c['author']) === $key); if ($state === 'commented' && trim($body) === '' && empty($pending)) return null; $id = (int)($i['rseq'] ?? 1); $review = ['id' => $id, 'author' => $author, 'state' => $state, 'body' => $body, 'at' => time(), 'commit' => $commit]; $i['rseq'] = $id + 1; $i['reviews'][] = $review; foreach ($i['comments'] as $k => $c) { if (!empty($c['pending']) && self::authorKey($c['author']) === $key) { $i['comments'][$k]['pending'] = false; $i['comments'][$k]['review'] = $id; $i['comments'][$k]['created'] = time(); } } if (isset($author['user'])) $i['subscribers'][$author['user']] = true; return $i; }); if ($issue === null || $review === null) return Result::Error('コメントを入力して下さい。'); return Result::Success('レビューを出しました。', ['issue' => $issue, 'review' => $review]); } /** コメントを変える(nullなら削除) */ public function editComment(int $n, int $id, ?string $body): ?array { if ($body !== null && (trim($body) === '' || strlen($body) > self::MAX_BODY)) return null; $found = null; $this->update($n, function(array $i) use ($id, $body, &$found): ?array { foreach ($i['comments'] as $k => $c) { if ($c['id'] !== $id) continue; $found = $c; if ($body === null) { array_splice($i['comments'], $k, 1); } else { $i['comments'][$k]['body'] = $body; $i['comments'][$k]['updated'] = time(); $found = $i['comments'][$k]; } return $i; } return null; }); return $found; } /** 閉じる・開く(変わらなければnull) */ public function setState(int $n, bool $open, array $by, array $extra = []): ?array { return $this->update($n, function(array $i) use ($open, $by, $extra): ?array { if (($i['state'] === 'open') === $open) return null; $i['state'] = $open ? 'open' : 'closed'; $i['closed'] = $open ? null : time(); $i['events'][] = ['type' => $open ? 'reopened' : 'closed', 'by' => $by, 'at' => time()] + $extra; return $i; }); } /** ラベル・担当者・マイルストーン(書き込み権限の有る人) */ public function triage(int $n, array $by, ?array $labels, ?array $assignees, int|false|null $milestone): ?array { return $this->update($n, function(array $i) use ($by, $labels, $assignees, $milestone): array { $at = time(); if ($labels !== null) { $labels = $this->validLabels($labels); $add = array_values(array_diff($labels, $i['labels'])); $remove = array_values(array_diff($i['labels'], $labels)); if ($add || $remove) $i['events'][] = ['type' => 'labels', 'by' => $by, 'at' => $at, 'add' => $add, 'remove' => $remove]; $i['labels'] = $labels; } if ($assignees !== null) { $assignees = array_values(array_unique(array_map('strval', $assignees))); $add = array_values(array_diff($assignees, $i['assignees'])); $remove = array_values(array_diff($i['assignees'], $assignees)); if ($add || $remove) $i['events'][] = ['type' => 'assignees', 'by' => $by, 'at' => $at, 'add' => $add, 'remove' => $remove]; foreach ($add as $u) $i['subscribers'][$u] = true; $i['assignees'] = $assignees; } if ($milestone !== false) { $m = $this->validMilestone($milestone); if ($m !== $i['milestone']) $i['events'][] = ['type' => 'milestone', 'by' => $by, 'at' => $at, 'from' => $i['milestone'], 'to' => $m]; $i['milestone'] = $m; } return $i; }); } /** 通知を受け取る・止める(明示的に止めた人はtrueに戻るまで受け取らない) */ public function subscribe(int $n, array $users, bool $on): ?array { return $this->update($n, function(array $i) use ($users, $on): array { foreach ($users as $u) $i['subscribers'][(string)$u] = $on; return $i; }); } /** イシューを消す(番号は使い回さない) */ public function delete(int $n): bool { return $this->locked(function() use ($n): bool { if ($this->get($n) === null) return false; $idx = $this->index(); unset($idx['issues'][(string)$n]); foreach ($idx['remote'] as $id => [$num]) if ((int)$num === $n) unset($idx['remote'][$id]); $this->writeJson($this->dir.'/issues.json', $idx); @unlink($this->file($n)); return true; }); } //////// プッシュ /** * プッシュしたコミットのメッセージの「#3」をイシューに記録し、既定のブランチなら「fixes #3」等で閉じる * * @param array $updates [参照, 前, 後]の配列 * @param string $default 既定のブランチ(refs/heads/…) * @return array 閉じたイシューの番号 */ public function processPush(array $updates, string $default): array { $zero = $this->repo->zeroId(); $closed = []; if (empty($this->index()['issues'])) return []; foreach ($updates as [$ref, $old, $new]) { if (!str_starts_with($ref, 'refs/heads/') || $new === $zero) continue; // 新しいブランチは他のブランチに無いコミットだけ $haves = $old !== $zero ? [$old] : array_values(array_filter($this->repo->refs->all('refs/heads/'), fn($id, $r) => $r !== $ref, ARRAY_FILTER_USE_BOTH)); try { [$ids] = (new Walk($this->repo))->commitsBetween([$new], $haves); } catch (GitException) { continue; } // 古い順に foreach (array_reverse(array_slice($ids, 0, self::MAX_PUSH_COMMITS)) as $id) { $c = $this->repo->commit($id); if (!preg_match_all('/(?message, $m)) continue; preg_match_all(self::CLOSE_RE, $c->message, $cm); $closes = array_map('intval', $cm[1]); $by = ['commit' => $id, 'name' => $c->author?->name ?? '']; foreach (array_unique(array_map('intval', $m[1])) as $n) { $close = $ref === $default && in_array($n, $closes, true) && empty($this->index()['issues'][$n]['pull']); $didClose = false; $this->update($n, function(array $i) use ($id, $c, $by, $close, $ref, &$didClose): ?array { // マージしたプルリクエスト自身のコミットは記録しない $seen = ($i['pull']['merged']['commit'] ?? '') === $id; foreach ($i['events'] as $e) if ($e['type'] === 'commit' && $e['commit'] === $id) $seen = true; if (!$seen) $i['events'][] = ['type' => 'commit', 'by' => $by, 'at' => time(), 'commit' => $id, 'subject' => $c->subject(), 'branch' => substr($ref, 11)]; $didClose = $close && $i['state'] === 'open'; if ($didClose) { $i['state'] = 'closed'; $i['closed'] = time(); $i['events'][] = ['type' => 'closed', 'by' => $by, 'at' => time(), 'commit' => $id]; } return $seen && !$didClose ? null : $i; }); if ($didClose) $closed[$n] = true; } } } return array_keys($closed); } //////// ラベルとマイルストーン /** ラベル(ID => name、color、description) */ public function labels(): array { return $this->readList('labels.json', 'labels'); } public function saveLabel(?int $id, string $name, string $color, string $description): Result { $name = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $name)); if ($name === '' || mb_strlen($name) > 50) return Result::Error('ラベルの名前は1〜50文字です。'); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) return Result::Error('色が不正です。'); return $this->locked(function() use ($id, $name, $color, $description): Result { $l = $this->readList('labels.json', 'labels', true); foreach ($l['labels'] as $k => $x) if ((int)$k !== $id && mb_strtolower($x['name']) === mb_strtolower($name)) return Result::Error("ラベル「{$name}」は既に有ります。"); if ($id !== null && !isset($l['labels'][$id])) return Result::Error('ラベルが有りません。'); $id ??= $l['next']++; $l['labels'][$id] = ['name' => $name, 'color' => strtolower($color), 'description' => mb_substr(trim($description), 0, 200)]; $this->writeJson($this->dir.'/labels.json', $l); return Result::Success('保存しました。', ['id' => $id]); }); } public function deleteLabel(int $id): void { $this->locked(function() use ($id): void { $l = $this->readList('labels.json', 'labels', true); unset($l['labels'][$id]); $this->writeJson($this->dir.'/labels.json', $l); }); foreach ($this->search(['state' => 'all', 'label' => $id]) as $s) { $this->update($s['number'], function(array $i) use ($id): array { $i['labels'] = array_values(array_diff($i['labels'], [$id])); return $i; }); } } /** マイルストーン(ID => title、description、due(Y-m-d又は空)、state) */ public function milestones(): array { return $this->readList('milestones.json', 'milestones'); } public function saveMilestone(?int $id, string $title, string $description, string $due, string $state = 'open'): Result { $title = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $title)); if ($title === '' || mb_strlen($title) > 100) return Result::Error('マイルストーンの名前は1〜100文字です。'); 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('期日が不正です。'); return $this->locked(function() use ($id, $title, $description, $due, $state): Result { $l = $this->readList('milestones.json', 'milestones', true); if ($id !== null && !isset($l['milestones'][$id])) return Result::Error('マイルストーンが有りません。'); $id ??= $l['next']++; $l['milestones'][$id] = ['title' => $title, 'description' => mb_substr(trim($description), 0, 2000), 'due' => $due, 'state' => $state === 'closed' ? 'closed' : 'open']; $this->writeJson($this->dir.'/milestones.json', $l); return Result::Success('保存しました。', ['id' => $id]); }); } public function deleteMilestone(int $id): void { $this->locked(function() use ($id): void { $l = $this->readList('milestones.json', 'milestones', true); unset($l['milestones'][$id]); $this->writeJson($this->dir.'/milestones.json', $l); }); foreach ($this->search(['state' => 'all', 'milestone' => $id]) as $s) { $this->update($s['number'], function(array $i): array { $i['milestone'] = null; return $i; }); } } //////// 表示 /** * MarkdownをHTMLにする(安全なリンクだけ、「#3」をイシューへのリンクに) * * @param string $issuesUrl イシューの一覧のURL(…/issues) */ public static function render(string $text, string $issuesUrl): string { $html = Markdown::fromString($text)->parse(); $html = (string)preg_replace_callback('/\s(href|src)="([^"]*)"/i', function(array $m): string { $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); return preg_match('~^(https?:|mailto:|#|/(?!/))~i', $url) ? " {$m[1]}=\"{$m[2]}\"" : " {$m[1]}=\"#\""; }, $html); return self::linkify($html, $issuesUrl); } /** HTMLの文字の部分(a、code、preの外)の「#3」、「@ユーザー」(居れば)とURLをリンクにする */ public static function linkify(string $html, string $issuesUrl): string { $parts = preg_split('/(<[^>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE); $depth = 0; foreach ($parts as $k => $p) { if ($p === '') continue; if ($p[0] === '<') { if (preg_match('~^<(/?)(a|code|pre)\b~i', $p, $m)) $depth = max(0, $depth + ($m[1] === '/' ? -1 : 1)); continue; } if ($depth > 0) continue; $parts[$k] = (string)preg_replace_callback('~(https?://[^\s<>"]+[^\s<>".,;:!?)\]」。、])|(?@'.$m[3].'' : $m[0]; if (($m[2] ?? '') !== '') return '#'.$m[2].''; return ''.$m[1].''; }, $p); } return implode('', $parts); } /** 書いた者の比較用のキー(ローカルはユーザー名、リモートはアクターのURL) */ public static function authorKey(array $a): string { return (string)($a['user'] ?? $a['actor'] ?? ('commit:'.($a['commit'] ?? ''))); } //////// 内部 private function file(int $n): string { return $this->dir.'/issues/'.$n.'.json'; } private static function cleanTitle(string $t): string { return mb_substr(trim(preg_replace('/[\x00-\x1f\x7f]+/', ' ', $t)), 0, self::MAX_TITLE); } private function validLabels(array $ids): array { $all = $this->labels(); return array_values(array_unique(array_filter(array_map('intval', $ids), fn($id) => isset($all[$id])))); } private function validMilestone(mixed $id): ?int { $id = (int)$id; return $id > 0 && isset($this->milestones()[$id]) ? $id : null; } /** ID => 物($withNextなら[next, 名前 => ID => 物]) */ private function readList(string $file, string $key, bool $withNext = false): array { $j = json_decode((string)@file_get_contents($this->dir.'/'.$file), true); $list = []; foreach ((array)($j[$key] ?? []) as $id => $x) if (is_array($x)) $list[(int)$id] = $x; return $withNext ? ['next' => max(1, (int)($j['next'] ?? 1), ...array_map(fn($id) => $id + 1, array_keys($list))), $key => $list] : $list; } /** イシューと索引を書く */ private function save(array $issue, array $idx): void { $last = $issue['created']; foreach ($issue['comments'] as $c) $last = max($last, $c['created']); $idx['issues'][(string)$issue['number']] = [ 'number' => $issue['number'], 'title' => $issue['title'], 'state' => $issue['state'], 'author' => $issue['author'], 'authorKey' => self::authorKey($issue['author']), 'created' => $issue['created'], 'updated' => $issue['updated'], 'closed' => $issue['closed'], 'labels' => $issue['labels'], 'assignees' => $issue['assignees'], 'milestone' => $issue['milestone'], 'comments' => count(array_filter($issue['comments'], fn($c) => empty($c['pending']))), 'last' => $last, 'pull' => isset($issue['pull']), 'merged' => !empty($issue['pull']['merged']), ]; $this->repo->mkdir($this->dir.'/issues'); $this->writeJson($this->file($issue['number']), $issue); $this->writeJson($this->dir.'/issues.json', $idx); } private function writeJson(string $file, array $data): void { $this->repo->mkdir(dirname($file)); $this->repo->writeFile($file, (string)json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } /** gitler/の中を書く間はロックする(Webとgitユーザーの両方から書けるロックファイル) */ private function locked(callable $fn): mixed { if ($this->depth > 0) return $fn(); $this->repo->mkdir($this->dir); $file = $this->dir.'/issues.lock'; $new = !is_file($file); $lock = @fopen($file, 'c'); if ($lock && $new) $this->repo->chmodFile($file); if ($lock) flock($lock, LOCK_EX); $this->depth++; try { return $fn(); } finally { $this->depth--; if ($lock) { flock($lock, LOCK_UN); fclose($lock); } } } }