Watches the session's pull request and wakes the session when it is merged or Codex reviews it

セッションの PR を 1 分ごとに確かめ、マージされたときと Codex がレビューを付けたときだけセッションを起こす Claude Code の mod です。 人が GitHub を見て「マージした」「レビューが来た」と打たなくても、セッションが次の作業を始めます。 モデルがレビューの到着を待つために sleep やポーリングの tool を呼ぶ必要もなくなります。
Desktop アプリでは、セッションの PR バーの先頭に表示される PR(最後に紐付けた PR)を監視します。 新しい PR が紐付けられると、次の確認で監視先を切り替えます。 先頭の PR が再オープンした場合も、その PR の監視を始めます。 PR バーのほかの PR はマージとクローズだけを追い、トーストで知らせます。 マージされた PR には、それぞれ cleanup の行も出します。 この確認にはアプリのキャッシュを使い、GitHub への問い合わせは増やしません。
ターミナルでは、これまでどおりセッションの開始時に gh pr view で現在のブランチの OPEN な PR を探します。 Desktop アプリでも、先頭に OPEN な PR がない場合やアプリの情報を取得できない場合は、同じ方法で探します。
セッションの途中で gh pr create を実行すると、その出力の URL から監視を始めます。 gh pr new や gh -R owner/repo pr create も認識し、-Rowner/repo、--repo owner/repo、--repo=owner/repo の形式でも指定できます。 git push を実行したときに PR を監視していなければ、もう一度 gh pr view で探します。 git -C <dir> push では、指定したディレクトリのブランチの PR を探します。 相対パスはセッションの作業ディレクトリを基準にし、-C が複数ある場合は指定順に結合して、.. の解決はファイルシステムに任せます。 --work-tree=<dir> または --work-tree <dir> があれば、そのディレクトリの PR を探し、相対パスはすべての -C を適用した後のディレクトリを基準にします。 引用符で囲まれていない先頭の ~ や ~/ は HOME で展開し、引用符で囲まれたチルダは文字どおりの相対パスとして扱います。 ディレクトリに $ やバッククォートが含まれる場合、引用符のない ~user の形式の場合、HOME がなく ~ を展開できない場合は、その push による PR 検索を省略します。 1 回の Bash 呼び出しに複数の git push があり、解決した検索先のディレクトリが異なる場合も、push の追跡は続けて PR 検索だけを省略します。 git push-deploy や gh pr create-extra、gh pr new-extra は検出対象に含めません。 mcp__pr-relay__watch に pr_url を渡した場合も、その PR の監視を始めます。 Desktop アプリの先頭の PR が同じで OPEN のままなら、これらの方法で指定した監視先を保ちます。 アプリの情報が一部省略されても、確認済みの PR や状態は保ちます。
| GitHub で起きたこと | セッションへの知らせ方 |
|---|---|
| Codex がレビューを付けた | 指摘の本文と場所を載せたプロンプトを送る |
| Codex が 👍 を付けた、または「Didn't find any major issues」とコメントした | CI の確認と完了の報告を促すプロンプトを送る |
| Codex の利用上限に達した | トーストだけ出す |
| 監視中の PR またはアプリに紐付いた PR のいずれかがマージされた | トーストを出し、プロンプトの上の帯にその PR の cleanup ボタンを出す。押すと、PR のブランチから対象の worktree を探して、worktree とローカルブランチを片付けるよう促すプロンプトを送る。監視中の PR なら、ほかのセッションへお知らせも出す |
| 監視中の PR またはアプリに紐付いた PR がクローズされた | トーストだけ出す |
Desktop アプリで Auto-fix(CI モニター)が有効な PR では、Codex のレビュー、コメントによる approved、利用上限の通知を、コメント本文も届ける CI モニターに任せます。 その場合、pr-relay は Codex の 👍 とマージを引き続き知らせ、ステータス行に「CI モニター併用」と表示します。 先頭の PR はアプリの状態から、ほかの PR はセッションに届いた CI モニター自身の通知から Auto-fix が有効だと判断します。 アプリから PR の紐付けがないと報告された場合は、この判定をリセットします。 アプリが紐付いた PR の一覧を返した場合は、その一覧と先頭の PR から外れた PR の判定も解除します。 一覧が省略された場合は、これまでの判定を保ちます。
マージ後の cleanup は worktree とローカルブランチを消すので、自動では始めずボタンにしています。 プロンプトでは PR の URL からブランチとリポジトリを確かめ、そのブランチを checkout している worktree を探すよう伝えます。 見つからない場合や、別のリポジトリの PR の場合は、何も消さずに報告するよう伝えます。 プロンプトでは削除前に未コミットの変更や push していないコミットを確かめ、残っていれば消さずに報告するよう伝えます。 cleanup の行は新しいものから最大 3 件を表示し、cleanup と 閉じる はその行だけに作用します。 デスクトップでは各行を角丸の枠で囲み、使用量メーターとは別のカードとして表示します。 監視先が変わってもほかの PR の行は残り、同じ PR を再び監視するとその行を消します。 アプリで OPEN と確認した PR の行も消します。 アプリのほかの PR は、このセッションで OPEN だったものがマージまたはクローズされたときだけ知らせます。 /resume、/clear、/branch で会話が変わると、アプリの PR の状態と cleanup の行をリセットします。 マージとクローズを見たら、その PR のポーリングは止めます。
PR がマージされると、同じリポジトリで作業しているほかのセッションのブランチは、マージ先より古くなります。 監視中の PR のマージを見たセッションは、notice-board の /notice で次のお知らせを出します。
main advanced (#12). Rebase before the next push.
main の位置には、PR のマージ先のブランチ名が入ります。 ブランチ名が - で始まる場合は、/notice のオプションと読まれないよう Branch を前に付けます。 rebase を次の push の前まで待たせるのは、作業の途中で rebase すると、コンフリクトの解消が実装の変更と混ざるからです。
お知らせを出さない場合が 4 つあります。
/notice が存在しないので、トーストと cleanup ボタンだけが出ますmcp__pr-relay__watch に別のリポジトリの URL を渡した場合など)。/notice はセッションのリポジトリ宛てに出るので、origin が PR の GitHub リポジトリと一致するときだけ出しますcleanup の行だけです同じ PR を複数のセッションが見ていても、お知らせを出すのはトーストを出すセッションだけです。 notice-board は投稿したセッションにも同じお知らせを伝えるので、マージを見たセッション自身の帯とモデルにも届きます。 2 つのセッションがまったく同時にマージを見た場合は、同じお知らせが 2 件並ぶことがあります。 お知らせは自動では消えないので、不要になったら /notice clear で消してください。 /notice はセッションが待機中になってから実行されるので、お知らせは数分遅れることがあります。
プロンプトは $.prompt.submit で送るので、実行中のターンには割り込まず、セッションが待機中になってから新しいターンを始めます。
ステータス行には、監視中の PR 番号と最後に確かめた時刻(JST)を出します。 起動時に gh pr view が失敗して PR を探せなかったとき(未認証、ネットワークの切断)は、PR 検索失敗 21:00: <gh のエラー> を出し、1 分ごとに探し直します。
Codex の 👍 は PR に 1 つしか付かず、作成時刻が前の push のときのまま残ることがあります。 そのため「👍 がある」だけでは approved と見なさず、最後の push より後のイベントだけを数えます。
最後の push の時刻には、次のうち最も新しいものを使います。
mcp__pr-relay__watch の since で指定した時刻、または gh pr create を始めた時刻git push を始めた時刻(push が終わってから、PR の head がその push の出力にあるコミットになったときだけ使う。出力にコミットがなければ、push 開始時に分かっていた head から変わったときに使う。Everything up-to-date や別ブランチへの push では基準時刻を進めない)まだ知らせていないレビューがあれば、approved より先に知らせます。 最新のレビューより後に approved のイベントがあれば、レビューのプロンプトの末尾にその時刻を添え、別の approved のプロンプトは送りません。 最新のレビュー以前の approved も通知済みとして記録し、レビューだけを知らせます。
レビューを知らせるときだけ、対象のレビューの詳細を 1 回の追加クエリで取得し、指摘の本文と場所、コメント ID、URL をプロンプトに載せます。 本文は 1 件につき 4,000 文字で切り、プロンプト全体は 30,000 文字以内に収めます。 各レビューの inline コメントは 100 件まで取得し、載せきれないコメントは残りの件数と PR の files ページで案内します。 inline コメントがないレビューでは、<details> の部分を除いたレビュー本文を載せます。 詳細を取得できなくても、件数と gh api でコメントを読む手順を載せてセッションを起こします。
mod は通知済みかどうかを記録しますが、指摘への対応が済んだかや、現在の head がレビュー済みかは判定しません。 利用上限のトーストは、最後の push より後に上限のコメントがあれば、上限が解けた後でも出ます。
監視中の PR について、知らせたイベントは $.store に PR ごとに記録します。 同じ PR を見ている別のセッションや、再起動したセッションが、同じイベントでもう一度起こされることはありません。 同じ PR を複数のセッションが同時に確かめたときは、先に確認を始めたセッションだけを起こします(下の「制約」)。 プロンプトがセッションに入らなかったとき(送信の失敗や、別の plugin による拒否)は記録を戻し、次の確認で送り直します。
mod はツール mcp__pr-relay__watch を登録するので、モデルのツール一覧にこのツールがあれば mod が動いています。 /dev スキルからこのツールに pr_url と since を渡すと、PR と最後の push の時刻を mod に伝えられます。 引数なしで呼ぶと、監視中の PR を返します。 ツールの説明では、mod が監視している間はモデル自身でポーリングせず、ターンを終えて起こされるのを待つよう伝えています。 mod はスキルがないセッションでも動き、レビューへの対応や作業の完了に必要な手順をプロンプトで伝えます。
gh が PATH にあり、認証済みである必要があります。
Claude Code のプロンプトで、次の 3 つを順に実行します。
/plugin marketplace add HolyGrail/claude-mods
/plugin install pr-relay@claude-mods
/reload-plugins
更新の手順と注意点は、リポジトリの README にまとめてあります。
clone したリポジトリのルートで、次のように起動します。
claude --plugin-dir ./plugins/pr-relay
手元のコードを常に読み込むなら、~/.claude/settings.json の env にある CLAUDE_CODE_PLUGIN_DIRS にこのディレクトリの絶対パスを足します。
GitHub への定期的な問い合わせは、1 分に 1 回の GraphQL クエリです。 レビュー、コメント、👍 はそれぞれ新しい順に 100 件まで取り、100 件目がまだ最後の push より後なら、前のページを最大 10 ページまでたどります。 レビューを知らせるときは、指摘の本文を取得するクエリを 1 回追加します。 単純なクエリは 1 回 1 ポイントなので、12 セッションが同時に動いても毎時 720 ポイントで、上限の毎時 5,000 ポイントに収まります。
監視開始時に基準時刻が指定されず、どのセッションも git push の実行を見ていない PR では、最新コミットのコミット時刻を最後の push の時刻とみなします。 コミットしてから push するまでの間に付いた Codex のイベントを、新しいものと見なすことがあります。
$.store には atomic な更新がないので、同じ PR を見ているセッションは、確認を始めるたびに自分のキー(poll:<セッション ID>:<PR>)へ開始時刻を書きます。 30 秒以内に同じ PR の確認を先に始めたセッション、またはそれより前に始めてまだ GitHub の応答を待っているセッション(10 分まで)があれば、新しいイベントの通知はそのセッションに任せ、PR の記録にも書きません(読んだ時点より古い内容で上書きしないため)。 自分が知っている push の時刻(監視開始時の基準時刻や、GitHub にまだ head が反映されていない自分の git push を含む)は確認の開始時から自分のキーに書き、通知するセッションがそれを読んで基準時刻に加えます。 1 つのセッションの中では、前の確認が GitHub の応答を待っている間は次の確認を始めません。 GitHub への問い合わせは store への書き込みよりずっと長くかかるので、問い合わせの後に読めば先のセッションの書き込みが見えるという前提です。 任されたセッションが通知の前に終了しても、次の確認(1 分後)で残ったセッションが送ります。 確認に失敗したセッションや終了したセッションのキーは、通知を任される対象から外れますが、知っていた push の時刻は残ります。 このキーは 1 時間で消しますが、PR の記録がまだ数えていない push の時刻を持つものは 14 日まで残します。 マージやクローズも任されたセッションだけがトーストを出し、任せた側は次の確認で記録からそれを知って、ポーリングを止め cleanup ボタンを出します。 自分のキーを書けなかった確認は、他のセッションから見えないので、GitHub に問い合わせずに失敗としてステータス行に出します。 他のセッションのキーを読めなかった確認も、何も通知せずに失敗として出します。 どれかのセッションが git push を実行している間(開始から 10 分まで)は、GitHub がまだ置き換わる前の head を見せていることがあるので、どのセッションも通知しません。 どのセッションが起こされるかは確認の順番で決まり、/dev のセッションを優先するわけではありません。
gh の失敗(未認証、ネットワークの切断)はステータス行に出し、次の確認で再試行します。 レビュー詳細の取得に失敗した場合は、再試行を待たずにコメントの確認手順をプロンプトで伝えます。
CI の結果は監視しません。 Desktop アプリが PR の CI の結果をセッションへ送るので、両方で見ると同じ失敗で二重に起こされます。
cd plugins/pr-relay
claude plugin testhooks/register.js 1628 lines1// Watches this session's pull request on a timer and wakes the session only when something
2// changed: a merge, a Codex review, or Codex's thumbs-up. The model then never has to poll.
3
4// How often to ask GitHub about the pull request. One GraphQL query a tick costs one point of the
5// 5,000 an hour, so even a dozen sessions polling at once stay far below the limit.
6const TICK_MS = 60_000
7// The desktop app exposes its cached pull requests through this tool
8const DESKTOP_STATUS = 'mcp__ccd_pr__get_status'
9// Codex's login as GraphQL spells it (REST adds "[bot]")
10const CODEX = 'chatgpt-codex-connector'
11// What this module has already relayed for a pull request lives under this prefix plus its URL, in
12// $.store, so another session on the same pull request, or this one after a restart, is not woken
13// again for the same event
14const KEY_PREFIX = 'pr:'
15// Each session notes under this prefix plus its id and the pull request's when it last started a
16// poll of it, as { pr, at, since, pending?, running?, idle? }, so sessions polling the same pull
17// request together let only the first of them relay. since is the last push the session knows of,
18// which the session that relays takes from it, and pending a push it ran that counts once the head
19// has moved, { at, head } with the head it last saw;
20// running stays set until the poll has written what it relayed; idle marks a note whose session
21// stopped polling, kept only for its since. A session writes only its own key, since $.store has
22// no atomic update.
23const POLL_PREFIX = 'poll:'
24// Polls of one pull request that started this close together are one round: the one that started
25// first relays what is new, and the others leave it to that one. The query a poll waits on takes
26// far longer than a store write, so each of them sees the earlier one's note by the time it reads.
27const ROUND_MS = 30_000
28// A poll still running holds back the later ones of other sessions past its round, since it has not
29// written what it relayed yet; one this old is taken to belong to a session that died mid-poll
30const RUNNING_MS = 10 * 60_000
31const LATE = 'poll outlasted its turn'
32const PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/
33// Option values are one unquoted, single-quoted or double-quoted shell word
34const SHELL_WORD = String.raw`(?:"[^"]*"|'[^']*'|[^\s"';&|<>]+)`
35// Accepts gh pr create or new, optionally preceded by -R repo, -Rrepo, --repo repo or --repo=repo
36const GH_PR_CREATE = new RegExp(String.raw`\bgh\s+(?:(?:-R\s*|--repo(?:\s+|=))${SHELL_WORD}\s+)*pr\s+(?:create|new)(?=$|[\s;&|)])`)
37const GIT_OPTION = String.raw`(?:-C\s+(${SHELL_WORD})|--work-tree(?:=|\s+)(${SHELL_WORD})|-c\s+${SHELL_WORD}|--git-dir(?:=|\s+)${SHELL_WORD}|--no-pager)`
38// Accepts git push with any combination of -C dir, -c key=value, --git-dir=dir,
39// --work-tree=dir and --no-pager before push, including space-separated directory values
40const GIT_PUSH = new RegExp(String.raw`\bgit\s+((?:${GIT_OPTION}\s+)*)push(?=$|[\s;&|)])`, 'g')
41// What gh pr view says when the branch has no pull request, as opposed to failing to ask
42const NO_PR = /no pull requests found/i
43// The session.end reasons after which this module stops
44const FINAL_REASONS = ['prompt_input_exit', 'other']
45
46const HOUR_MS = 3_600_000
47// Records of pull requests that ended and are untouched this long are of no more use. One left
48// open keeps what was relayed for it far longer, since a session that finds it again would
49// otherwise be woken for the same events.
50const STALE_MS = 14 * 24 * HOUR_MS
51const OPEN_STALE_MS = 90 * 24 * HOUR_MS
52// Events are paged back only to the previous poll of the same watch, less this much
53const PAGE_OVERLAP_MS = 5 * 60_000
54// How a pull request that is no longer open reads
55const ENDED = { MERGED: 'マージ', CLOSED: 'クローズ' }
56// JST has no daylight saving time, so a fixed offset gives its clock
57const JST_OFFSET_MS = 9 * HOUR_MS
58
59// The newest events come last, and only those after the last push count. A page holds 100, the
60// most GraphQL gives; a connection whose oldest item on it is still after the push is followed
61// back a page at a time, up to MAX_PAGES.
62const PAGE = 'last: 100, before: $before'
63const PAGE_INFO = 'pageInfo { hasPreviousPage startCursor }'
64const CONNECTIONS = {
65 reactors: `reactionGroups { content reactors(${PAGE}) { ${PAGE_INFO} edges { reactedAt node { ... on Bot { login } ... on User { login } } } } }`,
66 reviews: `reviews(${PAGE}) { ${PAGE_INFO} nodes { id databaseId fullDatabaseId submittedAt author { login } comments { totalCount } } }`,
67 comments: `comments(${PAGE}) { ${PAGE_INFO} nodes { createdAt author { login } body } }`,
68}
69const MAX_PAGES = 10
70const queryOf = (fields) => `query($owner: String!, $name: String!, $number: Int!, $before: String) {
71 repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${fields} } }
72}`
73const QUERY = queryOf(`state headRefOid headRefName baseRefName commits(last: 1) { nodes { commit { committedDate } } } ${Object.values(CONNECTIONS).join(' ')}`)
74// Review bodies stay out of the per-minute query so gh's output does not get cut
75const REVIEW_QUERY = `query($ids: [ID!]!) {
76 nodes(ids: $ids) { ... on PullRequestReview {
77 databaseId fullDatabaseId url submittedAt commit { oid } body
78 comments(first: 100) { totalCount nodes { databaseId fullDatabaseId path line originalLine url body outdated replyTo { id } subjectType } }
79 } }
80}`
81// The most characters of a body to include before linking to the full text
82const BODY_LIMIT = 4_000
83// The most characters in a review prompt, including the omitted count and approval note
84const PROMPT_LIMIT = 30_000
85
86// The pull request this session watches: { url, id, owner, name, number, since, pagedAt?, ended? },
87// or null. since is the last push the watch started from.
88let watched = null
89// The timers that poll it, kept so the next watch or session.start can stop them
90let timers = []
91// Bumped by every watch and stop, so a poll or a lookup started before leaves the state alone
92let generation = 0
93// The last poll: { at, error? }, for the status line
94let lastCheck = null
95// The last lookup of the pull request when gh could not answer it, { at, error }, for the status line
96let lookupFailure = null
97// The generation of the poll running now, so a tick that comes while one still waits on GitHub
98// leaves it its note rather than overwriting it with a later round's
99let polling = null
100// The last push this session knows of for the watched pull request, which its poll note carries
101let knownSince = 0
102// The head of the watched pull request as this session's last poll found it, and when
103let lastHead = null
104let lastHeadAt = 0
105// This session's latest poll note, { key, pr, at, since, pending, running, serial }, as last written,
106// so an older poll does not mark it finished, a push can update it and a stop can leave it idle
107let lastNote = null
108// The notes a stop left idle, by key, which a push that finishes later still updates
109const retired = new Map()
110// The last write of each note key still going out
111const noteWrites = new Map()
112// Tells apart the polls of one session, which may share a key and a time
113let noteSerial = 0
114// Cleanup offers stay newest first, with at most three rows shown
115let cleanupOffers = []
116// Offers waiting for their prompts stay in order but are hidden until submission settles
117const cleanupPending = new Set()
118// Desktop availability is asked once per conversation and kept separately from watch timers
119let desktopAvailable = null
120// The desktop timer survives a watch change or the watched pull request ending
121let desktopTimer = null
122// This generation is reading the desktop cache, so slow calls do not pile up within a watch
123let desktopPolling = null
124// Successful desktop reads keep the last primary URL here
125let lastPrimary = null
126// A primary becoming open counts as a change even when its URL stays the same
127let lastPrimaryOpen = false
128// This map keeps the last known bound states in this conversation by parsed URL id
129let boundStates = new Map()
130// The desktop CI monitor relays comments for these pull requests in this conversation
131const monitored = new Set()
132// The git push calls this session ran for the watched pull request that have not counted yet, each
133// { id, pr, at, head, shas, refs, running }: its own id, the pull request it was bound to (null when
134// none was watched), when it started, the head the pull request had as far as the session knew
135// then, and once it has finished, the commits it moved refs to and the commit each branch moved to
136// (null when its output does not say). A push becomes the baseline only once GitHub shows what it pushed, since one that changed
137// nothing ("Everything up-to-date", another branch) must not hide the events that came before it.
138// Notes hold the same objects, so a push that finishes after the session turned to another pull
139// request still updates the note it was in.
140let pushes = []
141// How many pushes this session started for each pull request
142const pushStarts = new Map()
143// The session id as last asked, so a push need not wait on it
144let sessionId = null
145let idAsks = 0
146// git push calls still running. While one is, what Codex said may be about the head it replaces,
147// so nothing is relayed until it ends.
148// The read, change and write of a record in this session, one after another: a poll and a send
149// that takes its mark back would otherwise each write what they read before the other wrote
150let writes = Promise.resolve()
151
152export function register(on) {
153 // Fires again on an enable or a worker respawn, which may keep this module's variables
154 on('session.start', async ($, e, next) => {
155 sessionIdOf($).catch(() => {})
156 stop({ desktop: true })
157 await retire($)
158 reset($)
159 await $.tool.register({
160 name: 'watch',
161 description:
162 'pr-relay polls a pull request for this session and sends a prompt when it is merged, when Codex ' +
163 'reviews it, or when Codex gives it a thumbs-up. While it watches, do not poll for those yourself ' +
164 '(sleep loops, polling scripts, ScheduleWakeup): end the turn and wait to be woken. ' +
165 'Without arguments, returns what it watches. With pr_url, watches that pull request; since (ISO ' +
166 '8601) is the last push, and only Codex activity after it counts.',
167 inputSchema: {
168 type: 'object',
169 properties: {
170 pr_url: { type: 'string', description: 'https://github.com/<owner>/<repo>/pull/<number>' },
171 since: { type: 'string', description: 'The last push, ISO 8601' },
172 },
173 },
174 })
175 startDesktop($)
176 // Finding the pull request calls tools, so it starts once the session is ready
177 timers.push($.clock.after(0, () => discover($)))
178 return next(e)
179 })
180
181 on('session.end', async ($, e, next) => {
182 if (FINAL_REASONS.includes(e.reason)) {
183 stop({ desktop: true })
184 await retire($)
185 }
186 return next(e)
187 })
188
189 // /resume can turn this process to another conversation without a new session.start, and that
190 // conversation may belong to another worktree and pull request. /clear and /branch (fork) go on
191 // in the same one, but under a new id as well: the note under the last id is left idle, since
192 // that conversation no longer runs here, and the pull request is looked up again
193 on('classic.SessionStart', { source: ['resume', 'clear', 'fork'] }, async ($, e, next) => {
194 // The new conversation has its own id, so a push before its first poll must not write under
195 // the last one's
196 sessionId = null
197 sessionIdOf($).catch(() => {})
198 stop({ desktop: true })
199 await retire($)
200 reset($)
201 startDesktop($)
202 timers.push($.clock.after(0, () => discover($)))
203 return next(e)
204 })
205
206 on('tool.call', { tool: 'mcp__pr-relay__watch' }, async ($, e) => {
207 if (typeof e.pr_url === 'string') {
208 if (!PR_URL.test(e.pr_url)) return { result: `Not a pull request URL: ${e.pr_url}` }
209 const since = typeof e.since === 'string' ? Date.parse(e.since) : NaN
210 watch($, { url: e.pr_url, since: Number.isNaN(since) ? 0 : since })
211 }
212 return { result: describe() }
213 })
214
215 on('prompt.submit', async ($, e, next) => {
216 if (e.origin?.kind !== 'sdk' || typeof e.text !== 'string' || !e.text.startsWith('<ci-monitor-event>')) return next(e)
217 let result
218 try {
219 result = await next(e)
220 if (result?.drop === undefined) {
221 // Only the notice's first line names its pull request; later lines may quote GitHub text
222 const firstLine = e.text.slice('<ci-monitor-event>'.length).trimStart().split(/\r?\n/, 1)[0]
223 const match = firstLine.match(/\bwatching ([\w.-]+\/[\w.-]+) PR #(\d+)\b/)
224 if (match) monitored.add(parse(`https://github.com/${match[1]}/pull/${match[2]}`).id)
225 }
226 } catch {}
227 return result
228 })
229
230 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
231 const command = e.command ?? ''
232 // Best effort: a push made another way is caught by the head commit's date, and session.start
233 // looks up the branch's pull request
234 const creates = GH_PR_CREATE.test(command)
235 const pushMatches = creates ? [] : [...command.matchAll(GIT_PUSH)]
236 const pushing = pushMatches.length > 0
237 if (!creates && !pushing) return next(e)
238 // Bound to the pull request watched as the push starts, before anything else can turn the
239 // watch to another
240 const prId = watched && !watched.ended ? watched.id : null
241 // A failure to publish is shown only while the watch the push started under lasts
242 const watchGen = generation
243 // Codex reviews what was pushed, so its earlier activity says nothing about this push. The push
244 // is counted as started before anything is awaited, so a poll finishing meanwhile holds back
245 // what it read from the head being replaced; a watch that turns to another pull request while
246 // the time is read leaves it behind with the rest of the last one's pushes
247 let push = null
248 if (pushing) {
249 push = { id: null, pr: prId, at: null, head: null, shas: null, running: true }
250 if (prId) pushStarts.set(prId, (pushStarts.get(prId) ?? 0) + 1)
251 pushes.push(push)
252 }
253 // The time is set before the push runs, so a poll that sees the new head while it is still
254 // finishing already applies it, and put back if the push failed
255 const startedAt = await $.clock.now()
256 if (pushing) {
257 push.at = startedAt
258 // id tells parallel pushes started in the same millisecond apart, in this session and others
259 push.id = pushId(startedAt)
260 for (const note of notesOf(prId)) {
261 note.pending = [...note.pending.filter((p) => p !== push), push]
262 note.dirty = true
263 }
264 // A pull request with no note yet (its first poll has not written one, or the watch turned
265 // away first) gets an idle one, so the other sessions see the push run; the first poll takes
266 // it up
267 if (prId && !notesOf(prId).length) {
268 // The id is known from the start, so the push goes out without waiting on it
269 const key = `${POLL_PREFIX}${sessionId ?? (await sessionIdOf($))}:${prId}`
270 if (!notesOf(prId).length) retired.set(key, { key, pr: prId, at: startedAt, since: 0, pending: [push], idle: true, dirty: true })
271 }
272 // The other sessions hold back their relays only if they can see the push, so it goes out
273 // running before its head is looked up, and again with the head; a store that will not take
274 // it is shown rather than holding up the push
275 const publish = () =>
276 refreshNote($, { strict: true }).catch((error) => {
277 if (generation !== watchGen || (watched?.id ?? null) !== prId) return
278 lastCheck = { at: startedAt, error: `push note: ${error?.message ?? error}` }
279 showStatus($)
280 })
281 await publish()
282 push.head = await headBefore($, prId)
283 for (const note of notesOf(prId)) note.dirty = true
284 await publish()
285 // Codex activity that came while the notes went out is still about the head being replaced,
286 // so the push counts from when it actually runs; the notes take the time with its end
287 push.at = await $.clock.now()
288 for (const note of [lastNote, ...retired.values()]) if (note?.pending.includes(push)) note.dirty = true
289 // The other sessions hold back for RUNNING_MS from the time they see, so they see this one
290 await publish()
291 // Activity that came while that went out is still about the head being replaced, so the push
292 // counts from here; the notes take this time with the push's end, which is when it applies
293 push.at = await $.clock.now()
294 for (const note of [lastNote, ...retired.values()]) if (note?.pending.includes(push)) note.dirty = true
295 // and the other sessions hold back from it too, so it goes out as Bash runs, not before
296 publish()
297 }
298 // The other sessions hold back their relays only while the push runs
299 const finish = async ({ dropped = false, shas = null, refs = null } = {}) => {
300 // A poll whose GitHub answer came before this may have seen the head the push replaced
301 push.doneAt = await $.clock.now()
302 push.running = false
303 if (dropped) push.dropped = true
304 else {
305 push.shas = shas
306 push.refs = refs
307 }
308 pushes = pushes.filter((p) => !p.dropped)
309 for (const note of [lastNote, ...retired.values()]) if (note?.pending.includes(push)) note.dirty = true
310 await refreshNote($)
311 }
312 let ran
313 try {
314 ran = await next(e)
315 } catch (error) {
316 // Whether it pushed is unknown, so it counts only once the head moves
317 if (push) await finish().catch(() => {})
318 throw error
319 }
320 // A push that failed or moved nothing says nothing about what Codex reviewed
321 const failed = ran.deny !== undefined || ran.isError
322 const moved = push && !failed ? pushedCommits(ran) : null
323 if (push) await finish({ dropped: failed || moved?.shas.length === 0, shas: moved?.shas, refs: moved?.refs })
324 if (failed) return ran
325 if (creates) {
326 const url = ran.text?.match(PR_URL)?.[0]
327 if (url) watch($, { url, since: startedAt })
328 } else {
329 if (!watched || watched.ended) timers.push($.clock.after(0, async () => {
330 const directories = await Promise.all(pushMatches.map((match) => pushDirectory($, match[1])))
331 if (directories.includes(null)) return
332 const cwd = directories[0]
333 // A Bash call may push several repositories, so only an unambiguous directory is looked up
334 if (directories.length > 1) {
335 const sessionCwd = directories.includes(undefined) ? await $.session.cwd() : undefined
336 if (directories.some((dir) => (dir ?? sessionCwd) !== (cwd ?? sessionCwd))) return
337 }
338 await discover($, { branchOnly: true, cwd })
339 }))
340 }
341 return ran
342 })
343
344 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
345 if (!cleanupOffers.length) return next(e)
346 const elements = $.ui.resolve(e)
347 const lines = cleanupOffers.filter((offer) => !cleanupPending.has(offer)).slice(0, 3).map((offer) => {
348 const { url, number } = offer
349 const { id } = parse(url)
350 const close = () => {
351 cleanupOffers = cleanupOffers.filter((entry) => entry !== offer)
352 $.ui.invalidate('ui.render')
353 }
354 return elements.Box({
355 flexDirection: 'row',
356 columnGap: 2,
357 // On the desktop each offer is a card of its own, apart from the meters below it
358 ...(e.surface === 'desktop' && { borderStyle: 'round', paddingX: 1 }),
359 children: [
360 elements.Text({ children: [`PR #${number} がマージされました`] }),
361 elements.Button({
362 key: `cleanup-${id}`,
363 label: 'cleanup',
364 variant: 'primary',
365 onPress: async () => {
366 cleanupPending.add(offer)
367 $.ui.invalidate('ui.render')
368 const text =
369 `PR ${url} がマージされました。この PR のブランチの worktree とローカルブランチを片付けてください。\n` +
370 `gh pr view ${url} --json headRefName,headRepository でブランチを確かめ、git worktree list でそのブランチを checkout している worktree を探してください。\n` +
371 '見つからない場合や、別のリポジトリの PR の場合は、何も消さずに報告してください。\n' +
372 '消す前に、未コミットの変更や push していないコミットが残っていないかを確かめ、残っていれば消さずに報告してください。'
373 // A prompt that did not enter reveals its row in place, unless a new watch or a
374 // conversation reset removed that offer while it waited
375 if (await submit($, text)) close()
376 cleanupPending.delete(offer)
377 $.ui.invalidate('ui.render')
378 },
379 }),
380 elements.Button({ key: `dismiss-${id}`, label: '閉じる', role: 'dismiss', onPress: close }),
381 ],
382 })
383 })
384 // Keep what the mods after this one draw in the band
385 const rest = await next(e)
386 return elements.Box({
387 flexDirection: 'column',
388 ...(e.surface === 'desktop' && { rowGap: 1 }),
389 children: [...lines, ...(rest ? [rest] : [])],
390 })
391 })
392}
393
394function reset($) {
395 watched = null
396 lastCheck = null
397 lookupFailure = null
398 cleanupOffers = []
399 cleanupPending.clear()
400 desktopAvailable = null
401 desktopPolling = null
402 lastPrimary = null
403 lastPrimaryOpen = false
404 boundStates = new Map()
405 monitored.clear()
406 pushes = []
407 $.ui.invalidate('ui.render')
408 showStatus($)
409}
410
411// Looks for the pull request to watch, and asks again a tick later when gh could not answer
412async function discover($, { branchOnly = false, cwd } = {}) {
413 if (watched && !watched.ended) return
414 const gen = generation
415 // Pruning is startup housekeeping, skipped for a lookup after a push; a store that fails it
416 // does not keep the pull request unwatched
417 if (!branchOnly) await prune($).catch(() => {})
418 if (gen !== generation) return
419 const desktop = branchOnly ? null : await readDesktop($)
420 if (gen !== generation) return
421 const found = desktop?.primary?.state === 'OPEN'
422 ? { url: desktop.primary.url, since: 0 }
423 : await findForBranch($, cwd)
424 const at = await $.clock.now()
425 if ((watched && !watched.ended) || gen !== generation) return
426 if (desktop) {
427 trackBound($, desktop)
428 }
429 if (found?.error !== undefined) {
430 // Shown in place of the watch's line, or of the ended pull request's when a push looks again
431 lookupFailure = { at, error: found.error }
432 showStatus($)
433 timers.push($.clock.after(TICK_MS, () => discover($, { branchOnly, cwd })))
434 return
435 }
436 if (lookupFailure) {
437 lookupFailure = null
438 showStatus($)
439 }
440 if (found) watch($, found)
441}
442
443// Starts watching. The first poll runs on the clock, never inside the caller's hook: a prompt it
444// submits starts its turn only once the session is idle, which a tool call still running is not.
445function watch($, { url, since }) {
446 stop()
447 const gen = generation
448 const pr = parse(url)
449 // The note of the poll this stops must not hold back the sessions still watching its pull request
450 // while this watch's first poll starts; watching the same pull request again, its first poll
451 // takes up what the note held
452 if (lastNote) retire($)
453 // A push still waiting for its head to move stays with the pull request it was bound to, even one
454 // that ended; one made while none was watched is the push that led here
455 if (watched?.id !== pr.id) pushes = pushes.filter((push) => !push.pr)
456 // The pull request that takes up a push made while none was watched keeps it
457 for (const push of pushes) push.pr ??= pr.id
458 // Watching the same pull request again keeps the baseline its polls learned
459 if (watched?.id !== pr.id) knownSince = 0
460 watched = { ...pr, since }
461 lookupFailure = null
462 lastHead = null
463 lastHeadAt = 0
464 // Watching a pull request again removes only its own cleanup offer
465 removeCleanup($, pr.id)
466 // Ask at once: a review or a merge may have come while no session watched
467 timers.push($.clock.after(0, () => poll($, gen)))
468 timers.push($.clock.every(TICK_MS, () => poll($, gen)))
469}
470
471function stop({ desktop = false } = {}) {
472 for (const timer of timers) timer?.cancel()
473 timers = []
474 if (desktop) {
475 desktopTimer?.cancel()
476 desktopTimer = null
477 }
478 generation += 1
479}
480
481// Each conversation starts its own desktop timer and asks once whether the tool is available
482function startDesktop($) {
483 desktopAvailable = $.tool.list().then(
484 (tools) => tools.some((tool) => tool.name === DESKTOP_STATUS),
485 ).catch(() => false)
486 desktopTimer = $.clock.every(TICK_MS, () => refreshDesktop($))
487}
488
489// This source reads only the app's cache and leaves gh discovery available after any tool failure
490async function readDesktop($) {
491 try {
492 if (!(await desktopAvailable)) return null
493 const ran = await $.tool.call({ tool: DESKTOP_STATUS })
494 if (ran.deny !== undefined || ran.isError) return null
495 const status = JSON.parse(ran.text)
496 if (!status || typeof status !== 'object' || Array.isArray(status)) return null
497 if (status.bound === false) return { bound: false, primary: null, others: [] }
498 const pull = (url, state) => {
499 if (typeof url !== 'string' || url.match(PR_URL)?.index !== 0) return null
500 const pr = parse(url)
501 if (!Number.isSafeInteger(pr.number) || pr.number <= 0) return null
502 return { url: pr.url, number: pr.number, state: typeof state === 'string' ? state.toUpperCase() : null }
503 }
504 const pr = status.pr
505 const primary = pr?.host == null || pr.host.toLowerCase?.() === 'github.com' ? pull(pr?.url, pr?.state) : null
506 const others = Array.isArray(status.otherBoundPrs) ? status.otherBoundPrs.flatMap((other) => {
507 if (typeof other?.repo !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(other.repo)) return []
508 if (!Number.isSafeInteger(other.number) || other.number <= 0) return []
509 const parsed = pull(`https://github.com/${other.repo}/pull/${other.number}`, other.state)
510 return parsed ? [parsed] : []
511 }) : null
512 return {
513 bound: status.bound,
514 primary: primary ? { url: primary.url, state: primary.state, autoFix: status.monitor?.auto_fix } : null,
515 others,
516 }
517 } catch {
518 return null
519 }
520}
521
522// A changed open primary takes over the watch, and an ended primary resumes when reopened
523async function refreshDesktop($) {
524 const gen = generation
525 if (desktopPolling === gen) return
526 desktopPolling = gen
527 try {
528 const desktop = await readDesktop($)
529 if (!desktop || gen !== generation) return
530 const primary = desktop.primary
531 const id = primary ? parse(primary.url).id : null
532 const changed = (id !== (lastPrimary ? parse(lastPrimary).id : null) || !lastPrimaryOpen) && id !== watched?.id
533 const reopened = id === watched?.id && watched.ended
534 if (primary?.state === 'OPEN' && (changed || reopened)) {
535 watch($, { url: primary.url, since: 0 })
536 }
537 trackBound($, desktop)
538 } finally {
539 if (desktopPolling === gen) desktopPolling = null
540 }
541}
542
543// Bound states include the watched pull request, whose notifications still come from its poll
544function trackBound($, { bound, primary, others }) {
545 if (bound === false) {
546 lastPrimary = null
547 lastPrimaryOpen = false
548 boundStates.clear()
549 const wasMonitored = monitored.has(watched?.id)
550 monitored.clear()
551 if (wasMonitored) showStatus($)
552 return
553 }
554 if (primary) {
555 const { id } = parse(primary.url)
556 const previous = monitored.has(id)
557 if (primary.autoFix === true) monitored.add(id)
558 else if (primary.autoFix === false) monitored.delete(id)
559 if (id === watched?.id && monitored.has(id) !== previous) showStatus($)
560 }
561 // An incomplete primary must not hide a later open state for the same URL
562 if (primary && ['OPEN', 'MERGED', 'CLOSED'].includes(primary.state)) {
563 lastPrimary = primary.url
564 lastPrimaryOpen = primary.state === 'OPEN'
565 }
566 const entries = [...(others ?? []), ...(primary ? [primary] : [])]
567 // Only a supplied list can unbind other pull requests; an omitted primary remains known
568 if (Array.isArray(others)) {
569 const ids = new Set(entries.map((entry) => parse(entry.url).id))
570 if (!primary && lastPrimary) ids.add(parse(lastPrimary).id)
571 for (const id of boundStates.keys()) if (!ids.has(id)) boundStates.delete(id)
572 const wasMonitored = monitored.has(watched?.id)
573 for (const id of monitored) if (!ids.has(id)) monitored.delete(id)
574 if (wasMonitored !== monitored.has(watched?.id)) showStatus($)
575 }
576 for (const entry of entries) {
577 const pr = parse(entry.url)
578 const { state } = entry
579 if (state !== 'OPEN' && state !== 'MERGED' && state !== 'CLOSED') continue
580 const previous = boundStates.get(pr.id)
581 boundStates.set(pr.id, state)
582 if (state === 'OPEN') removeCleanup($, pr.id)
583 if (pr.id === watched?.id || previous !== 'OPEN' || !ENDED[state]) continue
584 $.ui.toast(`PR #${pr.number} が${ENDED[state]}されました`)
585 if (state === 'MERGED') offerCleanup($, pr)
586 }
587}
588
589// A merged pull request goes at the front without duplicating an existing offer
590function offerCleanup($, { url, number }) {
591 const { id } = parse(url)
592 if (cleanupOffers.some((offer) => parse(offer.url).id === id)) return
593 cleanupOffers.unshift({ url, number })
594 $.ui.invalidate('ui.render')
595}
596
597// Watching or seeing a pull request open removes its own offer, including one awaiting a prompt
598function removeCleanup($, id) {
599 const remaining = cleanupOffers.filter((offer) => parse(offer.url).id !== id)
600 if (remaining.length === cleanupOffers.length) return
601 cleanupOffers = remaining
602 $.ui.invalidate('ui.render')
603}
604
605async function poll($, gen) {
606 if (polling === gen) return
607 polling = gen
608 try {
609 await pollOnce($, gen)
610 } finally {
611 if (polling === gen) polling = null
612 }
613}
614
615async function pollOnce($, gen) {
616 if (gen !== generation || !watched || watched.ended) return
617 await settleRetired($)
618 const pr = watched
619 const key = KEY_PREFIX + pr.id
620 // A watch that began while the retired notes settled owns what the session knows now
621 if (gen !== generation) return
622 // What an earlier poll of this watch paged through is not asked for again
623 const floor = Math.max(pr.since, (pr.pagedAt ?? 0) - PAGE_OVERLAP_MS)
624 // Noted before asking, so a session that starts polling while this one waits on GitHub finds it,
625 // with the pushes known already, so a session that relays before this one finishes counts them
626 knownSince = Math.max(knownSince, pr.since)
627 // A poll the other sessions cannot see would relay beside the one they elect, so it waits a tick
628 const note = await notePoll($, pr, gen).catch(async (error) => {
629 lastCheck = { at: await $.clock.now(), error: `poll note: ${error?.message ?? error}` }
630 showStatus($)
631 return null
632 })
633 if (!note) return
634 const now = note.at
635 try {
636 await pollNoted($, gen, { pr, key, now, floor, note })
637 } finally {
638 await finishNote($, note)
639 }
640}
641
642async function pollNoted($, gen, { pr, key, now, floor, note }) {
643 // Read before GitHub is asked: a push that starts, and even finishes, while the query or the
644 // record is on its way may have replaced the head the query saw
645 const started = pushStarts.get(pr.id) ?? 0
646 const answer = await query($, pr, floor).catch((error) => ({ error }))
647 // When GitHub showed the head, which is what tells a fresher head from a staler one
648 const seenAt = answer.seenAt
649 const answeredAt = await $.clock.now()
650 // A watch that began meanwhile owns the state now
651 if (gen !== generation) return
652 // A query that outlasted the time the others wait on a running note may have seen another
653 // session take the round over, which this one's election cannot tell: it relays nothing
654 if (!answer.error && lapsed(now, answeredAt)) answer.error = new Error(LATE)
655 if (answer.error) {
656 // A poll that learned nothing relays nothing, so it must not hold back the others' this round,
657 // but the push it knows of still counts
658 await retire($)
659 // A watch that began while the note was left idle shows its own state
660 if (gen !== generation) return
661 lastCheck = { at: now, error: String(answer.error?.message ?? answer.error) }
662 showStatus($)
663 return
664 }
665 const { data } = answer
666 // Another session polling this pull request in the same round, ahead of this one, relays what is
667 // new; this one leaves the record to it and passes on what it knows through its note
668 // Notes it could not read may hold an earlier poll, so this one then learned nothing either
669 const notes = await readNotes($, pr, note).catch((error) => ({ error }))
670 // Read before the watch is checked, so nothing waits between the check and what follows
671 const readAt = await $.clock.now()
672 if (gen !== generation) return
673 if (notes.error) {
674 await retire($)
675 if (gen !== generation) return
676 lastCheck = { at: now, error: `poll notes: ${notes.error?.message ?? notes.error}` }
677 showStatus($)
678 return
679 }
680 // So may one whose store answered too late
681 if (lapsed(now, readAt)) return late($, now, pr, note)
682 const { defers, since: othersSince, pending, pushing } = notes
683 // Said in the note, so a later poll of another session does not yield to this one in turn
684 if (defers && lastNote?.serial === note.serial) {
685 lastNote.deferred = true
686 // Written before the record is read, which the store may keep waiting. One the store refused
687 // would leave the others yielding to a poll that relays nothing, so the poll stops as failed
688 const refused = await writeNote($, lastNote, { strict: true }).then(
689 () => null,
690 (error) => error,
691 )
692 // A watch that began while it went out owns the head and the baseline now
693 if (gen !== generation) return
694 if (refused) {
695 await retire($)
696 if (gen !== generation) return
697 lastCheck = { at: now, error: `poll note: ${refused?.message ?? refused}` }
698 showStatus($)
699 return
700 }
701 }
702 if (data.headRefOid) {
703 lastHead = data.headRefOid
704 lastHeadAt = seenAt
705 }
706 lastCheck = { at: now }
707 // One that leaves the waking to another session leaves the end to it too, and sees it next round
708 // from the record, as the session that relays: only then does it stop and offer the cleanup
709 const ended = !defers && (data.state === 'MERGED' || data.state === 'CLOSED')
710 let endedGen = null
711 if (ended) {
712 stop()
713 endedGen = generation
714 watched = { ...watched, ended: data.state }
715 if (data.state === 'MERGED') {
716 offerCleanup($, pr)
717 }
718 }
719
720 // The watch ends only once the end is told: one a push took back, or that never reached the
721 // record, polls on so a later round tells it
722 let told = !ended
723 try {
724 let noted = 0
725 const sends = await exclusive(async () => {
726 const record = normalize(await $.store.get(key))
727 const writeAt = await $.clock.now()
728 // A watch that began while this waited its turn owns the pushes and the marks now
729 if (gen !== generation && !ended) return null
730 // Checked once more just before the record is written, since the store may have kept it waiting
731 if (lapsed(now, writeAt)) return LATE
732 // An ended poll goes on past a new watch, whose baseline is not this pull request's: it goes by
733 // what its note said
734 const owns = watched?.id === pr.id
735 noted = Math.max(othersSince, owns ? knownSince : note.since)
736 const sends = update($, pr, data, record, noted, pending, defers, pushing, now, seenAt)
737 // The session that relays writes the record at about this moment, so a copy read before its
738 // marks must not land over them: one that leaves it the waking writes only its own note
739 if (owns) knownSince = Math.max(knownSince, record.since)
740 if (defers) return sends
741 record.at = now
742 if (data.headRefOid) record.headAt = seenAt
743 await $.store.set(key, record)
744 // A write that landed past the lease may have gone over a session that took the round over and
745 // relayed the same, so this one sends nothing; $.store has no conditional write to stop it.
746 // Its marks are taken back, since a session that has not taken the round over yet would read
747 // them as sent, and nothing would send them
748 if (lapsed(now, await $.clock.now())) return { late: sends }
749 // Pages are skipped next time only once what they held is in the record: a session that leaves
750 // the waking to another goes through them again in case that one never writes it
751 if (gen === generation) watched = { ...watched, pagedAt: now }
752 return sends
753 })
754 if (sends === null) return
755 if (sends === LATE) return late($, now, pr, note)
756 if (sends.late) {
757 takeBackAll($, key, sends.late, pr)
758 return late($, now, pr, note)
759 }
760 // Only once the record says they were relayed, so a send that fails can take its mark back. A
761 // watch that began during the write (an ended pull request stopped the timers itself) takes
762 // the marks back instead, and a later watch of this pull request sends them.
763 // A push this session started meanwhile, running or already done, may have replaced the head
764 // they were read from
765 let pushedSince = (pushStarts.get(pr.id) ?? 0) !== started
766 // So may one another session started after its notes were read, which only they say
767 if (sends.length && !pushedSince) {
768 // A push known already that has finished since, even one that had run past the time it holds
769 // the relays back, counts the same as a new one
770 const state = (push) => `${push.running ? 'running' : 'done'}:${push.doneAt ?? ''}`
771 const known = new Map(pending.map((push) => [pushKey(push), state(push)]))
772 const moved = (again) =>
773 !again ||
774 again.pushing ||
775 again.since > noted ||
776 again.pending.some((push) => known.get(pushKey(push)) !== state(push))
777 pushedSince = moved(await readNotes($, pr, note).catch(() => null))
778 // A read the store kept waiting past the lease may have let another session take the round
779 // over; it read the marks, which this one wrote in time, so it sent nothing: they are taken back
780 // for the next round to send
781 if (lapsed(now, await $.clock.now())) {
782 takeBackAll($, key, sends, pr)
783 return late($, now, pr, note)
784 }
785 // A push this session started while the clock was read counts too, and so does one another
786 // session started meanwhile, which a last read of the notes shows; the lease keeps ROUND_MS
787 // for it, and nothing waits between it and the sends
788 pushedSince ||= (pushStarts.get(pr.id) ?? 0) !== started
789 if (!pushedSince) pushedSince = moved(await readNotes($, pr, note).catch(() => null))
790 pushedSince ||= (pushStarts.get(pr.id) ?? 0) !== started
791 }
792 const current = (gen === generation || ended) && !pushedSince && !(watched?.id === pr.id && pushes.some((push) => holds(push, now)))
793 if (current) {
794 told = true
795 for (const send of sends) deliver($, key, send)
796 } else takeBackAll($, key, sends, pr)
797 showStatus($)
798 } finally {
799 if (!told) reopen($, pr, endedGen)
800 }
801}
802
803// Watches a pull request again whose end a poll could not tell, unless a new watch began since
804function reopen($, pr, gen) {
805 if (gen !== generation || watched?.id !== pr.id || !watched.ended) return
806 watched = { ...watched, ended: null }
807 removeCleanup($, pr.id)
808 timers.push($.clock.every(TICK_MS, () => poll($, gen)))
809 showStatus($)
810}
811
812// Whether a poll started at now has run so long that another session may have taken its round over
813function lapsed(now, at) {
814 return at >= now + RUNNING_MS - ROUND_MS
815}
816
817// A poll that lapsed relays nothing, and says so. An ended one that a new watch went past leaves
818// that watch's note and status alone.
819async function late($, now, pr, note) {
820 if (lastNote?.serial === note.serial) await retire($)
821 if (watched?.id !== pr.id) return
822 lastCheck = { at: now, error: LATE }
823 showStatus($)
824}
825
826// Brings the record up to the pull request's state, and returns the prompts to send for what is new
827function update($, pr, data, record, notedPush, pending, defers, pushing, now, seenAt) {
828 // Codex reviews pushes, so only its activity after the latest push counts. The thumbs-up in
829 // particular is one reaction per pull request whose time can stay at an earlier push: its mere
830 // presence would read as an approval of every later push.
831 const committedAt = Date.parse(data.commits?.nodes?.[0]?.commit?.committedDate ?? '') || 0
832 record.since = Math.max(record.since, pr.since, committedAt, notedPush)
833 // An ended poll that finishes after the watch turned leaves the next pull request's pushes alone
834 if (watched?.id === pr.id) {
835 pushes = pushes.filter((push) => {
836 // The record's head is the head before the push only if GitHub showed it before the push
837 // started; one seen later may already be what the push put there
838 const recordHead = (record.headAt ?? 0) < push.at ? record.head : null
839 if (!pushCounts(push, data, now, { recordHead })) return true
840 record.since = Math.max(record.since, push.at)
841 return false
842 })
843 }
844 // Another session's push counts by the same rule; one that knew no head before it cannot tell a
845 // push that moved nothing from one that did, so it waits for that session's own poll
846 for (const push of pending) {
847 if (pushCounts(push, data, now)) record.since = Math.max(record.since, push.at)
848 }
849 record.head = data.headRefOid ?? record.head
850 if (data.state === 'MERGED' || data.state === 'CLOSED') {
851 if (!record.ended && !defers) {
852 const state = data.state
853 record.ended = state
854 // Raised once the record says so in time, like a prompt
855 return [
856 {
857 toast: `PR #${pr.number} が${ENDED[state]}されました`,
858 // Only a merge moves the base branch under the other sessions' work
859 notice: state === 'MERGED' ? `${baseName(data)} advanced (#${pr.number}). Rebase before the next push.` : null,
860 pr,
861 undo: (r) => {
862 if (r.ended === state) r.ended = null
863 },
864 },
865 ]
866 }
867 return []
868 }
869 // A reopened pull request ends again, and that end is news again
870 record.ended = null
871 // A push running in any session may be replacing the head GitHub still shows; this session's
872 // own are those it bound to this pull request
873 const running = pushes.some((push) => holds(push, now))
874 // So may one that finished after GitHub answered, which the head GitHub showed cannot tell from a
875 // push that moved nothing; the next poll sees what it left. One that finished in the same
876 // millisecond may have too, and one handed on with no end time counts from its start
877 const unseen = (push) => !push.running && (typeof push.doneAt === 'number' ? push.doneAt >= seenAt : push.at >= seenAt)
878 const replaced =
879 (watched?.id === pr.id && pushes.some(unseen)) || pending.some((push) => unseen(push) && !pushCounts(push, data, now))
880 return running || pushing || defers || replaced ? [] : relay($, pr, read(data, record.since), record)
881}
882
883// The cache takes only the latest answer, so one asked before a /resume cannot land over it
884async function sessionIdOf($) {
885 const ask = ++idAsks
886 const id = await $.session.id()
887 if (ask === idAsks) sessionId = id
888 return id
889}
890
891// Notes that this session starts a poll of the pull request now, and returns the note
892async function notePoll($, pr, gen) {
893 // One note per pull request, so a session that turns to another leaves this one's push behind
894 const key = `${POLL_PREFIX}${await sessionIdOf($)}:${pr.id}`
895 // A worker respawn starts this module afresh while the session's note from before stays in the
896 // store, still holding what the record may not have counted; a read that fails is retried
897 const stored = lastNote?.key === key || retired.has(key) ? null : await $.store.get(key)
898 // Read just before the note goes out, since the time is this poll's place in the election
899 const at = await $.clock.now()
900 if (gen !== generation) return null
901 // The note under the id before /clear or /resume would read as another session's poll
902 if (lastNote && lastNote.key !== key) await retire($)
903 // Watching the same pull request again starts from what its note last said
904 if (lastNote?.key === key) knownSince = Math.max(knownSince, lastNote.since)
905 // Coming back to a pull request this session left, its note still holds pushes the record may
906 // not have counted yet
907 const left = retired.get(key)
908 if (left) {
909 knownSince = Math.max(knownSince, left.since)
910 for (const push of left.pending) if (!push.dropped && !pushes.includes(push)) pushes.push(push)
911 retired.delete(key)
912 } else if (stored?.pr === pr.id) {
913 if (typeof stored.since === 'number') knownSince = Math.max(knownSince, stored.since)
914 for (const push of [].concat(stored.pending ?? [])) {
915 if (typeof push?.at !== 'number' || pushes.some((p) => pushKey(p) === pushKey(push))) continue
916 // Whether a push the last module saw running pushed anything is unknown, so it counts once
917 // the head moves; one that may still run (the worker went, not the Bash call) holds the
918 // relays back as long as another session's would
919 const finished = !push.running
920 const live = !finished && push.at >= at - RUNNING_MS
921 pushes.push({
922 id: push.id ?? null,
923 pr: pr.id,
924 at: push.at,
925 head: push.head ?? null,
926 shas: finished ? (push.shas ?? null) : null,
927 refs: finished ? (push.refs ?? null) : null,
928 ...(finished && typeof push.doneAt === 'number' ? { doneAt: push.doneAt } : {}),
929 running: live,
930 })
931 }
932 }
933 lastNote = { key, pr: pr.id, at, since: knownSince, pending: [...pushes], running: true, serial: ++noteSerial }
934 await writeNote($, lastNote, { strict: true })
935 // A note other sessions wrote while this one's was on its way may have been elected without
936 // seeing it, so the poll yields to those too
937 return { ...lastNote, landedAt: await $.clock.now() }
938}
939
940// Marks the note of a poll that has written what it relayed as finished, with what it now knows
941async function finishNote($, note) {
942 if (lastNote?.serial !== note.serial) return
943 catchUp(lastNote)
944 lastNote.running = false
945 await writeNote($, lastNote)
946}
947
948// Puts a push this session just started into its running or finished note, so a session that
949// relays before this one polls again counts it
950async function refreshNote($, { strict = false } = {}) {
951 const writes = []
952 for (const note of [lastNote, ...retired.values()]) {
953 if (!note || (note.pr !== watched?.id && !note.dirty)) continue
954 catchUp(note)
955 writes.push(writeNote($, note, { strict }))
956 }
957 await Promise.all(writes)
958}
959
960// Rewrites the notes this session left until the store has taken each as idle and with its pushes
961// as they stand; a write the store refused is tried again on the next poll. Each is kept for as
962// long as the session runs, since coming back to its pull request takes up what it held.
963async function settleRetired($) {
964 await Promise.all(
965 [...retired.values()].map(async (note) => {
966 if (note.dirty || !note.writtenIdle) {
967 catchUp(note)
968 await writeNote($, note, { strict: true }).catch(() => {})
969 }
970 }),
971 )
972}
973
974// The notes this session holds for a pull request
975function notesOf(prId) {
976 return prId ? [lastNote, ...retired.values()].filter((note) => note?.pr === prId) : []
977}
978
979// Leaves this session's note idle: it holds back no other session, and still hands on its push
980async function retire($) {
981 if (!lastNote) return
982 const note = lastNote
983 lastNote = null
984 // What the session knows belongs to the watch, which may have moved on to another pull request;
985 // then the note keeps what it last said
986 catchUp(note)
987 const idle = { ...note, pending: [...note.pending], running: false, idle: true, dirty: true }
988 retired.set(note.key, idle)
989 await writeNote($, idle)
990}
991
992// Brings a note of the watched pull request up to what this session knows of its pushes
993function catchUp(note) {
994 if (note.pr !== watched?.id) return
995 note.since = Math.max(note.since, knownSince)
996 note.pending = [...pushes]
997}
998
999// Writes a note as it stands, its pushes included. dirty says the store has not taken what the note
1000// holds now, and writtenIdle whether it last took the note as idle.
1001// Writes to one key go out one after another, each with the note as it stands when it goes out, so
1002// an earlier write that the store answers late never lands over a later one.
1003function writeNote($, note, { strict = false } = {}) {
1004 note.dirty = false
1005 const { key } = note
1006 let idle = false
1007 const write = (noteWrites.get(key) ?? Promise.resolve())
1008 .then(() => {
1009 idle = Boolean(note.idle)
1010 return $.store.set(key, noteValue(note))
1011 })
1012 .then(
1013 () => {
1014 note.writtenIdle = idle
1015 },
1016 (error) => {
1017 note.dirty = true
1018 throw error
1019 },
1020 )
1021 const tail = write.catch(() => {})
1022 noteWrites.set(key, tail)
1023 tail.then(() => {
1024 if (noteWrites.get(key) === tail) noteWrites.delete(key)
1025 })
1026 return strict ? write : tail
1027}
1028
1029function noteValue({ pr, at, since, pending = [], running, idle, deferred }) {
1030 const pushes = pending.filter((push) => !push.dropped).map(pushNote)
1031 return {
1032 pr,
1033 at,
1034 since,
1035 ...(pushes.length ? { pending: pushes } : {}),
1036 ...(running ? { running } : {}),
1037 ...(idle ? { idle } : {}),
1038 ...(deferred ? { deferred } : {}),
1039 }
1040}
1041
1042// A push as a note hands it on, for the session that relays to apply
1043function pushNote({ id, at, head, shas, refs, running, doneAt }) {
1044 return {
1045 ...(id ? { id } : {}),
1046 at,
1047 head,
1048 ...(shas ? { shas } : {}),
1049 ...(refs ? { refs } : {}),
1050 ...(running ? { running } : {}),
1051 ...(typeof doneAt === 'number' ? { doneAt } : {}),
1052 }
1053}
1054
1055// Whether a push of this session still holds back a poll started at now: one whose Bash call hung
1056// holds nothing back for long, the same as another session's in readNotes
1057function holds(push, now) {
1058 return push.running && (push.at === null || push.at >= now - RUNNING_MS)
1059}
1060
1061function pushId(at) {
1062 return `${at.toString(36)}-${Math.random().toString(36).slice(2, 10)}`
1063}
1064
1065// What tells one push from another: its id, or for a note written without one, its start
1066function pushKey(push) {
1067 return push.id ?? `at:${push.at}`
1068}
1069
1070// Whether a push moved the pull request to the head GitHub shows now: once it has finished, by the
1071// commits it pushed, or, when its output named none, by the head having changed since it started.
1072// A pushed commit the head already had before is some other ref moved to it. The session that
1073// pushed falls back on the record's head as GitHub showed it before the push; with no head known at all, the push stays pending, and
1074// the head commit's date is the baseline.
1075function pushCounts(push, data, now, { recordHead = null } = {}) {
1076 const head = data.headRefOid
1077 // A push whose Bash call hung past the time it holds the relays back is taken as finished with
1078 // nothing known of what it pushed: it counts once the head moves
1079 if (holds(push, now) || !head) return false
1080 const before = push.head ?? recordHead
1081 if (Array.isArray(push.shas)) {
1082 // A pushed commit the head has may be another ref moved to it, where the pull request's branch
1083 // got by itself: when the output says which branch moved where, only its own branch counts
1084 const own = data.headRefName ? push.refs?.[data.headRefName] : null
1085 const named = push.refs && Object.keys(push.refs).length > 0
1086 const moved = named ? typeof own === 'string' && head.startsWith(own) : push.shas.some((sha) => head.startsWith(sha))
1087 if (before != null) return before !== head && moved
1088 // Without the head before it, only the pull request's own branch moving to it says the push
1089 // moved the head
1090 return typeof own === 'string' && head.startsWith(own)
1091 }
1092 return before != null && head !== before
1093}
1094
1095// The commits a finished git push moved refs to, and the commit each branch moved to: none when it
1096// moved nothing, or null when its output does not say (a new branch, a quiet push)
1097function pushedCommits(ran) {
1098 const text = [ran.text, ran.result?.stdout, ran.result?.stderr].filter((t) => typeof t === 'string').join('\n')
1099 // The usual form (old..new src -> dst) and --porcelain's (flag TAB src:dst TAB old..new)
1100 const moves = [
1101 ...[...text.matchAll(/\b[0-9a-f]{7,40}\.{2,3}([0-9a-f]{7,40})\s+\S+\s+->\s+(\S+)/g)].map((m) => [m[1], m[2]]),
1102 ...[...text.matchAll(/^[ +\-*!=]\t[^\t]*:(\S+)\t[0-9a-f]{7,40}\.{2,3}([0-9a-f]{7,40})\b/gm)].map((m) => [m[2], m[1]]),
1103 ]
1104 // Moved nothing only when no update was printed: one Bash call may run several pushes, of which
1105 // only the last was up to date. --porcelain says a ref already up to date with =
1106 if (!moves.length) return /Everything up-to-date|^=\t/m.test(text) ? { shas: [], refs: {} } : null
1107 // The branch each ref moved to its commit, so a push can be told to have moved the pull request's
1108 // own branch even with no head known before it
1109 const refs = Object.fromEntries(moves.map(([sha, ref]) => [ref.replace(/^refs\/heads\//, ''), sha]))
1110 return { shas: [...new Set(moves.map(([sha]) => sha))], refs }
1111}
1112
1113// The head the watched pull request had as lately as this session knows: its own last poll or the
1114// record another session wrote since
1115async function headBefore($, prId) {
1116 if (!prId) return null
1117 const own = watched?.id === prId ? lastHead : null
1118 // A record it cannot read may hold a fresher head than this session saw, so the head is unknown
1119 const record = await $.store.get(KEY_PREFIX + prId).catch(() => null)
1120 if (record === null) return null
1121 if (!record?.head || !own) return record?.head ?? own
1122 const recordAt = record.headAt ?? record.at ?? 0
1123 // Two heads seen in the same millisecond cannot be told apart, so neither is the head before
1124 if (recordAt === lastHeadAt && record.head !== own) return null
1125 return recordAt > lastHeadAt ? record.head : own
1126}
1127
1128// What the other sessions' notes on the same pull request say: defers, whether one of them started
1129// its poll before this one's (or at the same moment, under a smaller key) in the same round, or is
1130// still running it, so that every session agrees on the one that relays; since and pending, the
1131// pushes they know; and pushing, whether one of them is running a push. One that stopped polling or
1132// failed drops out of the next round by itself, or at once when it left its note idle. A store that
1133// cannot be read throws, since a note it hides may be the one this poll should yield to.
1134
1135async function readNotes($, pr, note) {
1136 const keys = (await $.store.keys()).filter((key) => key.startsWith(POLL_PREFIX) && key !== note.key)
1137 let defers = false
1138 let pushing = false
1139 let since = 0
1140 const pending = []
1141 for (const key of keys) {
1142 const other = await $.store.get(key)
1143 if (other?.pr !== pr.id || typeof other.at !== 'number') continue
1144 if (typeof other.since === 'number') since = Math.max(since, other.since)
1145 for (const push of [].concat(other.pending ?? [])) {
1146 if (typeof push?.at !== 'number') continue
1147 pending.push(push)
1148 // One left running by a session that died mid-push holds nothing back for long
1149 if (push.running && push.at >= note.at - RUNNING_MS) pushing = true
1150 }
1151 // A poll that left the round to another holds nothing back either: polls a few seconds apart
1152 // would otherwise each yield to the one before, round after round, and none would relay
1153 if (other.idle || other.deferred) continue
1154 // A note written while this one's was on its way, even in the same millisecond it was timed,
1155 // may have been elected without seeing it
1156 const landedAt = note.landedAt ?? note.at
1157 if (landedAt > note.at && other.at >= note.at && other.at <= landedAt) {
1158 defers = true
1159 continue
1160 }
1161 if (other.at > note.at) continue
1162 if (other.at < note.at - (other.running ? RUNNING_MS : ROUND_MS)) continue
1163 if (other.at < note.at || key < note.key) defers = true
1164 }
1165 return { defers, since, pending, pushing }
1166}
1167
1168// Marks what is new since the last push as relayed: reviews before an approval, then the usage
1169// limit. A newer approval is marked with the reviews, and mentioned only if it came after them.
1170// Review details are fetched at delivery, once the record is written, outside the write queue
1171function relay($, pr, signals, record) {
1172 const fresh = signals.reviews.filter((r) => !record.reviews.some((id) => String(id) === String(r.id))).sort((a, b) => a.at - b.at)
1173 const usesMonitor = monitored.has(pr.id)
1174 const approvedAt = usesMonitor ? signals.reactedAt : signals.approvedAt
1175 const previous = record.approvedAt
1176 if (usesMonitor) {
1177 // The monitor owns these comments, so their silent marks survive a failed approval prompt
1178 record.reviews.push(...fresh.map((r) => r.id))
1179 record.usageLimitAt = Math.max(record.usageLimitAt, signals.usageLimitAt)
1180 record.approvedAt = Math.max(record.approvedAt, signals.approvedAt)
1181 if (fresh.length && approvedAt <= fresh[fresh.length - 1].at) return []
1182 }
1183 if (fresh.length > 0 && !usesMonitor) {
1184 const previous = record.approvedAt
1185 const approvedAt = signals.approvedAt > previous ? signals.approvedAt : 0
1186 record.reviews.push(...fresh.map((r) => r.id))
1187 if (approvedAt) record.approvedAt = approvedAt
1188 return [
1189 {
1190 pr,
1191 reviews: fresh,
1192 approvedAt: approvedAt > fresh[fresh.length - 1].at ? approvedAt : 0,
1193 undo: (r) => {
1194 if (approvedAt && r.approvedAt === approvedAt) r.approvedAt = previous
1195 const pending = fresh.map((f) => String(f.id))
1196 r.reviews = r.reviews.filter((id) => !pending.includes(String(id)))
1197 },
1198 },
1199 ]
1200 }