Files
kandev/apps/backend/internal/github/models.go
T
José Almeida 14c19ea538 feat(web): add CI hover popover on PR top-bar button (#846)
* feat(github): plumb CI popover fields + branch protection cache

Adds RequiredReviews/UnresolvedReviewThreads/ChecksTotal/ChecksPassing
to TaskPR + PRStatus. Extends the GraphQL PR query with reviewThreads
to populate UnresolvedReviewThreads on every poll. New branch_protection.go
holds an in-memory TTL cache + BranchProtectionFetcher interface;
PATClient/GHClient implement REST/CLI lookups so SyncTaskPR can resolve
required_approving_review_count for the PR's base branch. SyncTaskPR
preserves existing counts when a lightweight (GraphQL) sync returns 0
so the popover doesn't flap during the polling cycle.

* feat(github): mock controller hooks for CI popover e2e

Extends associateTaskPR to accept review/check counts, required_reviews,
and unresolved_review_threads, and switches to ReplaceTaskPR so re-POSTing
the same task drives the popover live-refresh path. Adds POST /pr-feedback
to seed PRFeedback (checks/reviews/comments) keyed by a deterministic
synthetic head_sha, and PUT /auth-health to flip the mock client's
authenticated flag for the "Reconnect GitHub" branch. Mock client gains
ReplaceCheckRuns/ReplaceReviews/ReplaceComments helpers and an
authenticated field that defaults to true.

* feat(web): CI hover popover on PR top-bar button

Hovering the PR top-bar button now reveals a popover with
Passed/In-Progress/Failed counts, a workflow-rollup list under the
non-empty buckets (with "open on GitHub" + "+ ctx" affordances on
failed rows), a review-state row ("Approved 1 / 2 required"), and
unresolved-comments count. Click on the button keeps the existing
"open PR detail panel" behavior so shipped tests stay green.

PRFeedback is lazy-fetched on hover-open via a new SWR-style hook
backed by a 20-entry LRU cache slice; the popover refetches whenever
the underlying TaskPR's updated_at changes (the WS handler already
applies poll deltas). Bucket logic + workflow grouping live in a
small util with vitest coverage. The aggregate-popover variant for
multi-PR tasks ships but the multi-PR trigger preserves its existing
DropdownMenu so per-row click → addPRPanel still works.

* test(e2e): pr-topbar-popover scenarios A–F covering CI popover

11 specs covering: count rows + zero-bucket hiding, workflow rollup
with (N/M passed) badge under Failed, review row + unresolved comments,
hover open + leave close, failed-row open/+ctx affordances, header
external link, "No checks have started" empty state, "Reconnect GitHub"
auth-lost branch, and live count updates on a re-associate.

Adds api-client helpers (mockGitHubSeedPRFeedback, mockGitHubSetAuthHealth,
extended mockGitHubAssociateTaskPR) and SessionPage popover accessors.

* fix(github): address bot review comments on CI popover

- branch_protection.go: evict stale entries on read so the cache map
  cannot grow unbounded over a long-running deployment; rewrite the
  comment to drop the inaccurate singleflight claim (no dedup logic
  exists, concurrent misses race harmlessly to set()).
- gh_client.go: replace fragile strings.Contains "HTTP 404" with an
  isNotFoundErr helper that tolerates the formats current and future
  gh CLI versions use ("HTTP 404", "404 Not Found", "status: 404").
- graphql.go: extend reviewThreads with totalCount and attribute the
  un-fetched tail to the unresolved bucket so PRs with >100 threads
  don't silently undercount in the popover.
- pr-ci-popover.tsx: drop the dead "icon" initializer in PRReviewRow
  (every branch overwrites it). Memoize useAddCheckToContext so
  PRWorkflowRow's reference equality stays stable across renders.
- pr-topbar-button.tsx: collapse the duplicate JSDoc on
  usePopoverInteractions.

* fix(github): address remaining bot review comments + tests

Real correctness fixes:
- Replace the "0/0 means counts omitted" heuristic with an explicit
  PRStatus.ChecksPopulated flag so the rich REST sync can clear stale
  totals when checks are removed from a PR upstream.
- Refresh tp.BaseBranch from status.PR on each SyncTaskPR so a
  retargeted PR's branch-protection lookup keys off the new branch.
  Persist base_branch in UpdateTaskPR.
- Cache 403 from /branches/.../protection (token lacks Administration:
  Read scope) the same way as 404 — otherwise every poller tick burns
  rate-limit quota retrying. Both PATClient and GHClient.
- usePRCIPopover: refetch on every popover-open transition (not just
  on updated_at change), via a wasEnabledRef gate. Restores the
  intended SWR semantics that the lastSyncedRef-only guard suppressed.
- groupChecksByWorkflow: prefer the URL of a job matching the group's
  bucket so clicking a "failed" workflow row navigates to a failing
  job, not an earlier passing one.

Tests:
- branch_protection_test.go: cache hit/miss/TTL/eviction, error
  not cached, no-rule cached, client-without-fetcher path.
- pat_client_test.go: TestPATClient_FetchBranchProtection table-test
  for 200/200-with-null/404/403/500.
- service_test.go: ChecksPopulated preserve-on-zero, accept real
  zero, base-branch retarget, change-publish for the new fields.

* refactor(github): trim associateTaskPR + drop unreachable PRAggregatePopover

- mock_controller.go: extract associateTaskPRRequest struct,
  buildTaskPRFromRequest, and ensureMockPRForRequest helpers so the
  handler is back under the 80-line Go limit.
- pr-ci-popover.tsx: remove unreachable PRAggregatePopover export.
  Multi-PR tasks currently use the existing DropdownMenu trigger
  (no hover popover), so the aggregate component was dead code.

* fix(github): drop unused anchorRef + tests for is{NotFound,Forbidden}Err

- pr-topbar-button.tsx: anchorRef was leftover from the dropped
  click-to-pin onInteractOutside guard. Removed.
- gh_client_test.go: tests for the new isNotFoundErr / isForbiddenErr
  helpers covering each accepted format and a few non-matches so a
  future gh CLI wording change is caught.

* fix(github): UnresolvedReviewThreadsPopulated guard + drop unreachable nil checks

- service.go: mirror the ChecksPopulated/preserve dance for
  UnresolvedReviewThreads. The REST single-PR poller (getPRStatus)
  doesn't fetch review threads, so without this guard every REST
  sync would clobber the GraphQL-populated value to 0 and emit a
  spurious github.task_pr.updated event. The GraphQL path
  (convertBatchedPRResult) sets the new flag.
- branch_protection.go: drop unreachable s == nil / fetcher == nil
  checks. fetchRequiredReviews is only invoked from SyncTaskPR on a
  service constructed via NewService, which always initialises the
  cache. After a successful type assertion, the interface value
  cannot be nil; the !ok branch already covers nil-interface inputs.
- service_test.go: new TestSyncTaskPR_UnresolvedThreads_PreservesOnLightweightSync
  covers the regression. Existing NewFieldsPublishEvent threads-change
  case updated to set UnresolvedReviewThreadsPopulated.

* refactor(web): drop CI popover progress bar — last-updated is enough

The animated progress bar was visual noise; the "updated Ns ago"
timestamp tells the user the same thing without the indicator. Footer
also hides entirely until the first fetch lands so an empty popover
doesn't show a useless "" line.

* fix(web): background-sync PRFeedback so popover is fresh on first hover

The on-hover SWR fetch was the only refresh path, so opening the popover
showed the previously-cached value while a new fetch raced behind it —
visibly stale until the refetch landed. The button icon, by contrast,
updates the moment a github.task_pr.updated WS push lands.

Add a usePRFeedbackBackgroundSync hook mounted at the PRTopbarButton
that subscribes to the same pr.updated_at change signal and refetches
PRFeedback into the cache in the background. The popover hook still
fires an on-open refetch as a safety net for the very first hover
(before any sync). No extra polling — fetches are gated on real
TaskPR changes, matching the icon's cadence.

* feat(web): CI status chip in chat status bar + tweak elapsed label

- New <PRStatusChip> renders a compact colored circle in the chat
  status bar (left of the TodoIndicator, just above the input). Hover
  opens the same PRCIPopover anchored to the chip's top edge so the
  card expands upward — matching the user's "open bottom-up" ask. The
  chip mounts the background-sync hook so the popover is fresh on
  first hover even when the top-bar PR button isn't visible.
- elapsedLabel: "just now" → "updated just now" so the footer reads
  the same regardless of how recently the cache was refreshed.

* fix(web): chip color was missing — use static class map for Tailwind JIT

\`getPRStatusColor().replace("text-", "bg-")\` produced class strings at
runtime, which Tailwind's content scanner doesn't pick up — the chip
rendered with no background. Replace with a static lookup so each
literal `bg-*` class appears in the source and gets compiled into the
bundle.

* fix(web): chip uses currentColor SVG so the dot actually renders

The previous bg-* approach relied on Tailwind compiling the literal
class strings. Even with a static map some palette entries (notably
bg-muted-foreground) didn't surface as a usable utility, so the chip
remained transparent. Apply getPRStatusColor's text-* class to the
button wrapper and fill an SVG circle with currentColor — the text-*
utilities are already compiled (PRTaskIcon uses them) so the dot
is guaranteed to render in the right color.

* fix(web): redesign PR status chip — clipboard icon + status glyph

- Move chip to right of TodoIndicator (was left).
- Render as "[clipboard icon] [status glyph]" instead of a bare circle:
  passed → green CircleCheck, failed → red CircleX, in_progress →
  yellow Loader2 (spinning), merged/neutral → muted dot. Status icons
  reuse already-compiled text-* utilities so colors render reliably.

* fix(github): ReviewCount = approved-reviewer count + ReviewCountsPopulated guard

Two bugs feeding the popover's "Approved (0)" misreport:

- Semantic: REST path set ReviewCount to len(reviews), which double-counts
  authors who left multiple reviews and includes COMMENTED rows. Replace
  with countApprovedReviewers — distinct authors whose latest review
  state is APPROVED. GraphQL converter gets the parallel
  countApprovedReviewerNodes helper and also populates PendingReviewCount
  from ReviewRequests.TotalCount (was unset, so the chip's "(K requested)"
  suffix was always missing).

- Clobber: GraphQL polls used to write ReviewCount/PendingReviewCount=0
  on every cycle because the converter never set them. Add
  ReviewCountsPopulated to PRStatus and gate SyncTaskPR's writes on it
  the same way ChecksPopulated already does, so a partial sync path
  preserves the persisted values instead of resetting them to zero.

* fix(web): popover nitpicks (icon, review row, indent, badge)

- Chip icon: ClipboardCheck → ListCheck.
- PRReviewRow always renders (drop the early-return). Fresh PRs with
  no approvals + no protection rule now show "Awaiting review" instead
  of vanishing.
- Indent workflow rows under the In Progress / Failed group headers
  (pl-5) so the hierarchy reads more clearly.
- In-progress workflow badge: "0/1 ran" → "1 running". The old phrasing
  hid whether the job had even started.

* feat(web): stacked pass-rate progress bar in CI popover

Mirrors TodoIndicator's progress bar at the top of the CI section, but
split into three coloured segments:
  green (passed) | yellow (in_progress) | red (failed)
sized proportionally to each bucket's count. The label reads
"Pass rate · K/N (P%)" so the user gets the same at-a-glance summary
TODOs shows. Skipped/cancelled-but-not-failed jobs aren't counted —
the bar always reflects the actionable picture.

* fix(web): right-align review counts + drop chip hover bg

- Move approval/required counts to the right edge of the review row
  ("Approved" on the left, "1 / 2" on the right), mirroring the
  bucket-row layout. The "(K requested)" suffix becomes a "· K requested"
  trailing fragment on the right side.
- PRStatusChip: drop hover:bg-accent/50 so the trigger doesn't grow a
  background plate when the popover is open.

* fix(web): switch CI chip icon to IconChecklist

* fix(github): clear bot review-comment backlog

Real correctness fixes:
- pr-status-chip: pending checks / pending review now beat
  checks_state === "success" in chipStatus precedence — a PR with
  CI green but reviewers outstanding is no longer rendered as
  "passed".
- branch_protection: drop the unreachable Reset() method.
- pr-topbar-button: stop double-mounting usePRFeedbackBackgroundSync;
  the chip already covers it.
- use-pr-ci-popover: dedupe key combines prFeedbackKey + updated_at
  so switching tasks with same-timestamp PRs still refetches.
- use-pr-ci-popover: drop stale comment about the removed progress bar.

Refactor:
- client_helpers + graphql: extract reviewSample +
  latestReviewStateByAuthor helper so the three near-identical
  per-reviewer dedup loops share one implementation.

Tests:
- countApprovedReviewers + reduceReviewSummary unit tests.
- TestSyncTaskPR_ReviewCounts_PreservesOnLightweightSync +
  AcceptsRealZero mirror the existing ChecksPopulated guards.
- groupChecksByWorkflow distinct-URL test verifies the failed-job
  URL-selection fix.
- branch_protection TTL tests now use testing/synctest — instant
  virtual time, no real-sleep flake on CI.

* fix(web): clicking the CI chip no longer closes the popover

HoverCard treats the trigger button as outside the content's bounding
box, so a click on the chip fired Radix's onPointerDownOutside and
auto-closed the popover. Add a triggerRef + onPointerDownOutside guard
on HoverCardContent that filters out clicks landing on the trigger —
clicking the chip is now a no-op while the popover stays open via hover.
2026-05-11 16:55:58 +01:00

573 lines
26 KiB
Go

// Package github provides GitHub integration for Kandev, including PR monitoring,
// review queue management, and CI/check status tracking.
package github
import "time"
// PR represents a GitHub Pull Request.
type PR struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
State string `json:"state"` // open, closed, merged
HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"`
BaseBranch string `json:"base_branch"`
AuthorLogin string `json:"author_login"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Mergeable bool `json:"mergeable"`
MergeableState string `json:"mergeable_state"` // clean, blocked, behind, dirty, has_hooks, unstable, draft, unknown, ""
Additions int `json:"additions"`
Deletions int `json:"deletions"`
RequestedReviewers []RequestedReviewer `json:"requested_reviewers"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MergedAt *time.Time `json:"merged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// RequestedReviewer represents a pending reviewer request on a PR.
type RequestedReviewer struct {
Login string `json:"login"`
Type string `json:"type"` // user, team
}
// PRReview represents a review on a PR.
type PRReview struct {
ID int64 `json:"id"`
Author string `json:"author"`
AuthorAvatar string `json:"author_avatar"`
State string `json:"state"` // APPROVED, CHANGES_REQUESTED, COMMENTED, PENDING, DISMISSED
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
}
// PRComment represents a review comment on specific code.
type PRComment struct {
ID int64 `json:"id"`
Author string `json:"author"`
AuthorAvatar string `json:"author_avatar"`
AuthorIsBot bool `json:"author_is_bot"`
Body string `json:"body"`
Path string `json:"path"`
Line int `json:"line"`
Side string `json:"side"` // LEFT, RIGHT
CommentType string `json:"comment_type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
InReplyTo *int64 `json:"in_reply_to,omitempty"`
}
// CheckRun represents a CI check result.
type CheckRun struct {
Name string `json:"name"`
Source string `json:"source"` // check_run, status_context
Status string `json:"status"` // queued, in_progress, completed
Conclusion string `json:"conclusion"` // success, failure, neutral, cancelled, timed_out, action_required, skipped
HTMLURL string `json:"html_url"`
Output string `json:"output"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// PRFeedback aggregates all feedback for a PR (fetched live from GitHub).
type PRFeedback struct {
PR *PR `json:"pr"`
Reviews []PRReview `json:"reviews"`
Comments []PRComment `json:"comments"`
Checks []CheckRun `json:"checks"`
HasIssues bool `json:"has_issues"`
}
// PRStatus contains lightweight PR state used by the background poller.
// Unlike PRFeedback, it skips comments to reduce API calls.
type PRStatus struct {
PR *PR `json:"pr"`
ReviewState string `json:"review_state"` // "approved", "changes_requested", "pending", ""
ChecksState string `json:"checks_state"` // "success", "failure", "pending", ""
MergeableState string `json:"mergeable_state"` // "clean", "blocked", "behind", "dirty", "has_hooks", "unstable", "draft", "unknown", ""
ReviewCount int `json:"review_count"`
PendingReviewCount int `json:"pending_review_count"`
RequiredReviews *int `json:"required_reviews,omitempty"` // nil when no branch protection rule found
ChecksTotal int `json:"checks_total"`
ChecksPassing int `json:"checks_passing"`
// ChecksPopulated reports whether the sync path actually computed
// ChecksTotal / ChecksPassing. The batched GraphQL poller doesn't (it
// only carries the rollup state), so SyncTaskPR uses this flag to
// decide whether to overwrite the persisted counts. A value of true
// with both counts at 0 is a real "no checks" answer; a value of false
// means "I didn't look, keep what's there."
ChecksPopulated bool `json:"checks_populated,omitempty"`
UnresolvedReviewThreads int `json:"unresolved_review_threads"`
// UnresolvedReviewThreadsPopulated mirrors ChecksPopulated for the
// review-threads field. The REST path (getPRStatus) doesn't fetch
// review threads, so it leaves the field at zero; SyncTaskPR uses
// this flag to avoid clobbering a non-zero value set by the GraphQL
// path during a subsequent REST sync.
UnresolvedReviewThreadsPopulated bool `json:"unresolved_review_threads_populated,omitempty"`
// ReviewCountsPopulated covers ReviewCount + PendingReviewCount. Both
// REST and GraphQL paths compute these now, but historically only the
// REST path did, so without the guard a GraphQL poll would clobber
// the REST value back to 0 (the popover's "Approved (1)" turning
// into "Approved (0)" until a new REST call landed).
ReviewCountsPopulated bool `json:"review_counts_populated,omitempty"`
}
// PRSearchPage is a paginated slice of PR search results, with the total
// count reported by the GitHub Search API (capped at 1000).
type PRSearchPage struct {
PRs []*PR `json:"prs"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// IssueSearchPage is a paginated slice of Issue search results.
type IssueSearchPage struct {
Issues []*Issue `json:"issues"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// PRWatch tracks active PR monitoring (session → PR). RepositoryID identifies
// which task repository the watched PR belongs to (multi-repo support; empty
// for legacy rows).
type PRWatch struct {
ID string `json:"id" db:"id"`
SessionID string `json:"session_id" db:"session_id"`
TaskID string `json:"task_id" db:"task_id"`
RepositoryID string `json:"repository_id,omitempty" db:"repository_id"`
Owner string `json:"owner" db:"owner"`
Repo string `json:"repo" db:"repo"`
PRNumber int `json:"pr_number" db:"pr_number"`
Branch string `json:"branch" db:"branch"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty" db:"last_checked_at"`
LastCommentAt *time.Time `json:"last_comment_at,omitempty" db:"last_comment_at"`
LastCheckStatus string `json:"last_check_status" db:"last_check_status"`
LastReviewState string `json:"last_review_state" db:"last_review_state"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TaskPR associates a PR with a task. RepositoryID identifies which task
// repository this PR belongs to (multi-repo tasks can have one PR per repo).
// Empty for legacy rows persisted before multi-repo support.
type TaskPR struct {
ID string `json:"id" db:"id"`
TaskID string `json:"task_id" db:"task_id"`
RepositoryID string `json:"repository_id,omitempty" db:"repository_id"`
Owner string `json:"owner" db:"owner"`
Repo string `json:"repo" db:"repo"`
PRNumber int `json:"pr_number" db:"pr_number"`
PRURL string `json:"pr_url" db:"pr_url"`
PRTitle string `json:"pr_title" db:"pr_title"`
HeadBranch string `json:"head_branch" db:"head_branch"`
BaseBranch string `json:"base_branch" db:"base_branch"`
AuthorLogin string `json:"author_login" db:"author_login"`
State string `json:"state" db:"state"` // open, closed, merged
ReviewState string `json:"review_state" db:"review_state"` // approved, changes_requested, pending, ""
ChecksState string `json:"checks_state" db:"checks_state"` // success, failure, pending, ""
MergeableState string `json:"mergeable_state" db:"mergeable_state"` // clean, blocked, behind, dirty, has_hooks, unstable, draft, unknown, ""
ReviewCount int `json:"review_count" db:"review_count"`
PendingReviewCount int `json:"pending_review_count" db:"pending_review_count"`
// RequiredReviews is the branch protection's required_approving_review_count.
// Nil when no protection rule exists or the token lacks scope to read it.
RequiredReviews *int `json:"required_reviews,omitempty" db:"required_reviews"`
CommentCount int `json:"comment_count" db:"comment_count"`
UnresolvedReviewThreads int `json:"unresolved_review_threads" db:"unresolved_review_threads"`
ChecksTotal int `json:"checks_total" db:"checks_total"`
ChecksPassing int `json:"checks_passing" db:"checks_passing"`
Additions int `json:"additions" db:"additions"`
Deletions int `json:"deletions" db:"deletions"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
MergedAt *time.Time `json:"merged_at,omitempty" db:"merged_at"`
ClosedAt *time.Time `json:"closed_at,omitempty" db:"closed_at"`
LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// RepoFilter identifies a GitHub repository for review watch filtering.
type RepoFilter struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// ReviewScope controls which GitHub search qualifier is used for review-requested PRs.
const (
// ReviewScopeUser matches only PRs where the user is explicitly requested
// (user-review-requested:@me).
ReviewScopeUser = "user"
// ReviewScopeUserAndTeams matches PRs requested from the user or any of their teams
// (review-requested:@me). This is the default for backwards compatibility.
ReviewScopeUserAndTeams = "user_and_teams"
)
// ReviewWatch configures periodic polling for PRs needing the user's review.
// Repos holds the list of repositories to monitor. An empty list means all repos.
type ReviewWatch struct {
ID string `json:"id" db:"id"`
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
WorkflowID string `json:"workflow_id" db:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"`
Repos []RepoFilter `json:"repos" db:"-"`
ReposJSON string `json:"-" db:"repos"`
AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"`
Prompt string `json:"prompt" db:"prompt"`
ReviewScope string `json:"review_scope" db:"review_scope"`
CustomQuery string `json:"custom_query" db:"custom_query"`
Enabled bool `json:"enabled" db:"enabled"`
PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"`
LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ReviewPRTask records which PRs have already had tasks created (deduplication).
type ReviewPRTask struct {
ID string `json:"id" db:"id"`
ReviewWatchID string `json:"review_watch_id" db:"review_watch_id"`
RepoOwner string `json:"repo_owner" db:"repo_owner"`
RepoName string `json:"repo_name" db:"repo_name"`
PRNumber int `json:"pr_number" db:"pr_number"`
PRURL string `json:"pr_url" db:"pr_url"`
TaskID string `json:"task_id" db:"task_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// GitHubOrg represents a GitHub organization the authenticated user belongs to.
type GitHubOrg struct {
Login string `json:"login"`
AvatarURL string `json:"avatar_url"`
}
// GitHubRepo represents a GitHub repository (lightweight, for autocomplete).
type GitHubRepo struct {
FullName string `json:"full_name"`
Owner string `json:"owner"`
Name string `json:"name"`
Private bool `json:"private"`
}
// RepoBranch represents a branch in a GitHub repository.
type RepoBranch struct {
Name string `json:"name"`
}
// GitHubStatus represents GitHub connection status.
type GitHubStatus struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username"`
AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none"
TokenConfigured bool `json:"token_configured"`
TokenSecretID string `json:"token_secret_id,omitempty"`
RequiredScopes []string `json:"required_scopes"`
Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"`
RateLimit *GitHubRateLimitInfo `json:"rate_limit,omitempty"`
}
// GitHubRateLimitInfo bundles known rate-limit snapshots per resource bucket
// for surfacing in the UI.
type GitHubRateLimitInfo struct {
Core *RateSnapshot `json:"core,omitempty"`
GraphQL *RateSnapshot `json:"graphql,omitempty"`
Search *RateSnapshot `json:"search,omitempty"`
}
// ConfigureTokenRequest is the request body for configuring a GitHub token.
type ConfigureTokenRequest struct {
Token string `json:"token" binding:"required"`
}
// AuthDiagnostics captures the output of gh auth status for troubleshooting.
type AuthDiagnostics struct {
Command string `json:"command"`
Output string `json:"output"`
ExitCode int `json:"exit_code"`
}
// CreateReviewWatchRequest is the request body for creating a review watch.
type CreateReviewWatchRequest struct {
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
Repos []RepoFilter `json:"repos"`
AgentProfileID string `json:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id"`
Prompt string `json:"prompt"`
ReviewScope string `json:"review_scope"`
CustomQuery string `json:"custom_query"`
PollIntervalSeconds int `json:"poll_interval_seconds"`
}
// UpdateReviewWatchRequest is the request body for updating a review watch.
type UpdateReviewWatchRequest struct {
WorkflowID *string `json:"workflow_id,omitempty"`
WorkflowStepID *string `json:"workflow_step_id,omitempty"`
Repos *[]RepoFilter `json:"repos,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
ExecutorProfileID *string `json:"executor_profile_id,omitempty"`
Prompt *string `json:"prompt,omitempty"`
ReviewScope *string `json:"review_scope,omitempty"`
CustomQuery *string `json:"custom_query,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"`
}
// PRFeedbackEvent is published to the event bus when a PR has new feedback.
type PRFeedbackEvent struct {
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
PRNumber int `json:"pr_number"`
Owner string `json:"owner"`
Repo string `json:"repo"`
NewCheckStatus string `json:"new_check_status"`
NewReviewState string `json:"new_review_state"`
}
// NewReviewPREvent is published when a new PR needing review is found.
type NewReviewPREvent struct {
ReviewWatchID string `json:"review_watch_id"`
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
AgentProfileID string `json:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id"`
Prompt string `json:"prompt"`
PR *PR `json:"pr"`
}
// PRStatsRequest defines filters for PR stats queries.
type PRStatsRequest struct {
WorkspaceID string `json:"workspace_id"`
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
}
// PRStats holds aggregated PR analytics.
type PRStats struct {
TotalPRsCreated int `json:"total_prs_created"`
TotalPRsReviewed int `json:"total_prs_reviewed"`
TotalComments int `json:"total_comments"`
CIPassRate float64 `json:"ci_pass_rate"`
ApprovalRate float64 `json:"approval_rate"`
AvgTimeToMergeHours float64 `json:"avg_time_to_merge_hours"`
PRsByDay []DailyCount `json:"prs_by_day"`
}
// PRFile represents a file changed in a pull request.
type PRFile struct {
Filename string `json:"filename"`
Status string `json:"status"` // added, removed, modified, renamed, copied, changed, unchanged
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Patch string `json:"patch,omitempty"`
OldPath string `json:"old_path,omitempty"`
}
// PRCommitInfo represents a commit in a pull request.
type PRCommitInfo struct {
SHA string `json:"sha"`
Message string `json:"message"`
AuthorLogin string `json:"author_login"`
AuthorDate string `json:"author_date"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
}
// DailyCount holds a date and count for chart data.
type DailyCount struct {
Date string `json:"date"`
Count int `json:"count"`
}
// --- Issue Watch models ---
// Issue represents a GitHub Issue (not a PR).
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
State string `json:"state"` // open, closed
AuthorLogin string `json:"author_login"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Labels []string `json:"labels"`
Assignees []string `json:"assignees"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// IssueWatch configures periodic polling for GitHub issues matching a query.
// Repos holds the list of repositories to monitor. An empty list means all repos.
type IssueWatch struct {
ID string `json:"id" db:"id"`
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
WorkflowID string `json:"workflow_id" db:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"`
Repos []RepoFilter `json:"repos" db:"-"`
ReposJSON string `json:"-" db:"repos"`
AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"`
Prompt string `json:"prompt" db:"prompt"`
Labels []string `json:"labels" db:"-"`
LabelsJSON string `json:"-" db:"labels"`
CustomQuery string `json:"custom_query" db:"custom_query"`
Enabled bool `json:"enabled" db:"enabled"`
PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"`
LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// IssueWatchTask records which issues have already had tasks created (deduplication).
type IssueWatchTask struct {
ID string `json:"id" db:"id"`
IssueWatchID string `json:"issue_watch_id" db:"issue_watch_id"`
RepoOwner string `json:"repo_owner" db:"repo_owner"`
RepoName string `json:"repo_name" db:"repo_name"`
IssueNumber int `json:"issue_number" db:"issue_number"`
IssueURL string `json:"issue_url" db:"issue_url"`
TaskID string `json:"task_id" db:"task_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// NewIssueEvent is published when a new issue matching a watch is found.
type NewIssueEvent struct {
IssueWatchID string `json:"issue_watch_id"`
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
AgentProfileID string `json:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id"`
Prompt string `json:"prompt"`
Issue *Issue `json:"issue"`
}
// CreateIssueWatchRequest is the request body for creating an issue watch.
type CreateIssueWatchRequest struct {
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
Repos []RepoFilter `json:"repos"`
AgentProfileID string `json:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id"`
Prompt string `json:"prompt"`
Labels []string `json:"labels"`
CustomQuery string `json:"custom_query"`
PollIntervalSeconds int `json:"poll_interval_seconds"`
}
// UpdateIssueWatchRequest is the request body for updating an issue watch.
type UpdateIssueWatchRequest struct {
WorkflowID *string `json:"workflow_id,omitempty"`
WorkflowStepID *string `json:"workflow_step_id,omitempty"`
Repos *[]RepoFilter `json:"repos,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
ExecutorProfileID *string `json:"executor_profile_id,omitempty"`
Prompt *string `json:"prompt,omitempty"`
Labels *[]string `json:"labels,omitempty"`
CustomQuery *string `json:"custom_query,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"`
}
// --- Action presets (quick-launch prompts on the /github page) ---
// ActionPresetKind enumerates the two lists of quick-launch presets.
const (
ActionPresetKindPR = "pr"
ActionPresetKindIssue = "issue"
)
// ActionPreset is a single configurable quick-task launcher entry.
// PromptTemplate supports `{{url}}` and `{{title}}` placeholders which are
// substituted client-side when the dialog is opened.
type ActionPreset struct {
ID string `json:"id"`
Label string `json:"label"`
Hint string `json:"hint"`
Icon string `json:"icon"`
PromptTemplate string `json:"prompt_template"`
}
// ActionPresets groups the PR and Issue preset lists for a workspace.
type ActionPresets struct {
WorkspaceID string `json:"workspace_id"`
PR []ActionPreset `json:"pr"`
Issue []ActionPreset `json:"issue"`
}
// UpdateActionPresetsRequest replaces one or both preset lists for a workspace.
// Nil fields are left unchanged.
type UpdateActionPresetsRequest struct {
WorkspaceID string `json:"workspace_id"`
PR *[]ActionPreset `json:"pr,omitempty"`
Issue *[]ActionPreset `json:"issue,omitempty"`
}
// DefaultPRActionPresets returns the built-in PR presets used when a workspace
// has no stored overrides.
func DefaultPRActionPresets() []ActionPreset {
return []ActionPreset{
{
ID: "review",
Label: "Review",
Hint: "Read the diff, flag issues",
Icon: "eye",
PromptTemplate: "Review the pull request at {{url}}. Provide feedback on code quality, correctness, and suggest improvements.",
},
{
ID: "address_feedback",
Label: "Address feedback",
Hint: "Apply review comments",
Icon: "message",
PromptTemplate: "Review the feedback on the pull request at {{url}}. Evaluate each comment critically — apply changes that improve the code, push back on suggestions that are unnecessary or harmful, and explain your reasoning. Push the changes when done.",
},
{
ID: "fix_ci",
Label: "Fix CI",
Hint: "Diagnose failing checks",
Icon: "tool",
PromptTemplate: "Investigate and fix the CI failures and merge conflicts on the pull request at {{url}}. Run the failing checks locally, resolve any conflicts, diagnose issues, and push fixes.",
},
}
}
// DefaultIssueActionPresets returns the built-in Issue presets used when a
// workspace has no stored overrides.
func DefaultIssueActionPresets() []ActionPreset {
return []ActionPreset{
{
ID: "implement",
Label: "Implement",
Hint: "Build and open a PR",
Icon: "code",
PromptTemplate: `Implement the changes described in the GitHub issue at {{url}} (title: "{{title}}"). Open a pull request when complete.`,
},
{
ID: "investigate",
Label: "Investigate",
Hint: "Find the root cause",
Icon: "search",
PromptTemplate: `Investigate the GitHub issue at {{url}} (title: "{{title}}"). Identify root cause and summarize findings.`,
},
{
ID: "reproduce",
Label: "Reproduce",
Hint: "Document repro steps",
Icon: "bug",
PromptTemplate: `Reproduce the bug described in the GitHub issue at {{url}} (title: "{{title}}"). Document the reproduction steps.`,
},
}
}