Files
kandev/apps/backend/internal/github/models.go
T
João Salavisa 2a1f7c2edf feat: sync workflows from a configured github repo (#1721)
* feat: sync workflows from a configured github repo

Per-workspace setting pointing at a GitHub repo directory of portable
workflow export files. A background poller fetches it on an interval and
creates, updates, or deletes the matching workflows; a force-sync endpoint
bypasses the content-hash check. Synced workflows carry source/source_path
provenance and coexist with manual ones; changes that cannot be applied
safely (steps or workflows that still hold tasks, ambiguous step names,
unparseable files) surface as warnings in the new settings section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): fill workflow sync form from a pasted github link

A new Repository link field parses plain repo URLs, SSH remotes, and
tree/blob links (which also carry branch and directory) into the
structured owner/repo/branch/path fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): simplify workflow sync form to link, branch, and interval

The repository link is now the only repo input: owner, repo, and
directory resolve from the pasted URL (echoed as a summary under the
field), and a stored config renders back into a canonical tree link via
buildGitHubRepoUrl. Branch and poll interval stay individually editable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): collapse workflow sync into a button, dialog, and status card

The Workflows settings page now shows only a GitHub Sync button by
default; the link/branch/interval form lives in a dialog that closes on
successful save or removal. Once configured, a compact status card
(mirroring the GitHub connection-status card) shows the synced repo,
branch, directory, last result, warnings, and a Sync now action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): move github sync button into the workflows page header

The GitHub Sync trigger now stacks under the Workspace settings button
on the top right, and the extra separator around the sync area is gone —
unconfigured workspaces show no sync chrome at all, configured ones show
just the status card above the workflow list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): move github sync button next to the workflow actions

GitHub Sync now sits in the Workflows section toolbar alongside
Export All, Import, and Add Workflow; the status card renders inside the
section above the workflow list. The toolbar moved to its own component
to keep the page under the file-size limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): separate sync status card from the workflow list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): align sync status card with the workflow list gutter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: repair local edits to synced workflows on periodic sync

Periodic syncs previously skipped applying when the repo content hash
was unchanged, so local UI edits to a synced workflow were only reverted
by a manual Sync now. Every sync now reconciles against the repo; the
applier compares each step against its definition and only writes (and
broadcasts) actual differences, so a no-drift sync stays silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: make github-synced workflows read-only

Workflows with source=github reject UI mutations: workflow rename,
description, agent-profile, and delete return 409 from the task-domain
handlers; step create/update/delete/reorder and template application are
guarded in the workflow controller. The sync applier writes through the
service/repo layer and stays exempt. The frontend disables the card's
inputs, save/delete, and the step pipeline editor for synced workflows,
with a tooltip pointing at the synced repository. Drift repair on sync
remains as the backstop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: optional workflow sync polling and repo-derived branch picker

A per-workspace auto-sync toggle (poll_enabled, default on) controls
the background polling loop; when off the workspace only syncs via Sync
now. The dialog's branch field becomes a select fed by the repository's
actual branches (same loader the task-create dialog uses), falling back
to free text until branches load. Existing dev databases gain the new
column via an idempotent ALTER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: release synced workflows when the sync config is removed

Deleting a workspace's workflow sync configuration converts its
GitHub-sourced workflows back to manual ownership (source=manual), so
they become editable instead of being stranded read-only. Release runs
before the config row is deleted so a failure keeps the config for
retry. The settings page reloads after removal (and after syncs that
changed workflows) so cards unlock without a manual refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): drop branch selector and compact the auto-sync row

The pasted link already carries the branch (main by default), so the
dialog no longer needs a branch field — the resolved summary echoes it.
The auto-sync toggle is now one compact row with the interval inline and
a single short helper line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address PR review feedback

Backend: fail-open read-only guard on workflow lookup errors (fixes MCP
step handler CI failures); poller Stop holds its mutex through drain;
per-workspace lock serializes syncs against config delete/replace;
rollback workflow creation when provenance stamping fails; idiomatic
duplicate-column migration via db.IsDuplicateColumnError; branch-name
validation and a 30-day interval cap; generic 500s with logged detail;
WS read-only rejections use CONFLICT with the real reason; e2e reset
aborts when sync-config cleanup fails; test hygiene (t.Cleanup,
SetMaxOpenConns).

Frontend: malformed percent escapes parse as invalid instead of
throwing; save disabled during initial load and for invalid intervals;
Remove disabled while saving; stale async config responses discarded;
delete-dialog confirms re-check read-only; focusable Synced badge;
explicit button types; failed polls labeled 'last attempt'; unique
warning list keys; test cleanup via afterEach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: guard workflow sync test fake against concurrent poller access

The poller invokes the fake applier from its own goroutine while the
synctest test asserts on call counts; -race caught the unguarded reads.
Also documents that the per-workspace lock is deliberately held across
the GitHub fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 08:31:20 +01:00

808 lines
36 KiB
Go

// Package github provides GitHub integration for Kandev, including PR monitoring,
// review queue management, and CI/check status tracking.
package github
import (
"encoding/json"
"time"
)
// TaskCIAutoFixMaxRounds is the server-enforced CI auto-fix loop guard.
const TaskCIAutoFixMaxRounds = 10
// 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"`
}
// TaskCIOptions stores task-level PR automation preferences.
type TaskCIOptions struct {
TaskID string `json:"task_id" db:"task_id"`
AutoFixEnabled bool `json:"auto_fix_enabled" db:"auto_fix_enabled"`
AutoMergeEnabled bool `json:"auto_merge_enabled" db:"auto_merge_enabled"`
AutoFixPromptOverride *string `json:"auto_fix_prompt_override,omitempty" db:"auto_fix_prompt_override"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TaskCIOptionsPatch is a partial update for task CI automation options.
type TaskCIOptionsPatch struct {
AutoFixEnabled *bool
AutoMergeEnabled *bool
AutoFixPromptOverride *string
}
// HasAny reports whether the patch contains at least one requested field change.
func (p TaskCIOptionsPatch) HasAny() bool {
return p.AutoFixEnabled != nil || p.AutoMergeEnabled != nil || p.AutoFixPromptOverride != nil
}
// TaskCIOptionsResponse is the HTTP shape for task CI automation options.
type TaskCIOptionsResponse struct {
TaskID string `json:"task_id"`
AutoFixEnabled bool `json:"auto_fix_enabled"`
AutoMergeEnabled bool `json:"auto_merge_enabled"`
AutoFixPromptOverride *string `json:"auto_fix_prompt_override"`
AutoFixMaxRounds int `json:"auto_fix_max_rounds"`
EffectiveAutoFixPrompt string `json:"effective_auto_fix_prompt"`
UsingDefaultPrompt bool `json:"using_default_prompt"`
UpdatedAt time.Time `json:"updated_at"`
PRStates []*TaskCIPRAutomationState `json:"pr_states"`
}
// TaskCIPRAutomationState stores per-PR dedupe and error state for CI automation.
type TaskCIPRAutomationState struct {
TaskID string `json:"task_id" db:"task_id"`
RepositoryID string `json:"repository_id" db:"repository_id"`
PRNumber int `json:"pr_number" db:"pr_number"`
LastFixSignature string `json:"last_fix_signature" db:"last_fix_signature"`
LastFixCheckpointJSON string `json:"last_fix_checkpoint_json" db:"last_fix_checkpoint_json"`
LastFixEnqueuedAt *time.Time `json:"last_fix_enqueued_at,omitempty" db:"last_fix_enqueued_at"`
LastFixSessionID *string `json:"last_fix_session_id,omitempty" db:"last_fix_session_id"`
AutoFixRoundCount int `json:"auto_fix_round_count" db:"auto_fix_round_count"`
AutoFixExhaustedAt *time.Time `json:"auto_fix_exhausted_at" db:"auto_fix_exhausted_at"`
LastMergeSignature string `json:"last_merge_signature" db:"last_merge_signature"`
LastMergeAttemptAt *time.Time `json:"last_merge_attempt_at,omitempty" db:"last_merge_attempt_at"`
LastError *string `json:"last_error,omitempty" db:"last_error"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TaskCIFixAttempt records an auto-fix prompt attempt for a task PR.
type TaskCIFixAttempt struct {
TaskID string
RepositoryID string
PRNumber int
Signature string
CheckpointJSON string
SessionID string
EnqueuedAt time.Time
IncrementRound bool
}
// TaskCIMergeAttempt records an auto-merge attempt for a task PR.
type TaskCIMergeAttempt struct {
TaskID string
RepositoryID string
PRNumber int
Signature string
AttemptedAt time.Time
}
// 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"
)
// CleanupPolicy controls how a review or issue watch handles its auto-created
// tasks once the underlying PR / issue reaches a terminal state.
const (
// CleanupPolicyAuto deletes the task once the PR/issue is merged or closed
// UNLESS the user authored at least one message in the task (the agent's
// auto-start prompt does not count).
CleanupPolicyAuto = "auto"
// CleanupPolicyAlways deletes the task on terminal state regardless of
// user interaction. Use when the watch is purely informational and the
// user never expects a banner / history for merged PRs.
CleanupPolicyAlways = "always"
// CleanupPolicyNever disables automatic cleanup. Tasks pile up until the
// user invokes manual cleanup or deletes them by hand.
CleanupPolicyNever = "never"
)
// IsValidCleanupPolicy reports whether s is one of the recognized policies.
// Empty string is treated as valid so legacy rows (pre-migration) and zero
// values default to "auto" downstream.
func IsValidCleanupPolicy(s string) bool {
switch s {
case "", CleanupPolicyAuto, CleanupPolicyAlways, CleanupPolicyNever:
return true
}
return false
}
// NormalizeCleanupPolicy maps the empty string to CleanupPolicyAuto. Unknown
// values are returned unchanged so the caller can surface a validation error.
func NormalizeCleanupPolicy(s string) string {
if s == "" {
return CleanupPolicyAuto
}
return s
}
// 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"`
CleanupPolicy string `json:"cleanup_policy" db:"cleanup_policy"`
LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"`
// LastError / LastErrorAt are stamped when the dispatch pipeline self-
// heals the watcher (e.g. the bound agent profile was soft-deleted).
LastError string `json:"last_error,omitempty" db:"last_error"`
LastErrorAt *time.Time `json:"last_error_at,omitempty" db:"last_error_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).
// PushedAt is the timestamp of the latest push, used to sort the
// list-accessible-repos result (most-recently-active first). It is a pointer
// so `omitempty` actually drops it from JSON when unset — a zero `time.Time`
// struct would otherwise serialize as `"0001-01-01T00:00:00Z"`.
//
// DefaultBranch lets the Remote picker pre-fill the row's branch immediately
// on selection (skips the branch-list round-trip for the common case). The
// GitHub API returns it on every repo, so it is required. Description is
// omitempty because GitHub may return null/empty.
type GitHubRepo struct {
FullName string `json:"full_name"`
Owner string `json:"owner"`
Name string `json:"name"`
Private bool `json:"private"`
DefaultBranch string `json:"default_branch"`
Description string `json:"description,omitempty"`
PushedAt *time.Time `json:"pushed_at,omitempty"`
}
// RepoBranch represents a branch in a GitHub repository.
type RepoBranch struct {
Name string `json:"name"`
}
// Repo content entry types, as reported by the GitHub contents API's "type" field.
const (
RepoContentTypeFile = "file"
RepoContentTypeDir = "dir"
)
// RepoContentEntry describes one entry returned when listing a repository directory.
type RepoContentEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // "file" or "dir"
SHA string `json:"sha"`
Size int `json:"size"`
}
// RepoMergeMethods reports which merge methods a repository allows. Mirrors
// the allow_*_merge booleans from GET /repos/{owner}/{repo}.
type RepoMergeMethods struct {
Merge bool `json:"merge"`
Squash bool `json:"squash"`
Rebase bool `json:"rebase"`
}
// 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"`
CleanupPolicy string `json:"cleanup_policy"`
}
// 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"`
CleanupPolicy *string `json:"cleanup_policy,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"`
CleanupPolicy string `json:"cleanup_policy" db:"cleanup_policy"`
LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"`
// LastError / LastErrorAt are stamped when the dispatch pipeline self-
// heals the watcher (e.g. the bound agent profile was soft-deleted).
LastError string `json:"last_error,omitempty" db:"last_error"`
LastErrorAt *time.Time `json:"last_error_at,omitempty" db:"last_error_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"`
CleanupPolicy string `json:"cleanup_policy"`
}
// 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"`
CleanupPolicy *string `json:"cleanup_policy,omitempty"`
}
// --- Workspace GitHub scope/settings ---
const (
RepoScopeModeAll = "all"
RepoScopeModeOrgs = "orgs"
RepoScopeModeRepos = "repos"
)
// WorkspaceSettings stores per-workspace GitHub operational settings.
// Authentication remains install-wide; these settings scope GitHub UI/search
// surfaces to the workspace's intended repositories.
type WorkspaceSettings struct {
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
RepoScopeMode string `json:"repo_scope_mode" db:"repo_scope_mode"`
RepoScopeOrgs []string `json:"repo_scope_orgs" db:"-"`
RepoScopeRepos []RepoFilter `json:"repo_scope_repos" db:"-"`
RepoScopeOrgsJSON string `json:"-" db:"repo_scope_orgs"`
RepoScopeReposJSON string `json:"-" db:"repo_scope_repos"`
SavedPresets json.RawMessage `json:"saved_presets,omitempty" db:"saved_presets"`
DefaultQueryPresets json.RawMessage `json:"default_query_presets,omitempty" db:"default_query_presets"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// UpdateWorkspaceSettingsRequest replaces the workspace GitHub scope and/or
// preference blobs. Nil blobs leave values unchanged; explicit JSON null clears
// default query presets back to built-ins.
type UpdateWorkspaceSettingsRequest struct {
WorkspaceID string `json:"workspace_id"`
RepoScopeMode *string `json:"repo_scope_mode,omitempty"`
RepoScopeOrgs *[]string `json:"repo_scope_orgs,omitempty"`
RepoScopeRepos *[]RepoFilter `json:"repo_scope_repos,omitempty"`
SavedPresets *json.RawMessage `json:"saved_presets,omitempty"`
DefaultQueryPresets *json.RawMessage `json:"default_query_presets,omitempty"`
SavedPresetsSet bool `json:"-"`
DefaultQueriesSet bool `json:"-"`
}
func (r *UpdateWorkspaceSettingsRequest) UnmarshalJSON(data []byte) error {
type alias UpdateWorkspaceSettingsRequest
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*r = UpdateWorkspaceSettingsRequest(decoded)
if value, ok := raw["saved_presets"]; ok {
r.SavedPresetsSet = true
if string(value) == jsonNullLiteral {
r.SavedPresets = nil
} else {
next := cloneRawMessage(value)
r.SavedPresets = &next
}
}
if value, ok := raw["default_query_presets"]; ok {
r.DefaultQueriesSet = true
if string(value) == jsonNullLiteral {
r.DefaultQueryPresets = nil
} else {
next := cloneRawMessage(value)
r.DefaultQueryPresets = &next
}
}
return nil
}
// --- 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.`,
},
}
}