Git ホスティング↔タスク連携 仕様書
ステータス: Draft / 作成日: 2026-05-27 / 改訂: 2026-09-02(データモデルを PR 実体表へ変更、ホスト中立化) PR #9b — 依存: コア(PR #1), 自動化(PR #8), GitHub App 基盤(PR #9a), レビュー指摘
1. 概要
GitHub App 基盤の上に、PR・コミット・ブランチとタスクの自動リンクを追加する。
コミットメッセージや PR タイトルに KEY-N(プロジェクトキー + 連番、例: ENG-42)を書くだけで自動リンクされ、Closes ENG-42 を含む PR がマージされるとタスクが自動クローズされる。リンクされたコミットはタスクのアクティビティに並ぶ(TASK-140)。
なぜ
KEY-Nか: ホスト自身が#Nを Issue 番号として使用するため、#42ではどちらを指すか曖昧になる。ENG-42のようなプロジェクトキー付き形式はホストの Issue と衝突せず、Linear・Jira ユーザーにも馴染み深い。
ホスト中立に作る
最初の実装は GitHub だが、GitLab と Forgejo を後から足す前提で、タスク側のデータと処理はホストを知らない形にする。
ホストごとに違うのは次の 4 点だけで、それぞれ薄いアダプタに閉じる(forge-core の TokenProvider / Repository と同じ方針)。
ホストの識別は host(github / gitlab / forgejo)+ host_url(インスタンスの origin。https://github.com / https://gitlab.example.com)の組で行う。GitLab と Forgejo はセルフホストが普通なので host だけでは足りない。
タスクをハブにする
ホスト側の 3 つの実体(Issue / PR / コミット)は、それぞれタスクに結ぶ。Issue と PR を直接結ぶ表は持たない。
github_issue_links ──▶ tasks ◀── forge_pull_request_links ──▶ forge_pull_requests ◀── reviews
▲ ▲
└── forge_commit_links ──▶ forge_commits ────┘ (任意)
- Issue はすでにタスクの GitHub 側の写し(1 対 1、GitHub App 基盤 §8)。PR もタスクに結べば、Issue との対応はタスク経由で自動的に取れる
- Issue を取り込んでいないプロジェクトでも PR / コミットのリンクは使える
- レビュー指摘のラウンド(
reviews)は PR に付く。PR を独立した実体にすることで、レビュー指摘 → PR → タスクと辿れるようになる(今はreviewsがrepo_owner / repo_name / pr_numberの生の組を控えているだけで、タスクとは無縁)
github_issue_links / github_integrations は GitHub 専用の名前のまま残す。GitLab を足すときに
forge_integrations へ寄せる(別仕様)。本仕様の新しい表はそれを先取りして forge_ で揃え、
連携表への FK を持たない(§2)。
既存の設計との差分
旧 Draft は task_github_links 1 表に link_type(pull_request / commit / branch)で混載する設計だった。これをやめた理由:
- PR の状態(open / merged)や head は「PR 1 つに 1 つ」の属性で、タスクとの組ごとに持つと同じ PR が 3 タスクに付いたとき 3 行が食い違う
reviewsから PR を参照できない(reviewsの側も生の組を持つ二重管理になる)- コミットは SHA、PR は番号、と自然キーが違うものを NULL 混じりの UNIQUE で束ねることになる
2. データモデル
共通: リポジトリの識別
新しい表はどれも次の 5 列でリポジトリを指す。連携表(github_integrations)への FK は持たない。
連携は解除・再連携・ホスト移行で差し替わるが、PR やコミットの記録は「どのホストのどのリポジトリの何番か」で
それ自体が完結しているべきで、連携行の生死に引きずられない(レビュー指摘が連携解除で消えないのと同じ)。
API 呼び出しに使う連携は、必要になった時点でプロジェクトの現在の連携先から引く。
host / host_url / repo_owner / repo_name の 4 列は forge-core::Repository に host を足した形で、
各ホストの実装クレートがこの形へ正規化する。レビュー指摘の連携前ラウンドは host = ''、他も空文字列。
forge_pull_requests(PR / MR)
UNIQUE (project_id, host, host_url, repo_owner, repo_name, number)。リポジトリを含める理由は
レビュー指摘 §3 と同じで、連携先の差し替え後に旧リポジトリの
#10 と新リポジトリの #10 が同じ PR として続かないようにするため。空文字列を NULL に
しないのも同じ理由(Postgres の UNIQUE は NULL 同士を別物とみなす)。
commits_complete は「PR のコミット一覧を読み切れたか」を持つ。§5 のコミット集合の同期は
一覧に無い行を削除するので、一覧が途中までしか取れていないときに削除すると関連が黙って消える。
判定を行ごとに残しておき、確定していない PR では削除を止め、UI と再同期の判断にも使う。
host_updated_at は「この行がどの時点のペイロードから作られたか」を持つ。webhook の配送順も
ジョブの実行順も保証されないので、これが無いと Closed { merged: true } を処理した後に古い
Edited が走って state が open へ戻り、タイトルや head_sha も古い値に巻き戻る。
ホスト側の更新時刻(GitHub / Forgejo の pull_request.updated_at、GitLab の
object_attributes.updated_at)をそのまま入れ、§5 の前処理で比較する。
merge_effects_processed_at は PR の現在状態ではなく、マージ時のタスク完了と Automation を
一度だけ行ったかを表す。マージ後の Edited から現在状態が先に merged になってもこの列は
更新しない。マージ時点のタイトル・本文を持つ Closed { merged: true } を処理し終えたときだけ
同じトランザクションで設定する。
例外は §9 の「確認済みにする」操作で、こちらはタスクを完了させずにこの列だけを設定し、
merge_effects_acknowledged_by に操作者を残す。2 つの値の組で「副作用を実行した」と
「利用者が手で対応を終えたので実行しないと決めた」を区別でき、どちらの場合も NULL 判定が
一度きりの保証をそのまま担う。
reviews は integration_id / repo_owner / repo_name / pr_number / pr_title / pr_author の 6 列をやめ、
pull_request_id(FK→forge_pull_requests RESTRICT)を持つ。採番の UNIQUE は (pull_request_id, round) になる。
forge_pull_request_links(PR ↔ タスク)
PRIMARY KEY (pull_request_id, task_id, source)。1 PR が複数タスクを閉じる、1 タスクに複数 PR、どちらも起きるので多対多。
参照元ごとに 1 行。 同じタスクがタイトル・本文・ブランチ・手動リンクの複数経路から参照されることは
普通にある(タイトルに ENG-42、本文に Closes ENG-42 等)。(pull_request_id, task_id) を PK に
すると 1 行に潰れて source / closes が後勝ちになり、§5 の Edited(title / body 由来だけを
作り直す)が成立しない。行を参照元単位にし、上位の概念は集計で導く:
- PR ↔ タスクが「リンクされている」= 行が 1 件以上ある
- 表示する現在のリンク集合 = 残存行を
task_idごとにまとめたもの
マージ時の自動完了はこの表の現在集合をそのまま集計しない。Closed { merged: true } の
ペイロードから作るマージ時点の title / body / branch 集合と、マージ時刻以前に作られて
処理時点にも残る manual 行を対象に task_id ごとの bool_or(closes) を評価する(§5)。
これにより、後着したマージ後編集のリンクを誤って完了対象に混ぜず、古い Closed の処理で
現在表示用のリンク集合を巻き戻さない。
forge_commits(コミット)
UNIQUE (project_id, host, host_url, repo_owner, repo_name, sha)。rebase / force-push で同じ変更が別 SHA に
なった場合は別のコミットとして積む。履歴としてはそれが正しく、古い SHA を消す根拠も無い。
project_id は webhook を受けた連携のプロジェクトではなく、リンク先タスクのプロジェクトにする。
KEY-N はテナント全体で解決する(§4)ので、同じリポジトリを同じテナントの複数プロジェクトへ連携すると、
連携ごとのジョブが同じタスクに届く。行を連携側に置くと同じコミットが連携の数だけ別の id になり、
同じタスクへのリンクと forge_commit_linked の履歴が重複する。1 つのコミットが複数プロジェクトの
タスクを参照するときは、プロジェクトごとに 1 行になる。PR のコミット集合の同期(§5)でタスクへ結ぶ
リンクも同じ規則に従う。
forge_pull_request_commits(PR ↔ コミット)
PRIMARY KEY (pull_request_id, commit_id)。コミット実体は SHA で UNIQUE の 1 行なので、
同じコミットが複数の PR に含まれるケース(同一ブランチから別 base への PR、stacked PR)を
実体側の単一 FK で持つと、後から処理した PR の Synchronize が先の関連を上書きする。
中間表で多対多として持つ。
forge_commit_links(コミット ↔ タスク)
PRIMARY KEY (commit_id, task_id)。
forge_branch_links(ブランチ ↔ タスク)
UNIQUE (task_id, host, host_url, repo_owner, repo_name, branch)。ブランチは状態も本文も持たないので
実体表を分けない。ブランチ削除イベントで消す。
forge_webhook_deliveries(webhook の受信記録)
UNIQUE (host, host_url, delivery_id)。配信 ID はホストのインスタンス内でしか一意でないので、
host_url まで含める。テナントやプロジェクトは含めない(受信そのものを一意にする鍵なので、
ペイロードをテナント・プロジェクトへ解釈するより手前で効かせる)。行を作るのは
署名検証を通った後(§5「冪等性設計」)。
揮発ストア(Redis の SET NX + TTL)ではなく永続表に置く理由は §5「冪等性設計」。
アクティビティ
コミットのリンクは task_activities に event_type = "forge_commit_linked" で 1 行積む
(payload: host / sha / message 先頭行 / author_handle / author_name / html_url)。
PR も同様に forge_pull_request_linked / forge_pull_request_merged を積む。
user_id はホスト上のアカウントが連携済みユーザーに解決できたときだけ入れ、それ以外は NULL。
コミットは作者のホスト上のログイン名(author_handle)を oauth_connections.provider_login と照合する
(大文字小文字を区別しない。provider = host かつ instance_url IS NULL)。一致する接続がちょうど 1 件で、
そのユーザーがリンク先タスクのプロジェクトに入れる人かテナントオーナーのときだけ user_id に入れる
(プロジェクトの外の人の名前を履歴に載せない)。解決できなければ表示は author_handle / author_name に頼る。
provider_login は OAuth でログイン・連携したときに控える(OAuth 認証 §2.2)ので、
それより前の接続は次回ログインまで解決できず、過去のアクティビティも遡って埋めない。
author_handle は GitHub がコミットのメールアドレスから解決した値で、署名の無いコミットはメールを偽って
他人に見せかけられる。user_id は「ホストがそのアカウントに帰属させた」の意味にとどめ、通知や権限の根拠に使わない。
アクティビティは追記のみで、リンクが消えても行は残す(履歴)。
webhook 由来ではない行が 1 つある。§9 の「確認済みにする」操作は、その時点で
リンクされている各タスクへ forge_merge_effects_acknowledged を積む
(user_id は操作者、payload は host /
pull_request_id / number / html_url)。リンクが 1 件も無い PR では積む先が無いので、
操作者は PR 行の merge_effects_acknowledged_by にも持つ。
同じ出来事を 2 回積まないための鍵を行に持たせる。 task_activities に
dedupe_key VARCHAR NULLABLE UNIQUE を足す。連携由来の行だけが鍵を持ち、
既存のアクティビティ(手で起きる操作の履歴。同じ操作を 2 回すれば 2 行が正しい)は NULL のまま。
UNIQUE は NULL 同士を別物とみなすので、NULL の行は何行でも積める。
部分 UNIQUE インデックス(WHERE dedupe_key IS NOT NULL)にはしない。起動時の SeaORM の schema sync は
entity に無い UNIQUE インデックスを DROP CONSTRAINT で消そうとし、インデックスは制約ではないので
起動に失敗する。
積むときは次で、行が返ったときだけ「初回」とみなす。
INSERT INTO task_activities (...) VALUES (...)
ON CONFLICT (dedupe_key) DO NOTHING
RETURNING id
現在のリンク行を数えて初回を判定しない。 リンクを外す DELETE は
その PR ↔ タスクの全 source の行を消す(§8)ので、その後の Edited で自動リンクが復活すると
「他の source の行が無い」が再び成立し、履歴に同じ行がもう 1 本積まれる。
同時に届いた 2 つのイベントでも、事前確認どうしはすれ違うが UNIQUE はすれ違わない。
3. マイグレーション
1 本のマイグレーションで次を行う。順序が重要(reviews の付け替えは PR 行を先に作る)。
CREATE TABLE forge_pull_requests (...); -- §2
CREATE TABLE forge_pull_request_links (...);
CREATE TABLE forge_commits (...);
CREATE TABLE forge_pull_request_commits (...);
CREATE TABLE forge_commit_links (...);
CREATE TABLE forge_branch_links (...);
CREATE TABLE forge_webhook_deliveries (...);
-- 連携由来のアクティビティだけが冪等キーを持つ(既存行は NULL のまま)
ALTER TABLE task_activities ADD COLUMN dedupe_key VARCHAR UNIQUE;
-- 既存の reviews から PR 行を起こす(DISTINCT な組ごとに 1 行。
-- title/author は round が新しい順で最初の非 NULL 値を写す)
-- 既存行はすべて GitHub.com なので host は固定。連携前ラウンド(repo_owner = '')は host も ''
-- reviews.pr_title / pr_author は NULLABLE で、CLI 以外から作られたラウンドは両方 NULL。
-- 移行先は NOT NULL DEFAULT '' だが、INSERT ... SELECT は NULL を明示的に入れるので
-- DEFAULT は効かない。COALESCE で空文字列に落とす
INSERT INTO forge_pull_requests (id, project_id, host, host_url, repo_owner, repo_name, number, title, author_handle, state, html_url)
SELECT gen_random_uuid(), project_id,
CASE WHEN repo_owner = '' THEN '' ELSE 'github' END,
CASE WHEN repo_owner = '' THEN '' ELSE 'https://github.com' END,
repo_owner, repo_name, pr_number,
COALESCE(
(array_agg(pr_title ORDER BY round DESC)
FILTER (WHERE pr_title IS NOT NULL))[1],
''
),
COALESCE(
(array_agg(pr_author ORDER BY round DESC)
FILTER (WHERE pr_author IS NOT NULL))[1],
''
),
'unknown',
CASE WHEN repo_owner = '' THEN '' ELSE 'https://github.com/' || repo_owner || '/' || repo_name || '/pull/' || pr_number END
FROM reviews GROUP BY project_id, repo_owner, repo_name, pr_number;
ALTER TABLE reviews ADD COLUMN pull_request_id UUID REFERENCES forge_pull_requests(id) ON DELETE RESTRICT;
UPDATE reviews r SET pull_request_id = p.id FROM forge_pull_requests p
WHERE (r.project_id, r.repo_owner, r.repo_name, r.pr_number) = (p.project_id, p.repo_owner, p.repo_name, p.number);
ALTER TABLE reviews ALTER COLUMN pull_request_id SET NOT NULL;
ALTER TABLE reviews DROP CONSTRAINT <uq_reviews_round>;
ALTER TABLE reviews DROP COLUMN repo_owner, DROP COLUMN repo_name, DROP COLUMN pr_number,
DROP COLUMN pr_title, DROP COLUMN pr_author, DROP COLUMN integration_id;
ALTER TABLE reviews ADD CONSTRAINT uq_reviews_round UNIQUE (pull_request_id, round);
CREATE INDEX idx_forge_pr_links_task ON forge_pull_request_links(task_id);
CREATE INDEX idx_forge_commit_links_task ON forge_commit_links(task_id);
CREATE INDEX idx_forge_pr_commits_commit ON forge_pull_request_commits(commit_id);
CREATE INDEX idx_forge_branch_links_task ON forge_branch_links(task_id);
CREATE INDEX idx_forge_webhook_deliveries_created_at ON forge_webhook_deliveries(created_at);
reviews.integration_id は消える。「連携が無いラウンド」は PR 行の host = '' で表す。
4. タスクへの自動リンク
パターン一覧
PR タイトル・PR 本文・コミットメッセージ・ブランチ名から KEY-N を正規表現でパースする。
正規表現: \b([A-Z][A-Z0-9]{1,9})-(\d+)\b(大小文字不問でマッチさせ、KEY は大文字に正規化して照合)
クローズキーワード: Close / Closes / Closed / Fix / Fixes / Fixed / Resolve / Resolves / Resolved / Implement / Implements / Implemented(大小文字不問。GitHub と GitLab の集合の和)
PR タイトルおよび PR 本文の両方が対象。ホスト自身の Closes #N(Issue クローズ)と構文が異なるため衝突しない。
KEY-N の解決
KEY-N はまず KEY でテナント内のプロジェクトを特定し、その seq_id=N のタスクを探す。
KEY が不明またはタスクが見つからない場合はリンク追加をスキップ(エラーにしない)。
1 つのコミット / PR が複数プロジェクトのキーを含む場合、それぞれのプロジェクトのタスクへリンクする。
リンクできるのは、webhook を受けた連携と同じテナントのプロジェクトだけ。
リポジトリは 1 プロジェクトに連携されるが、キーの解決はテナント内の全プロジェクトに及ぶ
(ENG-42 BACK-7 のように複数プロジェクトを 1 PR で触ることは普通にある)。
別テナントのキーは同じ文字列でも解決しない(他テナントのタスクへ外部から行を足せてしまう)。
抽出は service::forge::task_refs に置き、固定テストで次を確認する:
TASK-140 / task-140 / [TASK-140] / feat/TASK-140-foo / 複数キー / 別プロジェクト混在 /
TASK-1400 が TASK-140 に誤マッチしない / SHA256-1 のような偽陽性の扱い(KEY として
解決できなければ捨てるだけなので許容) / クローズキーワードの有無で closes が変わる。
Issue 経由の解決
PR 本文の Closes #12(ホストの Issue 番号)は、同じリポジトリの Issue #12 が
github_issue_links でタスクに結ばれていれば、そのタスクへの closes = true リンクとして扱う。
Issue を取り込んでいるプロジェクトでは KEY-N を書かなくてもホストの流儀のままでリンクされる。
GitLab は #12 が Issue の iid、!12 が MR なので、# だけを Issue として見る(! は無視)。
5. Webhook
受信口と正規化
ホストごとに受信口を分け、署名検証とペイロード変換だけをそこで行う。
変換後は共通の正規化イベントとして 1 つのジョブ(job::forge_webhook)へ積む。
タスク側の処理はホストを知らない。
正規化イベント(service::forge::events):
enum ForgeEvent {
Push { repo: ForgeRepo, ref_name: String, forced: bool, after: String, commits_truncated: bool, commits: Vec<ForgeCommit> },
PullRequest { repo: ForgeRepo, action: PrAction, pr: ForgePullRequest }, // Opened / Edited / Synchronize / Closed { merged } / Reopened
BranchCreated { repo: ForgeRepo, branch: String },
BranchDeleted { repo: ForgeRepo, branch: String },
}
ForgeRepo は §2 の共通 4 列。ホスト固有の語彙(GitHub の installation、GitLab の object_kind /
iid、Forgejo の X-Forgejo-Event)は受信口の外へ出さない。
切り詰められた push: ホストは push の commits に上限を設ける(GitHub は
2048 件。載るのは古い側で、
新しい側が欠ける)。ペイロードに総件数を示すフィールドは無いので、受信口は「上限ちょうどで届いたか」
を commits_truncated に落とし、範囲として before(push 前の ref の先頭。ブランチを作った
push では None)と after(push 後の ref の先頭)を持たせる。
ワーカーは commits_truncated のとき service::github::commits で差分を取り直す。
既存ブランチへの push は GET /repos/{owner}/{repo}/compare/{before}...{after} を引く。
push で増えたコミットはこの比較そのもので、after からの履歴を辿る方法では、マージを含む履歴で
別の親から辿れる push 前のコミットまで混ざる(一覧は日付順なので、受信済みの最新コミットより先に
出てくる)。ブランチを作った push は比較の起点が無いので、after から履歴を辿る。
取り直しは 1 ジョブにつき 1 ページ(100 件)で、続きがあれば次のページを持つジョブを積む。 1 回のジョブで全ページを読もうとすると、極端に大きな push ではページ上限に当たり続け、 何度再試行しても同じ場所で失敗して進まない。取り直せなければジョブを成功させず再試行する。 配信 ID は受信時点で記録済みで、成功にすると欠けたコミットは二度と処理されないため。
続きの投入は、backfill:{配信 ID}:{連携 ID}:{プロジェクト ID}:{after}:{ページ} を鍵として
受信記録(forge_webhook_deliveries)へ入れ、取れたときだけ行う。鍵の INSERT とジョブ投入は
受信時と同じく 1 トランザクションで確定する。ページ N が N+1 を積んだあと、N の完了が記録される
前にワーカーが落ちると N は再実行されるので、鍵が無いと続きのジョブがページごとに増えていく。
ForgePullRequest は number / title / body / author_handle / state / head_branch / head_sha /
html_url / merged_at に加えて、ホスト側の更新時刻 updated_at を必ず持つ。受信口が
ホストのペイロードから取り出して詰める(GitHub / Forgejo は pull_request.updated_at、
GitLab は object_attributes.updated_at)。§5 の前処理はこれを世代として使うので、
取り出せないホストを足すときは、そのホストの適用規則を先に決める。
登録
登録が要るホストでは、連携表に hook の id と secret(暗号化)を持つ。これは
forge_integrations を作るときに入れる列で、本仕様では GitHub のみなので触らない。
冪等性設計
ホストは障害時に同一イベントを複数回送信することがある。配信固有 ID(GitHub X-GitHub-Delivery、
GitLab X-Gitlab-Event-UUID、Forgejo X-Forgejo-Delivery)を使って二重処理を防ぐ。
受信記録は Postgres に置き、ジョブ投入と同じトランザクションで確定する。
書き込む順序は「署名検証 → 受信記録 → ペイロードの解釈」。 署名検証より先に受信記録を作ると、
誰でも任意の delivery_id で永続表を増やせるうえ、正規のイベントより先に同じ配信 ID を
入れられると本物が重複として捨てられる。
受信時:
署名検証 → 失敗なら 403 を返して終わり(受信記録もジョブも作らない)
署名検証を通ってから、ペイロードを業務データへ解釈する前に(1 トランザクション):
INSERT INTO forge_webhook_deliveries (host, host_url, delivery_id)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING id
→ 行が返る(初回): 同じトランザクションでジョブを積む → COMMIT → 200 OK
→ 行が返らない(重複): 何もせず 200 OK
ジョブ投入が失敗したら ROLLBACK。受信記録も消えるので、ホストの再送が初回として処理される
apalis のキューは同じ Postgres(apalis.jobs)なので、受信記録とジョブ投入を 1 つの
トランザクションに入れられる。ジョブ投入後の失敗はキュー側の再試行に任せる。
重複排除の鍵を先に立てて後からジョブを積む形(Redis の SET NX + TTL など)にはしない。
署名検証を通った正当なイベントでも、鍵を立てた後にジョブ投入が落ちるとホストの再送が
「重複」として 200 で捨てられ、そのイベントは二度と処理されない。揮発ストアでは
「処理済み」と「鍵だけ立った」を区別できないため、この穴は TTL では塞げない。
古い受信記録は掃除ジョブで 30 日分だけ残す(ホストの再送は長くても数時間で終わる。 UNIQUE を保つのに必要なのは再送が届きうる期間だけ)。
実体(forge_pull_requests / forge_commits)とリンク表の INSERT は常に UPSERT で行い、
再送でも行が増えないようにする。アクティビティ行は「そのタスクにとって初めてのリンク」の
ときだけ積む。判定は現在のリンク行を数えるのではなく、task_activities.dedupe_key の
UNIQUE で行う(§2「アクティビティ」)。
Closed { merged: true } 時のタスク遷移は、タスクが既に done 系ステータス(is_done_state = true)の場合はスキップして二重遷移を防ぐ。
イベント一覧(正規化後)
PRイベント共通の前処理
PullRequest の すべての action は、下記の比較で適用対象のスナップショットを決め、
forge_pull_requests の更新とリンク集合の再構築を共通処理として行う。PR 行がまだ無ければ
ここで作成するため、Opened を受信していない既存 PR でも最初の Synchronize / Edited /
Closed / Reopened だけで実体とリンクが揃う。
何を適用するかは、イベントの updated_at と既存行の host_updated_at の比較で決める。
webhook の配送順もジョブの実行順も保証されないので、無条件に適用すると
Closed { merged: true } の後に届いた古い Edited が state を open へ戻し、
タイトルと head_sha も巻き戻る。
この比較から UPSERT、title / body / branch の現在リンク再構築、トランザクションの
COMMIT までを、PR の自然キー(project_id + host + host_url + repo_owner +
repo_name + number)から導いた同じ transaction-level advisory lock の中で行う。
トランザクションを開始してこのロックを取得してから host_updated_at を読み、= のときの
ホスト API 取得もロック保持中に行う。行がまだ無い場合も pull_request_id ではなくこの自然キーの
ロックを先に取るため、同じ PR を初めて作る並行ジョブが別々の行を作ったり、同じ旧世代を読んで
後から古いスナップショットとリンク集合を COMMIT したりしない。ロック解放は COMMIT / ROLLBACK
に任せ、< の判定を含む比較と書き込みを同じトランザクションで再確認する。
既存行に host_updated_at が無い(NULL = CLI の review submit だけで作られた行や
移行直後の行)ときは > として扱う。
同値(=)でペイロードをそのまま使わないのは、ホストの更新時刻が秒精度だから。
同じ秒に起きた別 action(本文編集とクローズなど)はどちらも = になり、どちらが後かを
イベントからは決められない。ペイロードを適用すると、同じ秒の古い Edited / Synchronize が
後に届いたときに title・本文由来のリンク・head_sha が古い内容へ戻る。
かといって捨てると同じ秒の別 action を取りこぼす。現在のスナップショットを取り直せば
どちらも起きない。取得に失敗したら(レート制限・5xx・ネットワーク断)部分的に適用せず
ジョブを失敗させ、キューの再試行に任せる。曖昧なイベントを推測で確定しない。
適用対象が決まったら、元の action を状態遷移の根拠にはしない。スナップショットの
merged_at があれば実効状態を merged、無ければスナップショットの state(open / closed)
とし、少なくとも title / author_handle / state / head_sha / html_url / merged_at /
host_updated_at を UPSERT する。これにより、同じ秒の Closed { merged: false } と Reopened が
逆順で届いても、古い action の state → closed / state → open を取得後に重ねて実行しない。
マージは取り消せないので、既存行が merged なら取得・ペイロードのスナップショットが
open / closed でも戻さない。ここで確定する実効状態は現在表示用であり、マージ時の
タスク完了・Automation の処理済み判定には使わない。
さらに、適用対象になった すべての PR action で、そのスナップショットのタイトル・本文・
head ブランチから title / body / branch のリンクを再構築する。元の action が
Synchronize / Reopened / Closed { merged: false } でも例外にしない。manual のリンクは残す。
「再構築」は追加だけでなく削除も含む。 対象の source について、適用対象のスナップショットから
導いたリンクの集合と一致させる(現在の行のうち集合に無いものを削除し、足りないものを INSERT する)。
削除まで含めるのは同一トランザクション内で行う。この集合は現在表示用であり、マージ時の
完了対象は次の独立した処理で決める。
どの action でもこれを行うのは、連携開始前から存在する PR の最初のイベントが Opened とは
限らないためである。また、マージイベントが現在状態にも適用される場合は、この再構築によって
マージ時点の表示へ揃う。ただし、後着した古いマージイベントは現在表示用リンクを再構築しない。
マージ時副作用の独立処理
Closed { merged: true } だけが、タスク完了と Automation の根拠になる。現在状態の同期とは
独立に、イベントの updated_at が host_updated_at より古い場合もこの処理を行う。
マージ後の Edited / Synchronize や、PR 取得 API の現在スナップショットで merged_at を
観測しただけでは行わない。現在スナップショットからはマージ後に編集される前の本文を復元できないためである。
処理では、元の Closed { merged: true } ペイロードのタイトル・本文・head ブランチを解析し、
そのイベント固有のマージ時点集合をメモリ上で作る。現在表示用の forge_pull_request_links は
この集合へ戻さない。自動完了の候補は、次の行を task_id ごとにまとめて bool_or(closes) が
true になる未完了タスクである。
- マージ時点集合の
title/body/branch由来の参照 created_at <= merged_atで、処理時点にも残っているmanual行
manual 行は自動再構築の対象外なので残し、マージ後に追加された手動リンクは過去のマージの
完了対象にしない。マージ前に作られていても処理前にユーザーが削除した手動リンクは、明示的な
リンク解除を尊重して対象にしない。
PR 行をロックし、merge_effects_processed_at IS NULL の場合だけ、上記タスクの完了、Automation
github_pr_merged の永続ジョブ投入(トリガー名はホスト中立に pull_request_merged へ改名し、
旧名は別名として受ける)、merge_effects_processed_at の設定を同一 DB トランザクションで行う。
これにより同じ PR の再試行・重複配送・並行 Closed でも副作用は一度だけになる。処理に失敗した
場合はすべてロールバックし、列を NULL のままにして再試行する。
Closed { merged: true } を一度も受信できず、別 action の現在スナップショットだけで初めて
merged を観測した場合、PR の表示状態は merged に更新するが、副作用は推測せず
merge_effects_processed_at = NULL のまま保持する。後から Closed が届けば、そのイベントが
古くても上記処理を行う。現在スナップショットからはマージ時点の本文を復元できないため、
現在リンクされているタスクを一括で自動完了する回収処理は設けない。
Closed が届く見込みが無い PR は、利用者が手で対応を終えた時点で §9 の「確認済みにする」操作
(§8 の POST .../merge-effects/acknowledge)から merge_effects_processed_at を設定できる。
以後この PR の副作用は上の NULL 判定で実行されないため、後から Closed が届いてもタスクを
二重に完了させない。
PR のコミット集合の同期
Opened と Synchronize の両方で、updated_at の比較結果にかかわらず PR の
コミット一覧 API(GitHub は GET /repos/{owner}/{repo}/pulls/{number}/commits)を引き、
その PR に属する forge_pull_request_commits を 一覧と一致させる。
これは同値の場合だけ呼ぶ PR 取得 API(GET /pulls/{number})とは別の取得である。
同じ PR の同期は、最初の API リクエストから DB 反映の COMMIT まで直列化する。
ワーカー全体の並列度には依存せず、トランザクション開始後に Postgres の pull_request_id 由来の
pg_advisory_xact_lock を API の 1 ページ目を取得する前に取り、手順 1〜4 と
commits_complete の更新を確定してから解放する。別プロセスのワーカーにも同じ PR 識別キーが
効き、COMMIT / ROLLBACK で自動解放されるものとする。ロックを DB 反映時だけ取ると、古い一覧を
先に取得したジョブが新しい一覧の反映後に上書きできるため不十分である。
このロックにより、先行ジョブの取得中に force-push と後続イベントが起きても、後続ジョブは 先行ジョブの反映完了後に API を取得する。したがって最終的には後続ジョブが取得した現在一覧へ 収束し、取得 A → force-push → 取得・反映 B → 反映 A という逆転は起きない。
取得は全ページを読み切る。 ホストの既定ページサイズに任せない(GitHub は既定 30 件・
最大 100 件)。per_page を最大にして次が無くなるまで読む。終端の判定は、返った件数が
per_page 未満になったとき(既存の Issue 取り込み service::github::issues と同じ判定)か、
ホストが Link ヘッダを返す場合は rel="next" が無くなったとき。
読み切れたかを持ち回る。 全ページを読めたら forge_pull_requests.commits_complete = true、
途中のページで失敗した・ホストの上限に当たったら false にする。
-
一覧の各コミットで
forge_commitsを UPSERT する(Pushと同じ正規化。Pushwebhook を 受けていないコミット(連携前の push、PR 作成だけで push が続かない場合)はここで実体ができる) -
メッセージをパースして
forge_commit_linksを UPSERT し、アクティビティは新規行のときだけ積む(Pushと同じ) -
forge_pull_request_commitsを UPSERT する -
読み切れたときだけ、その
pull_request_idの行のうち一覧に無いものを削除する
4 が要る理由は、rebase / force-push で PR から外れたコミットが所属したまま残るため。
コミット実体を履歴として残すことと、現在の PR 所属関係を保つことは別に扱う
(forge_commits 本体・forge_commit_links・アクティビティは削除しない。消すのは中間表の行だけ)。
削除は その pull_request_id に限る。同じコミットが別の PR にも属している場合、
他の PR の行は対象外なので残る(§2 の多対多が要る理由と同じ)。
読み切れなかったときに削除しない理由
一覧が短く返るのは、ホストがエラーではなく 200 で返す。取得の不完全さがそのまま 削除の指示に化けるため、手順 4 に条件を付けないと次の形で関連が黙って消える。
-
既定の
per_page(GitHub は 30)で 1 回だけ呼ぶと、31 コミット以上の PR では それ以外の全行が消える。同期は毎回「今回の一覧」を正とするので、次のSynchronizeでも戻らない -
ページングの途中で失敗しても、そこまでの一覧を正としてしまえば同じことが起きる
読み切れなかったときは手順 1〜3 の UPSERT だけ行う。追加は取りこぼしても次の同期で入るが、 削除は一度実行すると戻らないため、倒れる向きを追加側に寄せる。
ホストの上限に当たる PR
GitHub の「PR のコミット一覧」は 250 件が上限で、それを超える PR ではこの API から
集合を確定できない(ページングを読み切っても 250 件で頭打ちになる)。この場合は
commits_complete = false のままにして削除を行わない。§9 のリポジトリセクションでは
「コミット一覧が多すぎて確定できていない」ことを出す。
確定させる必要が出たら、上限のない GET /repos/{owner}/{repo}/commits?sha={head_sha} 側で
base との差分を組む方針にする(この仕様の範囲では実装しない)。
GitLab / Forgejo も同種のページングを持つので、条件はホストごとの件数ではなく
**「全ページを読み切れたときだけ削除する」**という形で持つ。ホスト固有の上限
(GitHub の 250 件)は受信口側で commits_complete に落とし、タスク側の処理は
フラグだけを見る。
Opened を同期の起点に含めるのは、PR 作成時点のコミットがその後 push されない限り
中間表へ入らないため。PR 作成後に何も push しない運用は普通にある。
GitHub の issues イベントは従来どおり(GitHub App 基盤)。正規化の対象外。
過去分の取り込み
コミットの取り込み口は 2 つ。webhook の Push と、Opened / Synchronize を契機に引く
PR のコミット一覧(上記)。後者は Push を受けていないコミット(連携前の push、PR 作成だけで
push が続かない場合)の実体・タスクリンク・アクティビティもここで作る。
ただし どちらも「webhook を受けた PR / push」の範囲に限る。連携開始時に
リポジトリ全体を遡る処理は行わない(POST /github/import は Issue 専用のまま)。
必要になったら別 Issue で「open な PR と既定ブランチの直近 N コミット」を
取り込む拡張を足す。
6. CLI との合流
task review submit は pr 番号を受け取っている。ラウンド作成時に
forge_pull_requests を (project_id, 現在の連携先のホストとリポジトリ, pr) で UPSERT してから
reviews.pull_request_id に結ぶ。webhook をまだ受けていなければ state = unknown の行が
先にできる。これで CLI から出たレビュー指摘も、後から届いた webhook で同じ PR 行に合流する。
review summary の head 一致検査は従来どおりラウンドの head_sha を見る
(forge_pull_requests.head_sha は表示用の補助であり、ゲートの根拠にはしない。webhook の
遅延で古い値になりうる)。
読み取りの review list / review rounds / review summary と対応する API は、
--repo owner/name / repo=owner/name に加えて --host / host、--host-url /
host_url、または --pull-request-id / pull_request_id を PR の明示的な識別子として
受け取る。pull_request_id を指定した場合はそれを正とし、併記した repo / host /
host_url が対象行と一致しなければ 400 で弾く。
--repo owner/name --pr N(API の repo + pr)だけの指定は、同じプロジェクト内で
host + host_url まで含む候補が 1 件のときだけ許可する。複数候補なら API は 409、CLI は
非 0 で終了し、host_url(必要なら host)または pull_request_id の再指定を要求する。
候補が無い場合も黙って現在の連携先へ切り替えない。GitLab の nested namespace は
group/sub/name を最後の / で割る(owner に / を含みうるのは §2 のとおり)。
7. ホスト側へのフィードバック
PR 作成時に、リンクされたタスクへの URL をコメントとして PR に自動追加する(GitHub は権限 pull_requests: write が必要)。
---
🔗 Linked Task: [ENG-42 OAuth 対応を実装する](https://app.example.com/projects/.../tasks/42)
コメント投稿はホストごとの薄い関数(forge-github / 将来の forge-gitlab)に閉じる。
レビュー指摘 §7 の要約コメントと同じ PR に 2 本のコメントが並ぶ。
統合は将来の課題とし、本 PR ではマーカー(<!-- task:linked -->)で自分のコメントを見分けて
再解析時に同じコメントを更新するところまでにする。
8. API
パスはすべて /v1/tenants/{tenant_id}/projects/{project_id} を前に付けたもので、以下は相対で書く。
PR 行は project_id を持つ(§2)ので、PR を主語にする API もプロジェクトの下に置く。
{pull_request_id} だけを鍵にした口を作ると、id を知っているだけの利用者に
別テナント・別プロジェクトの PR とリンク先タスクが返る。
レスポンスの各行は host / host_url / repo_owner / repo_name / html_url を含む。
フロントエンドは host で表示ラベル(Pull Request / Merge Request)とアイコンを切り替え、URL は html_url をそのまま使う(組み立てない)。
GET /tasks/{id}/forge の PR 配列、GET /forge/pull-requests/{pull_request_id}、レビュー画面の
PR 一覧が使う ReviewedPullRequest には merge_effects_processed_at も含め、state = merged かつ
同フィールドが NULL の状態を通常のマージ済みと区別できるようにする。
POST /tasks/{id}/forge/pull-requests リクエスト:
{ "number": 87, "closes": false }
リポジトリはプロジェクトの現在の連携先で確定する(repo は受け取らない。連携が無ければ 409)。
PR 行が無ければ state = unknown で作り、ホストの PR 取得 API が引ければ title / author / state / html_url を埋める。
手動リンクは source = manual の行として、自動解析(title / body / branch)の行と独立に共存する。
同じタスクが本文にも書かれていても、手動の行を自動解析が上書き・削除することはない(§2)。
DELETE /tasks/{id}/forge/pull-requests/{pull_request_id} は その PR ↔ タスクの全 source の行を消す
(ユーザーの意図は「このタスクから外す」であって参照元の指定ではない)。ただしタイトル・本文に
KEY-N が残っていれば、次に適用対象となる PR イベントの共通処理で title / body 由来の行が復活する。
リンクを恒久的に外したいならホスト側の記述も直す必要がある(GitHub の Issue リンクと同じ挙動)。
コミットとブランチの手動リンクは無し(コミットは KEY-N をメッセージに書き直せない事情があるが、
手動 PR リンクで代替できる)。
POST /forge/pull-requests/{pull_request_id}/merge-effects/acknowledge は PR 行をロックし、
state = merged かつ merge_effects_processed_at IS NULL のときだけ、
merge_effects_processed_at を現在時刻、merge_effects_acknowledged_by を呼び出し元にして
200 を返す。タスクの完了と Automation の投入は行わない(リクエストボディは無い)。
同じトランザクションでリンク先タスクへ forge_merge_effects_acknowledged を積む(§2)。
すでに列が設定済み(副作用の処理済み・確認済みのどちらも)なら 409、state が merged で
なければ 409。テナント・プロジェクトの境界は GET /forge/pull-requests/{pull_request_id} と
同じで、パスのプロジェクトと project_id が一致しない id は 404。
権限
webhook 経由のリンクはシステムが行うので権限検査を経ない。
この口だけ 2 スコープを要求するのは、1 レスポンスでタスク名(task 系)とレビューラウンド
(review 系)の両方を返すため。レビュー指摘 §4 は
「レビュー専用の AI にタスク書き換え権限を渡さない」ために read:review を独立させており、
逆向き(task 系のスコープしか持たない PAT がレビューを読める)も同じ分離を崩す。
write:review は read:review を包含する(同 §4)ので、read:task + write:review でも通る。
PR 詳細の境界。 この口はレビュー内容とタスク名を返すので、次の 3 つを守る。
-
パスのテナント・プロジェクトに対する認可を先に通す。PAT は
allowed_project_idsの制限もここで効かせる -
PR 行の
project_idがパスのプロジェクトと一致しなければ 404。403 にすると、id を総当たりするだけで 他テナントの PR 行の有無が分かる -
linked_tasksは呼び出し元が閲覧できるプロジェクトのタスクだけ返す。KEY-Nの解決はテナント内の 全プロジェクトに及ぶ(§4)ので、1 つの PR のリンク先が呼び出し元の見えないプロジェクトに伸びる。 隠した件数も返さない(件数だけでも他プロジェクトの作業量が漏れる)
レビューラウンドは PR 行にぶら下がる(reviews.pull_request_id)ので、2 を通せばそのまま同じ境界に収まる。
ReviewResponse / ReviewedPullRequest(レビュー指摘 §5)は
pull_request_id と linked_tasks: [{ id, seq_id, key, title }] を返すようになる。
pr_number / repo_owner / repo_name / pr_title / pr_author のフィールドは PR 行から取って同じ名前で返す(CLI の互換維持)。
この 2 型を返すすべての経路(レビューの一覧・詳細・作成結果とレビュー済み PR 一覧を含む)で、
各経路の既存の review 認可(読み取りは read:review、作成は write:review)に加えて
linked_tasks のフィールド単位の認可を行う。呼び出し元が
有効な read:task(write:task / admin:tenant による包含を含む)を持たない場合、レスポンス
自体の従来の成否は変えず、成功応答の linked_tasks は空配列にする。これにより review 専用の
既存 PAT を新たに 403 にせず互換性を保ち、タスクのキー・タイトルを漏らさない。
read:task を持つ場合も、各リンク先についてテナントとプロジェクトの閲覧認可を適用する。
PAT に allowed_project_ids の制限が設定されている場合は、その集合に含まれるプロジェクトの
タスクだけを返す。セッション認証にも同じプロジェクト閲覧認可を適用する。見えないリンク先は
配列から除き、隠した件数も返さない。
9. フロントエンド(Phase B)
タスク詳細「リポジトリ」セクション
┌─────────────────────────────────────────────┐
│ リポジトリ [+ PR をリンク] │
├─────────────────────────────────────────────┤
│ Pull Requests │
│ ✅ #87 feat: OAuth 対応 ENG-42 [Merged] 未解決なし │
│ 🔄 #91 fix: エラーハンドリング [Open] 2 件が未解決 │
│ │
│ Commits │
│ a3f92c1 fix: トークン期限切れを修正 yupix │
│ e7b81d4 feat: GitHub App 初期実装 yupix │
│ │
│ Branches │
│ feat/ENG-42-oauth │
└─────────────────────────────────────────────┘
見出しの「Pull Requests」は host = gitlab なら「Merge Requests」。
PR 行の「未解決 N 件」は reviews/summary と同じ集計(レビュー指摘 §8 のバッジと同じ文言)。
クリックでレビュー画面のその PR へ遷移する。
コミットはタスク詳細のアクティビティにも時系列で並ぶ(§2 アクティビティ)。
commits_complete = false の PR は、コミット集合を確定できていない(ホストの上限に当たった・
一覧を読み切れなかった)。その PR の行に「コミット一覧は一部のみ」と添える。空表示にはしない:
取れているコミットは正しく、確定していないのは「これで全部か」だけなので、
出ているものが間違っていると読ませない。
state = merged かつ merge_effects_processed_at = NULL の PR は、PR 一覧と PR 詳細の両方に
「マージ済み(自動完了は未適用)」と表示する。詳細では、現在リンクされているタスクを確認して
必要なものだけ各タスクの通常操作で手動完了する導線と、配送履歴がある場合は連携管理者へ元の
Closed { merged: true } webhook の再配送を依頼する案内を出す。現在のリンク集合はマージ後の
編集で変わりうるため、そこから完了対象を推測して一括適用する操作は置かない。再配送を受信できれば
§5 のマージ時副作用を通常どおり一度だけ処理し、表示も解消する。
表示を解消する経路を再配送だけにしない。 連携前にすでにマージ済みだった PR には配送履歴
そのものが無く、依頼する相手もいない。ホスト側の再配送も配送履歴の保持期間を過ぎれば行えない。
再配送しか無いと、利用者がリンク先タスクを手で完了させて対応を終えても印が恒久的に残り、
対応済みと未対応の区別が付かなくなって印そのものが読み飛ばされる。そのため PR 詳細には
「自動完了は未適用のまま確認済みにする」 操作を置く。この操作はタスクを完了させず、
merge_effects_processed_at を現在時刻、merge_effects_acknowledged_by を操作者にして表示だけを
解消し、誰がいつ行ったかをリンク先タスクのアクティビティに残す(§8 / §2)。確認済みにした後に
Closed { merged: true } が届いても、§5 の副作用は merge_effects_processed_at IS NULL の
ときだけ動くので、タスクを二重に完了させることはない。
設定画面(PR #9a の設定画面に使い方を追加)
┌──────────────────────────────────────────────┐
│ GitHub 連携 │
├──────────────────────────────────────────────┤
│ 接続リポジトリ: myorg/myapp [解除] │
│ │
│ 使い方: │
│ コミットや PR タイトルに ENG-42 を含めると │
│ タスク ENG-42 に自動でリンクされます。 │
│ Closes ENG-42 と書くと PR マージ時に │
│ タスクが自動的に完了になります。 │
└──────────────────────────────────────────────┘
自動リンクの ON/OFF 設定は持たない(切りたい理由が出てから足す)。
10. 実装の分割(1 関心 = 1 PR)
TASK-140(コミットのリンク)は 1 より先に出す。2 のうちコミットに要る部分 —
forge_commits / forge_commit_links / forge_webhook_deliveries / task_activities.dedupe_key の
マイグレーション、service::forge::{events, task_refs}、GitHub 受信口の push 変換と受信記録による
冪等化、Push の処理とアクティビティ — を先行させる。どれも PR 行に依存しないので 1 と独立に入れられる。
§3 のマイグレーションはこの分と残り(PR 系の表と reviews の付け替え)の 2 本に分かれる。
- PR エンティティ +
reviewsの付け替え: §2 のforge_pull_requestsと §3 のマイグレーション、review submitの UPSERT(§6)、ReviewResponseの形の維持。挙動は変わらない(回帰テストは既存の reviews 統合テストがそのまま通ること) - 正規化イベント + 抽出 + コミット / PR / ブランチのリンク:
service::forge::{events, task_refs}、GitHub 受信口の変換、残りのテーブル、§5 のイベント処理、アクティビティ。受信記録(forge_webhook_deliveries)による冪等化とdedupe_keyによるアクティビティの一意化もここで効かせる(コミット分は上記のとおり先行済み。TASK-140 はそこで閉じる) - 手動リンク API + フロントエンド: §8 / §9
- ホストへのコメント(§7)。Automation
pull_request_mergedは PR #8 の実装状況に合わせる - (別仕様)
forge_integrations+ GitLab / Forgejo の連携フローと受信口。本仕様の表と処理はそのまま使える
11. 受け入れ条件(テスト観点)
task_refs: §4 の固定テスト一式- GitHub 受信口の変換:
push/pull_request(各 action)/create/deleteの実ペイロード(fixture)が §5 の正規化イベントになる。installation等のホスト固有フィールドが正規化イベントに漏れていない(構造体に無いので型で担保) - webhook:
Pushで 1 コミット / 複数コミット /KEY-N無し / 同じ delivery の再送(行が増えない・アクティビティが増えない) / 別テナントのキー(リンクされない) - webhook の署名検証: 署名が不正なリクエストは 403 で、
forge_webhook_deliveriesの行もジョブも作られない。その後に同じdelivery_idの正当なイベントが届いたら初回として処理される(先取りで本物が重複扱いにならない) - webhook の受信記録: ジョブ投入が失敗した配信は受信記録も残らず、同じ
delivery_idの再送が初回として処理される(鍵だけ立って本処理が消えない)。host_urlだけが違う同じdelivery_idは別の配信として扱う - アクティビティの冪等: リンクを外してから本文の
KEY-Nで再リンクしてもforge_pull_request_linkedは増えない / タイトルと本文の両方にKEY-Nがあっても 1 行 / 同じ PR ↔ タスクを指す 2 つのイベントを同時に処理しても 1 行(UNIQUE で落ちて片方が DO NOTHING になる)/ 手で行う操作の履歴はdedupe_keyが NULL のままで、同じ操作を 2 回すれば 2 行積まれる Closed { merged: true }: イベントのタイトル・本文・head ブランチから作ったマージ時点集合と、created_at <= merged_atで処理時点にも残るmanual行のbool_or(closes)が true のタスクだけ完了する / 既に完了のタスクは遷移しない / 全参照がcloses = falseのタスクは触らない / マージ後に追加したmanual行は過去のマージで完了させない- マージ副作用の冪等性: 同じ PR の
Closed { merged: true }を再送・再試行・2 ジョブ並行で処理しても、対象タスクの遷移と Automation の永続ジョブはそれぞれ 1 回だけで、merge_effects_processed_atも一度だけ設定される。タスク遷移または Automation 投入を失敗させると両方と列更新がロールバックされ、再試行でまとめて成功する - マージ副作用の未適用表示: 連携前にすでにマージ済みの PR を現在スナップショットから初めて観測すると、
state = mergedにはなるがタスク完了と Automation は実行せず、merge_effects_processed_at = NULLのままになる。PR 一覧と詳細には「マージ済み(自動完了は未適用)」が表示され、詳細からリンク済みタスクを個別に確認・手動完了でき、配送履歴があれば元のClosed { merged: true }webhook の再配送を連携管理者へ依頼できる。一括の推測適用は表示しない - マージ副作用の確認済み操作: 未適用表示が出ている PR で「確認済みにする」を実行すると、リンク先タスクが 1 件も完了せず Automation の永続ジョブも投入されないまま
merge_effects_processed_atとmerge_effects_acknowledged_byが設定され、PR 一覧と詳細の未適用表示が消える。リンク先タスクにはforge_merge_effects_acknowledgedが 1 行ずつ積まれ(user_idは操作者)、2 回目の実行や再送では増えない。リンク先が 0 件の PR でも操作は成功し、記録はmerge_effects_acknowledged_byに残る。その後に元のClosed { merged: true }が届いても副作用は実行されず、タスクは未完了のままになる(対照として、確認済みにしていない PR では同じClosedで通常どおり完了する)。すでに副作用を処理済みの PR と確認済みの PR は 409、stateがmergedでない PR も 409。別プロジェクトのpull_request_idは 404、タスク編集権限が無い呼び出し元は 403(対照として編集権限のある呼び出し元は 200) Closed { merged: true }の初回観測: 連携開始時から存在していてOpenedを受信していない open PRに対し、最初に届いたClosedだけでPR実体(最新の title / author / state / head_sha / html_url)とタイトル・本文・headブランチ由来のリンクが作成され、Closes KEY-Nのタスクが完了する- PRイベントの共通スナップショット: 各 action を
Openedなしの最初のイベントとして受信しても、PR 行が作成または最新スナップショットで更新され、title/body/branchのリンク集合がすべて再構築される。特に最初のイベントがSynchronize/Reopened/Closed { merged: false }の各ケースで、タイトル・本文・head ブランチのKEY-Nが直ちにリンクされる - PRイベントのスナップショット並行実行: ワーカー並列度を 2 以上にし、同じ自然キーで行が無い状態から 2 ジョブを開始する。先行ジョブをスナップショット取得後に停止し、後続ジョブを開始して旧世代の取得・逆順 COMMIT を狙う停止点を入れても、自然キー由来の transaction-level advisory lock が比較から UPSERT・3 source のリンク再構築・COMMIT までを直列化し、後続ジョブは比較前にロック取得を待って世代を再読する。古い PR 状態やリンクへ巻き戻さず、同じ PR の初回作成でも行は 1 件だけになる。別 PR のジョブは並行して進められる
- 配送順の逆転:
Closed { merged: true }を処理した後に、それより古いupdated_atのEdited/Opened/Synchronizeが届いても、stateがmergedのまま・titleとhead_shaが巻き戻らない・リンクが再構築されない。古いOpened/Synchronizeでもコミット一覧 API は呼ばれ、コミット集合だけは現在一覧に同期する。対照として、新しいupdated_atのイベントはペイロードどおり現在状態へ適用される。host_updated_atがNULLの既存行(CLI のreview submitだけで作られた行)に最初のイベントが届いたときも適用される - マージ後編集の先着: マージ時本文が
Closes ENG-42、マージ後本文がCloses ENG-43のとき、新しいEditedを古いClosed { merged: true }より先に処理すると、現在表示用リンクと PR 行は編集後の内容になり、merge_effects_processed_atは NULL のままになる。後着したClosedは現在表示を巻き戻さず、イベント本文から ENG-42 だけを完了し、ENG-43 は完了せず、副作用と列の設定を一度だけ行う - 同じ秒のイベント: 同一
updated_atのEdited/Synchronize/Closed { merged: true }を逆順(Closed→Synchronize→Edited)で処理しても、title/body由来のリンク /head_shaが古いペイロードの内容に戻らず、PR 取得 API が返す現在のスナップショットと一致する(ペイロードにしか無い値が入らないこと)。同値のときだけ PR 取得 API(GitHub はGET /repos/{owner}/{repo}/pulls/{number}) が呼ばれ、>と<では呼ばれない。PR 取得 API が 5xx を返したときは PR 行もリンクも一切変わらずジョブが再試行される - 同じ秒の状態遷移: 同一
updated_atのClosed { merged: false }とReopenedを両方の配送順で処理し、いずれも最終的なstateとリンク集合が PR 取得 API の現在のスナップショットと一致する。後着した元 action の状態遷移を重ねて実行しない。取得結果がmergedでもClosed { merged: true }を受信していなければ、表示状態だけをmergedにしてmerge_effects_processed_atは NULL のまま、タスク完了と Automation は行わない Closedのリンクと副作用: 本文がCloses KEY-Nの PR から本文の参照が消えたままEditedを受け取れず、その状態でClosed { merged: true }が届いたとき、現在表示用のbodyリンクから KEY-N が消え、マージ時点集合にも無いため完了しない。対照としてマージ時点のイベント本文にCloses KEY-Nが残っていれば完了する。manualリンクはどちらの場合も自動再構築で消えず、マージ時刻以前に作られ処理時点にも残るcloses = trueの行だけが完了判定に加わるEdited: 本文からKEY-Nを消すとbody由来のリンクが消え、manualは残る- 複数参照元: タイトルと本文の両方に同じ
KEY-Nがあるとsource違いの 2 行になり、本文から消してもtitle由来が残る。マージ時のイベントでは現在行ではなくイベント固有の集合を参照元ごとに集計し、本文だけCloses付きでマージ時点には本文から消えていれば自動完了しない - 手動リンクの共存:
manualの行があるタスクが本文にも書かれ、その後本文から消えてもmanualは残る。アクティビティは(pull_request_id, task_id)として初回の 1 回だけ積まれる - コミット集合の同期:
Openedだけで(Pushを受けずに)PR のコミットが中間表に入る /Opened/Synchronizeはupdated_atの比較が>/=/<のどれでもコミット一覧 API を呼び、同じ秒の古いイベントが後着しても集合が現在の一覧から巻き戻らない / force-push で一覧から外れたコミットは中間表の行が消え、forge_commitsとforge_commit_linksとアクティビティは残る / 同じコミットが 2 つの PR のコミット一覧に出たとき両方の PR に結ばれ、片方の同期が他方の行を消さない - コミット集合の同期(並行実行): ワーカー並列度を 2 以上にし、同じ PR のジョブ A が force-push 前の一覧取得を開始したところで停止させ、force-push 後のジョブ B も開始する。B は A が反映を COMMIT するまでコミット API を呼ばず、ロック取得後に新しい一覧を取得・反映する。最終集合は force-push 後の一覧になり、別 PR の同期は同時に進められる
- コミット集合の同期(取得の不完全さ): モックは既定ページサイズ(30)と
per_pageの最大(100)の両方を越える件数を返す(境界ちょうどにするとページングの欠落を隠す)。全ページを読み切ると中間表が一覧と一致しcommits_complete = trueになる / 2 ページ目が 5xx を返したときは 1 ページ目に無い既存の行が消えずcommits_complete = falseのまま / ホストの上限(250 件)に達した一覧では削除を行わない /commits_complete = falseの状態から次の同期で読み切れたら削除まで進む reviewsの付け替え:pr_titleとpr_authorが両方 NULL の既存ラウンド(API 経由で作られたラウンド)を含めても移行が完走し、非 NULL の控えが一度も無い PR 行のtitle/author_handleは空文字列になる。複数ラウンドでタイトルまたは作者の控えが変わっている場合は、辞書順の最大値ではなくroundが最も大きい非 NULL 値が入る。既存ラウンド(連携前の空文字列リポジトリを含む)が移行後も同じpr_number/roundで一覧に出る。連携先差し替え後の同番号 PR が別の PR 行になる。host_urlが違う同名リポジトリ(github.com と自前 Forgejo のorg/app)が別の PR 行になる- レビュー履歴の PR 選択: GitHub と Forgejo に同じ
org/appの PR #10 があるとき、--repo org/app --pr 10またはrepo=org/app&pr=10だけの指定は API が 409、CLI が非 0 で終了し、host_url(必要ならhost)またはpull_request_idを指定した場合だけ対象を一意に選べる。候補が 1 件なら repo-only 指定を許可し、現在の連携先へ暗黙に差し替えない。pull_request_idと併記したrepo/host/host_urlのいずれかが対象行と一致しない場合は API が 400、CLI が非 0 で終了する - API: 手動リンクの 201 / 連携なし 409 / メンバー外 403 / 他プロジェクトのタスク 404
- API(PR 詳細の境界): 別テナントの
pull_request_idは 404 / 同一テナントの別プロジェクトのpull_request_idも 404(403 にしない)/allowed_project_idsにパスのプロジェクトを含まない PAT は 403 /linked_tasksは呼び出し元が読めないプロジェクトのタスクを含まず、件数にも出ない。対照として、閲覧権限のある呼び出し元には 200 でリンク先タスクとレビューラウンドが揃って返る(過剰拒否になっていないこと) - API(PR 詳細のスコープ):
read:taskだけの PAT は 403(レビューラウンドが漏れないこと)/read:reviewだけの PAT も 403 /read:task+read:reviewの PAT は 200 /read:task+write:reviewの PAT も 200(包含が効いていること) - API(既存レビュー応答の
linked_tasks):ReviewResponse/ReviewedPullRequestを返すすべての経路で、各エンドポイントに必要な review スコープは持つがread:taskは持たない PAT は従来の成否を保ち、成功応答のlinked_tasks = [](一覧・詳細はread:review、作成はwrite:review)/read:task(write:task/admin:tenantの包含を含む)も持つ PAT は閲覧可能なリンク先だけ返る / リンク先の一部がallowed_project_ids外または閲覧不可ならそのタスクと件数は出ない / セッション認証でも閲覧不可プロジェクトのタスクは出ない。レスポンス種別ごとに同じフィルタが効き、一覧だけ安全で詳細・作成結果から漏れるといった差がない
12. 決定事項ログ
- 2026-09-02: データモデルを
task_github_links1 表混載から、PR / コミットの実体表 + リンク表へ変更。reviewsは PR 行を FK で参照する(後方互換は考えない。旧 Draft は未着手のため実装は無い) - 2026-09-02: Issue ↔ PR を直接結ばず、タスクをハブにする
- 2026-09-02: ホスト中立化。新しい表は
forge_接頭辞でhost+host_url+repo_owner+repo_name+html_urlを持ち、連携表への FK を持たない。webhook はホストごとの受信口で正規化イベントに変換し、タスク側の処理はホストを知らない。GitLab / Forgejo を足すときに変わるのは受信口・連携フロー・コメント投稿だけ - 2026-09-02: コミットの取り込みは webhook の
Pushのみ。過去分の遡りは初回は無し(前段は 2026-09-03 に見直し。§5「PR のコミット集合の同期」でOpened/Synchronizeからも取り込む。連携開始時にリポジトリ全体を遡らない方針は変わらない) - 2026-09-02:
KEY-Nの解決範囲は webhook を受けた連携と同じテナント内。他テナントのキーは解決しない - 2026-09-02: コミットのリンクはタスクのアクティビティ(
task_activities)に積む(TASK-140) - 2026-09-03:
forge_pull_request_linksの PK を(pull_request_id, task_id, source)に変更し、参照元ごとに 1 行にする。リンクの有効性は「行が 1 件以上」で判定(レビュー指摘: 単一sourceでは複数経路の参照を表現できず、編集時の再解析がmanualやタイトル由来を巻き込んで消す。自動完了の集合は 2026-09-07 に見直し) - 2026-09-03: PR ↔ コミットも
forge_commits.pull_request_idの単一 FK をやめ、中間表forge_pull_request_commitsで多対多にする(レビュー指摘: 同じ SHA が複数 PR に含まれると Synchronize の後勝ちで先の関連が消える) - 2026-09-03: PR のコミット集合は
OpenedとSynchronizeの両方で一覧 API と同期し、そのpull_request_idの行だけを一覧に合わせて削除する(レビュー指摘: Opened で取らないと push が続かない PR のコミットが入らず、追加のみだと force-push で外れたコミットが残る) - 2026-09-04: 一覧に無い行の削除は「全ページを読み切れたとき」に限り、判定を
forge_pull_requests.commits_completeに持つ。GitHub の PR コミット一覧は既定 30 件・最大 100 件・全体 250 件の上限があり、短い配列が 200 で返るため取得の不完全さがそのまま削除の指示になる。上限を超える PR は一覧 API では集合を確定できないので削除の対象外とし、必要になったらGET /repos/{owner}/{repo}/commits?sha={head_sha}側で組む(レビュー指摘: 31 コミット以上の PR で Synchronize のたびに関連が消え、次の同期でも戻らない) - 2026-09-05: PR 詳細 API を
/v1/tenants/{t}/projects/{p}/forge/pull-requests/{id}へスコープし、PR 行のproject_id不一致は 404、linked_tasksは呼び出し元が読めるプロジェクトのタスクだけに絞る(レビュー指摘:pull_request_idだけを鍵にすると、id を知っているだけで別テナントのタスク名とレビュー内容が読める) - 2026-09-05: webhook の重複排除を Redis の
SET NX+ TTL から、永続表forge_webhook_deliveriesへの INSERT とジョブ投入を同一トランザクションで確定する形へ変更(レビュー指摘: 鍵を先に立てるとジョブ投入の失敗後にホストの再送が重複として捨てられ、正当なイベントが消える) - 2026-09-05: アクティビティの初回判定を「現在のリンク行の有無」から
task_activities.dedupe_keyの UNIQUE へ変更(レビュー指摘: リンク解除で全sourceの行が消えるため、再リンクで同じ履歴がもう 1 本積まれる。並行処理でも事前確認どうしがすれ違う) - 2026-09-05: PR 詳細 API の PAT スコープを
read:task+read:reviewの両方必須に変更(レビュー指摘: 1 レスポンスでレビューラウンドを返すため、read:taskだけの PAT がレビューを読め、レビュー指摘 §4 のスコープ分離を迂回する) - 2026-09-07: PR イベントの適用条件に世代(
forge_pull_requests.host_updated_atと正規化イベントのupdated_at)を導入。古いイベントは PR 行・リンク・状態を変更しない。一方、Opened/Synchronizeのコミット集合は世代に関係なく現在の一覧 API と同期する(レビュー指摘: 配送順・実行順が保証されず、無条件 UPSERT だと新しい状態が巻き戻る。並行する一覧取得・反映の直列化条件は同日追記) - 2026-09-07: 適用対象になったすべての PR action で
title/body/branchの現在表示用リンクを最新集合へ再構築する(レビュー指摘:Openedより先にSynchronize/Reopened/ 未マージのClosedを初回観測するとリンクされない) - 2026-09-07:
updated_atが同値のイベントは、ペイロードではなくホストの PR 取得 API で取り直したスナップショットを現在状態に適用する(取得できなければジョブを再試行)。title・本文由来のリンク・head_shaの巻き戻しを防ぐだけでなく、元 action の状態遷移を実行しない(レビュー指摘: 同秒のClosedとReopenedは action を後から重ねると現在状態を巻き戻せる) - 2026-09-07: マージ時副作用を現在状態の同期から分離し、
Closed { merged: true }のイベント固有のタイトル・本文・head ブランチとマージ前のmanual行から一度だけ処理する。merge_effects_processed_atはタスク遷移・Automation の永続ジョブ投入と同じトランザクションで確定し、古いClosedでも未処理なら実行する(レビュー指摘: マージ後のEditedが先着すると現在リンクから別タスクを完了し、後着したClosedが捨てられる) - 2026-09-07: PR コミット集合の同期は
pull_request_idごとの transaction-level advisory lock を API 取得前から DB 反映完了まで保持する(レビュー指摘: API が各取得時点で最新でも、force-push 前に取得したジョブが後から反映すると新しい集合を巻き戻せる) - 2026-09-07: PR スナップショットの世代比較・UPSERT・現在リンク再構築は、行の有無にかかわらず PR 自然キー由来の transaction-level advisory lock で COMMIT まで直列化する。逆順 COMMIT と初回行作成の並行テストで、古い状態やリンク集合へ巻き戻さないことを受入条件にする
- 2026-09-07: レビュー履歴の読み取りは
host/host_urlまたはpull_request_idを指定でき、repo + prだけは候補が一意な場合に限る。複数候補は API 409 / CLI 非 0 とし、GitHub と Forgejo の同名リポジトリ・同番 PR を混同しない - 2026-09-07:
ReviewResponse/ReviewedPullRequestのlinked_tasksは全返却経路でread:taskとリンク先プロジェクトの閲覧権限を検査し、PAT のallowed_project_idsも適用する。各経路に必要な review スコープだけを持つ既存 PAT には成功応答で空配列を返して互換性を保つ(レビュー指摘: 新しいフィールドから別プロジェクトのタスク名が漏れる) - 2026-09-09:
state = mergedかつmerge_effects_processed_at IS NULLの PR を一覧・詳細で「自動完了は未適用」と表示し、タスクの個別確認・手動完了と元のClosedwebhook の再配送へ案内する。一括の推測適用は行わない(レビュー指摘: 連携前のマージや配送欠落ではClosedが後から届かず、タスクが未完了のままでも利用者が気づけない) - 2026-09-09: レビュー履歴の
pull_request_idは候補探索を省略する識別子とする一方、併記されたrepo/host/host_urlは対象行との一致を検証し、不一致を 400 で拒否する(レビュー指摘: 文書間で併記した自然キーを無視するか拒否するかが一致していなかった) - 2026-09-10: 未適用表示を解消する経路として、副作用を実行しない「確認済みにする」操作(
POST .../merge-effects/acknowledge)を追加する。merge_effects_processed_atを設定してタスクは完了させず、merge_effects_acknowledged_byとリンク先タスクのforge_merge_effects_acknowledgedアクティビティで誰がいつ行ったかを残す(レビュー指摘: 解消経路が再配送だけだと、配送履歴の無い連携前マージや保持期間切れで印が恒久的に残り、対応済みと未対応の区別が付かなくなって印が読み飛ばされる) - 2026-09-06: 受信記録の INSERT は署名検証を通した後に行うと明記(署名検証前だと、誰でも任意の
delivery_idで永続表を増やせ、配信 ID を先取りされると正規のイベントが重複として捨てられる) - 2026-09-07:
reviewsから PR 行を起こす移行で、title/author_handleはround DESCで最初の非 NULL 値を選び、無ければ空文字列にする(レビュー指摘:max(VARCHAR)は最新ラウンドではなく辞書順で値を選び、単純なCOALESCE(max(...), '')では変更後のタイトルを失う) - 2026-09-11: TASK-140 のコミットリンクを PR エンティティ(§10 の 1)より先に出し、マイグレーションもコミット分を分ける。
Pushではタスクに 1 件もリンクしないコミットをforge_commitsに積まない(読む口が無く、push のたびに全コミットが溜まるだけのため)。コミットのアクティビティのuser_idは、push のペイロードに作者のアカウント ID が無いので当面 NULL - 2026-09-11:
task_activities.dedupe_keyを部分 UNIQUE インデックスから列の UNIQUE 制約へ変更(レビュー指摘: 起動時の schema sync が部分インデックスを DROP CONSTRAINT しようとしてバックエンドが起動しない)。forge_commits.project_idは受信した連携ではなくリンク先タスクのプロジェクトにする(レビュー指摘: 同じリポジトリを同じテナントの複数プロジェクトへ連携すると、連携ごとに別のコミット行ができて同じタスクの履歴が重複する) - 2026-09-11: コミットのアクティビティの
user_idを「当面 NULL」から、作者のログイン名とoauth_connections.provider_login(OAuth ログイン時に控える小文字のログイン名)の照合で埋める形へ変更(TASK-209)。GitHub API(GET /users/{login})で数値 ID を引く案は、作者ごとに API 呼び出しとキャッシュが要るうえ、解決先は結局 GitHub を連携した Task ユーザーに限られるので見送る。noreply メールから数値 ID を取る案は、実メールでコミットする人を拾えず、メールは作者が自由に書けるので見送る