package api import ( "bytes" "compress/gzip" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/bluesky-social/indigo/atproto/syntax" ) const ( // PullRecordNSID is the AT Protocol collection for Tangled pull requests. PullRecordNSID = "sh.tangled.repo.pull" // PullCommentNSID is the AT Protocol collection for PR comments. PullCommentNSID = "sh.tangled.repo.pull.comment" // PullStatusNSID is the AT Protocol collection for PR status changes. PullStatusNSID = "sh.tangled.repo.pull.status" // MergeNSID is the knot XRPC method for merging. MergeNSID = "sh.tangled.repo.merge" // MergeCheckNSID is the knot XRPC method for checking merge conflicts. MergeCheckNSID = "sh.tangled.repo.mergeCheck" PullStatusOpen = "sh.tangled.repo.pull.status.open" PullStatusClosed = "sh.tangled.repo.pull.status.closed" PullStatusMerged = "sh.tangled.repo.pull.status.merged" ) // pullRecord is written to the user's PDS when creating a PR. type pullRecord struct { Type string `json:"$type"` Title string `json:"title"` Body *string `json:"body,omitempty"` Target *pullTarget `json:"target"` Source *pullSource `json:"source,omitempty"` PatchBlob json.RawMessage `json:"patchBlob"` CreatedAt string `json:"createdAt"` } type pullTarget struct { Repo string `json:"repo"` // AT URI of the target repo Branch string `json:"branch"` // target branch name } type pullSource struct { Branch string `json:"branch"` Sha string `json:"sha"` // 40-char commit hash Repo *string `json:"repo,omitempty"` // AT URI (for forks, same as target for same-repo) } // pullCommentRecord is written to the user's PDS when commenting on a PR. type pullCommentRecord struct { Type string `json:"$type"` Pull string `json:"pull"` // AT URI of the PR Body string `json:"body"` CreatedAt string `json:"createdAt"` } // pullStatusRecord is written to change a PR's status. type pullStatusRecord struct { Type string `json:"$type"` Pull string `json:"pull"` // AT URI of the PR Status string `json:"status"` // PullStatusOpen, PullStatusClosed, or PullStatusMerged } // MergeInput is the request body for sh.tangled.repo.merge on a knot. type MergeInput struct { DID string `json:"did"` Name string `json:"name"` Branch string `json:"branch"` Patch string `json:"patch"` AuthorName string `json:"authorName,omitempty"` AuthorEmail string `json:"authorEmail,omitempty"` CommitMessage string `json:"commitMessage,omitempty"` CommitBody string `json:"commitBody,omitempty"` } // MergeCheckInput is the request body for sh.tangled.repo.mergeCheck. type MergeCheckInput struct { DID string `json:"did"` Name string `json:"name"` Branch string `json:"branch"` Patch string `json:"patch"` } // MergeCheckResponse is the response from sh.tangled.repo.mergeCheck. type MergeCheckResponse struct { IsConflicted bool `json:"is_conflicted"` Conflicts []MergeConflict `json:"conflicts,omitempty"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` } type MergeConflict struct { Filename string `json:"filename"` Reason string `json:"reason"` } // PullEntry represents a parsed pull request record for display. type PullEntry struct { URI string Rkey string AuthorDID string Title string Body string TargetRepo string // AT URI of target repo TargetRef string // target branch SourceRef string // source branch SourceSha string // source commit SHA CreatedAt string } // CreatePullParams holds the parameters for creating a pull request. type CreatePullParams struct { Title string Body string TargetRepo string // AT URI of the target repo TargetRef string // target branch (e.g. "main") SourceRef string // source branch SourceSha string // 40-char HEAD SHA of the source branch Patch []byte // raw format-patch content } // CreatePull creates a pull request by uploading the patch blob and writing // an AT Protocol record to the user's PDS. Returns the rkey. func (c *Client) CreatePull(ctx context.Context, params *CreatePullParams) (string, error) { // Gzip-compress the patch var compressed bytes.Buffer gz := gzip.NewWriter(&compressed) if _, err := gz.Write(params.Patch); err != nil { return "", fmt.Errorf("failed to compress patch: %w", err) } if err := gz.Close(); err != nil { return "", fmt.Errorf("failed to finalize patch compression: %w", err) } // Upload blob to PDS as application/gzip — the blob bytes are gzip and the // decompressed payload is a text-based git format-patch. This matches the // Tangled appview, which uploads the patch blob with the same content type. blobRef, err := c.UploadBlob(ctx, &compressed, "application/gzip") if err != nil { return "", err } rkey := newTID() var bodyPtr *string if params.Body != "" { bodyPtr = ¶ms.Body } sourceRepo := params.TargetRepo // same-repo PR record := &pullRecord{ Type: PullRecordNSID, Title: params.Title, Body: bodyPtr, Target: &pullTarget{ Repo: params.TargetRepo, Branch: params.TargetRef, }, Source: &pullSource{ Branch: params.SourceRef, Sha: params.SourceSha, Repo: &sourceRepo, }, PatchBlob: blobRef, CreatedAt: time.Now().Format(time.RFC3339), } _, err = c.PutRecord(ctx, PullRecordNSID, rkey, record) if err != nil { return "", err } return rkey, nil } // ListOwnPulls lists pull requests created by the current user. If repoURI // is non-empty, only PRs targeting that repo are returned. func (c *Client) ListOwnPulls(ctx context.Context, repoURI string) ([]PullEntry, error) { records, err := Paginate(ctx, func(cursor string) ([]RecordEntry, string, error) { rl, err := c.ListRecords(ctx, c.did.String(), PullRecordNSID, cursor, 100) if err != nil { return nil, "", err } return rl.Records, rl.Cursor, nil }) if err != nil { return nil, err } var pulls []PullEntry for _, rec := range records { entry, err := parsePullRecord(rec, c.did.String()) if err != nil { continue } if repoURI != "" && entry.TargetRepo != repoURI { continue } pulls = append(pulls, *entry) } return pulls, nil } // GetPullRecord fetches a specific PR record from a user's PDS. func (c *Client) GetPullRecord(ctx context.Context, authorDID, rkey string) (*PullEntry, error) { rec, err := c.GetRecord(ctx, authorDID, PullRecordNSID, rkey) if err != nil { return nil, err } return parsePullRecord(*rec, authorDID) } func parsePullRecord(rec RecordEntry, authorDID string) (*PullEntry, error) { var r struct { Title string `json:"title"` Body *string `json:"body"` Target *struct { Repo string `json:"repo"` Branch string `json:"branch"` } `json:"target"` Source *struct { Branch string `json:"branch"` Sha string `json:"sha"` Repo *string `json:"repo"` } `json:"source"` CreatedAt string `json:"createdAt"` } if err := json.Unmarshal(rec.Value, &r); err != nil { return nil, fmt.Errorf("failed to parse pull record: %w", err) } entry := &PullEntry{ URI: rec.URI, Rkey: RkeyFromURI(rec.URI), AuthorDID: authorDID, Title: r.Title, CreatedAt: r.CreatedAt, } if r.Body != nil { entry.Body = *r.Body } if r.Target != nil { entry.TargetRepo = r.Target.Repo entry.TargetRef = r.Target.Branch } if r.Source != nil { entry.SourceRef = r.Source.Branch entry.SourceSha = r.Source.Sha } return entry, nil } // ClosePull writes a PR status record to close the PR. func (c *Client) ClosePull(ctx context.Context, pullURI string) error { rkey := newTID() record := &pullStatusRecord{ Type: PullStatusNSID, Pull: pullURI, Status: PullStatusClosed, } _, err := c.PutRecord(ctx, PullStatusNSID, rkey, record) return err } // CreatePullComment adds a comment to a pull request. func (c *Client) CreatePullComment(ctx context.Context, pullURI, body string) error { rkey := newTID() record := &pullCommentRecord{ Type: PullCommentNSID, Pull: pullURI, Body: body, CreatedAt: time.Now().Format(time.RFC3339), } _, err := c.PutRecord(ctx, PullCommentNSID, rkey, record) return err } // MergePull merges a pull request by calling the knot's sh.tangled.repo.merge endpoint. func (c *Client) MergePull(ctx context.Context, knotURL string, input *MergeInput) error { return c.PostToKnot(ctx, knotURL, MergeNSID, input, nil) } // CheckMerge checks if a merge would have conflicts. func (c *Client) CheckMerge(ctx context.Context, knotURL string, input *MergeCheckInput) (*MergeCheckResponse, error) { var out MergeCheckResponse err := c.PostToKnot(ctx, knotURL, MergeCheckNSID, input, &out) return &out, err } // GetPullPatch downloads the patch content for a PR by reading the record's // patchBlob via the PDS blob endpoint. Returns the decompressed patch. func (c *Client) GetPullPatch(ctx context.Context, authorDID, rkey string) (string, error) { rec, err := c.GetRecord(ctx, authorDID, PullRecordNSID, rkey) if err != nil { return "", err } // Extract the blob CID from the record var r struct { PatchBlob *struct { Ref *struct { Link string `json:"$link"` } `json:"ref"` } `json:"patchBlob"` } if err := json.Unmarshal(rec.Value, &r); err != nil { return "", fmt.Errorf("failed to parse pull record: %w", err) } if r.PatchBlob == nil || r.PatchBlob.Ref == nil { return "", fmt.Errorf("pull request has no patch blob") } // Download blob from PDS via com.atproto.sync.getBlob blobData, err := c.GetBlob(ctx, authorDID, r.PatchBlob.Ref.Link) if err != nil { return "", err } // Not gzipped (older records stored the patch uncompressed). if len(blobData) < 2 || blobData[0] != 0x1f || blobData[1] != 0x8b { return string(blobData), nil } gz, err := gzip.NewReader(bytes.NewReader(blobData)) if err != nil { return "", fmt.Errorf("failed to decompress patch: %w", err) } defer gz.Close() // Bound the decompressed size — the blob is untrusted (any author's PDS) // and could otherwise be a decompression bomb. data, err := io.ReadAll(io.LimitReader(gz, maxPatchBytes+1)) if err != nil { return "", fmt.Errorf("failed to decompress patch: %w", err) } if len(data) > maxPatchBytes { return "", fmt.Errorf("decompressed patch exceeds %d bytes", maxPatchBytes) } return string(data), nil } // maxPatchBytes caps the decompressed size of a PR patch blob. const maxPatchBytes = 256 << 20 // 256 MiB // GetBlob downloads a blob. It tries the authenticated user's PDS first and // falls back to the blob owner's PDS for cross-PDS reads. func (c *Client) GetBlob(ctx context.Context, did, cid string) ([]byte, error) { nsid, err := syntax.ParseNSID("com.atproto.sync.getBlob") if err != nil { return nil, err } params := map[string]any{ "did": did, "cid": cid, } var buf bytes.Buffer ownErr := c.pdsClient.LexDo(ctx, "GET", "", nsid.String(), params, nil, &buf) if ownErr == nil { return buf.Bytes(), nil } // The user's PDS may not host this DID — fetch from the owner's PDS. pdsURL, resolvedDID, err := c.resolvePDS(ctx, did) if err != nil { return nil, fmt.Errorf("failed to download blob: %w", ownErr) } u := pdsURL + "/xrpc/com.atproto.sync.getBlob?" + url.Values{ "did": {resolvedDID}, "cid": {cid}, }.Encode() req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return nil, err } data, err := c.doRawRequest(req, "com.atproto.sync.getBlob") if err != nil { return nil, fmt.Errorf("failed to download blob: %w", err) } return data, nil } // FormatPullURL returns the web URL for a repo's pull requests on tangled.sh. func FormatPullURL(repo string) string { return fmt.Sprintf("https://tangled.sh/%s/pulls", repo) } // GetRepoDiff fetches a diff between two refs from a knot (for viewing PR changes). func (c *Client) GetRepoDiff(ctx context.Context, knotURL, repo, base, head string) (string, error) { params := url.Values{} params.Set("repo", repo) params.Set("base", base) params.Set("head", head) var out struct { Diff string `json:"diff"` } err := c.GetFromKnot(ctx, knotURL, "sh.tangled.repo.diff", params, &out) return out.Diff, err }