diff --git a/frontend/client/src/bowtie/repo.gleam b/frontend/client/src/bowtie/repo.gleam index 7bb5242..fe85431 100644 --- a/frontend/client/src/bowtie/repo.gleam +++ b/frontend/client/src/bowtie/repo.gleam @@ -2,7 +2,10 @@ import alicia/core/at_uri import alicia/core/record_key import alicia/identity/did.{type Did} import gleam/dynamic/decode.{type Decoder} +import gleam/int +import gleam/list import gleam/option.{type Option, None, Some} +import gleam/order.{type Order} import gleam/result import gleam/string import gleam/uri @@ -304,3 +307,283 @@ fn commit_decoder() -> Decoder(Commit) { author:, )) } + +fn kind_from_mode(mode: String) -> EntryKind { + case string.ends_with(mode, "40000") { + True -> Folder + False -> File + } +} + +fn entry_decoder() -> Decoder(Entry) { + use name <- decode.field("name", decode.string) + use mode <- decode.field("mode", decode.string) + use last_commit <- decode.optional_field( + "last_commit", + None, + decode.optional(commit_decoder()), + ) + decode.success(Entry( + kind: kind_from_mode(mode), + name: EntryName(name), + last_commit:, + )) +} + +fn readme_decoder() -> Decoder(String) { + use contents <- decode.field("contents", decode.string) + decode.success(contents) +} + +fn entry_name_string(entry: Entry) -> String { + let EntryName(name) = entry.name + name +} + +fn entry_order(one: Entry, other: Entry) -> Order { + case one.kind, other.kind { + Folder, File -> order.Lt + File, Folder -> order.Gt + _, _ -> string.compare(entry_name_string(one), entry_name_string(other)) + } +} + +pub fn tree_decoder() -> Decoder(Section) { + use entries <- decode.field("files", decode.list(entry_decoder())) + use readme <- decode.optional_field( + "readme", + None, + decode.optional(readme_decoder()), + ) + decode.success(Files(Tree(entries: list.sort(entries, entry_order), readme:))) +} + +pub fn default_branch_decoder() -> Decoder(Section) { + use name <- decode.field("name", decode.string) + decode.success(DefaultBranch(BranchName(name))) +} + +fn list_count_decoder( + field field: String, + wrap wrap: fn(Count(of)) -> Section, +) -> Decoder(Section) { + use items <- decode.optional_field(field, [], decode.list(decode.dynamic)) + use total <- decode.optional_field("total", list.length(items), decode.int) + total + |> count() + |> wrap() + |> decode.success() +} + +fn hex_byte(byte: Int) -> String { + let raw = int.to_base16(byte) |> string.lowercase + case string.length(raw) { + 1 -> "0" <> raw + _ -> raw + } +} + +fn bytes_to_hex(bytes: List(Int)) -> String { + bytes + |> list.map(hex_byte) + |> string.concat +} + +fn log_commit_decoder() -> Decoder(Commit) { + use hash <- decode.field("hash", decode.list(decode.int)) + use message <- decode.field("message", decode.string) + use when <- decode.subfield(["author", "When"], decode.string) + use author <- decode.subfield(["author", "Name"], decode.string) + decode.success(Commit( + hash: CommitHash(bytes_to_hex(hash)), + message: CommitMessage(subject(message)), + when: When(when), + author: Some(AuthorName(author)), + )) +} + +pub fn log_decoder() -> Decoder(Section) { + use total <- decode.optional_field("total", 0, decode.int) + use commits <- decode.optional_field( + "commits", + [], + decode.list(log_commit_decoder()), + ) + decode.success(Log( + commits: count(total), + top: option.from_result(list.first(commits)), + )) +} + +fn language_decoder() -> Decoder(Language) { + use name <- decode.field("name", decode.string) + use share <- decode.field("percentage", decode.int) + decode.success(Language(name: LanguageName(name), share: Percent(share))) +} + +fn share_int(language: Language) -> Int { + let Language(share: Percent(share), ..) = language + share +} + +fn share_descending(one: Language, other: Language) -> Order { + int.compare(share_int(other), share_int(one)) +} + +fn with_other(languages: List(Language)) -> List(Language) { + case languages { + [] -> [] + _ -> { + let total = + list.fold(languages, 0, fn(acc, language) { acc + share_int(language) }) + case 100 - total { + remainder if remainder > 0 -> + list.append(languages, [ + Language(name: LanguageName("Other"), share: Percent(remainder)), + ]) + _ -> languages + } + } + } +} + +pub fn languages_decoder() -> Decoder(Section) { + use langs <- decode.optional_field( + "languages", + [], + decode.list(language_decoder()), + ) + langs + |> list.sort(share_descending) + |> with_other() + |> Languages() + |> decode.success() +} + +pub fn count_decoder(wrap: fn(Count(of)) -> Section) -> Decoder(Section) { + use n <- decode.field("count", decode.int) + n + |> count() + |> wrap() + |> decode.success() +} + +pub type Query { + Query( + method: String, + params: List(#(String, String)), + decoder: Decoder(Section), + ) +} + +pub fn meta_query(repo_did: Did) -> Query { + Query( + method: "sh.tangled.repo.getRepoByRepoDid", + params: [#("repoDid", did.to_string(repo_did))], + decoder: meta_decoder(), + ) +} + +type SectionSource { + SectionSource(query: Query, in_page: fn(RepoPage) -> Bool) +} + +fn section_sources( + subject subject: Did, + at at: at_uri.AtUri, +) -> List(SectionSource) { + let subject = did.to_string(subject) + let at = at_uri.to_string(at) + [ + SectionSource( + Query("sh.tangled.repo.tree", [#("repo", at)], tree_decoder()), + fn(page) { option.is_some(page.tree) }, + ), + SectionSource( + Query( + "sh.tangled.repo.getDefaultBranch", + [#("repo", at)], + default_branch_decoder(), + ), + fn(page) { option.is_some(page.default_branch) }, + ), + SectionSource( + Query( + "sh.tangled.repo.branches", + [#("repo", at)], + list_count_decoder(field: "branches", wrap: BranchCount), + ), + fn(page) { option.is_some(page.branches) }, + ), + SectionSource( + Query( + "sh.tangled.repo.tags", + [#("repo", at)], + list_count_decoder(field: "tags", wrap: TagCount), + ), + fn(page) { option.is_some(page.tags) }, + ), + SectionSource( + Query( + "sh.tangled.repo.log", + [#("repo", at), #("limit", "1")], + log_decoder(), + ), + fn(page) { option.is_some(page.commits) }, + ), + SectionSource( + Query("sh.tangled.repo.languages", [#("repo", at)], languages_decoder()), + fn(page) { option.is_some(page.languages) }, + ), + SectionSource( + Query( + "sh.tangled.feed.countStars", + [#("subject", subject)], + count_decoder(StarCount), + ), + fn(page) { option.is_some(page.stars) }, + ), + SectionSource( + Query( + "sh.tangled.repo.countIssues", + [#("subject", subject)], + count_decoder(IssueCount), + ), + fn(page) { option.is_some(page.issues) }, + ), + SectionSource( + Query( + "sh.tangled.repo.countPulls", + [#("subject", subject)], + count_decoder(PullCount), + ), + fn(page) { option.is_some(page.pulls) }, + ), + ] +} + +pub fn section_queries( + subject subject: Did, + at at: at_uri.AtUri, +) -> List(Query) { + section_sources(subject:, at:) + |> list.map(fn(source) { source.query }) +} + +pub fn missing_queries(page: RepoPage, subject subject: Did) -> List(Query) { + section_sources(subject:, at: page.meta.uri) + |> list.filter(fn(source) { !source.in_page(page) }) + |> list.map(fn(source) { source.query }) +} + +pub fn query_url(base: String, query: Query) -> String { + let params = + query.params + |> list.map(fn(param) { + let #(key, value) = param + key <> "=" <> uri.percent_encode(value) + }) + |> string.join("&") + + base <> "/xrpc/" <> query.method <> "?" <> params +} diff --git a/frontend/client/test/bowtie/repo_test.gleam b/frontend/client/test/bowtie/repo_test.gleam new file mode 100644 index 0000000..0a159aa --- /dev/null +++ b/frontend/client/test/bowtie/repo_test.gleam @@ -0,0 +1,68 @@ +import alicia/core/at_uri +import alicia/identity/did +import bowtie/repo +import gleam/json +import gleam/list +import gleam/option.{None, Some} + +const record_json = "{\"cid\":\"bafyabc\",\"uri\":\"at://did:plc:i5bdzkbmq5vnoyqiilwulmuq/sh.tangled.repo/alicia\",\"value\":{\"$type\":\"sh.tangled.repo\",\"createdAt\":\"2026-05-20T02:41:28Z\",\"description\":\"Gleam ATProto packages\",\"website\":\"https://lesbian.skin\",\"knot\":\"knot1.tangled.sh\",\"repoDid\":\"did:plc:lfi6q7vkupeii6mkbw4rmhl2\"}}" + +pub fn log_reads_total_and_latest_commit_test() { + let json = + "{\"commits\":[{\"hash\":[177,242,130,39,247,7,199,10,225,17,183,206,37,3,103,230,126,125,195,59],\"author\":{\"Name\":\"Naomi\",\"Email\":\"noreply@lesbian.skin\",\"When\":\"2026-06-29T23:17:11+01:00\"},\"message\":\"identity: did parsing\\n\\nSigned-off-by: Naomi \\n\"}],\"ref\":\"main\",\"total\":31,\"page\":1}" + let assert Ok(repo.Log(commits: n, top: Some(commit))) = + json.parse(json, repo.log_decoder()) + + assert repo.count_value(n) == 31 + assert commit.hash + == repo.CommitHash("b1f28227f707c70ae111b7ce250367e67e7dc33b") + assert commit.message == repo.CommitMessage("identity: did parsing") + assert commit.author == Some(repo.AuthorName("Naomi")) + assert commit.when == repo.When("2026-06-29T23:17:11+01:00") +} + +pub fn languages_decoder_sorts_and_fills_remainder_test() { + let json = + "{\"languages\":[{\"name\":\"Nix\",\"percentage\":1,\"size\":1},{\"name\":\"Gleam\",\"percentage\":97,\"size\":1}],\"ref\":\"\",\"totalSize\":2}" + let assert Ok(repo.Languages(langs)) = + json.parse(json, repo.languages_decoder()) + let assert [gleam, nix, other] = langs + assert gleam == repo.Language(repo.LanguageName("Gleam"), repo.Percent(97)) + assert nix.name == repo.LanguageName("Nix") + assert other == repo.Language(repo.LanguageName("Other"), repo.Percent(2)) +} + +pub fn meta_decoder_drops_non_http_website_test() { + let json = + "{\"uri\":\"at://did:plc:i5bdzkbmq5vnoyqiilwulmuq/sh.tangled.repo/alicia\",\"value\":{\"knot\":\"knot1.tangled.sh\",\"website\":\"javascript:alert(document.cookie)\"}}" + let assert Ok(repo.Meta(meta)) = json.parse(json, repo.meta_decoder()) + assert meta.website == None +} + +pub fn query_url_percent_encodes_params_test() { + let base = "https://bob.oyster.cafe" + + let assert Ok(repo_did) = did.parse("did:plc:lfi6q7vkupeii6mkbw4rmhl2") + assert repo.query_url(base, repo.meta_query(repo_did)) + == base + <> "/xrpc/sh.tangled.repo.getRepoByRepoDid?repoDid=did%3Aplc%3Alfi6q7vkupeii6mkbw4rmhl2" + + let assert Ok(at) = + at_uri.parse("at://did:plc:i5bdzkbmq5vnoyqiilwulmuq/sh.tangled.repo/alicia") + let assert [tree_query, ..] = repo.section_queries(subject: repo_did, at:) + assert repo.query_url(base, tree_query) + == base + <> "/xrpc/sh.tangled.repo.tree?repo=at%3A%2F%2Fdid%3Aplc%3Ai5bdzkbmq5vnoyqiilwulmuq%2Fsh.tangled.repo%2Falicia" +} + +pub fn missing_queries_skips_already_fetched_sections_test() { + let assert Ok(repo.Meta(meta)) = json.parse(record_json, repo.meta_decoder()) + let page = repo.new_page(meta) |> repo.fold(repo.StarCount(repo.count(3))) + + let methods = + repo.missing_queries(page, subject: meta.owner) + |> list.map(fn(query) { query.method }) + + assert !list.contains(methods, "sh.tangled.feed.countStars") + assert list.contains(methods, "sh.tangled.repo.tree") +}