Git ホスティング↔タスク連携 仕様書

PR・コミット・ブランチとタスクの自動リンク・クローズ連動(GitHub / GitLab / Forgejo)

ステータス: 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 と同じ方針)。

違うもの GitHub GitLab Forgejo 閉じ込める場所
呼び名 Pull Request Merge Request Pull Request 表示だけ。データは pull_request で統一
番号の意味 リポジトリ内で Issue と共有 プロジェクト内 iid(MR と Issue で別空間) リポジトリ内で Issue と共有 number 列。意味の違いは §4 の Issue 経由解決で吸収
webhook の届き方 App 設定で自動 プロジェクト単位に API で登録 リポジトリ単位に API で登録 連携フロー(§5)
署名 / イベント形式 X-Hub-Signature-256 + JSON X-Gitlab-Token + JSON X-Forgejo-Signature(HMAC)+ JSON ホストごとの受信口 → 正規化イベント(§5)

ホストの識別は 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 VARCHAR github / gitlab / forgejo
host_url VARCHAR インスタンスの origin。末尾スラッシュ無し
repo_owner VARCHAR GitHub の owner、GitLab の namespace 全体(group/subgroup。/ を含みうる)、Forgejo の owner
repo_name VARCHAR リポジトリ(GitLab のプロジェクト)パス
html_url VARCHAR 行ごとの人間向け URL。ホストごとに URL の組み立て規則が違うので合成せず保存する

host / host_url / repo_owner / repo_name の 4 列は forge-core::Repository に host を足した形で、 各ホストの実装クレートがこの形へ正規化する。レビュー指摘の連携前ラウンドは host = ''、他も空文字列。

forge_pull_requests(PR / MR)

カラム 型 制約 説明
id UUID PK  
project_id UUID NOT NULL, FK→projects CASCADE  
host / host_url / repo_owner / repo_name VARCHAR NOT NULL 共通列
number INT NOT NULL GitHub / Forgejo の PR 番号、GitLab の MR iid
title VARCHAR NOT NULL DEFAULT '' 表示用。webhook / API で取得できたときだけ埋まる
author_handle VARCHAR NOT NULL DEFAULT '' ホスト上のアカウント名。同上
state VARCHAR NOT NULL DEFAULT 'unknown' open / closed / merged / unknown(CLI の review submit だけで作られ、まだ webhook を受けていない)
head_sha VARCHAR(40) NULLABLE 最後に観測した head。review summary の head 一致検査の補助
html_url VARCHAR NOT NULL DEFAULT ''  
merged_at TIMESTAMPTZ NULLABLE  
host_updated_at TIMESTAMPTZ NULLABLE ホスト側で PR が最後に更新された時刻。webhook の配送順が前後したときに古いペイロードで新しい状態を上書きしないための世代(§5「PRイベント共通の前処理」)
merge_effects_processed_at TIMESTAMPTZ NULLABLE Closed { merged: true } のマージ時副作用を完了した時刻。現在状態の世代とは独立に、NULL の間だけ処理する(§5)
merge_effects_acknowledged_by UUID NULLABLE, FK→users SET NULL 副作用を実行しないまま「確認済み」にした操作者(§8 / §9)。実際に副作用を処理した場合は NULL
commits_complete BOOLEAN NOT NULL DEFAULT false コミット集合を確定できているか。false のあいだ §5 の削除を行わない(下記)
created_at / updated_at TIMESTAMPTZ NOT NULL DEFAULT now()  

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 ↔ タスク)

カラム 型 制約 説明
pull_request_id UUID NOT NULL, FK→forge_pull_requests CASCADE  
task_id UUID NOT NULL, FK→tasks CASCADE  
closes BOOLEAN NOT NULL DEFAULT false この参照元がクローズキーワード付きだったか
source VARCHAR NOT NULL title / body / branch / manual。再解析・手動操作の単位
created_at TIMESTAMPTZ NOT NULL DEFAULT now()  

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(コミット)

カラム 型 制約 説明
id UUID PK  
project_id UUID NOT NULL, FK→projects CASCADE リンク先タスクのプロジェクト(下記)
host / host_url / repo_owner / repo_name VARCHAR NOT NULL 共通列
sha VARCHAR(40) NOT NULL 小文字 16 進
message TEXT NOT NULL 全文。表示は先頭行
author_handle VARCHAR NOT NULL DEFAULT '' ホスト上のアカウントに解決できたとき
author_name VARCHAR NOT NULL git の author 名(解決できないときの表示用)
committed_at TIMESTAMPTZ NOT NULL  
html_url VARCHAR NOT NULL  
created_at TIMESTAMPTZ NOT NULL DEFAULT now()  

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 ↔ コミット)

カラム 型 制約
pull_request_id UUID NOT NULL, FK→forge_pull_requests CASCADE
commit_id UUID NOT NULL, FK→forge_commits CASCADE
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

PRIMARY KEY (pull_request_id, commit_id)。コミット実体は SHA で UNIQUE の 1 行なので、 同じコミットが複数の PR に含まれるケース(同一ブランチから別 base への PR、stacked PR)を 実体側の単一 FK で持つと、後から処理した PR の Synchronize が先の関連を上書きする。 中間表で多対多として持つ。

forge_commit_links(コミット ↔ タスク)

カラム 型 制約
commit_id UUID NOT NULL, FK→forge_commits CASCADE
task_id UUID NOT NULL, FK→tasks CASCADE
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

PRIMARY KEY (commit_id, task_id)。

forge_branch_links(ブランチ ↔ タスク)

カラム 型 制約
id UUID PK
project_id UUID NOT NULL, FK→projects CASCADE
task_id UUID NOT NULL, FK→tasks CASCADE
host / host_url / repo_owner / repo_name VARCHAR NOT NULL
branch VARCHAR NOT NULL
html_url VARCHAR NOT NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

UNIQUE (task_id, host, host_url, repo_owner, repo_name, branch)。ブランチは状態も本文も持たないので 実体表を分けない。ブランチ削除イベントで消す。

forge_webhook_deliveries(webhook の受信記録)

カラム 型 制約 説明
id UUID PK  
host / host_url VARCHAR NOT NULL どのホストのどのインスタンスからか
delivery_id VARCHAR NOT NULL 配信 ID ヘッダーの値
created_at TIMESTAMPTZ NOT NULL DEFAULT now() 掃除の基準

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 で消そうとし、インデックスは制約ではないので 起動に失敗する。

イベント dedupe_key
forge_pull_request_linked forge_pull_request_linked:{pull_request_id}:{task_id}
forge_pull_request_merged forge_pull_request_merged:{pull_request_id}:{task_id}
forge_commit_linked forge_commit_linked:{commit_id}:{task_id}
forge_merge_effects_acknowledged forge_merge_effects_acknowledged:{pull_request_id}:{task_id}

積むときは次で、行が返ったときだけ「初回」とみなす。

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 は大文字に正規化して照合)

パターン 例 動作
KEY-N(タイトル / メッセージ) fix: ログインバグ修正 ENG-42 タスク ENG-42 にリンク追加
複数 KEY-N feat: OAuth ENG-42 ENG-43 タスク ENG-42 と ENG-43 両方にリンク追加
異なるプロジェクト feat: OAuth ENG-42 BACK-7 各プロジェクトのタスクにそれぞれリンク
ブランチ名 feat/ENG-42-oauth タスク ENG-42 にブランチリンク追加
クローズキーワード + KEY-N Closes ENG-42 / Fixes ENG-42 / Resolves ENG-42 closes = true でリンク。PR マージ時にタスク ENG-42 を自動クローズ

クローズキーワード: 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)へ積む。 タスク側の処理はホストを知らない。

ホスト 受信口 署名検証 備考
GitHub POST /v1/github/webhook(既存) X-Hub-Signature-256 HMAC 現状は X-GitHub-Event を見ずジョブへ積み、job 側で issues だけ処理している。ここに変換を足す
GitLab POST /v1/gitlab/webhook(将来) X-Gitlab-Token の定数比較 object_kind で分岐
Forgejo POST /v1/forgejo/webhook(将来) X-Forgejo-Signature HMAC ペイロードは GitHub にほぼ互換

正規化イベント(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 の前処理はこれを世代として使うので、 取り出せないホストを足すときは、そのホストの適用規則を先に決める。

登録

ホスト 登録
GitHub 不要。App の webhook は App 側の設定 1 つで、インストール先リポジトリのイベントは自動で届く。必要なのは App の設定で Permissions に Contents: Read / Pull requests: Read、Subscribe to events に Push / Pull request / Create / Delete を加えることだけ。権限を増やした後は、既存インストールのオーナーが承認するまで新しいイベントは届かない
GitLab 連携フローで POST /projects/:id/hooks(push / merge_requests / tag は不要)を登録し、secret_token を控える。解除で DELETE
Forgejo 連携フローで POST /repos/{owner}/{repo}/hooks(push / pull_request / create / delete)を登録。解除で DELETE

登録が要るホストでは、連携表に 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 も巻き戻る。

比較 適用するもの
> イベントのペイロード
< PR スナップショットを適用しない。UPSERT・現在リンクの再構築・状態遷移は行わない。ただし元の action に応じたコミット集合の同期とマージ時副作用は、それぞれ後述の独立した規則で処理する
= ペイロードではなく、ホストの PR 取得 API(GitHub は GET /repos/{owner}/{repo}/pulls/{number})で取り直した現在のスナップショット

この比較から 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 が届いてもタスクを 二重に完了させない。

イベント 処理内容
Push 切り詰められていれば欠けたコミットを取り直したうえで(§5 の受信口と正規化)、commits[] ごとにメッセージをパースし、リンク先があれば forge_commits を UPSERT してリンク + アクティビティ。リンク先が 1 件も無いコミットは積まない(読む口が無い。PR のコミット集合の同期では下記のとおり実体を作る)。forced でも新 SHA を積むだけ
PullRequest / Opened 共通処理 + コミット集合を同期(下記)
PullRequest / Edited 共通処理
PullRequest / Synchronize 共通処理 + コミット集合を同期(下記)
PullRequest / Closed { merged: true } 共通処理 + マージ時副作用の独立処理。古いイベントでも副作用が未処理なら一度だけ実行
PullRequest / Closed { merged: false } 共通処理。state → closed を action から別途実行しない
PullRequest / Reopened 共通処理。state → open を action から別途実行しない
BranchCreated ブランチ名をパースし forge_branch_links を UPSERT
BranchDeleted 該当の forge_branch_links を削除

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 にする。

  1. 一覧の各コミットで forge_commits を UPSERT する(Push と同じ正規化。Push webhook を 受けていないコミット(連携前の push、PR 作成だけで push が続かない場合)はここで実体ができる)

  2. メッセージをパースして forge_commit_links を UPSERT し、アクティビティは新規行のときだけ積む(Push と同じ)

  3. forge_pull_request_commits を UPSERT する

  4. 読み切れたときだけ、その 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 とリンク先タスクが返る。

メソッド パス 説明
GET /tasks/{id}/forge タスクに紐付いた PR / コミット / ブランチ(3 配列を 1 レスポンスで返す)
POST /tasks/{id}/forge/pull-requests 手動で PR をリンク(source = manual)
DELETE /tasks/{id}/forge/pull-requests/{pull_request_id} PR のリンクを外す(PR 行自体は消さない)
DELETE /tasks/{id}/forge/commits/{commit_id} コミットのリンクを外す
DELETE /tasks/{id}/forge/branches/{link_id} ブランチのリンクを外す
GET /forge/pull-requests/{pull_request_id} PR 詳細(リンク先タスク一覧 + レビューラウンド一覧)。レビュー画面から辿る用
POST /forge/pull-requests/{pull_request_id}/merge-effects/acknowledge マージ時副作用を実行しないまま「確認済み」にし、未適用表示を解消(§9)

レスポンスの各行は 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。

権限

API 必要な権限 PAT スコープ
GET /tasks/{id}/forge そのタスクの閲覧権限 read:task
POST / DELETE の各リンク操作 タスクの編集権限(プロジェクトメンバー) write:task
GET /forge/pull-requests/{pull_request_id} パスのプロジェクトのタスク閲覧権限 read:task かつ read:review
POST /forge/pull-requests/{id}/merge-effects/acknowledge パスのプロジェクトのタスク編集権限 write:task

webhook 経由のリンクはシステムが行うので権限検査を経ない。

この口だけ 2 スコープを要求するのは、1 レスポンスでタスク名(task 系)とレビューラウンド (review 系)の両方を返すため。レビュー指摘 §4 は 「レビュー専用の AI にタスク書き換え権限を渡さない」ために read:review を独立させており、 逆向き(task 系のスコープしか持たない PAT がレビューを読める)も同じ分離を崩す。 write:review は read:review を包含する(同 §4)ので、read:task + write:review でも通る。

PR 詳細の境界。 この口はレビュー内容とタスク名を返すので、次の 3 つを守る。

  1. パスのテナント・プロジェクトに対する認可を先に通す。PAT は allowed_project_ids の制限もここで効かせる

  2. PR 行の project_id がパスのプロジェクトと一致しなければ 404。403 にすると、id を総当たりするだけで 他テナントの PR 行の有無が分かる

  3. 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 設定は持たない(切りたい理由が出てから足す)。

コンポーネント ファイル
TaskForgePanel components/tasks/TaskForgePanel.vue
ForgeLinkBadge components/tasks/ForgeLinkBadge.vue

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 本に分かれる。

  1. PR エンティティ + reviews の付け替え: §2 の forge_pull_requests と §3 のマイグレーション、review submit の UPSERT(§6)、ReviewResponse の形の維持。挙動は変わらない(回帰テストは既存の reviews 統合テストがそのまま通ること)
  2. 正規化イベント + 抽出 + コミット / PR / ブランチのリンク: service::forge::{events, task_refs}、GitHub 受信口の変換、残りのテーブル、§5 のイベント処理、アクティビティ。受信記録(forge_webhook_deliveries)による冪等化と dedupe_key によるアクティビティの一意化もここで効かせる(コミット分は上記のとおり先行済み。TASK-140 はそこで閉じる)
  3. 手動リンク API + フロントエンド: §8 / §9
  4. ホストへのコメント(§7)。Automation pull_request_merged は PR #8 の実装状況に合わせる
  5. (別仕様)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_links 1 表混載から、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 を一覧・詳細で「自動完了は未適用」と表示し、タスクの個別確認・手動完了と元の Closed webhook の再配送へ案内する。一括の推測適用は行わない(レビュー指摘: 連携前のマージや配送欠落では 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 を取る案は、実メールでコミットする人を拾えず、メールは作者が自由に書けるので見送る