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
+33
new file mode 100644 index 0000000..0a9141c --- /dev/null +++ b/app/models/github_app_installation.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# One installation of the siGit Code GitHub App on a github.com account +# (user or org). Synced from `installation` webhooks; soft-deleted on +# uninstall so late-arriving PR webhooks and in-flight jobs no-op instead of +# hitting a missing row. Also caches the short-lived (1h) installation access +# token in-row, so the Puma and jobs processes share it without relying on +# Rails.cache. +class GithubAppInstallation < ApplicationRecord + has_many :github_pr_reviews, dependent: :destroy + + scope :active, -> { where(suspended_at: nil, deleted_at: nil) } + + # Refresh the token when it has less than this long left, so a token that + # expires mid-review can't fail the job. + TOKEN_EXPIRY_MARGIN = 5.minutes + + def active? + suspended_at.nil? && deleted_at.nil? + end + + # The cached installation token, or nil when missing or too close to expiry. + def usable_access_token + return nil if access_token.blank? || access_token_expires_at.blank? + return nil if access_token_expires_at <= TOKEN_EXPIRY_MARGIN.from_now + + access_token + end + + def clear_access_token! + update!(access_token: nil, access_token_expires_at: nil) + end +end
app/models/github_pr_review.rb
+24
new file mode 100644 index 0000000..80e2639 --- /dev/null +++ b/app/models/github_pr_review.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# One automated review of one head SHA of one github.com pull request. The +# unique (repo, pr, sha) index is the idempotency guard: GithubPrReviewJob +# claims a row with create_or_find_by before doing any work, so webhook +# redeliveries and rapid `synchronize` storms collapse into a single review. +class GithubPrReview < ApplicationRecord + belongs_to :github_app_installation + + enum :status, %w[pending running completed failed skipped].index_by(&:itself) + + # A `running` row normally means another execution owns this SHA — but a + # worker killed mid-review (deploys kill -9) would leave the row running + # forever, so treat a long-stale claim as abandoned and re-enter. + STALE_RUNNING_AFTER = 30.minutes + + def claimable? + pending? || (running? && started_at.present? && started_at < STALE_RUNNING_AFTER.ago) + end + + def skip!(reason) + update!(status: "skipped", skip_reason: reason, completed_at: Time.current) + end +end
db/migrate/20260702000001_create_github_app_installations.rb
+20
new file mode 100644 index 0000000..7385c01 --- /dev/null +++ b/db/migrate/20260702000001_create_github_app_installations.rb @@ -0,0 +1,20 @@ +class CreateGithubAppInstallations < ActiveRecord::Migration[8.1] + def change + create_table :github_app_installations do |t| + t.bigint :installation_id, null: false # GitHub's installation id + t.string :account_login, null: false # org/user the app is installed on + t.string :account_type # "User" | "Organization" + t.bigint :account_id + t.string :repository_selection # "all" | "selected" + t.datetime :suspended_at + t.datetime :deleted_at # soft delete on installation.deleted + t.string :access_token # cached installation token (expires within 1h) + t.datetime :access_token_expires_at + + t.timestamps + end + + add_index :github_app_installations, :installation_id, unique: true + add_index :github_app_installations, :account_login + end +end
db/migrate/20260702000002_create_github_pr_reviews.rb
+22
new file mode 100644 index 0000000..8ba8a72 --- /dev/null +++ b/db/migrate/20260702000002_create_github_pr_reviews.rb @@ -0,0 +1,22 @@ +class CreateGithubPrReviews < ActiveRecord::Migration[8.1] + def change + create_table :github_pr_reviews do |t| + t.references :github_app_installation, null: false, foreign_key: true + t.string :repo_full_name, null: false # "owner/repo" on github.com + t.integer :pr_number, null: false + t.string :head_sha, null: false + t.string :status, null: false, default: "pending" + t.string :skip_reason # "draft", "too_large", "superseded", ... + t.text :error_message + t.integer :comment_count + t.string :model # onde tier that produced the review + t.datetime :started_at + t.datetime :completed_at + + t.timestamps + end + + add_index :github_pr_reviews, [ :repo_full_name, :pr_number, :head_sha ], + unique: true, name: "index_github_pr_reviews_on_repo_pr_sha" + end +end
db/schema.rb
+36 -1
index 182a008..cd31f62 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do +ActiveRecord::Schema[8.1].define(version: 2026_07_02_000002) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -56,6 +56,40 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do t.index ["user_id"], name: "index_comments_on_user_id" end + create_table "github_app_installations", force: :cascade do |t| + t.string "access_token" + t.datetime "access_token_expires_at" + t.bigint "account_id" + t.string "account_login", null: false + t.string "account_type" + t.datetime "created_at", null: false + t.datetime "deleted_at" + t.bigint "installation_id", null: false + t.string "repository_selection" + t.datetime "suspended_at" + t.datetime "updated_at", null: false + t.index ["account_login"], name: "index_github_app_installations_on_account_login" + t.index ["installation_id"], name: "index_github_app_installations_on_installation_id", unique: true + end + + create_table "github_pr_reviews", force: :cascade do |t| + t.integer "comment_count" + t.datetime "completed_at" + t.datetime "created_at", null: false + t.text "error_message" + t.bigint "github_app_installation_id", null: false + t.string "head_sha", null: false + t.string "model" + t.integer "pr_number", null: false + t.string "repo_full_name", null: false + t.string "skip_reason" + t.datetime "started_at" + t.string "status", default: "pending", null: false + t.datetime "updated_at", null: false + t.index ["github_app_installation_id"], name: "index_github_pr_reviews_on_github_app_installation_id" + t.index ["repo_full_name", "pr_number", "head_sha"], name: "index_github_pr_reviews_on_repo_pr_sha", unique: true + end + create_table "issues", force: :cascade do |t| t.text "body" t.datetime "closed_at" @@ -157,6 +191,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000010) do add_foreign_key "cloud_sessions", "users" add_foreign_key "cloud_usages", "users" add_foreign_key "comments", "users" + add_foreign_key "github_pr_reviews", "github_app_installations" add_foreign_key "issues", "repositories" add_foreign_key "issues", "users" add_foreign_key "pull_requests", "repositories"