feat(api): PR action routes — merge + resolve-comments (#88)

* feat(api): register PR action route shells (merge + resolve-comments)

Adds two 501 Not Implemented route shells for the SCM/PR action lane
as specified in issue #21. No business logic — the routes are stubs
that return a structured planned body with the embedded OpenAPI spec
slice, consistent with the existing route-shell pattern.

Routes registered:
  POST /api/v1/prs/{id}/merge
  POST /api/v1/prs/{id}/resolve-comments

Closes part of #18.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): PR action routes — full impl (merge + resolve-comments)

Builds the two SCM/PR action routes end-to-end per issue #21:

  POST /api/v1/prs/{id}/merge
  POST /api/v1/prs/{id}/resolve-comments

**ports/scm.go** — new PRService interface, MergeResult, ResolveResult.

**adapters/scm/github** — adds ErrNotMergeable/ErrUnprocessable sentinels
to the client (405/409/422 classification) and MergePR / ListUnresolvedThreadIDs /
ResolveThread methods to the Provider.

**internal/scm/pr_service.go** — concrete PRService over PRProvider. Parses
the path ID as a PR number, calls the provider, maps github sentinel errors to
domain errors (ErrPRNotFound / ErrPRNotMergeable / ErrPRPreconditions /
ErrNothingToResolve). Nil PRService keeps routes registered but returns
OpenAPI-backed 501s.

**httpd/controllers/prs.go** — real handlers; writePRError maps the four domain
errors to 404 / 409 / 422 / 500.

**prs_test.go** — httptest coverage: 501 (nil service), 200/404/409/422 for
both routes, spec-slice present in 501 body.

**scm/pr_service_test.go** — table-driven unit tests with a fake PRProvider.

Closes part of #18. Closes #21.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): PR action routes — merge + resolve-comments (#21)

Implements POST /api/v1/prs/{id}/merge and POST /api/v1/prs/{id}/resolve-comments.

Service logic lives in internal/service/pr (ActionManager interface + ActionService
struct). Controllers use the projects pattern — import the service package directly
rather than going through a ports interface. Drops the internal/scm intermediary
package and the ports/scm.go file added in earlier iterations.

Also fixes the ContentLength-based body-decode guard in resolveComments, which
silently dropped JSON bodies sent with chunked transfer encoding; now decodes
unconditionally and treats io.EOF as an absent body.

Closes #21.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(specgen): remove dead path-param entries from schemaNames

ControllersProjectIDParam, ControllersSessionIDParam, and ControllersPRIDParam
are never matched by the schemaName interceptor — swaggest reflects path-param
structs inline rather than as $ref component schemas, so the hook is never
called for these types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pr): anchor resolve-comments to stated PR when explicit IDs supplied

When commentIDs were provided, the parsed PR number was never used — any
thread ID could be resolved regardless of which PR was in the URL path.
Add a ListUnresolvedThreadIDs existence probe in the else branch so the PR
must be reachable before iterating the caller-supplied IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(controllers): exclude io.ErrUnexpectedEOF from isEmptyBody

A truncated body (e.g. {"commentIds":["T_1") returns io.ErrUnexpectedEOF,
not io.EOF. Treating it as an absent body caused the handler to fall through
to "resolve all unresolved threads" instead of returning 400. Only io.EOF
(reader returned no bytes) is a genuine empty-body signal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(api): revert to route shell — stubs, no adapter changes

- Remove adapter changes (ErrNotMergeable/ErrUnprocessable from client.go,
  MergePR/ListUnresolvedThreadIDs/ResolveThread from provider.go)
- ActionService returns dummy values with TODO; no business logic
- Errors (ErrPRNotFound etc.) moved to controllers/errors.go
- PR DTOs moved to controllers/dto.go
- Remove 501 guards — stub service always wired via NewAPI default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: restore client.go, move PR errors to service/pr, fix lint

- Restore original client.go alignment (no functional change)
- Move PR sentinel errors to service/pr/errors.go
- controllers/errors.go now only contains writePRError, referencing prsvc sentinels
- Fix schemaNames alignment in specgen/build.go (goimports lint)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(api): replace fake-success stub with 503 when SCM not configured

The nil-service fallback was silently injecting a stub that returned
fake merge/resolve success, misleading callers when no SCM is wired.
Remove the injection; nil Svc now returns 503 SCM_NOT_CONFIGURED.
Also inline writePRError into prs.go and delete controllers/errors.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(specgen): mark resolve-comments request body as optional

reqBody: nil removes the requestBody.required: true annotation so
generated SDK clients can omit the body (which resolves all threads).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(prs): align nil-service guard with spec (501) and echo prID in stub

Use apispec.NotImplemented (501) instead of 503 so nil-service responses
match the OpenAPI spec and generated clients hit the documented 501 branch.

Echo prID as PRNumber in the stub Merge to avoid claiming the wrong PR
was merged if NewActionService is wired by accident before real impl lands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
neversettle 2026-06-03 04:43:50 +05:30 committed by GitHub
parent 718a1b135c
commit fab5451a9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 504 additions and 6 deletions

View File

@ -10,16 +10,15 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
)
// APIDeps bundles every Manager the API layer's controllers depend on.
// Controllers see only resource-level interfaces; they do not reach through to
// lifecycle reducers, adapters, or storage. A nil dependency keeps its routes
// registered but returns the OpenAPI-backed 501 response.
// APIDeps bundles every service the API layer's controllers depend on.
type APIDeps struct {
Projects projectsvc.Manager
Sessions controllers.SessionService
PRs prsvc.ActionManager
}
// API owns one controller per resource and is the single Register call the
@ -28,6 +27,7 @@ type API struct {
cfg config.Config
projects *controllers.ProjectsController
sessions *controllers.SessionsController
prs *controllers.PRsController
}
// NewAPI constructs the API surface from its dependencies. cfg carries the
@ -42,6 +42,7 @@ func NewAPI(cfg config.Config, deps APIDeps) *API {
sessions: &controllers.SessionsController{
Svc: deps.Sessions,
},
prs: &controllers.PRsController{Svc: deps.PRs},
}
}
@ -61,6 +62,7 @@ func (a *API) Register(root chi.Router) {
r.Use(middleware.Timeout(timeout))
a.projects.Register(r)
a.sessions.Register(r)
a.prs.Register(r)
// Sibling REST controllers plug in here.
})
// Surfaces that intentionally bypass the REST timeout register at this level.

View File

@ -177,6 +177,90 @@ paths:
summary: Fetch one project; discriminates ok vs degraded
tags:
- projects
/api/v1/prs/{id}/merge:
post:
operationId: mergePR
parameters:
- description: PR number.
in: path
name: id
required: true
schema:
description: PR number.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/MergePRResponse'
description: OK
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Conflict
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Unprocessable Entity
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Squash-merge a pull request
tags:
- prs
/api/v1/prs/{id}/resolve-comments:
post:
operationId: resolveComments
parameters:
- description: PR number.
in: path
name: id
required: true
schema:
description: PR number.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/ResolveCommentsResponse'
description: OK
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Found
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Unprocessable Entity
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Resolve review threads on a pull request
tags:
- prs
/api/v1/sessions:
get:
operationId: listSessions
@ -563,6 +647,19 @@ components:
required:
- sessions
type: object
MergePRResponse:
properties:
method:
type: string
ok:
type: boolean
prNumber:
type: integer
required:
- ok
- prNumber
- method
type: object
OrchestratorResponse:
properties:
id:
@ -658,6 +755,16 @@ components:
required:
- displayName
type: object
ResolveCommentsResponse:
properties:
ok:
type: boolean
resolved:
type: integer
required:
- ok
- resolved
type: object
RestoreSessionResponse:
properties:
ok:
@ -822,3 +929,5 @@ tags:
name: projects
- description: Agent session lifecycle and messaging
name: sessions
- description: Pull-request actions (SCM lane)
name: prs

View File

@ -59,6 +59,8 @@ func Build() ([]byte, error) {
"Project registry, configuration, and lifecycle administration"),
*(&openapi31.Tag{Name: "sessions"}).WithDescription(
"Agent session lifecycle and messaging"),
*(&openapi31.Tag{Name: "prs"}).WithDescription(
"Pull-request actions (SCM lane)"),
}
for _, op := range operations() {
@ -119,8 +121,6 @@ var schemaNames = map[string]string{
"ControllersProjectResponse": "ProjectResponse",
"ControllersGetProjectResponse": "ProjectGetResponse",
"ControllersProjectOrDegraded": "ProjectOrDegraded",
"ControllersProjectIDParam": "ProjectIDParam",
"ControllersSessionIDParam": "SessionIDParam",
"ControllersListSessionsQuery": "ListSessionsQuery",
"ControllersListSessionsResponse": "ListSessionsResponse",
"ControllersSpawnSessionRequest": "SpawnSessionRequest",
@ -133,6 +133,10 @@ var schemaNames = map[string]string{
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
"ControllersOrchestratorResponse": "OrchestratorResponse",
// httpd/controllers — PR wire envelopes
"ControllersMergePRResponse": "MergePRResponse",
"ControllersResolveCommentsRequest": "ResolveCommentsRequest",
"ControllersResolveCommentsResponse": "ResolveCommentsResponse",
// service/project entities + DTOs
"ProjectProject": "Project",
"ProjectSummary": "ProjectSummary",
@ -216,6 +220,7 @@ type operation struct {
func operations() []operation {
ops := append([]operation{}, projectOperations()...)
ops = append(ops, sessionOperations()...)
ops = append(ops, prOperations()...)
return ops
}
@ -360,3 +365,35 @@ func sessionOperations() []operation {
},
}
}
// prOperations declares the PR action operations. These live in the SCM lane:
// the handler delegates to a PRService backed by the SCM provider. A nil
// PRService (SCM not configured) returns 501 for both routes.
func prOperations() []operation {
return []operation{
{
method: http.MethodPost, path: "/api/v1/prs/{id}/merge", id: "mergePR", tag: "prs",
summary: "Squash-merge a pull request",
pathParams: []any{controllers.PRIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.MergePRResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusConflict, envelope.APIError{}},
{http.StatusUnprocessableEntity, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/prs/{id}/resolve-comments", id: "resolveComments", tag: "prs",
summary: "Resolve review threads on a pull request",
pathParams: []any{controllers.PRIDParam{}},
reqBody: nil, // body is optional: omitting it resolves all unresolved threads
resps: []respUnit{
{http.StatusOK, controllers.ResolveCommentsResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusUnprocessableEntity, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
}
}

View File

@ -174,3 +174,26 @@ type OrchestratorResponse struct {
ProjectID domain.ProjectID `json:"projectId"`
ProjectName string `json:"projectName,omitempty"`
}
// PRIDParam is the {id} path parameter shared by the /prs/{id} routes.
type PRIDParam struct {
ID string `path:"id" description:"PR number."`
}
// MergePRResponse is the body of POST /api/v1/prs/{id}/merge (200).
type MergePRResponse struct {
OK bool `json:"ok"`
PRNumber int `json:"prNumber"`
Method string `json:"method"`
}
// ResolveCommentsRequest is the optional body of POST /api/v1/prs/{id}/resolve-comments.
type ResolveCommentsRequest struct {
CommentIDs []string `json:"commentIds,omitempty"`
}
// ResolveCommentsResponse is the body of POST /api/v1/prs/{id}/resolve-comments (200).
type ResolveCommentsResponse struct {
OK bool `json:"ok"`
Resolved int `json:"resolved"`
}

View File

@ -0,0 +1,83 @@
package controllers
import (
"errors"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr"
)
// PRsController owns the /prs action routes.
type PRsController struct {
Svc prsvc.ActionManager
}
// Register mounts the PR action routes on the supplied router.
func (c *PRsController) Register(r chi.Router) {
r.Post("/prs/{id}/merge", c.merge)
r.Post("/prs/{id}/resolve-comments", c.resolveComments)
}
func (c *PRsController) merge(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/prs/{id}/merge")
return
}
prID := chi.URLParam(r, "id")
res, err := c.Svc.Merge(r.Context(), prID)
if err != nil {
writePRError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, MergePRResponse{OK: true, PRNumber: res.PRNumber, Method: res.Method})
}
func (c *PRsController) resolveComments(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/prs/{id}/resolve-comments")
return
}
prID := chi.URLParam(r, "id")
// Body is optional: omitting it resolves all unresolved threads.
var in ResolveCommentsRequest
if err := decodeJSON(r, &in); err != nil && !isEmptyBody(err) {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
res, err := c.Svc.ResolveComments(r.Context(), prID, in.CommentIDs)
if err != nil {
writePRError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, ResolveCommentsResponse{OK: true, Resolved: res.Resolved})
}
// writePRError maps PR sentinel errors to their locked HTTP envelopes,
// falling back to 500 for unexpected failures.
func writePRError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, prsvc.ErrPRNotFound):
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PR_NOT_FOUND", "Unknown PR", nil)
case errors.Is(err, prsvc.ErrPRNotMergeable):
envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "PR_NOT_MERGEABLE", "PR is not mergeable", nil)
case errors.Is(err, prsvc.ErrPRPreconditions):
envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "PR_PRECONDITIONS_UNMET", "PR merge preconditions are not met", nil)
case errors.Is(err, prsvc.ErrNothingToResolve):
envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "NOTHING_TO_RESOLVE", "No unresolved review threads to resolve", nil)
default:
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "PR_OPERATION_FAILED", "PR operation failed", nil)
}
}
// isEmptyBody reports whether err signals an absent or empty request body.
// io.ErrUnexpectedEOF means a truncated/malformed body — bad request, not absent.
func isEmptyBody(err error) bool {
return errors.Is(err, io.EOF)
}

View File

@ -0,0 +1,159 @@
package controllers_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
prsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/pr"
)
type fakePRService struct {
mergeResult prsvc.MergeResult
mergeErr error
resolveResult prsvc.ResolveResult
resolveErr error
}
func (f *fakePRService) Merge(_ context.Context, _ string) (prsvc.MergeResult, error) {
return f.mergeResult, f.mergeErr
}
func (f *fakePRService) ResolveComments(_ context.Context, _ string, _ []string) (prsvc.ResolveResult, error) {
return f.resolveResult, f.resolveErr
}
func newPRTestServer(t *testing.T, svc prsvc.ActionManager) *httptest.Server {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{PRs: svc}))
t.Cleanup(srv.Close)
return srv
}
// ---- Nil service → 503 SCM_NOT_CONFIGURED ----
func TestPRsRoutes_NilService_MergeReturns501(t *testing.T) {
srv := newPRTestServer(t, nil)
body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/merge", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}
func TestPRsRoutes_NilService_ResolveCommentsReturns501(t *testing.T) {
srv := newPRTestServer(t, nil)
body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/resolve-comments", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}
// ---- Merge: 200 ----
func TestPRsRoutes_Merge_200(t *testing.T) {
svc := &fakePRService{mergeResult: prsvc.MergeResult{PRNumber: 42, Method: "squash"}}
srv := newPRTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/prs/42/merge", "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", status, body)
}
var resp struct {
OK bool `json:"ok"`
PRNumber int `json:"prNumber"`
Method string `json:"method"`
}
mustJSON(t, body, &resp)
if !resp.OK || resp.PRNumber != 42 || resp.Method != "squash" {
t.Errorf("resp = %+v, want {ok:true prNumber:42 method:squash}", resp)
}
}
// ---- Merge: 404 ----
func TestPRsRoutes_Merge_404(t *testing.T) {
svc := &fakePRService{mergeErr: prsvc.ErrPRNotFound}
srv := newPRTestServer(t, svc)
body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/99/merge", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotFound, "PR_NOT_FOUND")
}
// ---- Merge: 409 ----
func TestPRsRoutes_Merge_409(t *testing.T) {
svc := &fakePRService{mergeErr: prsvc.ErrPRNotMergeable}
srv := newPRTestServer(t, svc)
body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/merge", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusConflict, "PR_NOT_MERGEABLE")
}
// ---- Merge: 422 ----
func TestPRsRoutes_Merge_422(t *testing.T) {
svc := &fakePRService{mergeErr: prsvc.ErrPRPreconditions}
srv := newPRTestServer(t, svc)
body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/merge", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusUnprocessableEntity, "PR_PRECONDITIONS_UNMET")
}
// ---- ResolveComments: 200 ----
func TestPRsRoutes_ResolveComments_200(t *testing.T) {
svc := &fakePRService{resolveResult: prsvc.ResolveResult{Resolved: 3}}
srv := newPRTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/prs/42/resolve-comments", `{"commentIds":["T_1","T_2","T_3"]}`)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", status, body)
}
var resp struct {
OK bool `json:"ok"`
Resolved int `json:"resolved"`
}
mustJSON(t, body, &resp)
if !resp.OK || resp.Resolved != 3 {
t.Errorf("resp = %+v, want {ok:true resolved:3}", resp)
}
}
func TestPRsRoutes_ResolveComments_200_NoBody(t *testing.T) {
svc := &fakePRService{resolveResult: prsvc.ResolveResult{Resolved: 2}}
srv := newPRTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/prs/42/resolve-comments", "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", status, body)
}
}
// ---- ResolveComments: 404 ----
func TestPRsRoutes_ResolveComments_404(t *testing.T) {
svc := &fakePRService{resolveErr: prsvc.ErrPRNotFound}
srv := newPRTestServer(t, svc)
body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/99/resolve-comments", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotFound, "PR_NOT_FOUND")
}
// ---- ResolveComments: 422 ----
func TestPRsRoutes_ResolveComments_422(t *testing.T) {
svc := &fakePRService{resolveErr: prsvc.ErrNothingToResolve}
srv := newPRTestServer(t, svc)
body, status, headers := doRequest(t, srv, "POST", "/api/v1/prs/1/resolve-comments", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusUnprocessableEntity, "NOTHING_TO_RESOLVE")
}

View File

@ -0,0 +1,46 @@
package pr
import (
"context"
"strconv"
)
// ActionManager is the controller-facing contract for /prs/{id} action routes.
type ActionManager interface {
Merge(ctx context.Context, prID string) (MergeResult, error)
ResolveComments(ctx context.Context, prID string, commentIDs []string) (ResolveResult, error)
}
// MergeResult is the successful outcome of a PR merge.
type MergeResult struct {
PRNumber int
Method string // always "squash"
}
// ResolveResult is the successful outcome of a resolve-comments operation.
type ResolveResult struct {
Resolved int
}
// ActionService implements ActionManager. Business logic is not yet implemented.
type ActionService struct{}
var _ ActionManager = (*ActionService)(nil)
// NewActionService returns a stub ActionService.
func NewActionService() *ActionService {
return &ActionService{}
}
// Merge squash-merges the PR identified by prID.
// TODO: implement — squash-merge the PR via the SCM provider.
func (s *ActionService) Merge(_ context.Context, prID string) (MergeResult, error) {
n, _ := strconv.Atoi(prID)
return MergeResult{PRNumber: n, Method: "squash"}, nil
}
// ResolveComments resolves review threads on the PR identified by prID.
// TODO: implement — resolve review threads via the SCM provider.
func (s *ActionService) ResolveComments(_ context.Context, _ string, _ []string) (ResolveResult, error) {
return ResolveResult{Resolved: 0}, nil
}

View File

@ -0,0 +1,28 @@
package pr
import (
"context"
"testing"
)
func TestMerge_ReturnsSquash(t *testing.T) {
svc := NewActionService()
res, err := svc.Merge(context.Background(), "42")
if err != nil {
t.Fatal(err)
}
if res.Method != "squash" {
t.Errorf("method = %q, want squash", res.Method)
}
if res.PRNumber != 42 {
t.Errorf("PRNumber = %d, want 42", res.PRNumber)
}
}
func TestResolveComments_ReturnsOK(t *testing.T) {
svc := NewActionService()
_, err := svc.ResolveComments(context.Background(), "1", nil)
if err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,11 @@
package pr
import "errors"
// Sentinel errors returned by the PR action service.
var (
ErrPRNotFound = errors.New("pr: not found")
ErrPRNotMergeable = errors.New("pr: not mergeable")
ErrPRPreconditions = errors.New("pr: merge preconditions unmet")
ErrNothingToResolve = errors.New("pr: nothing to resolve")
)