// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package task import ( "bytes" stdctx "context" "encoding/json" "fmt" "io" "net/http" "gitea.dev/tea/modules/api" "gitea.dev/tea/modules/config" "gitea.dev/tea/modules/stack" ) func stackAPIError(resp *http.Response) error { data, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("body read on HTTP error %d: %w", resp.StatusCode, err) } var errBody struct { Message string `json:"message"` } if err := json.Unmarshal(data, &errBody); err == nil && errBody.Message != "" { return fmt.Errorf("%s", errBody.Message) } return fmt.Errorf("%s: %s", resp.Status, string(data)) } func decodeStackResponse(resp *http.Response, wantStatus int, out any) error { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != wantStatus { return stackAPIError(resp) } if out == nil { return nil } return json.NewDecoder(resp.Body).Decode(out) } // pullStackLinkOption links a parent and a child pull request in the same repository. type pullStackLinkOption struct { ParentIndex int64 `json:"parent_index"` ChildIndex int64 `json:"child_index"` } func postStackJSON(ctx stdctx.Context, login *config.Login, endpoint string, body any, wantStatus int, out any) error { client := api.NewClient(login) var reader io.Reader if body != nil { encoded, err := json.Marshal(body) if err != nil { return err } reader = bytes.NewReader(encoded) } resp, err := client.DoContext(ctx, http.MethodPost, endpoint, reader, nil) if err != nil { return err } return decodeStackResponse(resp, wantStatus, out) } // ListPullStacks lists the pull request stacks of a repository. func ListPullStacks(ctx stdctx.Context, login *config.Login, owner, repo string) ([]*stack.Stack, error) { client := api.NewClient(login) resp, err := client.DoContext(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/stacks", owner, repo), nil, nil) if err != nil { return nil, err } var stacks []*stack.Stack if err := decodeStackResponse(resp, http.StatusOK, &stacks); err != nil { return nil, err } return stacks, nil } // GetPullStack retrieves one stack with its ordered pull requests. func GetPullStack(ctx stdctx.Context, login *config.Login, owner, repo string, id int64) (*stack.Stack, error) { client := api.NewClient(login) resp, err := client.DoContext(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/stacks/%d", owner, repo, id), nil, nil) if err != nil { return nil, err } var s stack.Stack if err := decodeStackResponse(resp, http.StatusOK, &s); err != nil { return nil, err } return &s, nil } // CreatePullStack explicitly links two same-repository pull requests, forming a new stack. func CreatePullStack(ctx stdctx.Context, login *config.Login, owner, repo string, parentIndex, childIndex int64) (*stack.Stack, error) { var s stack.Stack err := postStackJSON(ctx, login, fmt.Sprintf("/repos/%s/%s/stacks", owner, repo), pullStackLinkOption{ParentIndex: parentIndex, ChildIndex: childIndex}, http.StatusCreated, &s) if err != nil { return nil, err } return &s, nil } // AddPullStackEntry appends one pull request to the tip of an existing stack. func AddPullStackEntry(ctx stdctx.Context, login *config.Login, owner, repo string, id, parentIndex, childIndex int64) (*stack.Stack, error) { var s stack.Stack err := postStackJSON(ctx, login, fmt.Sprintf("/repos/%s/%s/stacks/%d/entries", owner, repo, id), pullStackLinkOption{ParentIndex: parentIndex, ChildIndex: childIndex}, http.StatusCreated, &s) if err != nil { return nil, err } return &s, nil } // ReorderPullStack records the order of PR branches that are already linked. func ReorderPullStack(ctx stdctx.Context, login *config.Login, owner, repo string, id int64, pullIndices []int64) (*stack.Stack, error) { var s stack.Stack err := postStackJSON(ctx, login, fmt.Sprintf("/repos/%s/%s/stacks/%d/reorder", owner, repo, id), struct { PullIndices []int64 `json:"pull_indices"` }{PullIndices: pullIndices}, http.StatusOK, &s) if err != nil { return nil, err } return &s, nil } // RestackPullStack queues a restack starting at the stack's base branch. func RestackPullStack(ctx stdctx.Context, login *config.Login, owner, repo string, id int64) error { client := api.NewClient(login) resp, err := client.DoContext(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/stacks/%d/restack", owner, repo, id), nil, nil) if err != nil { return err } return decodeStackResponse(resp, http.StatusAccepted, nil) } type pullStackMergeOption struct { Style string `json:"style"` WaitForCI bool `json:"wait_for_ci"` } // MergePullStack merges every pull request of a stack in order. If waitForCI is set, the merge is // scheduled instead of applied immediately and MergePullStack returns with queued=true. Otherwise // it returns the number of pull requests merged; a non-nil error means the merge stopped partway, // with result.Merged reporting how many pull requests succeeded before that. func MergePullStack(ctx stdctx.Context, login *config.Login, owner, repo string, id int64, style string, waitForCI bool) (result *stack.MergeResult, queued bool, err error) { client := api.NewClient(login) encoded, err := json.Marshal(pullStackMergeOption{Style: style, WaitForCI: waitForCI}) if err != nil { return nil, false, err } resp, err := client.DoContext(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/stacks/%d/merge", owner, repo, id), bytes.NewReader(encoded), nil) if err != nil { return nil, false, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusAccepted { return nil, true, nil } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict { return nil, false, stackAPIError(resp) } var body stack.MergeResult if decodeErr := json.NewDecoder(resp.Body).Decode(&body); decodeErr != nil { return nil, false, fmt.Errorf("%s: failed to decode response: %w", resp.Status, decodeErr) } if resp.StatusCode != http.StatusOK { if body.Error == "" { return &body, false, fmt.Errorf("%s", resp.Status) } return &body, false, fmt.Errorf("%s", body.Error) } return &body, false, nil }