package api import ( "context" "encoding/json" "fmt" "time" ) const ( // IssueRecordNSID is the AT Protocol collection for Tangled issues. IssueRecordNSID = "sh.tangled.repo.issue" // IssueCommentNSID is the AT Protocol collection for issue comments. IssueCommentNSID = "sh.tangled.repo.issue.comment" // IssueStateNSID is the AT Protocol collection for issue state changes. IssueStateNSID = "sh.tangled.repo.issue.state" IssueStateOpen = "sh.tangled.repo.issue.state.open" IssueStateClosed = "sh.tangled.repo.issue.state.closed" ) // issueRecord is written to the user's PDS when creating an issue. type issueRecord struct { Type string `json:"$type"` Repo string `json:"repo"` // AT URI of the target repo Title string `json:"title"` Body *string `json:"body,omitempty"` CreatedAt string `json:"createdAt"` } // issueCommentRecord is written to the user's PDS when commenting on an issue. type issueCommentRecord struct { Type string `json:"$type"` Issue string `json:"issue"` // AT URI of the issue Body string `json:"body"` CreatedAt string `json:"createdAt"` ReplyTo *string `json:"replyTo,omitempty"` // AT URI of parent comment } // issueStateRecord is written to change an issue's state. type issueStateRecord struct { Type string `json:"$type"` Issue string `json:"issue"` // AT URI of the issue State string `json:"state"` // IssueStateOpen or IssueStateClosed } // IssueEntry represents a parsed issue record for display. type IssueEntry struct { URI string Rkey string AuthorDID string Repo string // AT URI of the target repo Title string Body string CreatedAt string } // CreateIssue creates a new issue by writing an AT Protocol record to the user's PDS. // repoURI is the AT URI of the target repository (at://did/sh.tangled.repo/rkey). // Returns the rkey of the created issue. func (c *Client) CreateIssue(ctx context.Context, repoURI, title, body string) (string, error) { rkey := newTID() var bodyPtr *string if body != "" { bodyPtr = &body } record := &issueRecord{ Type: IssueRecordNSID, Repo: repoURI, Title: title, Body: bodyPtr, CreatedAt: time.Now().Format(time.RFC3339), } _, err := c.PutRecord(ctx, IssueRecordNSID, rkey, record) if err != nil { return "", err } return rkey, nil } // ListOwnIssues lists issues created by the current user. If repoURI is // non-empty, only issues targeting that repo are returned. func (c *Client) ListOwnIssues(ctx context.Context, repoURI string) ([]IssueEntry, error) { records, err := Paginate(ctx, func(cursor string) ([]RecordEntry, string, error) { rl, err := c.ListRecords(ctx, c.did.String(), IssueRecordNSID, cursor, 100) if err != nil { return nil, "", err } return rl.Records, rl.Cursor, nil }) if err != nil { return nil, err } var issues []IssueEntry for _, rec := range records { var r struct { Repo string `json:"repo"` Title string `json:"title"` Body *string `json:"body"` CreatedAt string `json:"createdAt"` } if err := json.Unmarshal(rec.Value, &r); err != nil { continue } if repoURI != "" && r.Repo != repoURI { continue } body := "" if r.Body != nil { body = *r.Body } issues = append(issues, IssueEntry{ URI: rec.URI, Rkey: RkeyFromURI(rec.URI), AuthorDID: c.did.String(), Repo: r.Repo, Title: r.Title, Body: body, CreatedAt: r.CreatedAt, }) } return issues, nil } // GetIssueRecord fetches a specific issue record from a user's PDS. func (c *Client) GetIssueRecord(ctx context.Context, authorDID, rkey string) (*IssueEntry, error) { rec, err := c.GetRecord(ctx, authorDID, IssueRecordNSID, rkey) if err != nil { return nil, err } var r struct { Repo string `json:"repo"` Title string `json:"title"` Body *string `json:"body"` CreatedAt string `json:"createdAt"` } if err := json.Unmarshal(rec.Value, &r); err != nil { return nil, fmt.Errorf("failed to parse issue record: %w", err) } body := "" if r.Body != nil { body = *r.Body } return &IssueEntry{ URI: rec.URI, Rkey: rkey, AuthorDID: authorDID, Repo: r.Repo, Title: r.Title, Body: body, CreatedAt: r.CreatedAt, }, nil } // CloseIssue writes an issue state record to close the issue. // issueURI is the AT URI of the issue (at://did/sh.tangled.repo.issue/rkey). func (c *Client) CloseIssue(ctx context.Context, issueURI string) error { rkey := newTID() record := &issueStateRecord{ Type: IssueStateNSID, Issue: issueURI, State: IssueStateClosed, } _, err := c.PutRecord(ctx, IssueStateNSID, rkey, record) return err } // CreateIssueComment adds a comment to an issue. // issueURI is the AT URI of the issue. func (c *Client) CreateIssueComment(ctx context.Context, issueURI, body string) error { rkey := newTID() record := &issueCommentRecord{ Type: IssueCommentNSID, Issue: issueURI, Body: body, CreatedAt: time.Now().Format(time.RFC3339), } _, err := c.PutRecord(ctx, IssueCommentNSID, rkey, record) return err } // FormatIssueURL returns the web URL for an issue on tangled.sh. // Since the appview uses sequential numbers we can't map directly, // so we link to the repo's issues page. func FormatIssueURL(repo string) string { return fmt.Sprintf("https://tangled.sh/%s/issues", repo) }