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"