Something went wrong. Try again.
A command-line interface for tangled, the decentralized Git collaboration platform built on the AT Protocol. eric-wien.tngl.io/tng-cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413package 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}