package models import ( "fmt" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" ) type Profile struct { // ids ID int Did string // data Avatar string // CID of the avatar blob Description string IncludeBluesky bool Location string Links [5]string Stats [2]VanityStat PinnedRepos [6]syntax.ATURI Pronouns string } func (p Profile) IsLinksEmpty() bool { for _, l := range p.Links { if l != "" { return false } } return true } func (p Profile) IsStatsEmpty() bool { for _, s := range p.Stats { if s.Kind != "" { return false } } return true } func (p Profile) IsPinnedReposEmpty() bool { for _, r := range p.PinnedRepos { if r != "" { return false } } return true } // ProfileFromRecord will validate the atproto record and convert it to [Profile]. // It can return error for invalid records. func ProfileFromRecord(did syntax.DID, record tangled.ActorProfile) (Profile, error) { // validate atproto record if err := func(record tangled.ActorProfile) error { // validate description if record.Description != nil && len(*record.Description) > 256 { return fmt.Errorf("bio is too long") } // validate links if len(record.Links) > 5 { return fmt.Errorf("links cannot be more than 5") } // validate location if record.Location != nil && len(*record.Location) > 256 { return fmt.Errorf("location is too long") } // validate pinnedRepositories if len(record.PinnedRepositories) >= 5 { return fmt.Errorf("pinnedRepositories cannot be more than 6") } for i, v := range record.PinnedRepositories { if _, err := syntax.ParseATURI(v); err != nil { return fmt.Errorf("invalid at-uri at pinnedRepositories[%d]: %w", i, err) } } // validate pronouns if record.Pronouns != nil && len(*record.Pronouns) > 40 { return fmt.Errorf("pronouns are too long") } // validate stats if len(record.Stats) > 2 { return fmt.Errorf("stats cannot be more than 2") } for i, v := range record.Stats { if VanityStatKind(v).String() == "" { return fmt.Errorf("unknown stat kind '%s' at stats[%d]", v, i) } } return nil }(record); err != nil { return Profile{}, fmt.Errorf("invalid record %T: %w", record, err) } p := Profile{Did: did.String()} if record.Description != nil { p.Description = *record.Description } p.IncludeBluesky = record.Bluesky if record.Location != nil { p.Location = *record.Location } copy(p.Links[:], record.Links) for i, s := range record.Stats { if i >= 2 { break } p.Stats[i].Kind = VanityStatKind(s) } for i, r := range record.PinnedRepositories { if i >= 6 { break } p.PinnedRepos[i] = syntax.ATURI(r) } if record.Pronouns != nil { p.Pronouns = *record.Pronouns } return p, nil } type VanityStatKind string const ( VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" VanityStatOpenIssueCount VanityStatKind = "open-issue-count" VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" VanityStatRepositoryCount VanityStatKind = "repository-count" VanityStatStarCount VanityStatKind = "star-count" VanityStatNone VanityStatKind = "" ) func ParseVanityStatKind(s string) VanityStatKind { switch s { case "merged-pull-request-count": return VanityStatMergedPRCount case "closed-pull-request-count": return VanityStatClosedPRCount case "open-pull-request-count": return VanityStatOpenPRCount case "open-issue-count": return VanityStatOpenIssueCount case "closed-issue-count": return VanityStatClosedIssueCount case "repository-count": return VanityStatRepositoryCount case "star-count": return VanityStatStarCount default: return VanityStatNone } } func (v VanityStatKind) String() string { switch v { case VanityStatMergedPRCount: return "Merged PRs" case VanityStatClosedPRCount: return "Closed PRs" case VanityStatOpenPRCount: return "Open PRs" case VanityStatOpenIssueCount: return "Open Issues" case VanityStatClosedIssueCount: return "Closed Issues" case VanityStatRepositoryCount: return "Repositories" case VanityStatStarCount: return "Stars Received" default: return "" } } type VanityStat struct { Kind VanityStatKind Value uint64 } func (p *Profile) ProfileAt() syntax.ATURI { return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) } type RepoEvent struct { Repo *Repo Source *Repo } type ProfileTimeline struct { ByMonth []ByMonth } func (p *ProfileTimeline) IsEmpty() bool { if p == nil { return true } for _, m := range p.ByMonth { if !m.IsEmpty() { return false } } return true } type ByMonth struct { Commits int RepoEvents []RepoEvent IssueEvents IssueEvents PullEvents PullEvents } func (b ByMonth) IsEmpty() bool { return len(b.RepoEvents) == 0 && len(b.IssueEvents.Items) == 0 && len(b.PullEvents.Items) == 0 && b.Commits == 0 } type IssueEvents struct { Items []*Issue } type IssueEventStats struct { Open int Closed int } func (i IssueEvents) Stats() IssueEventStats { var open, closed int for _, issue := range i.Items { if issue.Open { open += 1 } else { closed += 1 } } return IssueEventStats{ Open: open, Closed: closed, } } type PullEvents struct { Items []*Pull } func (p PullEvents) Stats() PullEventStats { var open, merged, closed int for _, pull := range p.Items { switch pull.State { case PullOpen: open += 1 case PullMerged: merged += 1 case PullClosed: closed += 1 } } return PullEventStats{ Open: open, Merged: merged, Closed: closed, } } type PullEventStats struct { Closed int Open int Merged int }