Files
kandev/apps/backend/internal/github/controller.go
T
João Salavisa dcfec0d23c refactor(backend): classify errors via sentinels instead of substring matches (#1057)
* refactor(backend): classify errors via sentinels instead of substring matches

Introduce package-local sentinel errors in the github, task repo, agent
lifecycle, and office/skills packages. Producers wrap with fmt.Errorf("%w: ...")
so callers can classify via errors.Is/errors.As instead of brittle
strings.Contains(err.Error(), ...) checks that break on library upgrades.

Sentinels added (or relocated to errors.go):
- github: ErrInvalidToken (new); ErrInvalidPRURL, ErrTaskNotFound, ErrSelfApprove
  moved from service.go.
- task/repository/sqlite: ErrTaskNotFound, ErrOfficeSessionRaceConflict.
- lifecycle: ErrAgentAlreadyRunning, ErrAgentReported.
- office/skills: ErrSkillNotFound, ErrSkillFileNotFound.

Consumers updated to errors.Is/errors.As across orchestrator, task/handlers,
github, and office/skills. AssociateExistingPRByURL switches %v -> %w so the
inner parsePRURL error stays in the chain. process_handlers now uses
errors.Is(err, syscall.ECONNREFUSED) and lifecycle.ErrNoExecutionForSession
plus a small isTimeoutError helper backed by errors.As.

A new taskDeleterAdapter in cmd/kandev/turn_adapters.go translates
taskrepo.ErrTaskNotFound to github.ErrTaskNotFound at the wiring boundary,
avoiding a layering violation between the github and task packages.

The one remaining substring check, in CreateTaskSession, is encapsulated
inside the repo and converts the SQLite UNIQUE-violation message into the
typed ErrOfficeSessionRaceConflict sentinel so callers never see the driver
string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): add TestErrorsAreClassifiable for refactored packages

Pin the new sentinel-based contract for each refactored package. Each test
exercises the producer paths and asserts errors.Is reaches the sentinel
through the wrap chain, and (where applicable) confirms untyped lookalike
strings no longer classify.

- github: isTaskNotFound, AssociateExistingPRByURL/ErrInvalidPRURL,
  ConfigureToken/ErrInvalidToken (using a noop secret manager).
- task/repository/sqlite: GetTask, DeleteTask, UpdateTaskState wrap
  ErrTaskNotFound.
- task/handlers: isNotFound + isAgentReportedError follow the typed chain.
- orchestrator: each isXError classifies exact + wrapped sentinels, rejects
  untyped lookalikes.
- office/skills: readUserHomeSkillInventoryFile wraps ErrSkillFileNotFound;
  ErrSkillNotFound survives fmt.Errorf("%w", ...).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(github): drop network-hitting subtest from errors_test

The "ConfigureToken wraps ErrInvalidToken when validation fails" subtest
constructed a real PATClient and called GetAuthenticatedUser, which makes
a live HTTP request to api.github.com. That's flaky in CI / offline dev
and violates the unit-test boundary (caught in review).

Replace with a sentinel-survives-wrap-pattern check that locks in the
exact %w/%w shape used by ConfigureToken, plus an assertion that the
inner cause stays reachable through errors.Is. The full ConfigureToken
flow stays covered by service_token_test.go and integration tests.

Remove the now-unused noopSecretManager stub type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): restore "timed out" UX for plain-fmt.Errorf timeout producers

The original createPromptErrorMessage rendered the friendly "Request timed
out…" hint via strings.Contains(err.Error(), "timeout"). The refactor
replaced it with errors.Is(context.DeadlineExceeded) + a net.Error
Timeout() helper — strictly typed. But several upstream producers along
the prompt path (service.WaitForSessionReady, agent-stream connect waits
in lifecycle/session.go and manager_interaction.go, agentctl health waits
in agentctl/client.go and launcher.go, the orchestrator's own
waitForSessionReady) return plain fmt.Errorf("timeout …") with no typed
Timeout() method, so the typed check missed them and the user saw the
generic "Failed to send message to agent" instead.

Add a substring fallback inside isTimeoutError as a UX safety net while
keeping the primary typed paths intact. Lock the contract with table
tests covering all three classes (net.Error timeout, plain string,
context.DeadlineExceeded) so any future trim of the fallback breaks
loudly. Migrating each upstream producer to a typed sentinel is tracked
as out-of-scope follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): tighten classification tests to exercise typed-sentinel contracts

Addresses bot-review feedback on PR #1057:

- task/handlers/errors_test.go: cover all four not-found sentinels
  (taskrepo.ErrTaskNotFound, service.ErrDocumentNotFound,
  service.ErrTaskPlanNotFound, service.ErrRevisionNotFound) in
  TestErrorsAreClassifiable via a table-driven loop, instead of only
  one. Drop the spurious Deadline() *time.Time method on timeoutNetErr
  — it isn't part of net.Error and the existing `var _ net.Error =
  timeoutNetErr{}` line already pins the interface.

- orchestrator/scheduler/scheduler_test.go: testTaskRepository was
  still returning the local scheduler.ErrTaskNotFound, which doesn't
  satisfy the production processTasks check (now errors.Is on
  taskrepo.ErrTaskNotFound). Mirror the real sqlite repo's wrap so the
  mock and prod paths agree. The local scheduler.ErrTaskNotFound is
  truly dead after that, so drop it from scheduler.go.

- office/skills: the wrap-pattern subtest in errors_test.go was only
  testing fmt.Errorf, not production code. Move the contract assertion
  to service_test.go (external skills_test package) where it drives
  the real GetSkillFromConfig lookup path against an empty in-memory
  repository.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(backend): drop redundant context.DeadlineExceeded check and fix doc grammar

Addresses bot-review feedback on PR #1057:

- task/handlers/message_handlers.go::createPromptErrorMessage: the
  explicit errors.Is(promptErr, context.DeadlineExceeded) is redundant
  with isTimeoutError, which already matches via errors.As against
  Timeout() — context.DeadlineExceeded implements Timeout()==true.
  Inline comment documents the consolidation.

- github/errors.go: fix grammar in the ErrTaskNotFound doc comment
  ("is the sentinel cleanup paths check for to distinguish" →
  "is the sentinel that cleanup paths check to distinguish").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:07:46 +01:00

849 lines
27 KiB
Go

package github
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// Controller handles HTTP endpoints for GitHub integration.
type Controller struct {
service *Service
logger *logger.Logger
}
// NewController creates a new GitHub controller.
func NewController(svc *Service, log *logger.Logger) *Controller {
return &Controller{service: svc, logger: log}
}
// RegisterHTTPRoutes registers all GitHub HTTP routes.
func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api := router.Group("/api/v1/github")
api.GET("/status", c.httpGetStatus)
api.POST("/token", c.httpConfigureToken)
api.DELETE("/token", c.httpClearToken)
api.GET("/task-prs", c.httpListTaskPRs)
api.POST("/task-prs", c.httpCreateTaskPR)
api.GET("/task-prs/:taskId", c.httpGetTaskPR)
api.GET("/prs/:owner/:repo/:number", c.httpGetPRFeedback)
api.GET("/prs/:owner/:repo/:number/info", c.httpGetPRInfo)
api.GET("/prs/:owner/:repo/:number/status", c.httpGetPRStatus)
api.POST("/prs/statuses", c.httpGetPRStatusesBatch)
api.POST("/prs/:owner/:repo/:number/reviews", c.httpSubmitReview)
api.PUT("/prs/:owner/:repo/:number/merge", c.httpMergePR)
api.GET("/watches/pr", c.httpListPRWatches)
api.DELETE("/watches/pr/:id", c.httpDeletePRWatch)
api.GET("/watches/review", c.httpListReviewWatches)
api.POST("/watches/review", c.httpCreateReviewWatch)
api.PUT("/watches/review/:id", c.httpUpdateReviewWatch)
api.DELETE("/watches/review/:id", c.httpDeleteReviewWatch)
api.POST("/watches/review/:id/trigger", c.httpTriggerReviewWatch)
api.POST("/watches/review/trigger-all", c.httpTriggerAllReviewChecks)
api.GET("/watches/issue", c.httpListIssueWatches)
api.POST("/watches/issue", c.httpCreateIssueWatch)
api.PUT("/watches/issue/:id", c.httpUpdateIssueWatch)
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
api.POST("/watches/issue/trigger-all", c.httpTriggerAllIssueChecks)
api.POST("/cleanup/review-tasks", c.httpCleanupReviewTasks)
api.POST("/cleanup/issue-tasks", c.httpCleanupIssueTasks)
api.GET("/orgs", c.httpListUserOrgs)
api.GET("/repos/search", c.httpSearchRepos)
api.GET("/repos/:owner/:repo/branches", c.httpListRepoBranches)
api.GET("/repos/:owner/:repo/merge-methods", c.httpGetRepoMergeMethods)
api.GET("/user/prs", c.httpSearchUserPRs)
api.GET("/user/issues", c.httpSearchUserIssues)
api.GET("/action-presets", c.httpGetActionPresets)
api.PUT("/action-presets", c.httpUpdateActionPresets)
api.POST("/action-presets/reset", c.httpResetActionPresets)
api.GET("/stats", c.httpGetStats)
}
func (c *Controller) httpGetStatus(ctx *gin.Context) {
status, err := c.service.GetStatus(ctx.Request.Context())
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, status)
}
func (c *Controller) httpConfigureToken(ctx *gin.Context) {
var req ConfigureTokenRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: token is required"})
return
}
if err := c.service.ConfigureToken(ctx.Request.Context(), req.Token); err != nil {
if errors.Is(err, ErrInvalidToken) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"configured": true})
}
func (c *Controller) httpClearToken(ctx *gin.Context) {
if err := c.service.ClearToken(ctx.Request.Context()); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"cleared": true})
}
func (c *Controller) httpListTaskPRs(ctx *gin.Context) {
// Workspace-scoped: returns all PRs for a workspace, triggers background refresh for stale ones
workspaceID := ctx.Query("workspace_id")
if workspaceID != "" {
result, err := c.service.ListWorkspaceTaskPRs(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"task_prs": result})
return
}
// Legacy: filter by task IDs
taskIDsParam := ctx.Query("task_ids")
if taskIDsParam == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id or task_ids query parameter required"})
return
}
taskIDs := strings.Split(taskIDsParam, ",")
result, err := c.service.ListTaskPRs(ctx.Request.Context(), taskIDs)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"task_prs": result})
}
// httpCreateTaskPR creates a task-PR association from a known PR URL. Used
// by the GitHub PR list "+ Task" flow: the PR is already known, so we
// persist the linkage immediately instead of waiting for branch-based
// discovery (which fails for review tasks that use synthetic worktree
// branches that don't exist on GitHub).
func (c *Controller) httpCreateTaskPR(ctx *gin.Context) {
var req struct {
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"`
PRURL string `json:"pr_url"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if req.TaskID == "" || req.PRURL == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "task_id and pr_url are required"})
return
}
tp, err := c.service.AssociateExistingPRByURL(ctx.Request.Context(), req.TaskID, req.RepositoryID, req.PRURL)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, ErrInvalidPRURL) {
status = http.StatusBadRequest
}
ctx.JSON(status, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, tp)
}
func (c *Controller) httpGetTaskPR(ctx *gin.Context) {
taskID := ctx.Param("taskId")
tp, err := c.service.GetTaskPR(ctx.Request.Context(), taskID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if tp == nil {
ctx.Status(http.StatusNoContent)
return
}
ctx.JSON(http.StatusOK, tp)
}
func (c *Controller) httpGetPRFeedback(ctx *gin.Context) {
owner := ctx.Param("owner")
repo := ctx.Param("repo")
numberStr := ctx.Param("number")
number, err := strconv.Atoi(numberStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"})
return
}
feedback, err := c.service.GetPRFeedback(ctx.Request.Context(), owner, repo, number)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, feedback)
}
func (c *Controller) httpGetPRStatus(ctx *gin.Context) {
owner := ctx.Param("owner")
repo := ctx.Param("repo")
numberStr := ctx.Param("number")
number, err := strconv.Atoi(numberStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"})
return
}
status, err := c.service.GetPRStatus(ctx.Request.Context(), owner, repo, number)
if err != nil {
c.handleSearchError(ctx, err)
return
}
ctx.JSON(http.StatusOK, status)
}
// prStatusesBatchMaxRefs caps how many PRs a single batch request can ask for.
// The list page defaults to 25 per page and can go up to 100; double that
// gives slack without letting a rogue client ask for thousands.
const prStatusesBatchMaxRefs = 200
// httpGetPRStatusesBatch fetches statuses for multiple PRs in one request.
// Body: {"refs": [{"owner","repo","number"}, ...]}.
// Response: {"statuses": {"<owner>/<repo>#<number>": <PRStatus>}}. PRs that
// fail upstream are omitted rather than failing the whole batch.
func (c *Controller) httpGetPRStatusesBatch(ctx *gin.Context) {
var body struct {
Refs []PRRef `json:"refs"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if len(body.Refs) > prStatusesBatchMaxRefs {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "too many refs"})
return
}
statuses, err := c.service.GetPRStatusesBatch(ctx.Request.Context(), body.Refs)
if err != nil {
c.handleSearchError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"statuses": statuses})
}
func (c *Controller) httpGetPRInfo(ctx *gin.Context) {
owner := ctx.Param("owner")
repo := ctx.Param("repo")
numberStr := ctx.Param("number")
number, err := strconv.Atoi(numberStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"})
return
}
pr, err := c.service.GetPR(ctx.Request.Context(), owner, repo, number)
if err != nil {
status := http.StatusInternalServerError
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusNotFound:
status = http.StatusNotFound
case http.StatusUnauthorized:
status = http.StatusUnauthorized
case http.StatusForbidden:
status = http.StatusForbidden
}
}
ctx.JSON(status, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, pr)
}
func (c *Controller) httpSubmitReview(ctx *gin.Context) {
owner := ctx.Param("owner")
repo := ctx.Param("repo")
numberStr := ctx.Param("number")
number, err := strconv.Atoi(numberStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"})
return
}
var req struct {
Event string `json:"event"`
Body string `json:"body"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
validEvents := map[string]bool{reviewEventApprove: true, "COMMENT": true, "REQUEST_CHANGES": true}
if !validEvents[req.Event] {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "event must be APPROVE, COMMENT, or REQUEST_CHANGES"})
return
}
if err := c.service.SubmitReview(ctx.Request.Context(), owner, repo, number, req.Event, req.Body); err != nil {
if errors.Is(err, ErrSelfApprove) {
ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"submitted": true})
}
func (c *Controller) httpMergePR(ctx *gin.Context) {
owner := ctx.Param("owner")
repo := ctx.Param("repo")
numberStr := ctx.Param("number")
number, err := strconv.Atoi(numberStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"})
return
}
var req struct {
MergeMethod string `json:"merge_method"`
}
// Body is optional — an empty body (io.EOF) means "use the repo default
// merge method". A non-empty but malformed body is a client bug, not a
// silent "use default", so reject it with 400.
if bindErr := ctx.ShouldBindJSON(&req); bindErr != nil && !errors.Is(bindErr, io.EOF) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
validMethods := map[string]bool{"": true, "merge": true, "squash": true, "rebase": true}
if !validMethods[req.MergeMethod] {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "merge_method must be merge, squash, or rebase"})
return
}
if err := c.service.MergePR(ctx.Request.Context(), owner, repo, number, req.MergeMethod); err != nil {
if errors.Is(err, ErrNoClient) {
ctx.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.",
"code": "github_not_configured",
})
return
}
status := http.StatusInternalServerError
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusMethodNotAllowed, http.StatusConflict:
status = http.StatusConflict
case http.StatusUnauthorized:
status = http.StatusUnauthorized
case http.StatusForbidden:
status = http.StatusForbidden
case http.StatusNotFound:
status = http.StatusNotFound
}
}
ctx.JSON(status, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"merged": true})
}
func (c *Controller) httpListPRWatches(ctx *gin.Context) {
watches, err := c.service.ListActivePRWatches(ctx.Request.Context())
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"watches": watches})
}
func (c *Controller) httpDeletePRWatch(ctx *gin.Context) {
id := ctx.Param("id")
if err := c.service.DeletePRWatch(ctx.Request.Context(), id); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
}
// httpListReviewWatches returns watches scoped to one workspace when
// `workspace_id` is supplied, or every watch across all workspaces when it
// is absent. The integration settings page uses the unscoped form.
func (c *Controller) httpListReviewWatches(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
var (
watches []*ReviewWatch
err error
)
if workspaceID == "" {
watches, err = c.service.ListAllReviewWatches(ctx.Request.Context())
} else {
watches, err = c.service.ListReviewWatches(ctx.Request.Context(), workspaceID)
}
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"watches": watches})
}
func (c *Controller) httpCreateReviewWatch(ctx *gin.Context) {
var req CreateReviewWatchRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
rw, err := c.service.CreateReviewWatch(ctx.Request.Context(), &req)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusCreated, rw)
}
func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) {
id := ctx.Param("id")
var req UpdateReviewWatchRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if err := c.service.UpdateReviewWatch(ctx.Request.Context(), id, &req); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"updated": true})
}
func (c *Controller) httpDeleteReviewWatch(ctx *gin.Context) {
id := ctx.Param("id")
if err := c.service.DeleteReviewWatch(ctx.Request.Context(), id); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
}
func (c *Controller) httpTriggerReviewWatch(ctx *gin.Context) {
id := ctx.Param("id")
watch, err := c.service.GetReviewWatch(ctx.Request.Context(), id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if watch == nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "review watch not found"})
return
}
newPRs, err := c.service.CheckReviewWatch(ctx.Request.Context(), watch)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Clean up tasks for merged/closed PRs that haven't been started.
cleaned, _ := c.service.CleanupMergedReviewTasks(ctx.Request.Context(), watch)
ctx.JSON(http.StatusOK, gin.H{"new_prs": len(newPRs), "prs": newPRs, "cleaned": cleaned})
}
func (c *Controller) httpTriggerAllReviewChecks(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
return
}
count, err := c.service.TriggerAllReviewChecks(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"new_prs_found": count})
}
func (c *Controller) httpListUserOrgs(ctx *gin.Context) {
orgs, err := c.service.ListUserOrgs(ctx.Request.Context())
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"orgs": orgs})
}
func (c *Controller) httpSearchRepos(ctx *gin.Context) {
org := ctx.Query("org")
query := ctx.Query("q")
if org == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "org query parameter required"})
return
}
repos, err := c.service.SearchOrgRepos(ctx.Request.Context(), org, query, 20)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"repos": repos})
}
func (c *Controller) httpListRepoBranches(ctx *gin.Context) {
owner := ctx.Param("owner")
repo := ctx.Param("repo")
branches, err := c.service.ListRepoBranches(ctx.Request.Context(), owner, repo)
if err != nil {
if errors.Is(err, ErrNoClient) {
ctx.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.",
"code": "github_not_configured",
})
return
}
status := http.StatusInternalServerError
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusNotFound:
status = http.StatusNotFound
case http.StatusUnauthorized:
status = http.StatusUnauthorized
case http.StatusForbidden:
status = http.StatusForbidden
}
}
ctx.JSON(status, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"branches": branches})
}
func (c *Controller) httpGetRepoMergeMethods(ctx *gin.Context) {
owner := ctx.Param("owner")
repo := ctx.Param("repo")
methods, err := c.service.GetRepoMergeMethods(ctx.Request.Context(), owner, repo)
if err != nil {
if errors.Is(err, ErrNoClient) {
ctx.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.",
"code": "github_not_configured",
})
return
}
status := http.StatusInternalServerError
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusNotFound:
status = http.StatusNotFound
case http.StatusUnauthorized:
status = http.StatusUnauthorized
case http.StatusForbidden:
status = http.StatusForbidden
}
}
ctx.JSON(status, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, methods)
}
// httpSearchUserPRs searches for pull requests. Accepts:
// - query: the complete GitHub search query (e.g. "is:pr author:@me state:open")
// - filter: additional qualifiers appended to the default `type:pr`
// - page: 1-indexed page (default 1)
// - per_page: items per page, clamped to 1..100 (default 50)
//
// When query is non-empty it is used verbatim; otherwise filter is used.
func (c *Controller) httpSearchUserPRs(ctx *gin.Context) {
query := ctx.Query("query")
filter := ctx.Query("filter")
page, perPage := parsePaginationQuery(ctx)
result, err := c.service.SearchUserPRsPaged(ctx.Request.Context(), filter, query, page, perPage)
if err != nil {
c.handleSearchError(ctx, err)
return
}
if result.PRs == nil {
result.PRs = []*PR{}
}
ctx.JSON(http.StatusOK, result)
}
// httpSearchUserIssues mirrors httpSearchUserPRs for issues.
func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
query := ctx.Query("query")
filter := ctx.Query("filter")
page, perPage := parsePaginationQuery(ctx)
result, err := c.service.SearchUserIssuesPaged(ctx.Request.Context(), filter, query, page, perPage)
if err != nil {
c.handleSearchError(ctx, err)
return
}
if result.Issues == nil {
result.Issues = []*Issue{}
}
ctx.JSON(http.StatusOK, result)
}
// parsePaginationQuery reads `page` and `per_page` query params. Missing or
// non-positive values fall back to defaults (page=1, perPage=50). The upper
// bound for perPage (100) is applied later by clampSearchPage in the client
// layer, where it also gates the cache key.
func parsePaginationQuery(ctx *gin.Context) (int, int) {
page, _ := strconv.Atoi(ctx.Query("page"))
perPage, _ := strconv.Atoi(ctx.Query("per_page"))
if page < 1 {
page = 1
}
if perPage <= 0 {
perPage = 50
}
return page, perPage
}
// handleSearchError maps client errors to proper HTTP responses.
func (c *Controller) handleSearchError(ctx *gin.Context, err error) {
if errors.Is(err, ErrNoClient) {
ctx.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.",
"code": "github_not_configured",
})
return
}
status := http.StatusInternalServerError
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusNotFound:
status = http.StatusNotFound
case http.StatusUnauthorized:
status = http.StatusUnauthorized
case http.StatusForbidden:
status = http.StatusForbidden
}
}
ctx.JSON(status, gin.H{"error": err.Error()})
}
func (c *Controller) httpGetStats(ctx *gin.Context) {
req := &PRStatsRequest{
WorkspaceID: ctx.Query("workspace_id"),
}
if s := ctx.Query("start_date"); s != "" {
t, err := time.Parse("2006-01-02", s)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid start_date format, expected YYYY-MM-DD"})
return
}
req.StartDate = &t
}
if s := ctx.Query("end_date"); s != "" {
t, err := time.Parse("2006-01-02", s)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid end_date format, expected YYYY-MM-DD"})
return
}
req.EndDate = &t
}
stats, err := c.service.GetPRStats(ctx.Request.Context(), req)
if err != nil {
c.logger.Error("failed to get PR stats", zap.Error(err))
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, stats)
}
// --- Issue watch HTTP handlers ---
func (c *Controller) httpListIssueWatches(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
var (
watches []*IssueWatch
err error
)
if workspaceID == "" {
watches, err = c.service.ListAllIssueWatches(ctx.Request.Context())
} else {
watches, err = c.service.ListIssueWatches(ctx.Request.Context(), workspaceID)
}
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"watches": watches})
}
func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) {
var req CreateIssueWatchRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
iw, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusCreated, iw)
}
func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
var req UpdateIssueWatchRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if err := c.service.UpdateIssueWatch(ctx.Request.Context(), id, &req); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
iw, err := c.service.GetIssueWatch(ctx.Request.Context(), id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, iw)
}
func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
}
func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
watch, err := c.service.GetIssueWatch(ctx.Request.Context(), id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if watch == nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "issue watch not found"})
return
}
newIssues, err := c.service.CheckIssueWatch(ctx.Request.Context(), watch)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for _, issue := range newIssues {
c.service.publishNewIssueEvent(ctx.Request.Context(), watch, issue)
}
// Clean up tasks for closed issues that haven't been started.
cleaned, cleanErr := c.service.CleanupClosedIssueTasks(ctx.Request.Context(), watch)
if cleanErr != nil {
c.service.logger.Warn("cleanup closed issue tasks failed", zap.String("watch_id", id), zap.Error(cleanErr))
}
ctx.JSON(http.StatusOK, gin.H{"new_issues_found": len(newIssues), "issues": newIssues, "cleaned": cleaned})
}
func (c *Controller) httpTriggerAllIssueChecks(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
return
}
count, err := c.service.TriggerAllIssueChecks(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"new_issues_found": count})
}
// --- Action preset HTTP handlers ---
func (c *Controller) httpGetActionPresets(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
return
}
presets, err := c.service.GetActionPresets(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, presets)
}
func (c *Controller) httpUpdateActionPresets(ctx *gin.Context) {
var req UpdateActionPresetsRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if req.WorkspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"})
return
}
presets, err := c.service.UpdateActionPresets(ctx.Request.Context(), &req)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, presets)
}
func (c *Controller) httpResetActionPresets(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
return
}
presets, err := c.service.ResetActionPresets(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, presets)
}
// httpCleanupReviewTasks runs a sweep over every review-PR dedup row
// (including rows under enabled watches) applying each watch's cleanup
// policy. Manual trigger for users to drain a pile of merged-PR tasks
// without waiting for the next 5-minute poller cycle.
//
// SCOPE: install-wide — walks dedup rows across all workspaces. The
// trigger-all endpoints (httpTriggerAllReviewChecks /
// httpTriggerAllIssueChecks) take a required workspace_id, but cleanup
// is install-wide on purpose so a user can drain orphans whose original
// watch lived in a workspace they no longer have open. A future
// multi-tenant rollout would add an optional workspace_id query param.
func (c *Controller) httpCleanupReviewTasks(ctx *gin.Context) {
deleted, err := c.service.CleanupAllReviewTasks(ctx.Request.Context())
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": deleted})
}
// httpCleanupIssueTasks mirrors httpCleanupReviewTasks for issue watches.
func (c *Controller) httpCleanupIssueTasks(ctx *gin.Context) {
deleted, err := c.service.CleanupAllIssueTasks(ctx.Request.Context())
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": deleted})
}