Add GitHub App installation and PR review models

github_app_installations mirrors installations of the siGit Code GitHub App (synced from installation webhooks, soft-deleted on uninstall) and caches the 1h installation access token in-row so Puma and the jobs process share it. github_pr_reviews tracks one automated review per (repo, PR, head SHA) — the unique index is the idempotency guard against webhook redeliveries and synchronize storms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WhPzinYJUYgBed63XMvJX1

Claude committed Jul 2, 2026 at 12:23 UTC 1c834c2672757904de949e610895ac42f1ad8862
5 files changed +135 -1
app/models/github_app_installation.rb new
+33
@@ -0,0 +1,33 @@
1 +# frozen_string_literal: true
2 +
3 +# One installation of the siGit Code GitHub App on a github.com account
4 +# (user or org). Synced from `installation` webhooks; soft-deleted on
5 +# uninstall so late-arriving PR webhooks and in-flight jobs no-op instead of
6 +# hitting a missing row. Also caches the short-lived (1h) installation access
7 +# token in-row, so the Puma and jobs processes share it without relying on
8 +# Rails.cache.
9 +class GithubAppInstallation < ApplicationRecord
10 + has_many :github_pr_reviews, dependent: :destroy
11 +
12 + scope :active, -> { where(suspended_at: nil, deleted_at: nil) }
13 +
14 + # Refresh the token when it has less than this long left, so a token that
15 + # expires mid-review can't fail the job.
16 + TOKEN_EXPIRY_MARGIN = 5.minutes
17 +
18 + def active?
19 + suspended_at.nil? && deleted_at.nil?
20 + end
21 +
22 + # The cached installation token, or nil when missing or too close to expiry.
23 + def usable_access_token
24 + return nil if access_token.blank? || access_token_expires_at.blank?
25 + return nil if access_token_expires_at <= TOKEN_EXPIRY_MARGIN.from_now
26 +
27 + access_token
28 + end
29 +
30 + def clear_access_token!
31 + update!(access_token: nil, access_token_expires_at: nil)
32 + end
33 +end
app/models/github_pr_review.rb new
+24
@@ -0,0 +1,24 @@
1 +# frozen_string_literal: true
2 +
3 +# One automated review of one head SHA of one github.com pull request. The
4 +# unique (repo, pr, sha) index is the idempotency guard: GithubPrReviewJob
5 +# claims a row with create_or_find_by before doing any work, so webhook
6 +# redeliveries and rapid `synchronize` storms collapse into a single review.
7 +class GithubPrReview < ApplicationRecord
8 + belongs_to :github_app_installation
9 +
10 + enum :status, %w[pending running completed failed skipped].index_by(&:itself)
11 +
12 + # A `running` row normally means another execution owns this SHA — but a
13 + # worker killed mid-review (deploys kill -9) would leave the row running
14 + # forever, so treat a long-stale claim as abandoned and re-enter.
15 + STALE_RUNNING_AFTER = 30.minutes
16 +
17 + def claimable?
18 + pending? || (running? && started_at.present? && started_at < STALE_RUNNING_AFTER.ago)
19 + end
20 +
21 + def skip!(reason)
22 + update!(status: "skipped", skip_reason: reason, completed_at: Time.current)
23 + end
24 +end
db/migrate/20260702000001_create_github_app_installations.rb new
+20
@@ -0,0 +1,20 @@
1 +class CreateGithubAppInstallations < ActiveRecord::Migration[8.1]
2 + def change
3 + create_table :github_app_installations do |t|
4 + t.bigint :installation_id, null: false # GitHub's installation id
5 + t.string :account_login, null: false # org/user the app is installed on
6 + t.string :account_type # "User" | "Organization"
7 + t.bigint :account_id
8 + t.string :repository_selection # "all" | "selected"
9 + t.datetime :suspended_at
10 + t.datetime :deleted_at # soft delete on installation.deleted
11 + t.string :access_token # cached installation token (expires within 1h)
12 + t.datetime :access_token_expires_at
13 +
14 + t.timestamps
15 + end
16 +
17 + add_index :github_app_installations, :installation_id, unique: true
18 + add_index :github_app_installations, :account_login
19 + end
20 +end
db/migrate/20260702000002_create_github_pr_reviews.rb new
+22
@@ -0,0 +1,22 @@
1 +class CreateGithubPrReviews < ActiveRecord::Migration[8.1]
2 + def change
3 + create_table :github_pr_reviews do |t|
4 + t.references :github_app_installation, null: false, foreign_key: true
5 + t.string :repo_full_name, null: false # "owner/repo" on github.com
6 + t.integer :pr_number, null: false
7 + t.string :head_sha, null: false
8 + t.string :status, null: false, default: "pending"
9 + t.string :skip_reason # "draft", "too_large", "superseded", ...
10 + t.text :error_message
11 + t.integer :comment_count
12 + t.string :model # onde tier that produced the review
13 + t.datetime :started_at
14 + t.datetime :completed_at
15 +
16 + t.timestamps
17 + end
18 +
19 + add_index :github_pr_reviews, [ :repo_full_name, :pr_number, :head_sha ],
20 + unique: true, name: "index_github_pr_reviews_on_repo_pr_sha"
21 + end
22 +end
db/schema.rb
+36 -1
@@ -10,7 +10,7 @@
10 #
11 # It's strongly recommended that you check this file into your version control system.
12
13 -ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do
13 +ActiveRecord::Schema[8.1].define(version: 2026_07_02_000002) do
14 # These are extensions that must be enabled in order to support this database
15 enable_extension "pg_catalog.plpgsql"
16
@@ -56,6 +56,40 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do
56 t.index ["user_id"], name: "index_comments_on_user_id"
57 end
58
59 + create_table "github_app_installations", force: :cascade do |t|
60 + t.string "access_token"
61 + t.datetime "access_token_expires_at"
62 + t.bigint "account_id"
63 + t.string "account_login", null: false
64 + t.string "account_type"
65 + t.datetime "created_at", null: false
66 + t.datetime "deleted_at"
67 + t.bigint "installation_id", null: false
68 + t.string "repository_selection"
69 + t.datetime "suspended_at"
70 + t.datetime "updated_at", null: false
71 + t.index ["account_login"], name: "index_github_app_installations_on_account_login"
72 + t.index ["installation_id"], name: "index_github_app_installations_on_installation_id", unique: true
73 + end
74 +
75 + create_table "github_pr_reviews", force: :cascade do |t|
76 + t.integer "comment_count"
77 + t.datetime "completed_at"
78 + t.datetime "created_at", null: false
79 + t.text "error_message"
80 + t.bigint "github_app_installation_id", null: false
81 + t.string "head_sha", null: false
82 + t.string "model"
83 + t.integer "pr_number", null: false
84 + t.string "repo_full_name", null: false
85 + t.string "skip_reason"
86 + t.datetime "started_at"
87 + t.string "status", default: "pending", null: false
88 + t.datetime "updated_at", null: false
89 + t.index ["github_app_installation_id"], name: "index_github_pr_reviews_on_github_app_installation_id"
90 + t.index ["repo_full_name", "pr_number", "head_sha"], name: "index_github_pr_reviews_on_repo_pr_sha", unique: true
91 + end
92 +
93 create_table "issues", force: :cascade do |t|
94 t.text "body"
95 t.datetime "closed_at"
@@ -157,6 +191,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do
191 add_foreign_key "cloud_sessions", "users"
192 add_foreign_key "cloud_usages", "users"
193 add_foreign_key "comments", "users"
194 + add_foreign_key "github_pr_reviews", "github_app_installations"
195 add_foreign_key "issues", "repositories"
196 add_foreign_key "issues", "users"
197 add_foreign_key "pull_requests", "repositories"