- Core poller: 5min cycle, journal-backed state, signal dispatch - GitHub client: PR fetching, child issue enumeration - 11 action handlers: link/publish/merge/tick/resolve/etc. - core-ide: headless mode + MCP handler + systemd service - 39 tests, all passing
216 lines
5.7 KiB
Go
216 lines
5.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/host-uk/core/pkg/jobrunner"
|
|
)
|
|
|
|
const defaultGraphQLURL = "https://api.github.com/graphql"
|
|
|
|
// ResolveThreadsHandler resolves all unresolved review threads on a PR
|
|
// via the GitHub GraphQL API.
|
|
type ResolveThreadsHandler struct {
|
|
client *http.Client
|
|
graphqlURL string
|
|
}
|
|
|
|
// NewResolveThreadsHandler creates a handler that resolves review threads.
|
|
// If client is nil, http.DefaultClient is used.
|
|
// If graphqlURL is empty, the default GitHub GraphQL URL is used.
|
|
func NewResolveThreadsHandler(client *http.Client, graphqlURL string) *ResolveThreadsHandler {
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
if graphqlURL == "" {
|
|
graphqlURL = defaultGraphQLURL
|
|
}
|
|
return &ResolveThreadsHandler{client: client, graphqlURL: graphqlURL}
|
|
}
|
|
|
|
// Name returns the handler identifier.
|
|
func (h *ResolveThreadsHandler) Name() string {
|
|
return "resolve_threads"
|
|
}
|
|
|
|
// Match returns true when the PR is open and has unresolved review threads.
|
|
func (h *ResolveThreadsHandler) Match(signal *jobrunner.PipelineSignal) bool {
|
|
return signal.PRState == "OPEN" && signal.HasUnresolvedThreads()
|
|
}
|
|
|
|
// graphqlRequest is a generic GraphQL request body.
|
|
type graphqlRequest struct {
|
|
Query string `json:"query"`
|
|
Variables map[string]any `json:"variables,omitempty"`
|
|
}
|
|
|
|
// threadsResponse models the GraphQL response for fetching review threads.
|
|
type threadsResponse struct {
|
|
Data struct {
|
|
Repository struct {
|
|
PullRequest struct {
|
|
ReviewThreads struct {
|
|
Nodes []struct {
|
|
ID string `json:"id"`
|
|
IsResolved bool `json:"isResolved"`
|
|
} `json:"nodes"`
|
|
} `json:"reviewThreads"`
|
|
} `json:"pullRequest"`
|
|
} `json:"repository"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
// resolveResponse models the GraphQL mutation response for resolving a thread.
|
|
type resolveResponse struct {
|
|
Data struct {
|
|
ResolveReviewThread struct {
|
|
Thread struct {
|
|
ID string `json:"id"`
|
|
} `json:"thread"`
|
|
} `json:"resolveReviewThread"`
|
|
} `json:"data"`
|
|
Errors []struct {
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
|
|
// Execute fetches unresolved review threads and resolves each one.
|
|
func (h *ResolveThreadsHandler) Execute(ctx context.Context, signal *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) {
|
|
start := time.Now()
|
|
|
|
threadIDs, err := h.fetchUnresolvedThreads(ctx, signal)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve_threads: fetch threads: %w", err)
|
|
}
|
|
|
|
var resolveErrors []string
|
|
for _, threadID := range threadIDs {
|
|
if err := h.resolveThread(ctx, threadID); err != nil {
|
|
resolveErrors = append(resolveErrors, err.Error())
|
|
}
|
|
}
|
|
|
|
result := &jobrunner.ActionResult{
|
|
Action: "resolve_threads",
|
|
RepoOwner: signal.RepoOwner,
|
|
RepoName: signal.RepoName,
|
|
PRNumber: signal.PRNumber,
|
|
Success: len(resolveErrors) == 0,
|
|
Timestamp: time.Now(),
|
|
Duration: time.Since(start),
|
|
}
|
|
|
|
if len(resolveErrors) > 0 {
|
|
result.Error = fmt.Sprintf("failed to resolve %d thread(s): %s",
|
|
len(resolveErrors), resolveErrors[0])
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// fetchUnresolvedThreads queries the GraphQL API for unresolved review threads.
|
|
func (h *ResolveThreadsHandler) fetchUnresolvedThreads(ctx context.Context, signal *jobrunner.PipelineSignal) ([]string, error) {
|
|
query := `query($owner: String!, $repo: String!, $number: Int!) {
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequest(number: $number) {
|
|
reviewThreads(first: 100) {
|
|
nodes {
|
|
id
|
|
isResolved
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
variables := map[string]any{
|
|
"owner": signal.RepoOwner,
|
|
"repo": signal.RepoName,
|
|
"number": signal.PRNumber,
|
|
}
|
|
|
|
gqlReq := graphqlRequest{Query: query, Variables: variables}
|
|
respBody, err := h.doGraphQL(ctx, gqlReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp threadsResponse
|
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
|
return nil, fmt.Errorf("decode threads response: %w", err)
|
|
}
|
|
|
|
var ids []string
|
|
for _, node := range resp.Data.Repository.PullRequest.ReviewThreads.Nodes {
|
|
if !node.IsResolved {
|
|
ids = append(ids, node.ID)
|
|
}
|
|
}
|
|
|
|
return ids, nil
|
|
}
|
|
|
|
// resolveThread calls the resolveReviewThread GraphQL mutation.
|
|
func (h *ResolveThreadsHandler) resolveThread(ctx context.Context, threadID string) error {
|
|
mutation := `mutation($threadId: ID!) {
|
|
resolveReviewThread(input: {threadId: $threadId}) {
|
|
thread {
|
|
id
|
|
}
|
|
}
|
|
}`
|
|
|
|
variables := map[string]any{
|
|
"threadId": threadID,
|
|
}
|
|
|
|
gqlReq := graphqlRequest{Query: mutation, Variables: variables}
|
|
respBody, err := h.doGraphQL(ctx, gqlReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var resp resolveResponse
|
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
|
return fmt.Errorf("decode resolve response: %w", err)
|
|
}
|
|
|
|
if len(resp.Errors) > 0 {
|
|
return fmt.Errorf("graphql error: %s", resp.Errors[0].Message)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// doGraphQL sends a GraphQL request and returns the raw response body.
|
|
func (h *ResolveThreadsHandler) doGraphQL(ctx context.Context, gqlReq graphqlRequest) ([]byte, error) {
|
|
bodyBytes, err := json.Marshal(gqlReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal graphql request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.graphqlURL, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create graphql request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := h.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("execute graphql request: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("graphql unexpected status %d", resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|