Something went wrong. Try again.
Monorepo for Tangled — https://tangled.org forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531package db
import ( "database/sql" "fmt" "log" "net/url" "slices" "strings" "time"
"github.com/bluesky-social/indigo/atproto/syntax" "tangled.sh/tangled.sh/core/api/tangled")
type RepoEvent struct { Repo *Repo Source *Repo}
type ProfileTimeline struct { ByMonth []ByMonth}
type ByMonth struct { 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}
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}
const TimeframeMonths = 7
func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { timeline := ProfileTimeline{ ByMonth: make([]ByMonth, TimeframeMonths), } currentMonth := time.Now().Month() timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) if err != nil { return nil, fmt.Errorf("error getting pulls by owner did: %w", err) }
// group pulls by month for _, pull := range pulls { pullMonth := pull.Created.Month()
if currentMonth-pullMonth >= TimeframeMonths { // shouldn't happen; but times are weird continue }
idx := currentMonth - pullMonth items := &timeline.ByMonth[idx].PullEvents.Items
*items = append(*items, &pull) }
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) if err != nil { return nil, fmt.Errorf("error getting issues by owner did: %w", err) }
for _, issue := range issues { issueMonth := issue.Created.Month()
if currentMonth-issueMonth >= TimeframeMonths { // shouldn't happen; but times are weird continue }
idx := currentMonth - issueMonth items := &timeline.ByMonth[idx].IssueEvents.Items
*items = append(*items, &issue) }
repos, err := GetAllReposByDid(e, forDid) if err != nil { return nil, fmt.Errorf("error getting all repos by did: %w", err) }
for _, repo := range repos { // TODO: get this in the original query; requires COALESCE because nullable var sourceRepo *Repo if repo.Source != "" { sourceRepo, err = GetRepoByAtUri(e, repo.Source) if err != nil { return nil, err } }
repoMonth := repo.Created.Month()
if currentMonth-repoMonth >= TimeframeMonths { // shouldn't happen; but times are weird continue }
idx := currentMonth - repoMonth
items := &timeline.ByMonth[idx].RepoEvents *items = append(*items, RepoEvent{ Repo: &repo, Source: sourceRepo, }) }
return &timeline, nil}
type Profile struct { // ids ID int Did string
// data Description string IncludeBluesky bool Location string Links [5]string Stats [2]VanityStat PinnedRepos [6]syntax.ATURI}
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}
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")
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" } 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"))}
func UpsertProfile(tx *sql.Tx, profile *Profile) error { defer tx.Rollback()
// update links _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) if err != nil { return err } // update vanity stats _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did) if err != nil { return err }
// update pinned repos _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did) if err != nil { return err }
includeBskyValue := 0 if profile.IncludeBluesky { includeBskyValue = 1 }
_, err = tx.Exec( `insert or replace into profile ( did, description, include_bluesky, location ) values (?, ?, ?, ?)`, profile.Did, profile.Description, includeBskyValue, profile.Location, )
if err != nil { log.Println("profile", "err", err) return err }
for _, link := range profile.Links { if link == "" { continue }
_, err := tx.Exec( `insert into profile_links (did, link) values (?, ?)`, profile.Did, link, )
if err != nil { log.Println("profile_links", "err", err) return err } }
for _, v := range profile.Stats { if v.Kind == "" { continue }
_, err := tx.Exec( `insert into profile_stats (did, kind) values (?, ?)`, profile.Did, v.Kind, )
if err != nil { log.Println("profile_stats", "err", err) return err } }
for _, pin := range profile.PinnedRepos { if pin == "" { continue }
_, err := tx.Exec( `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`, profile.Did, pin, )
if err != nil { log.Println("profile_pinned_repositories", "err", err) return err } }
return tx.Commit()}
func GetProfile(e Execer, did string) (*Profile, error) { var profile Profile profile.Did = did
includeBluesky := 0 err := e.QueryRow( `select description, include_bluesky, location from profile where did = ?`, did, ).Scan(&profile.Description, &includeBluesky, &profile.Location) if err == sql.ErrNoRows { profile := Profile{} profile.Did = did return &profile, nil }
if err != nil { return nil, err }
if includeBluesky != 0 { profile.IncludeBluesky = true }
rows, err := e.Query(`select link from profile_links where did = ?`, did) if err != nil { return nil, err } defer rows.Close() i := 0 for rows.Next() { if err := rows.Scan(&profile.Links[i]); err != nil { return nil, err } i++ }
rows, err = e.Query(`select kind from profile_stats where did = ?`, did) if err != nil { return nil, err } defer rows.Close() i = 0 for rows.Next() { if err := rows.Scan(&profile.Stats[i].Kind); err != nil { return nil, err } value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) if err != nil { return nil, err } profile.Stats[i].Value = value i++ }
rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did) if err != nil { return nil, err } defer rows.Close() i = 0 for rows.Next() { if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { return nil, err } i++ }
return &profile, nil}
func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { query := "" var args []any switch stat { case VanityStatMergedPRCount: query = `select count(id) from pulls where owner_did = ? and state = ?` args = append(args, did, PullMerged) case VanityStatClosedPRCount: query = `select count(id) from pulls where owner_did = ? and state = ?` args = append(args, did, PullClosed) case VanityStatOpenPRCount: query = `select count(id) from pulls where owner_did = ? and state = ?` args = append(args, did, PullOpen) case VanityStatOpenIssueCount: query = `select count(id) from issues where owner_did = ? and open = 1` args = append(args, did) case VanityStatClosedIssueCount: query = `select count(id) from issues where owner_did = ? and open = 0` args = append(args, did) case VanityStatRepositoryCount: query = `select count(id) from repos where did = ?` args = append(args, did) }
var result uint64 err := e.QueryRow(query, args...).Scan(&result) if err != nil { return 0, err }
return result, nil}
func ValidateProfile(e Execer, profile *Profile) error { // ensure description is not too long if len(profile.Description) > 256 { return fmt.Errorf("Entered bio is too long.") }
// ensure description is not too long if len(profile.Location) > 40 { return fmt.Errorf("Entered location is too long.") }
// ensure links are in order err := validateLinks(profile) if err != nil { return err }
// ensure all pinned repos are either own repos or collaborating repos repos, err := GetAllReposByDid(e, profile.Did) if err != nil { log.Printf("getting repos for %s: %s", profile.Did, err) }
collaboratingRepos, err := CollaboratingIn(e, profile.Did) if err != nil { log.Printf("getting collaborating repos for %s: %s", profile.Did, err) }
var validRepos []syntax.ATURI for _, r := range repos { validRepos = append(validRepos, r.RepoAt()) } for _, r := range collaboratingRepos { validRepos = append(validRepos, r.RepoAt()) }
for _, pinned := range profile.PinnedRepos { if pinned == "" { continue } if !slices.Contains(validRepos, pinned) { return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) } }
return nil}
func validateLinks(profile *Profile) error { for i, link := range profile.Links { if link == "" { continue }
parsedURL, err := url.Parse(link) if err != nil { return fmt.Errorf("Invalid URL '%s': %v\n", link, err) }
if parsedURL.Scheme == "" { if strings.HasPrefix(link, "//") { profile.Links[i] = "https:" + link } else { profile.Links[i] = "https://" + link } continue } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) }
// catch relative paths if parsedURL.Host == "" { return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) } } return nil}