From 0d3e39a24e18a2727f461b0de81c6b6a4e8f41ab Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 14 Mar 2026 10:24:19 -0700 Subject: [PATCH] feat: add head_branch to pull request diff status (#23076) Adds the `head_branch` field (the source/feature branch name of a PR) to the diff status pipeline. Previously only `base_branch` (target branch) and the head commit SHA were captured from the GitHub API, but not the head branch name itself. ## Changes - **Migration 438**: Add `head_branch` nullable TEXT column to `chat_diff_statuses` - **gitprovider**: Parse `head.ref` from the GitHub API response (alongside `head.sha`) and add `HeadBranch` to `PRStatus` - **gitsync**: Wire `HeadBranch` through `refreshOne()` into the DB upsert params - **worker**: Map `HeadBranch` in `chatDiffStatusFromRow()` - **coderd**: Convert `HeadBranch` in `convertChatDiffStatus()` - **codersdk**: Expose as `head_branch` (`*string`, omitempty) in `ChatDiffStatus` API response - **Tests**: Updated `github_test.go` pull JSON fixtures and assertions --- coderd/chats.go | 3 ++ coderd/database/dump.sql | 3 +- ...0438_chat_diff_status_head_branch.down.sql | 1 + ...000438_chat_diff_status_head_branch.up.sql | 1 + coderd/database/models.go | 1 + coderd/database/queries.sql.go | 33 ++++++++++++------- coderd/database/queries/chats.sql | 3 ++ coderd/externalauth/gitprovider/github.go | 10 +++--- .../externalauth/gitprovider/github_test.go | 17 +++++----- .../externalauth/gitprovider/gitprovider.go | 2 ++ coderd/gitsync/gitsync.go | 1 + coderd/gitsync/worker.go | 1 + codersdk/chats.go | 1 + site/src/api/typesGenerated.ts | 1 + 14 files changed, 54 insertions(+), 24 deletions(-) create mode 100644 coderd/database/migrations/000438_chat_diff_status_head_branch.down.sql create mode 100644 coderd/database/migrations/000438_chat_diff_status_head_branch.up.sql diff --git a/coderd/chats.go b/coderd/chats.go index d3e5e4f36b..ce211c62cb 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -2629,6 +2629,9 @@ func convertChatDiffStatus(chatID uuid.UUID, status *database.ChatDiffStatus) co if status.BaseBranch.Valid { result.BaseBranch = &status.BaseBranch.String } + if status.HeadBranch.Valid { + result.HeadBranch = &status.HeadBranch.String + } if status.PrNumber.Valid { result.PRNumber = &status.PrNumber.Int32 } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index fd5db235ce..2da02f81c1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1207,7 +1207,8 @@ CREATE TABLE chat_diff_statuses ( pr_number integer, commits integer, approved boolean, - reviewer_count integer + reviewer_count integer, + head_branch text ); CREATE TABLE chat_files ( diff --git a/coderd/database/migrations/000438_chat_diff_status_head_branch.down.sql b/coderd/database/migrations/000438_chat_diff_status_head_branch.down.sql new file mode 100644 index 0000000000..b56cf95283 --- /dev/null +++ b/coderd/database/migrations/000438_chat_diff_status_head_branch.down.sql @@ -0,0 +1 @@ +ALTER TABLE chat_diff_statuses DROP COLUMN head_branch; diff --git a/coderd/database/migrations/000438_chat_diff_status_head_branch.up.sql b/coderd/database/migrations/000438_chat_diff_status_head_branch.up.sql new file mode 100644 index 0000000000..4c9bd30912 --- /dev/null +++ b/coderd/database/migrations/000438_chat_diff_status_head_branch.up.sql @@ -0,0 +1 @@ +ALTER TABLE chat_diff_statuses ADD COLUMN head_branch TEXT; diff --git a/coderd/database/models.go b/coderd/database/models.go index a0034795f5..93725ac787 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4053,6 +4053,7 @@ type ChatDiffStatus struct { Commits sql.NullInt32 `db:"commits" json:"commits"` Approved sql.NullBool `db:"approved" json:"approved"` ReviewerCount sql.NullInt32 `db:"reviewer_count" json:"reviewer_count"` + HeadBranch sql.NullString `db:"head_branch" json:"head_branch"` } type ChatFile struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a67b12ffe7..5ca326923c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3030,10 +3030,10 @@ WITH acquired AS ( LIMIT $1::int ) - RETURNING chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count + RETURNING chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count, head_branch ) SELECT - acquired.chat_id, acquired.url, acquired.pull_request_state, acquired.changes_requested, acquired.additions, acquired.deletions, acquired.changed_files, acquired.refreshed_at, acquired.stale_at, acquired.created_at, acquired.updated_at, acquired.git_branch, acquired.git_remote_origin, acquired.pull_request_title, acquired.pull_request_draft, acquired.author_login, acquired.author_avatar_url, acquired.base_branch, acquired.pr_number, acquired.commits, acquired.approved, acquired.reviewer_count, + acquired.chat_id, acquired.url, acquired.pull_request_state, acquired.changes_requested, acquired.additions, acquired.deletions, acquired.changed_files, acquired.refreshed_at, acquired.stale_at, acquired.created_at, acquired.updated_at, acquired.git_branch, acquired.git_remote_origin, acquired.pull_request_title, acquired.pull_request_draft, acquired.author_login, acquired.author_avatar_url, acquired.base_branch, acquired.pr_number, acquired.commits, acquired.approved, acquired.reviewer_count, acquired.head_branch, c.owner_id FROM acquired @@ -3064,6 +3064,7 @@ type AcquireStaleChatDiffStatusesRow struct { Commits sql.NullInt32 `db:"commits" json:"commits"` Approved sql.NullBool `db:"approved" json:"approved"` ReviewerCount sql.NullInt32 `db:"reviewer_count" json:"reviewer_count"` + HeadBranch sql.NullString `db:"head_branch" json:"head_branch"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` } @@ -3099,6 +3100,7 @@ func (q *sqlQuerier) AcquireStaleChatDiffStatuses(ctx context.Context, limitVal &i.Commits, &i.Approved, &i.ReviewerCount, + &i.HeadBranch, &i.OwnerID, ); err != nil { return nil, err @@ -3623,7 +3625,7 @@ func (q *sqlQuerier) GetChatCostSummary(ctx context.Context, arg GetChatCostSumm const getChatDiffStatusByChatID = `-- name: GetChatDiffStatusByChatID :one SELECT - chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count + chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count, head_branch FROM chat_diff_statuses WHERE @@ -3656,13 +3658,14 @@ func (q *sqlQuerier) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid. &i.Commits, &i.Approved, &i.ReviewerCount, + &i.HeadBranch, ) return i, err } const getChatDiffStatusesByChatIDs = `-- name: GetChatDiffStatusesByChatIDs :many SELECT - chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count + chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count, head_branch FROM chat_diff_statuses WHERE @@ -3701,6 +3704,7 @@ func (q *sqlQuerier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds [ &i.Commits, &i.Approved, &i.ReviewerCount, + &i.HeadBranch, ); err != nil { return nil, err } @@ -4586,6 +4590,7 @@ INSERT INTO chat_diff_statuses ( author_login, author_avatar_url, base_branch, + head_branch, pr_number, commits, approved, @@ -4605,12 +4610,13 @@ INSERT INTO chat_diff_statuses ( $10::text, $11::text, $12::text, - $13::integer, + $13::text, $14::integer, - $15::boolean, - $16::integer, - $17::timestamptz, - $18::timestamptz + $15::integer, + $16::boolean, + $17::integer, + $18::timestamptz, + $19::timestamptz ) ON CONFLICT (chat_id) DO UPDATE SET @@ -4625,6 +4631,7 @@ SET author_login = EXCLUDED.author_login, author_avatar_url = EXCLUDED.author_avatar_url, base_branch = EXCLUDED.base_branch, + head_branch = EXCLUDED.head_branch, pr_number = EXCLUDED.pr_number, commits = EXCLUDED.commits, approved = EXCLUDED.approved, @@ -4633,7 +4640,7 @@ SET stale_at = EXCLUDED.stale_at, updated_at = NOW() RETURNING - chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count + chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count, head_branch ` type UpsertChatDiffStatusParams struct { @@ -4649,6 +4656,7 @@ type UpsertChatDiffStatusParams struct { AuthorLogin sql.NullString `db:"author_login" json:"author_login"` AuthorAvatarUrl sql.NullString `db:"author_avatar_url" json:"author_avatar_url"` BaseBranch sql.NullString `db:"base_branch" json:"base_branch"` + HeadBranch sql.NullString `db:"head_branch" json:"head_branch"` PrNumber sql.NullInt32 `db:"pr_number" json:"pr_number"` Commits sql.NullInt32 `db:"commits" json:"commits"` Approved sql.NullBool `db:"approved" json:"approved"` @@ -4671,6 +4679,7 @@ func (q *sqlQuerier) UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDif arg.AuthorLogin, arg.AuthorAvatarUrl, arg.BaseBranch, + arg.HeadBranch, arg.PrNumber, arg.Commits, arg.Approved, @@ -4702,6 +4711,7 @@ func (q *sqlQuerier) UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDif &i.Commits, &i.Approved, &i.ReviewerCount, + &i.HeadBranch, ) return i, err } @@ -4737,7 +4747,7 @@ SET stale_at = EXCLUDED.stale_at, updated_at = NOW() RETURNING - chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count + chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count, head_branch ` type UpsertChatDiffStatusReferenceParams struct { @@ -4780,6 +4790,7 @@ func (q *sqlQuerier) UpsertChatDiffStatusReference(ctx context.Context, arg Upse &i.Commits, &i.Approved, &i.ReviewerCount, + &i.HeadBranch, ) return i, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index f6f1a16a83..6c20b46275 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -367,6 +367,7 @@ INSERT INTO chat_diff_statuses ( author_login, author_avatar_url, base_branch, + head_branch, pr_number, commits, approved, @@ -386,6 +387,7 @@ INSERT INTO chat_diff_statuses ( sqlc.narg('author_login')::text, sqlc.narg('author_avatar_url')::text, sqlc.narg('base_branch')::text, + sqlc.narg('head_branch')::text, sqlc.narg('pr_number')::integer, sqlc.narg('commits')::integer, sqlc.narg('approved')::boolean, @@ -406,6 +408,7 @@ SET author_login = EXCLUDED.author_login, author_avatar_url = EXCLUDED.author_avatar_url, base_branch = EXCLUDED.base_branch, + head_branch = EXCLUDED.head_branch, pr_number = EXCLUDED.pr_number, commits = EXCLUDED.commits, approved = EXCLUDED.approved, diff --git a/coderd/externalauth/gitprovider/github.go b/coderd/externalauth/gitprovider/github.go index 29928552af..8f177256cd 100644 --- a/coderd/externalauth/gitprovider/github.go +++ b/coderd/externalauth/gitprovider/github.go @@ -269,6 +269,7 @@ func (g *githubProvider) FetchPullRequestStatus( Commits int32 `json:"commits"` Head struct { SHA string `json:"sha"` + Ref string `json:"ref"` } `json:"head"` User struct { Login string `json:"login"` @@ -310,10 +311,11 @@ func (g *githubProvider) FetchPullRequestStatus( reviewInfo := summarizeReviews(reviews) return &PRStatus{ - Title: pull.Title, - State: state, - Draft: pull.Draft, - HeadSHA: pull.Head.SHA, + Title: pull.Title, + State: state, + Draft: pull.Draft, + HeadSHA: pull.Head.SHA, + HeadBranch: pull.Head.Ref, DiffStats: DiffStats{ Additions: pull.Additions, Deletions: pull.Deletions, diff --git a/coderd/externalauth/gitprovider/github_test.go b/coderd/externalauth/gitprovider/github_test.go index 346e7066b0..fb2b510553 100644 --- a/coderd/externalauth/gitprovider/github_test.go +++ b/coderd/externalauth/gitprovider/github_test.go @@ -657,7 +657,7 @@ func TestFetchPullRequestStatus(t *testing.T) { }{ { name: "OpenPR/NoReviews", - pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123"}}`, + pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`, reviews: []review{}, expectedState: gitprovider.PRStateOpen, expectedDraft: false, @@ -665,14 +665,14 @@ func TestFetchPullRequestStatus(t *testing.T) { }, { name: "OpenPR/SingleChangesRequested", - pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123"}}`, + pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`, reviews: []review{makeReview(1, "CHANGES_REQUESTED", "alice")}, expectedState: gitprovider.PRStateOpen, changesRequested: true, }, { name: "OpenPR/ChangesRequestedThenApproved", - pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123"}}`, + pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`, reviews: []review{ makeReview(1, "CHANGES_REQUESTED", "alice"), makeReview(2, "APPROVED", "alice"), @@ -682,7 +682,7 @@ func TestFetchPullRequestStatus(t *testing.T) { }, { name: "OpenPR/ChangesRequestedThenDismissed", - pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123"}}`, + pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`, reviews: []review{ makeReview(1, "CHANGES_REQUESTED", "alice"), makeReview(2, "DISMISSED", "alice"), @@ -692,7 +692,7 @@ func TestFetchPullRequestStatus(t *testing.T) { }, { name: "OpenPR/MultipleReviewersMixed", - pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123"}}`, + pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`, reviews: []review{ makeReview(1, "APPROVED", "alice"), makeReview(2, "CHANGES_REQUESTED", "bob"), @@ -702,7 +702,7 @@ func TestFetchPullRequestStatus(t *testing.T) { }, { name: "OpenPR/CommentedDoesNotAffect", - pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123"}}`, + pullJSON: `{"state":"open","merged":false,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`, reviews: []review{ makeReview(1, "COMMENTED", "alice"), }, @@ -711,14 +711,14 @@ func TestFetchPullRequestStatus(t *testing.T) { }, { name: "MergedPR", - pullJSON: `{"state":"closed","merged":true,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123"}}`, + pullJSON: `{"state":"closed","merged":true,"draft":false,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`, reviews: []review{}, expectedState: gitprovider.PRStateMerged, changesRequested: false, }, { name: "DraftPR", - pullJSON: `{"state":"open","merged":false,"draft":true,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123"}}`, + pullJSON: `{"state":"open","merged":false,"draft":true,"additions":10,"deletions":5,"changed_files":3,"head":{"sha":"abc123","ref":"feature-branch"}}`, reviews: []review{}, expectedState: gitprovider.PRStateOpen, expectedDraft: true, @@ -762,6 +762,7 @@ func TestFetchPullRequestStatus(t *testing.T) { assert.Equal(t, tc.expectedDraft, status.Draft) assert.Equal(t, tc.changesRequested, status.ChangesRequested) assert.Equal(t, "abc123", status.HeadSHA) + assert.Equal(t, "feature-branch", status.HeadBranch) assert.Equal(t, int32(10), status.DiffStats.Additions) assert.Equal(t, int32(5), status.DiffStats.Deletions) assert.Equal(t, int32(3), status.DiffStats.ChangedFiles) diff --git a/coderd/externalauth/gitprovider/gitprovider.go b/coderd/externalauth/gitprovider/gitprovider.go index ca6afccedc..50a254ae0d 100644 --- a/coderd/externalauth/gitprovider/gitprovider.go +++ b/coderd/externalauth/gitprovider/gitprovider.go @@ -74,6 +74,8 @@ type PRStatus struct { Draft bool // HeadSHA is the SHA of the head commit. HeadSHA string + // HeadBranch is the name of the branch containing the PR changes. + HeadBranch string // DiffStats summarizes additions/deletions/files changed. DiffStats DiffStats // ChangesRequested is a convenience boolean: true if any diff --git a/coderd/gitsync/gitsync.go b/coderd/gitsync/gitsync.go index fcf7e5f56b..6d2090b86e 100644 --- a/coderd/gitsync/gitsync.go +++ b/coderd/gitsync/gitsync.go @@ -319,6 +319,7 @@ func (r *Refresher) refreshOne( AuthorLogin: sql.NullString{String: status.AuthorLogin, Valid: status.AuthorLogin != ""}, AuthorAvatarUrl: sql.NullString{String: status.AuthorAvatarURL, Valid: status.AuthorAvatarURL != ""}, BaseBranch: sql.NullString{String: status.BaseBranch, Valid: status.BaseBranch != ""}, + HeadBranch: sql.NullString{String: status.HeadBranch, Valid: status.HeadBranch != ""}, PrNumber: sql.NullInt32{Int32: int32(status.PRNumber), Valid: true}, Commits: sql.NullInt32{Int32: status.Commits, Valid: true}, Approved: sql.NullBool{Bool: status.Approved, Valid: true}, diff --git a/coderd/gitsync/worker.go b/coderd/gitsync/worker.go index 1975077974..63a4a00319 100644 --- a/coderd/gitsync/worker.go +++ b/coderd/gitsync/worker.go @@ -143,6 +143,7 @@ func chatDiffStatusFromRow(row database.AcquireStaleChatDiffStatusesRow) databas AuthorLogin: row.AuthorLogin, AuthorAvatarUrl: row.AuthorAvatarUrl, BaseBranch: row.BaseBranch, + HeadBranch: row.HeadBranch, PrNumber: row.PrNumber, Commits: row.Commits, Approved: row.Approved, diff --git a/codersdk/chats.go b/codersdk/chats.go index 19f46e548a..49122210de 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -633,6 +633,7 @@ type ChatDiffStatus struct { AuthorLogin *string `json:"author_login,omitempty"` AuthorAvatarURL *string `json:"author_avatar_url,omitempty"` BaseBranch *string `json:"base_branch,omitempty"` + HeadBranch *string `json:"head_branch,omitempty"` PRNumber *int32 `json:"pr_number,omitempty"` Commits *int32 `json:"commits,omitempty"` Approved *bool `json:"approved,omitempty"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index faaf8f5bc6..5975b84cf2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1201,6 +1201,7 @@ export interface ChatDiffStatus { readonly author_login?: string; readonly author_avatar_url?: string; readonly base_branch?: string; + readonly head_branch?: string; readonly pr_number?: number; readonly commits?: number; readonly approved?: boolean;