[番号, コメント])
* 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);
}
}
}
}