Monorepo for Tangled tangled.org

Compare changes

Choose any two refs to compare.

+1485 -675
+2
api/tangled/repoblob.go
··· 21 21 Hash string `json:"hash" cborgen:"hash"` 22 22 // message: Commit message 23 23 Message string `json:"message" cborgen:"message"` 24 + // shortHash: Short commit hash 25 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 24 26 // when: Commit timestamp 25 27 When string `json:"when" cborgen:"when"` 26 28 }
+33
api/tangled/repotag.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tag 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagNSID = "sh.tangled.repo.tag" 16 + ) 17 + 18 + // RepoTag calls the XRPC method "sh.tangled.repo.tag". 19 + // 20 + // repo: Repository identifier in format 'did:plc:.../repoName' 21 + // tag: Name of tag, such as v1.3.0 22 + func RepoTag(ctx context.Context, c util.LexClient, repo string, tag string) ([]byte, error) { 23 + buf := new(bytes.Buffer) 24 + 25 + params := map[string]interface{}{} 26 + params["repo"] = repo 27 + params["tag"] = tag 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tag", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+2 -14
api/tangled/repotree.go
··· 16 16 17 17 // RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema. 18 18 type RepoTree_LastCommit struct { 19 - Author *RepoTree_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 19 // hash: Commit hash 21 20 Hash string `json:"hash" cborgen:"hash"` 22 21 // message: Commit message ··· 28 27 // RepoTree_Output is the output of a sh.tangled.repo.tree call. 29 28 type RepoTree_Output struct { 30 29 // dotdot: Parent directory path 31 - Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 32 - Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 33 - LastCommit *RepoTree_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 30 + Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 31 + Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 34 32 // parent: The parent path in the tree 35 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 36 34 // readme: Readme for this file tree ··· 45 43 Contents string `json:"contents" cborgen:"contents"` 46 44 // filename: Name of the readme file 47 45 Filename string `json:"filename" cborgen:"filename"` 48 - } 49 - 50 - // RepoTree_Signature is a "signature" in the sh.tangled.repo.tree schema. 51 - type RepoTree_Signature struct { 52 - // email: Author email 53 - Email string `json:"email" cborgen:"email"` 54 - // name: Author name 55 - Name string `json:"name" cborgen:"name"` 56 - // when: Author timestamp 57 - When string `json:"when" cborgen:"when"` 58 46 } 59 47 60 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
-2
appview/db/profile.go
··· 453 453 case models.VanityStatStarCount: 454 454 query = `select count(id) from stars where subject_at like 'at://' || ? || '%'` 455 455 args = append(args, did) 456 - case models.VanityStatNone: 457 - return 0, nil 458 456 default: 459 457 return 0, fmt.Errorf("invalid vanity stat kind: %s", stat) 460 458 }
+1 -1
appview/ingester.go
··· 317 317 var stats [2]models.VanityStat 318 318 for i, s := range record.Stats { 319 319 if i < 2 { 320 - stats[i].Kind = models.ParseVanityStatKind(s) 320 + stats[i].Kind = models.VanityStatKind(s) 321 321 } 322 322 } 323 323
+1 -24
appview/models/profile.go
··· 60 60 VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 61 61 VanityStatRepositoryCount VanityStatKind = "repository-count" 62 62 VanityStatStarCount VanityStatKind = "star-count" 63 - VanityStatNone VanityStatKind = "" 64 63 ) 65 64 66 - func ParseVanityStatKind(s string) VanityStatKind { 67 - switch s { 68 - case "merged-pull-request-count": 69 - return VanityStatMergedPRCount 70 - case "closed-pull-request-count": 71 - return VanityStatClosedPRCount 72 - case "open-pull-request-count": 73 - return VanityStatOpenPRCount 74 - case "open-issue-count": 75 - return VanityStatOpenIssueCount 76 - case "closed-issue-count": 77 - return VanityStatClosedIssueCount 78 - case "repository-count": 79 - return VanityStatRepositoryCount 80 - case "star-count": 81 - return VanityStatStarCount 82 - default: 83 - return VanityStatNone 84 - } 85 - } 86 - 87 65 func (v VanityStatKind) String() string { 88 66 switch v { 89 67 case VanityStatMergedPRCount: ··· 100 78 return "Repositories" 101 79 case VanityStatStarCount: 102 80 return "Stars Received" 103 - default: 104 - return "" 105 81 } 82 + return "" 106 83 } 107 84 108 85 type VanityStat struct {
+26 -16
appview/pages/pages.go
··· 764 764 } 765 765 766 766 type RepoTreeParams struct { 767 - LoggedInUser *oauth.MultiAccountUser 768 - RepoInfo repoinfo.RepoInfo 769 - Active string 770 - BreadCrumbs [][]string 771 - TreePath string 772 - Raw bool 773 - HTMLReadme template.HTML 774 - EmailToDid map[string]string 775 - LastCommitInfo *types.LastCommitInfo 767 + LoggedInUser *oauth.MultiAccountUser 768 + RepoInfo repoinfo.RepoInfo 769 + Active string 770 + BreadCrumbs [][]string 771 + TreePath string 772 + Raw bool 773 + HTMLReadme template.HTML 776 774 types.RepoTreeResponse 777 775 } 778 776 ··· 846 844 return p.executeRepo("repo/tags", w, params) 847 845 } 848 846 847 + type RepoTagParams struct { 848 + LoggedInUser *oauth.MultiAccountUser 849 + RepoInfo repoinfo.RepoInfo 850 + Active string 851 + types.RepoTagResponse 852 + ArtifactMap map[plumbing.Hash][]models.Artifact 853 + DanglingArtifacts []models.Artifact 854 + } 855 + 856 + func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error { 857 + params.Active = "overview" 858 + return p.executeRepo("repo/tag", w, params) 859 + } 860 + 849 861 type RepoArtifactParams struct { 850 862 LoggedInUser *oauth.MultiAccountUser 851 863 RepoInfo repoinfo.RepoInfo ··· 857 869 } 858 870 859 871 type RepoBlobParams struct { 860 - LoggedInUser *oauth.MultiAccountUser 861 - RepoInfo repoinfo.RepoInfo 862 - Active string 863 - BreadCrumbs [][]string 864 - BlobView models.BlobView 865 - EmailToDid map[string]string 866 - LastCommitInfo *types.LastCommitInfo 872 + LoggedInUser *oauth.MultiAccountUser 873 + RepoInfo repoinfo.RepoInfo 874 + Active string 875 + BreadCrumbs [][]string 876 + BlobView models.BlobView 867 877 *tangled.RepoBlob_Output 868 878 } 869 879
-6
appview/pages/templates/repo/blob.html
··· 12 12 13 13 {{ define "repoContent" }} 14 14 {{ $linkstyle := "no-underline hover:underline" }} 15 - 16 15 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 17 16 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 18 17 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> ··· 58 57 </div> 59 58 </div> 60 59 </div> 61 - 62 - {{ if .LastCommitInfo }} 63 - {{ template "repo/fragments/lastCommitPanel" $ }} 64 - {{ end }} 65 - 66 60 {{ if .BlobView.IsUnsupported }} 67 61 <p class="text-center text-gray-400 dark:text-gray-500"> 68 62 Previews are not supported for this file type.
+3 -2
appview/pages/templates/repo/fragments/artifact.html
··· 19 19 {{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }} 20 20 <button 21 21 id="delete-{{ $unique }}" 22 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2" 22 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 23 23 title="Delete artifact" 24 24 hx-delete="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/{{ .Artifact.Name | urlquery }}" 25 25 hx-swap="outerHTML" 26 26 hx-target="#artifact-{{ $unique }}" 27 27 hx-disabled-elt="#delete-{{ $unique }}" 28 28 hx-confirm="Are you sure you want to delete the artifact '{{ .Artifact.Name }}'?"> 29 - {{ i "trash-2" "w-4 h-4" }} 29 + {{ i "trash-2" "size-4 inline group-[.htmx-request]:hidden" }} 30 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 30 31 </button> 31 32 {{ end }} 32 33 </div>
+70
appview/pages/templates/repo/fragments/artifactList.html
··· 1 + {{ define "repo/fragments/artifactList" }} 2 + {{ $root := index . 0 }} 3 + {{ $tag := index . 1 }} 4 + {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 5 + {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 6 + 7 + <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 8 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 9 + {{ range $artifact := $artifacts }} 10 + {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 11 + {{ template "repo/fragments/artifact" $args }} 12 + {{ end }} 13 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 14 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 15 + {{ i "archive" "w-4 h-4" }} 16 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 17 + Source code (.tar.gz) 18 + </a> 19 + </div> 20 + </div> 21 + {{ if $isPushAllowed }} 22 + {{ template "uploadArtifact" (list $root $tag) }} 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + 27 + {{ define "uploadArtifact" }} 28 + {{ $root := index . 0 }} 29 + {{ $tag := index . 1 }} 30 + {{ $unique := $tag.Tag.Target.String }} 31 + <form 32 + id="upload-{{$unique}}" 33 + method="post" 34 + enctype="multipart/form-data" 35 + hx-post="/{{ $root.RepoInfo.FullName }}/tags/{{ $tag.Name | urlquery }}/upload" 36 + hx-on::after-request="if(event.detail.successful) this.reset()" 37 + hx-disabled-elt="#upload-btn-{{$unique}}" 38 + hx-swap="beforebegin" 39 + hx-target="#artifact-git-source" 40 + class="flex items-center gap-2 px-2 group"> 41 + <div class="flex-grow"> 42 + <input type="file" 43 + name="artifact" 44 + required 45 + class="block py-2 px-0 w-full border-none 46 + text-black dark:text-white 47 + bg-white dark:bg-gray-800 48 + file:mr-4 file:px-2 file:py-2 49 + file:rounded file:border-0 50 + file:text-sm file:font-medium 51 + file:text-gray-700 file:dark:text-gray-300 52 + file:bg-gray-200 file:dark:bg-gray-700 53 + file:hover:bg-gray-100 file:hover:dark:bg-gray-600 54 + "> 55 + </input> 56 + </div> 57 + <div class="flex justify-end"> 58 + <button 59 + type="submit" 60 + class="btn-create gap-2" 61 + id="upload-btn-{{$unique}}" 62 + title="Upload artifact"> 63 + {{ i "upload" "size-4 inline group-[.htmx-request]:hidden" }} 64 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 65 + <span class="hidden md:inline">upload</span> 66 + </button> 67 + </div> 68 + </form> 69 + {{ end }} 70 +
-29
appview/pages/templates/repo/fragments/lastCommitPanel.html
··· 1 - {{ define "repo/fragments/lastCommitPanel" }} 2 - {{ $messageParts := splitN .LastCommitInfo.Message "\n\n" 2 }} 3 - <div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between text-sm"> 4 - <div class="flex items-center gap-1"> 5 - {{ if .LastCommitInfo.Author }} 6 - {{ $authorDid := index .EmailToDid .LastCommitInfo.Author.Email }} 7 - <span class="flex items-center gap-1"> 8 - {{ if $authorDid }} 9 - {{ template "user/fragments/picHandleLink" $authorDid }} 10 - {{ else }} 11 - {{ placeholderAvatar "tiny" }} 12 - <a href="mailto:{{ .LastCommitInfo.Author.Email }}" class="no-underline hover:underline">{{ .LastCommitInfo.Author.Name }}</a> 13 - {{ end }} 14 - </span> 15 - <span class="px-1 select-none before:content-['\00B7']"></span> 16 - {{ end }} 17 - <a href="/{{ .RepoInfo.FullName }}/commit/{{ .LastCommitInfo.Hash }}" 18 - class="inline no-underline hover:underline dark:text-white"> 19 - {{ index $messageParts 0 }} 20 - </a> 21 - <span class="px-1 select-none before:content-['\00B7']"></span> 22 - <span class="text-gray-400 dark:text-gray-500">{{ template "repo/fragments/time" .LastCommitInfo.When }}</span> 23 - </div> 24 - <a href="/{{ .RepoInfo.FullName }}/commit/{{ .LastCommitInfo.Hash.String }}" 25 - class="no-underline hover:underline text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded font-mono text-xs"> 26 - {{ slice .LastCommitInfo.Hash.String 0 8 }} 27 - </a> 28 - </div> 29 - {{ end }}
+67
appview/pages/templates/repo/fragments/singleTag.html
··· 1 + {{ define "repo/fragments/singleTag" }} 2 + {{ $root := index . 0 }} 3 + {{ $item := index . 1 }} 4 + {{ with $item }} 5 + <div class="md:grid md:grid-cols-12 md:items-start flex flex-col"> 6 + <!-- Header column (top on mobile, left on md+) --> 7 + <div class="md:col-span-2 md:border-r border-b md:border-b-0 border-gray-200 dark:border-gray-700 w-full md:h-full"> 8 + <!-- Mobile layout: horizontal --> 9 + <div class="flex md:hidden flex-col py-2 px-2 text-xl"> 10 + <a href="/{{ $root.RepoInfo.FullName }}/tags/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 11 + {{ i "tag" "w-4 h-4" }} 12 + {{ .Name }} 13 + </a> 14 + 15 + <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 16 + {{ if .Tag }} 17 + <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 18 + class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 19 + {{ slice .Tag.Target.String 0 8 }} 20 + </a> 21 + 22 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 23 + <span>{{ .Tag.Tagger.Name }}</span> 24 + 25 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 26 + {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 27 + {{ end }} 28 + </div> 29 + </div> 30 + 31 + <!-- Desktop layout: vertical and left-aligned --> 32 + <div class="hidden md:block text-left px-2 pb-6"> 33 + <a href="/{{ $root.RepoInfo.FullName }}/tags/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 34 + {{ i "tag" "w-4 h-4" }} 35 + {{ .Name }} 36 + </a> 37 + <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 38 + {{ if .Tag }} 39 + <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 40 + class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 41 + {{ i "git-commit-horizontal" "w-4 h-4" }} 42 + {{ slice .Tag.Target.String 0 8 }} 43 + </a> 44 + <span>{{ .Tag.Tagger.Name }}</span> 45 + {{ template "repo/fragments/time" .Tag.Tagger.When }} 46 + {{ end }} 47 + </div> 48 + </div> 49 + </div> 50 + 51 + <!-- Content column (bottom on mobile, right on md+) --> 52 + <div class="md:col-span-10 px-2 py-3 md:py-0 md:pb-6"> 53 + {{ if .Tag }} 54 + {{ $messageParts := splitN .Tag.Message "\n\n" 2 }} 55 + <p class="font-bold text-lg">{{ index $messageParts 0 }}</p> 56 + {{ if gt (len $messageParts) 1 }} 57 + <p class="cursor-text py-2">{{ nl2br (index $messageParts 1) }}</p> 58 + {{ end }} 59 + {{ template "repo/fragments/artifactList" (list $root .) }} 60 + {{ else }} 61 + <p class="italic text-gray-500 dark:text-gray-400">no message</p> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + {{ end }} 67 +
+1 -1
appview/pages/templates/repo/index.html
··· 334 334 {{ with $tag }} 335 335 <div> 336 336 <div class="text-base flex items-center gap-2"> 337 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 337 + <a href="/{{ $.RepoInfo.FullName }}/tags/{{ .Reference.Name | urlquery }}" 338 338 class="inline no-underline hover:underline dark:text-white"> 339 339 {{ .Reference.Name }} 340 340 </a>
+16
appview/pages/templates/repo/tag.html
··· 1 + {{ define "title" }} 2 + tags ยท {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "extrameta" }} 6 + {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.org/%s/tag/%s" .RepoInfo.FullName .Tag.Name }} 8 + 9 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 + {{ end }} 11 + 12 + {{ define "repoContent" }} 13 + <section class="flex flex-col py-2 gap-12 md:gap-0"> 14 + {{ template "repo/fragments/singleTag" (list $ .Tag ) }} 15 + </section> 16 + {{ end }}
+1 -129
appview/pages/templates/repo/tags.html
··· 14 14 <h2 class="mb-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">tags</h2> 15 15 <div class="flex flex-col py-2 gap-12 md:gap-0"> 16 16 {{ range .Tags }} 17 - <div class="md:grid md:grid-cols-12 md:items-start flex flex-col"> 18 - <!-- Header column (top on mobile, left on md+) --> 19 - <div class="md:col-span-2 md:border-r border-b md:border-b-0 border-gray-200 dark:border-gray-700 w-full md:h-full"> 20 - <!-- Mobile layout: horizontal --> 21 - <div class="flex md:hidden flex-col py-2 px-2 text-xl"> 22 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 23 - {{ i "tag" "w-4 h-4" }} 24 - {{ .Name }} 25 - </a> 26 - 27 - <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 - {{ if .Tag }} 29 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 - class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 - {{ slice .Tag.Target.String 0 8 }} 32 - </a> 33 - 34 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 35 - <span>{{ .Tag.Tagger.Name }}</span> 36 - 37 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 38 - {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 39 - {{ end }} 40 - </div> 41 - </div> 42 - 43 - <!-- Desktop layout: vertical and left-aligned --> 44 - <div class="hidden md:block text-left px-2 pb-6"> 45 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 46 - {{ i "tag" "w-4 h-4" }} 47 - {{ .Name }} 48 - </a> 49 - <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 - {{ if .Tag }} 51 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 - class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 - {{ i "git-commit-horizontal" "w-4 h-4" }} 54 - {{ slice .Tag.Target.String 0 8 }} 55 - </a> 56 - <span>{{ .Tag.Tagger.Name }}</span> 57 - {{ template "repo/fragments/time" .Tag.Tagger.When }} 58 - {{ end }} 59 - </div> 60 - </div> 61 - </div> 62 - 63 - <!-- Content column (bottom on mobile, right on md+) --> 64 - <div class="md:col-span-10 px-2 py-3 md:py-0 md:pb-6"> 65 - {{ if .Tag }} 66 - {{ $messageParts := splitN .Tag.Message "\n\n" 2 }} 67 - <p class="font-bold text-lg">{{ index $messageParts 0 }}</p> 68 - {{ if gt (len $messageParts) 1 }} 69 - <p class="cursor-text py-2">{{ nl2br (index $messageParts 1) }}</p> 70 - {{ end }} 71 - {{ block "artifacts" (list $ .) }} {{ end }} 72 - {{ else }} 73 - <p class="italic text-gray-500 dark:text-gray-400">no message</p> 74 - {{ end }} 75 - </div> 76 - </div> 17 + {{ template "repo/fragments/singleTag" (list $ . ) }} 77 18 {{ else }} 78 19 <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 79 20 This repository does not contain any tags. ··· 89 30 {{ block "dangling" . }} {{ end }} 90 31 </section> 91 32 {{ end }} 92 - {{ end }} 93 - 94 - {{ define "artifacts" }} 95 - {{ $root := index . 0 }} 96 - {{ $tag := index . 1 }} 97 - {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 - {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 - 100 - <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 101 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 102 - {{ range $artifact := $artifacts }} 103 - {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 104 - {{ template "repo/fragments/artifact" $args }} 105 - {{ end }} 106 - <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 - <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 - {{ i "archive" "w-4 h-4" }} 109 - <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 - Source code (.tar.gz) 111 - </a> 112 - </div> 113 - </div> 114 - {{ if $isPushAllowed }} 115 - {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 116 - {{ end }} 117 - </div> 118 - {{ end }} 119 - 120 - {{ define "uploadArtifact" }} 121 - {{ $root := index . 0 }} 122 - {{ $tag := index . 1 }} 123 - {{ $unique := $tag.Tag.Target.String }} 124 - <form 125 - id="upload-{{$unique}}" 126 - method="post" 127 - enctype="multipart/form-data" 128 - hx-post="/{{ $root.RepoInfo.FullName }}/tags/{{ $tag.Name | urlquery }}/upload" 129 - hx-on::after-request="if(event.detail.successful) this.reset()" 130 - hx-disabled-elt="#upload-btn-{{$unique}}" 131 - hx-swap="beforebegin" 132 - hx-target="this" 133 - class="flex items-center gap-2 px-2"> 134 - <div class="flex-grow"> 135 - <input type="file" 136 - name="artifact" 137 - required 138 - class="block py-2 px-0 w-full border-none 139 - text-black dark:text-white 140 - bg-white dark:bg-gray-800 141 - file:mr-4 file:px-2 file:py-2 142 - file:rounded file:border-0 143 - file:text-sm file:font-medium 144 - file:text-gray-700 file:dark:text-gray-300 145 - file:bg-gray-200 file:dark:bg-gray-700 146 - file:hover:bg-gray-100 file:hover:dark:bg-gray-600 147 - "> 148 - </input> 149 - </div> 150 - <div class="flex justify-end"> 151 - <button 152 - type="submit" 153 - class="btn gap-2" 154 - id="upload-btn-{{$unique}}" 155 - title="Upload artifact"> 156 - {{ i "upload" "w-4 h-4" }} 157 - <span class="hidden md:inline">upload</span> 158 - </button> 159 - </div> 160 - </form> 161 33 {{ end }} 162 34 163 35 {{ define "dangling" }}
-4
appview/pages/templates/repo/tree.html
··· 52 52 </div> 53 53 </div> 54 54 55 - {{ if .LastCommitInfo }} 56 - {{ template "repo/fragments/lastCommitPanel" $ }} 57 - {{ end }} 58 - 59 55 {{ range .Files }} 60 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 61 57 <div class="col-span-8 md:col-span-4">
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 110 110 {{ $id := index . 0 }} 111 111 {{ $stat := index . 1 }} 112 112 <select class="stat-group w-full p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700 text-sm" id="stat{{$id}}" name="stat{{$id}}"> 113 - <option value="">Choose Stat</option> 113 + <option value="">choose stat</option> 114 114 {{ $stats := assoc 115 115 "merged-pull-request-count" "Merged PR Count" 116 116 "closed-pull-request-count" "Closed PR Count"
-32
appview/repo/blob.go
··· 9 9 "path/filepath" 10 10 "slices" 11 11 "strings" 12 - "time" 13 12 14 13 "tangled.org/core/api/tangled" 15 14 "tangled.org/core/appview/config" 16 - "tangled.org/core/appview/db" 17 15 "tangled.org/core/appview/models" 18 16 "tangled.org/core/appview/pages" 19 17 "tangled.org/core/appview/pages/markup" 20 18 "tangled.org/core/appview/reporesolver" 21 19 xrpcclient "tangled.org/core/appview/xrpcclient" 22 - "tangled.org/core/types" 23 20 24 21 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 22 "github.com/go-chi/chi/v5" 26 - "github.com/go-git/go-git/v5/plumbing" 27 23 ) 28 24 29 25 // the content can be one of the following: ··· 82 78 83 79 user := rp.oauth.GetMultiAccountUser(r) 84 80 85 - // Get email to DID mapping for commit author 86 - var emails []string 87 - if resp.LastCommit != nil && resp.LastCommit.Author != nil { 88 - emails = append(emails, resp.LastCommit.Author.Email) 89 - } 90 - emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 91 - if err != nil { 92 - l.Error("failed to get email to did mapping", "err", err) 93 - emailToDidMap = make(map[string]string) 94 - } 95 - 96 - var lastCommitInfo *types.LastCommitInfo 97 - if resp.LastCommit != nil { 98 - when, _ := time.Parse(time.RFC3339, resp.LastCommit.When) 99 - lastCommitInfo = &types.LastCommitInfo{ 100 - Hash: plumbing.NewHash(resp.LastCommit.Hash), 101 - Message: resp.LastCommit.Message, 102 - When: when, 103 - } 104 - if resp.LastCommit.Author != nil { 105 - lastCommitInfo.Author.Name = resp.LastCommit.Author.Name 106 - lastCommitInfo.Author.Email = resp.LastCommit.Author.Email 107 - lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When) 108 - } 109 - } 110 - 111 81 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 112 82 LoggedInUser: user, 113 83 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 114 84 BreadCrumbs: breadcrumbs, 115 85 BlobView: blobView, 116 - EmailToDid: emailToDidMap, 117 - LastCommitInfo: lastCommitInfo, 118 86 RepoBlob_Output: resp, 119 87 }) 120 88 }
+1
appview/repo/router.go
··· 23 23 r.Route("/tags", func(r chi.Router) { 24 24 r.Get("/", rp.Tags) 25 25 r.Route("/{tag}", func(r chi.Router) { 26 + r.Get("/", rp.Tag) 26 27 r.Get("/download/{file}", rp.DownloadArtifact) 27 28 28 29 // require repo:push to upload or delete artifacts
+58
appview/repo/tags.go
··· 14 14 "tangled.org/core/types" 15 15 16 16 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-chi/chi/v5" 17 18 "github.com/go-git/go-git/v5/plumbing" 18 19 ) 19 20 ··· 70 71 } 71 72 } 72 73 user := rp.oauth.GetMultiAccountUser(r) 74 + 73 75 rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 76 LoggedInUser: user, 75 77 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 78 80 DanglingArtifacts: danglingArtifacts, 79 81 }) 80 82 } 83 + 84 + func (rp *Repo) Tag(w http.ResponseWriter, r *http.Request) { 85 + l := rp.logger.With("handler", "RepoTag") 86 + f, err := rp.repoResolver.Resolve(r) 87 + if err != nil { 88 + l.Error("failed to get repo and knot", "err", err) 89 + return 90 + } 91 + scheme := "http" 92 + if !rp.config.Core.Dev { 93 + scheme = "https" 94 + } 95 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 96 + xrpcc := &indigoxrpc.Client{ 97 + Host: host, 98 + } 99 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 100 + tag := chi.URLParam(r, "tag") 101 + 102 + xrpcBytes, err := tangled.RepoTag(r.Context(), xrpcc, repo, tag) 103 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 104 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 105 + rp.pages.Error503(w) 106 + return 107 + } 108 + var result types.RepoTagResponse 109 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 110 + l.Error("failed to decode XRPC response", "err", err) 111 + rp.pages.Error503(w) 112 + return 113 + } 114 + 115 + filters := []orm.Filter{orm.FilterEq("repo_at", f.RepoAt())} 116 + if result.Tag.Tag != nil { 117 + filters = append(filters, orm.FilterEq("tag", result.Tag.Tag.Hash[:])) 118 + } 119 + 120 + artifacts, err := db.GetArtifact(rp.db, filters...) 121 + if err != nil { 122 + l.Error("failed grab artifacts", "err", err) 123 + return 124 + } 125 + // convert artifacts to map for easy UI building 126 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 127 + for _, a := range artifacts { 128 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 129 + } 130 + 131 + user := rp.oauth.GetMultiAccountUser(r) 132 + rp.pages.RepoTag(w, pages.RepoTagParams{ 133 + LoggedInUser: user, 134 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 135 + RepoTagResponse: result, 136 + ArtifactMap: artifactMap, 137 + }) 138 + }
-29
appview/repo/tree.go
··· 8 8 "time" 9 9 10 10 "tangled.org/core/api/tangled" 11 - "tangled.org/core/appview/db" 12 11 "tangled.org/core/appview/pages" 13 12 "tangled.org/core/appview/reporesolver" 14 13 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 99 98 } 100 99 sortFiles(result.Files) 101 100 102 - // Get email to DID mapping for commit author 103 - var emails []string 104 - if xrpcResp.LastCommit != nil && xrpcResp.LastCommit.Author != nil { 105 - emails = append(emails, xrpcResp.LastCommit.Author.Email) 106 - } 107 - emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 108 - if err != nil { 109 - l.Error("failed to get email to did mapping", "err", err) 110 - emailToDidMap = make(map[string]string) 111 - } 112 - 113 - var lastCommitInfo *types.LastCommitInfo 114 - if xrpcResp.LastCommit != nil { 115 - when, _ := time.Parse(time.RFC3339, xrpcResp.LastCommit.When) 116 - lastCommitInfo = &types.LastCommitInfo{ 117 - Hash: plumbing.NewHash(xrpcResp.LastCommit.Hash), 118 - Message: xrpcResp.LastCommit.Message, 119 - When: when, 120 - } 121 - if xrpcResp.LastCommit.Author != nil { 122 - lastCommitInfo.Author.Name = xrpcResp.LastCommit.Author.Name 123 - lastCommitInfo.Author.Email = xrpcResp.LastCommit.Author.Email 124 - lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, xrpcResp.LastCommit.Author.When) 125 - } 126 - } 127 - 128 101 rp.pages.RepoTree(w, pages.RepoTreeParams{ 129 102 LoggedInUser: user, 130 103 BreadCrumbs: breadcrumbs, 131 104 TreePath: treePath, 132 105 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 133 - EmailToDid: emailToDidMap, 134 - LastCommitInfo: lastCommitInfo, 135 106 RepoTreeResponse: result, 136 107 }) 137 108 }
+7 -2
appview/state/profile.go
··· 550 550 stat0 := r.FormValue("stat0") 551 551 stat1 := r.FormValue("stat1") 552 552 553 - profile.Stats[0].Kind = models.ParseVanityStatKind(stat0) 554 - profile.Stats[1].Kind = models.ParseVanityStatKind(stat1) 553 + if stat0 != "" { 554 + profile.Stats[0].Kind = models.VanityStatKind(stat0) 555 + } 556 + 557 + if stat1 != "" { 558 + profile.Stats[1].Kind = models.VanityStatKind(stat1) 559 + } 555 560 556 561 if err := db.ValidateProfile(s.db, profile); err != nil { 557 562 log.Println("invalid profile", err)
-9
input.css
··· 175 175 @apply opacity-70; 176 176 } 177 177 178 - .prose h1:target, 179 - .prose h2:target, 180 - .prose h3:target, 181 - .prose h4:target, 182 - .prose h5:target, 183 - .prose h6:target { 184 - @apply bg-yellow-200/30 dark:bg-yellow-600/30; 185 - } 186 - 187 178 .prose a.footnote-backref { 188 179 @apply no-underline; 189 180 }
+46 -3
knotserver/git/branch.go
··· 12 12 "tangled.org/core/types" 13 13 ) 14 14 15 - func (g *GitRepo) Branches() ([]types.Branch, error) { 15 + type BranchesOptions struct { 16 + Limit int 17 + Offset int 18 + } 19 + 20 + func (g *GitRepo) Branches(opts *BranchesOptions) ([]types.Branch, error) { 21 + if opts == nil { 22 + opts = &BranchesOptions{} 23 + } 24 + 16 25 fields := []string{ 17 26 "refname:short", 18 27 "objectname", ··· 33 42 if i != 0 { 34 43 outFormat.WriteString(fieldSeparator) 35 44 } 36 - outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 45 + fmt.Fprintf(&outFormat, "%%(%s)", f) 37 46 } 38 47 outFormat.WriteString("") 39 48 outFormat.WriteString(recordSeparator) 40 49 41 - output, err := g.forEachRef(outFormat.String(), "refs/heads") 50 + args := []string{outFormat.String(), "--sort=-creatordate"} 51 + 52 + // only add the count if the limit is a non-zero value, 53 + // if it is zero, get as many tags as we can 54 + if opts.Limit > 0 { 55 + args = append(args, fmt.Sprintf("--count=%d", opts.Offset+opts.Limit)) 56 + } 57 + 58 + args = append(args, "refs/heads") 59 + 60 + output, err := g.forEachRef(args...) 42 61 if err != nil { 43 62 return nil, fmt.Errorf("failed to get branches: %w", err) 44 63 } ··· 48 67 return nil, nil 49 68 } 50 69 70 + startIdx := opts.Offset 71 + if startIdx >= len(records) { 72 + return nil, nil 73 + } 74 + 75 + endIdx := len(records) 76 + if opts.Limit > 0 { 77 + endIdx = min(startIdx+opts.Limit, len(records)) 78 + } 79 + 80 + records = records[startIdx:endIdx] 51 81 branches := make([]types.Branch, 0, len(records)) 52 82 53 83 // ignore errors here ··· 109 139 110 140 slices.Reverse(branches) 111 141 return branches, nil 142 + } 143 + 144 + func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 145 + ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 146 + if err != nil { 147 + return nil, fmt.Errorf("branch: %w", err) 148 + } 149 + 150 + if !ref.Name().IsBranch() { 151 + return nil, fmt.Errorf("branch: %s is not a branch", ref.Name()) 152 + } 153 + 154 + return ref, nil 112 155 } 113 156 114 157 func (g *GitRepo) DeleteBranch(branch string) error {
+355
knotserver/git/branch_test.go
··· 1 + package git 2 + 3 + import ( 4 + "path/filepath" 5 + "slices" 6 + "testing" 7 + 8 + gogit "github.com/go-git/go-git/v5" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/stretchr/testify/assert" 11 + "github.com/stretchr/testify/require" 12 + "github.com/stretchr/testify/suite" 13 + 14 + "tangled.org/core/sets" 15 + ) 16 + 17 + type BranchSuite struct { 18 + suite.Suite 19 + *RepoSuite 20 + } 21 + 22 + func TestBranchSuite(t *testing.T) { 23 + t.Parallel() 24 + suite.Run(t, new(BranchSuite)) 25 + } 26 + 27 + func (s *BranchSuite) SetupTest() { 28 + s.RepoSuite = NewRepoSuite(s.T()) 29 + } 30 + 31 + func (s *BranchSuite) TearDownTest() { 32 + s.RepoSuite.cleanup() 33 + } 34 + 35 + func (s *BranchSuite) setupRepoWithBranches() { 36 + s.init() 37 + 38 + // get the initial commit on master 39 + head, err := s.repo.r.Head() 40 + require.NoError(s.T(), err) 41 + initialCommit := head.Hash() 42 + 43 + // create multiple branches with commits 44 + // branch-1 45 + s.createBranch("branch-1", initialCommit) 46 + s.checkoutBranch("branch-1") 47 + _ = s.commitFile("file1.txt", "content 1", "Add file1 on branch-1") 48 + 49 + // branch-2 50 + s.createBranch("branch-2", initialCommit) 51 + s.checkoutBranch("branch-2") 52 + _ = s.commitFile("file2.txt", "content 2", "Add file2 on branch-2") 53 + 54 + // branch-3 55 + s.createBranch("branch-3", initialCommit) 56 + s.checkoutBranch("branch-3") 57 + _ = s.commitFile("file3.txt", "content 3", "Add file3 on branch-3") 58 + 59 + // branch-4 60 + s.createBranch("branch-4", initialCommit) 61 + s.checkoutBranch("branch-4") 62 + s.commitFile("file4.txt", "content 4", "Add file4 on branch-4") 63 + 64 + // back to master and make a commit 65 + s.checkoutBranch("master") 66 + s.commitFile("master-file.txt", "master content", "Add file on master") 67 + 68 + // verify we have multiple branches 69 + refs, err := s.repo.r.References() 70 + require.NoError(s.T(), err) 71 + 72 + branchCount := 0 73 + err = refs.ForEach(func(ref *plumbing.Reference) error { 74 + if ref.Name().IsBranch() { 75 + branchCount++ 76 + } 77 + return nil 78 + }) 79 + require.NoError(s.T(), err) 80 + 81 + // we should have 5 branches: master, branch-1, branch-2, branch-3, branch-4 82 + assert.Equal(s.T(), 5, branchCount, "expected 5 branches") 83 + } 84 + 85 + func (s *BranchSuite) TestBranches_All() { 86 + s.setupRepoWithBranches() 87 + 88 + branches, err := s.repo.Branches(&BranchesOptions{}) 89 + require.NoError(s.T(), err) 90 + 91 + assert.Len(s.T(), branches, 5, "expected 5 branches") 92 + 93 + expectedBranches := sets.Collect(slices.Values([]string{ 94 + "master", 95 + "branch-1", 96 + "branch-2", 97 + "branch-3", 98 + "branch-4", 99 + })) 100 + 101 + for _, branch := range branches { 102 + assert.True(s.T(), expectedBranches.Contains(branch.Reference.Name), 103 + "unexpected branch: %s", branch.Reference.Name) 104 + assert.NotEmpty(s.T(), branch.Reference.Hash, "branch hash should not be empty") 105 + assert.NotNil(s.T(), branch.Commit, "branch commit should not be nil") 106 + } 107 + } 108 + 109 + func (s *BranchSuite) TestBranches_WithLimit() { 110 + s.setupRepoWithBranches() 111 + 112 + tests := []struct { 113 + name string 114 + limit int 115 + expectedCount int 116 + }{ 117 + { 118 + name: "limit 1", 119 + limit: 1, 120 + expectedCount: 1, 121 + }, 122 + { 123 + name: "limit 2", 124 + limit: 2, 125 + expectedCount: 2, 126 + }, 127 + { 128 + name: "limit 3", 129 + limit: 3, 130 + expectedCount: 3, 131 + }, 132 + { 133 + name: "limit 10 (more than available)", 134 + limit: 10, 135 + expectedCount: 5, 136 + }, 137 + } 138 + 139 + for _, tt := range tests { 140 + s.Run(tt.name, func() { 141 + branches, err := s.repo.Branches(&BranchesOptions{ 142 + Limit: tt.limit, 143 + }) 144 + require.NoError(s.T(), err) 145 + assert.Len(s.T(), branches, tt.expectedCount, "expected %d branches", tt.expectedCount) 146 + }) 147 + } 148 + } 149 + 150 + func (s *BranchSuite) TestBranches_WithOffset() { 151 + s.setupRepoWithBranches() 152 + 153 + tests := []struct { 154 + name string 155 + offset int 156 + expectedCount int 157 + }{ 158 + { 159 + name: "offset 0", 160 + offset: 0, 161 + expectedCount: 5, 162 + }, 163 + { 164 + name: "offset 1", 165 + offset: 1, 166 + expectedCount: 4, 167 + }, 168 + { 169 + name: "offset 2", 170 + offset: 2, 171 + expectedCount: 3, 172 + }, 173 + { 174 + name: "offset 4", 175 + offset: 4, 176 + expectedCount: 1, 177 + }, 178 + { 179 + name: "offset 5 (all skipped)", 180 + offset: 5, 181 + expectedCount: 0, 182 + }, 183 + { 184 + name: "offset 10 (more than available)", 185 + offset: 10, 186 + expectedCount: 0, 187 + }, 188 + } 189 + 190 + for _, tt := range tests { 191 + s.Run(tt.name, func() { 192 + branches, err := s.repo.Branches(&BranchesOptions{ 193 + Offset: tt.offset, 194 + }) 195 + require.NoError(s.T(), err) 196 + assert.Len(s.T(), branches, tt.expectedCount, "expected %d branches", tt.expectedCount) 197 + }) 198 + } 199 + } 200 + 201 + func (s *BranchSuite) TestBranches_WithLimitAndOffset() { 202 + s.setupRepoWithBranches() 203 + 204 + tests := []struct { 205 + name string 206 + limit int 207 + offset int 208 + expectedCount int 209 + }{ 210 + { 211 + name: "limit 2, offset 0", 212 + limit: 2, 213 + offset: 0, 214 + expectedCount: 2, 215 + }, 216 + { 217 + name: "limit 2, offset 1", 218 + limit: 2, 219 + offset: 1, 220 + expectedCount: 2, 221 + }, 222 + { 223 + name: "limit 2, offset 3", 224 + limit: 2, 225 + offset: 3, 226 + expectedCount: 2, 227 + }, 228 + { 229 + name: "limit 2, offset 4", 230 + limit: 2, 231 + offset: 4, 232 + expectedCount: 1, 233 + }, 234 + { 235 + name: "limit 3, offset 2", 236 + limit: 3, 237 + offset: 2, 238 + expectedCount: 3, 239 + }, 240 + { 241 + name: "limit 10, offset 3", 242 + limit: 10, 243 + offset: 3, 244 + expectedCount: 2, 245 + }, 246 + } 247 + 248 + for _, tt := range tests { 249 + s.Run(tt.name, func() { 250 + branches, err := s.repo.Branches(&BranchesOptions{ 251 + Limit: tt.limit, 252 + Offset: tt.offset, 253 + }) 254 + require.NoError(s.T(), err) 255 + assert.Len(s.T(), branches, tt.expectedCount, "expected %d branches", tt.expectedCount) 256 + }) 257 + } 258 + } 259 + 260 + func (s *BranchSuite) TestBranches_EmptyRepo() { 261 + repoPath := filepath.Join(s.tempDir, "empty-repo") 262 + 263 + _, err := gogit.PlainInit(repoPath, false) 264 + require.NoError(s.T(), err) 265 + 266 + gitRepo, err := PlainOpen(repoPath) 267 + require.NoError(s.T(), err) 268 + 269 + branches, err := gitRepo.Branches(&BranchesOptions{}) 270 + require.NoError(s.T(), err) 271 + 272 + if branches != nil { 273 + assert.Empty(s.T(), branches, "expected no branches in empty repo") 274 + } 275 + } 276 + 277 + func (s *BranchSuite) TestBranches_Pagination() { 278 + s.setupRepoWithBranches() 279 + 280 + allBranches, err := s.repo.Branches(&BranchesOptions{}) 281 + require.NoError(s.T(), err) 282 + assert.Len(s.T(), allBranches, 5, "expected 5 branches") 283 + 284 + pageSize := 2 285 + var paginatedBranches []string 286 + 287 + for offset := 0; offset < len(allBranches); offset += pageSize { 288 + branches, err := s.repo.Branches(&BranchesOptions{ 289 + Limit: pageSize, 290 + Offset: offset, 291 + }) 292 + require.NoError(s.T(), err) 293 + for _, branch := range branches { 294 + paginatedBranches = append(paginatedBranches, branch.Reference.Name) 295 + } 296 + } 297 + 298 + assert.Len(s.T(), paginatedBranches, len(allBranches), "pagination should return all branches") 299 + 300 + // create sets to verify all branches are present 301 + allBranchNames := sets.New[string]() 302 + for _, branch := range allBranches { 303 + allBranchNames.Insert(branch.Reference.Name) 304 + } 305 + 306 + paginatedBranchNames := sets.New[string]() 307 + for _, name := range paginatedBranches { 308 + paginatedBranchNames.Insert(name) 309 + } 310 + 311 + assert.EqualValues(s.T(), allBranchNames, paginatedBranchNames, 312 + "pagination should return the same set of branches") 313 + } 314 + 315 + func (s *BranchSuite) TestBranches_VerifyBranchFields() { 316 + s.setupRepoWithBranches() 317 + 318 + branches, err := s.repo.Branches(&BranchesOptions{}) 319 + require.NoError(s.T(), err) 320 + 321 + found := false 322 + for i := range branches { 323 + if branches[i].Reference.Name == "master" { 324 + found = true 325 + assert.Equal(s.T(), "master", branches[i].Reference.Name) 326 + assert.NotEmpty(s.T(), branches[i].Reference.Hash) 327 + assert.NotNil(s.T(), branches[i].Commit) 328 + assert.NotEmpty(s.T(), branches[i].Commit.Author.Name) 329 + assert.NotEmpty(s.T(), branches[i].Commit.Author.Email) 330 + assert.False(s.T(), branches[i].Commit.Hash.IsZero()) 331 + break 332 + } 333 + } 334 + 335 + assert.True(s.T(), found, "master branch not found") 336 + } 337 + 338 + func (s *BranchSuite) TestBranches_NilOptions() { 339 + s.setupRepoWithBranches() 340 + 341 + branches, err := s.repo.Branches(nil) 342 + require.NoError(s.T(), err) 343 + assert.Len(s.T(), branches, 5, "nil options should return all branches") 344 + } 345 + 346 + func (s *BranchSuite) TestBranches_ZeroLimitAndOffset() { 347 + s.setupRepoWithBranches() 348 + 349 + branches, err := s.repo.Branches(&BranchesOptions{ 350 + Limit: 0, 351 + Offset: 0, 352 + }) 353 + require.NoError(s.T(), err) 354 + assert.Len(s.T(), branches, 5, "zero limit should return all branches") 355 + }
+1 -14
knotserver/git/git.go
··· 122 122 func (g *GitRepo) TotalCommits() (int, error) { 123 123 output, err := g.revList( 124 124 g.h.String(), 125 - fmt.Sprintf("--count"), 125 + "--count", 126 126 ) 127 127 if err != nil { 128 128 return 0, fmt.Errorf("failed to run rev-list: %w", err) ··· 250 250 251 251 // path is not a submodule 252 252 return nil, ErrNotSubmodule 253 - } 254 - 255 - func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 256 - ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 257 - if err != nil { 258 - return nil, fmt.Errorf("branch: %w", err) 259 - } 260 - 261 - if !ref.Name().IsBranch() { 262 - return nil, fmt.Errorf("branch: %s is not a branch", ref.Name()) 263 - } 264 - 265 - return ref, nil 266 253 } 267 254 268 255 func (g *GitRepo) SetDefaultBranch(branch string) error {
+31 -94
knotserver/git/last_commit.go
··· 6 6 "crypto/sha256" 7 7 "fmt" 8 8 "io" 9 - "iter" 10 9 "os/exec" 11 10 "path" 12 - "strconv" 13 11 "strings" 14 12 "time" 15 13 16 14 "github.com/dgraph-io/ristretto" 17 15 "github.com/go-git/go-git/v5/plumbing" 18 - "tangled.org/core/sets" 19 - "tangled.org/core/types" 16 + "github.com/go-git/go-git/v5/plumbing/object" 20 17 ) 21 18 22 19 var ( ··· 75 72 type commit struct { 76 73 hash plumbing.Hash 77 74 when time.Time 78 - files sets.Set[string] 75 + files []string 79 76 message string 80 77 } 81 78 82 - func newCommit() commit { 83 - return commit{ 84 - files: sets.New[string](), 85 - } 86 - } 87 - 88 - type lastCommitDir struct { 89 - dir string 90 - entries []string 91 - } 92 - 93 - func (l lastCommitDir) children() iter.Seq[string] { 94 - return func(yield func(string) bool) { 95 - for _, child := range l.entries { 96 - if !yield(path.Join(l.dir, child)) { 97 - return 98 - } 99 - } 100 - } 101 - } 102 - 103 79 func cacheKey(g *GitRepo, path string) string { 104 80 sep := byte(':') 105 81 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, path)) 106 82 return fmt.Sprintf("%x", hash) 107 83 } 108 84 109 - func (g *GitRepo) lastCommitDirIn(ctx context.Context, parent lastCommitDir, timeout time.Duration) (map[string]commit, error) { 85 + func (g *GitRepo) calculateCommitTimeIn(ctx context.Context, subtree *object.Tree, parent string, timeout time.Duration) (map[string]commit, error) { 110 86 ctx, cancel := context.WithTimeout(ctx, timeout) 111 87 defer cancel() 112 - return g.lastCommitDir(ctx, parent) 88 + return g.calculateCommitTime(ctx, subtree, parent) 113 89 } 114 90 115 - func (g *GitRepo) lastCommitDir(ctx context.Context, parent lastCommitDir) (map[string]commit, error) { 116 - filesToDo := sets.Collect(parent.children()) 91 + func (g *GitRepo) calculateCommitTime(ctx context.Context, subtree *object.Tree, parent string) (map[string]commit, error) { 92 + filesToDo := make(map[string]struct{}) 117 93 filesDone := make(map[string]commit) 94 + for _, e := range subtree.Entries { 95 + fpath := path.Clean(path.Join(parent, e.Name)) 96 + filesToDo[fpath] = struct{}{} 97 + } 118 98 119 - for p := range filesToDo.All() { 120 - cacheKey := cacheKey(g, p) 99 + for _, e := range subtree.Entries { 100 + f := path.Clean(path.Join(parent, e.Name)) 101 + cacheKey := cacheKey(g, f) 121 102 if cached, ok := commitCache.Get(cacheKey); ok { 122 - filesDone[p] = cached.(commit) 123 - filesToDo.Remove(p) 103 + filesDone[f] = cached.(commit) 104 + delete(filesToDo, f) 124 105 } else { 125 - filesToDo.Insert(p) 106 + filesToDo[f] = struct{}{} 126 107 } 127 108 } 128 109 129 - if filesToDo.IsEmpty() { 110 + if len(filesToDo) == 0 { 130 111 return filesDone, nil 131 112 } 132 113 ··· 134 115 defer cancel() 135 116 136 117 pathSpec := "." 137 - if parent.dir != "" { 138 - pathSpec = parent.dir 118 + if parent != "" { 119 + pathSpec = parent 139 120 } 140 - if filesToDo.Len() == 1 { 141 - // this is an optimization for the scenario where we want to calculate 142 - // the last commit for just one path, we can directly set the pathspec to that path 143 - for s := range filesToDo.All() { 144 - pathSpec = s 145 - } 146 - } 147 - 148 - output, err := g.streamingGitLog(ctx, "--pretty=format:%H,%ad,%s", "--date=unix", "--name-only", "--", pathSpec) 121 + output, err := g.streamingGitLog(ctx, "--pretty=format:%H,%ad,%s", "--date=iso", "--name-only", "--", pathSpec) 149 122 if err != nil { 150 123 return nil, err 151 124 } 152 125 defer output.Close() // Ensure the git process is properly cleaned up 153 126 154 127 reader := bufio.NewReader(output) 155 - current := newCommit() 128 + var current commit 156 129 for { 157 130 line, err := reader.ReadString('\n') 158 131 if err != nil && err != io.EOF { ··· 163 136 if line == "" { 164 137 if !current.hash.IsZero() { 165 138 // we have a fully parsed commit 166 - for f := range current.files.All() { 167 - if filesToDo.Contains(f) { 139 + for _, f := range current.files { 140 + if _, ok := filesToDo[f]; ok { 168 141 filesDone[f] = current 169 - filesToDo.Remove(f) 142 + delete(filesToDo, f) 170 143 commitCache.Set(cacheKey(g, f), current, 0) 171 144 } 172 145 } 173 146 174 - if filesToDo.IsEmpty() { 147 + if len(filesToDo) == 0 { 148 + cancel() 175 149 break 176 150 } 177 - current = newCommit() 151 + current = commit{} 178 152 } 179 153 } else if current.hash.IsZero() { 180 154 parts := strings.SplitN(line, ",", 3) 181 155 if len(parts) == 3 { 182 156 current.hash = plumbing.NewHash(parts[0]) 183 - epochTime, _ := strconv.ParseInt(parts[1], 10, 64) 184 - current.when = time.Unix(epochTime, 0) 157 + current.when, _ = time.Parse("2006-01-02 15:04:05 -0700", parts[1]) 185 158 current.message = parts[2] 186 159 } 187 160 } else { 188 161 // all ancestors along this path should also be included 189 162 file := path.Clean(line) 190 - current.files.Insert(file) 191 - for _, a := range ancestors(file) { 192 - current.files.Insert(a) 193 - } 163 + ancestors := ancestors(file) 164 + current.files = append(current.files, file) 165 + current.files = append(current.files, ancestors...) 194 166 } 195 167 196 168 if err == io.EOF { ··· 199 171 } 200 172 201 173 return filesDone, nil 202 - } 203 - 204 - // LastCommitFile returns the last commit information for a specific file path 205 - func (g *GitRepo) LastCommitFile(ctx context.Context, filePath string) (*types.LastCommitInfo, error) { 206 - parent, child := path.Split(filePath) 207 - parent = path.Clean(parent) 208 - if parent == "." { 209 - parent = "" 210 - } 211 - 212 - lastCommitDir := lastCommitDir{ 213 - dir: parent, 214 - entries: []string{child}, 215 - } 216 - 217 - times, err := g.lastCommitDirIn(ctx, lastCommitDir, 2*time.Second) 218 - if err != nil { 219 - return nil, fmt.Errorf("calculate commit time: %w", err) 220 - } 221 - 222 - // extract the only element of the map, the commit info of the current path 223 - var commitInfo *commit 224 - for _, c := range times { 225 - commitInfo = &c 226 - } 227 - 228 - if commitInfo == nil { 229 - return nil, fmt.Errorf("no commit found for path: %s", filePath) 230 - } 231 - 232 - return &types.LastCommitInfo{ 233 - Hash: commitInfo.hash, 234 - Message: commitInfo.message, 235 - When: commitInfo.when, 236 - }, nil 237 174 } 238 175 239 176 func ancestors(p string) []string {
+1 -1
knotserver/git/post_receive.go
··· 95 95 // git rev-list <newsha> ^other-branches --not ^this-branch 96 96 args = append(args, line.NewSha.String()) 97 97 98 - branches, _ := g.Branches() 98 + branches, _ := g.Branches(nil) 99 99 for _, b := range branches { 100 100 if !strings.Contains(line.Ref, b.Name) { 101 101 args = append(args, fmt.Sprintf("^%s", b.Name))
+38 -3
knotserver/git/tag.go
··· 10 10 "github.com/go-git/go-git/v5/plumbing/object" 11 11 ) 12 12 13 - func (g *GitRepo) Tags() ([]object.Tag, error) { 13 + type TagsOptions struct { 14 + Limit int 15 + Offset int 16 + Pattern string 17 + } 18 + 19 + func (g *GitRepo) Tags(opts *TagsOptions) ([]object.Tag, error) { 20 + if opts == nil { 21 + opts = &TagsOptions{} 22 + } 23 + 24 + if opts.Pattern == "" { 25 + opts.Pattern = "refs/tags" 26 + } 27 + 14 28 fields := []string{ 15 29 "refname:short", 16 30 "objectname", ··· 29 43 if i != 0 { 30 44 outFormat.WriteString(fieldSeparator) 31 45 } 32 - outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 46 + fmt.Fprintf(&outFormat, "%%(%s)", f) 33 47 } 34 48 outFormat.WriteString("") 35 49 outFormat.WriteString(recordSeparator) 36 50 37 - output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 51 + args := []string{outFormat.String(), "--sort=-creatordate"} 52 + 53 + // only add the count if the limit is a non-zero value, 54 + // if it is zero, get as many tags as we can 55 + if opts.Limit > 0 { 56 + args = append(args, fmt.Sprintf("--count=%d", opts.Offset+opts.Limit)) 57 + } 58 + 59 + args = append(args, opts.Pattern) 60 + 61 + output, err := g.forEachRef(args...) 38 62 if err != nil { 39 63 return nil, fmt.Errorf("failed to get tags: %w", err) 40 64 } ··· 44 68 return nil, nil 45 69 } 46 70 71 + startIdx := opts.Offset 72 + if startIdx >= len(records) { 73 + return nil, nil 74 + } 75 + 76 + endIdx := len(records) 77 + if opts.Limit > 0 { 78 + endIdx = min(startIdx+opts.Limit, len(records)) 79 + } 80 + 81 + records = records[startIdx:endIdx] 47 82 tags := make([]object.Tag, 0, len(records)) 48 83 49 84 for _, line := range records {
+365
knotserver/git/tag_test.go
··· 1 + package git 2 + 3 + import ( 4 + "path/filepath" 5 + "testing" 6 + "time" 7 + 8 + gogit "github.com/go-git/go-git/v5" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/go-git/go-git/v5/plumbing/object" 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + "github.com/stretchr/testify/suite" 14 + ) 15 + 16 + type TagSuite struct { 17 + suite.Suite 18 + *RepoSuite 19 + } 20 + 21 + func TestTagSuite(t *testing.T) { 22 + t.Parallel() 23 + suite.Run(t, new(TagSuite)) 24 + } 25 + 26 + func (s *TagSuite) SetupTest() { 27 + s.RepoSuite = NewRepoSuite(s.T()) 28 + } 29 + 30 + func (s *TagSuite) TearDownTest() { 31 + s.RepoSuite.cleanup() 32 + } 33 + 34 + func (s *TagSuite) setupRepoWithTags() { 35 + s.init() 36 + 37 + // create commits for tagging 38 + commit1 := s.commitFile("file1.txt", "content 1", "Add file1") 39 + commit2 := s.commitFile("file2.txt", "content 2", "Add file2") 40 + commit3 := s.commitFile("file3.txt", "content 3", "Add file3") 41 + commit4 := s.commitFile("file4.txt", "content 4", "Add file4") 42 + commit5 := s.commitFile("file5.txt", "content 5", "Add file5") 43 + 44 + // create annotated tags 45 + s.createAnnotatedTag( 46 + "v1.0.0", 47 + commit1, 48 + "Tagger One", 49 + "tagger1@example.com", 50 + "Release version 1.0.0\n\nThis is the first stable release.", 51 + s.baseTime.Add(1*time.Hour), 52 + ) 53 + 54 + s.createAnnotatedTag( 55 + "v1.1.0", 56 + commit2, 57 + "Tagger Two", 58 + "tagger2@example.com", 59 + "Release version 1.1.0", 60 + s.baseTime.Add(2*time.Hour), 61 + ) 62 + 63 + // create lightweight tags 64 + s.createLightweightTag("v2.0.0", commit3) 65 + s.createLightweightTag("v2.1.0", commit4) 66 + 67 + // create another annotated tag 68 + s.createAnnotatedTag( 69 + "v3.0.0", 70 + commit5, 71 + "Tagger Three", 72 + "tagger3@example.com", 73 + "Major version 3.0.0\n\nBreaking changes included.", 74 + s.baseTime.Add(3*time.Hour), 75 + ) 76 + } 77 + 78 + func (s *TagSuite) TestTags_All() { 79 + s.setupRepoWithTags() 80 + 81 + tags, err := s.repo.Tags(nil) 82 + require.NoError(s.T(), err) 83 + 84 + // we created 5 tags total (3 annotated, 2 lightweight) 85 + assert.Len(s.T(), tags, 5, "expected 5 tags") 86 + 87 + // verify tags are sorted by creation date (newest first) 88 + expectedAnnotated := map[string]bool{ 89 + "v1.0.0": true, 90 + "v1.1.0": true, 91 + "v3.0.0": true, 92 + } 93 + 94 + expectedLightweight := map[string]bool{ 95 + "v2.0.0": true, 96 + "v2.1.0": true, 97 + } 98 + 99 + for _, tag := range tags { 100 + if expectedAnnotated[tag.Name] { 101 + // annotated tags should have tagger info 102 + assert.NotEmpty(s.T(), tag.Tagger.Name, "annotated tag %s should have tagger name", tag.Name) 103 + assert.NotEmpty(s.T(), tag.Message, "annotated tag %s should have message", tag.Name) 104 + } else if expectedLightweight[tag.Name] { 105 + // lightweight tags won't have tagger info or message (they'll have empty values) 106 + } else { 107 + s.T().Errorf("unexpected tag name: %s", tag.Name) 108 + } 109 + } 110 + } 111 + 112 + func (s *TagSuite) TestTags_WithLimit() { 113 + s.setupRepoWithTags() 114 + 115 + tests := []struct { 116 + name string 117 + limit int 118 + expectedCount int 119 + }{ 120 + { 121 + name: "limit 1", 122 + limit: 1, 123 + expectedCount: 1, 124 + }, 125 + { 126 + name: "limit 2", 127 + limit: 2, 128 + expectedCount: 2, 129 + }, 130 + { 131 + name: "limit 3", 132 + limit: 3, 133 + expectedCount: 3, 134 + }, 135 + { 136 + name: "limit 10 (more than available)", 137 + limit: 10, 138 + expectedCount: 5, 139 + }, 140 + } 141 + 142 + for _, tt := range tests { 143 + s.Run(tt.name, func() { 144 + tags, err := s.repo.Tags(&TagsOptions{ 145 + Limit: tt.limit, 146 + }) 147 + require.NoError(s.T(), err) 148 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 149 + }) 150 + } 151 + } 152 + 153 + func (s *TagSuite) TestTags_WithOffset() { 154 + s.setupRepoWithTags() 155 + 156 + tests := []struct { 157 + name string 158 + offset int 159 + expectedCount int 160 + }{ 161 + { 162 + name: "offset 0", 163 + offset: 0, 164 + expectedCount: 5, 165 + }, 166 + { 167 + name: "offset 1", 168 + offset: 1, 169 + expectedCount: 4, 170 + }, 171 + { 172 + name: "offset 2", 173 + offset: 2, 174 + expectedCount: 3, 175 + }, 176 + { 177 + name: "offset 4", 178 + offset: 4, 179 + expectedCount: 1, 180 + }, 181 + { 182 + name: "offset 5 (all skipped)", 183 + offset: 5, 184 + expectedCount: 0, 185 + }, 186 + { 187 + name: "offset 10 (more than available)", 188 + offset: 10, 189 + expectedCount: 0, 190 + }, 191 + } 192 + 193 + for _, tt := range tests { 194 + s.Run(tt.name, func() { 195 + tags, err := s.repo.Tags(&TagsOptions{ 196 + Offset: tt.offset, 197 + }) 198 + require.NoError(s.T(), err) 199 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 200 + }) 201 + } 202 + } 203 + 204 + func (s *TagSuite) TestTags_WithLimitAndOffset() { 205 + s.setupRepoWithTags() 206 + 207 + tests := []struct { 208 + name string 209 + limit int 210 + offset int 211 + expectedCount int 212 + }{ 213 + { 214 + name: "limit 2, offset 0", 215 + limit: 2, 216 + offset: 0, 217 + expectedCount: 2, 218 + }, 219 + { 220 + name: "limit 2, offset 1", 221 + limit: 2, 222 + offset: 1, 223 + expectedCount: 2, 224 + }, 225 + { 226 + name: "limit 2, offset 3", 227 + limit: 2, 228 + offset: 3, 229 + expectedCount: 2, 230 + }, 231 + { 232 + name: "limit 2, offset 4", 233 + limit: 2, 234 + offset: 4, 235 + expectedCount: 1, 236 + }, 237 + { 238 + name: "limit 3, offset 2", 239 + limit: 3, 240 + offset: 2, 241 + expectedCount: 3, 242 + }, 243 + { 244 + name: "limit 10, offset 3", 245 + limit: 10, 246 + offset: 3, 247 + expectedCount: 2, 248 + }, 249 + } 250 + 251 + for _, tt := range tests { 252 + s.Run(tt.name, func() { 253 + tags, err := s.repo.Tags(&TagsOptions{ 254 + Limit: tt.limit, 255 + Offset: tt.offset, 256 + }) 257 + require.NoError(s.T(), err) 258 + assert.Len(s.T(), tags, tt.expectedCount, "expected %d tags", tt.expectedCount) 259 + }) 260 + } 261 + } 262 + 263 + func (s *TagSuite) TestTags_EmptyRepo() { 264 + repoPath := filepath.Join(s.tempDir, "empty-repo") 265 + 266 + _, err := gogit.PlainInit(repoPath, false) 267 + require.NoError(s.T(), err) 268 + 269 + gitRepo, err := PlainOpen(repoPath) 270 + require.NoError(s.T(), err) 271 + 272 + tags, err := gitRepo.Tags(nil) 273 + require.NoError(s.T(), err) 274 + 275 + if tags != nil { 276 + assert.Empty(s.T(), tags, "expected no tags in empty repo") 277 + } 278 + } 279 + 280 + func (s *TagSuite) TestTags_Pagination() { 281 + s.setupRepoWithTags() 282 + 283 + allTags, err := s.repo.Tags(nil) 284 + require.NoError(s.T(), err) 285 + assert.Len(s.T(), allTags, 5, "expected 5 tags") 286 + 287 + pageSize := 2 288 + var paginatedTags []object.Tag 289 + 290 + for offset := 0; offset < len(allTags); offset += pageSize { 291 + tags, err := s.repo.Tags(&TagsOptions{ 292 + Limit: pageSize, 293 + Offset: offset, 294 + }) 295 + require.NoError(s.T(), err) 296 + paginatedTags = append(paginatedTags, tags...) 297 + } 298 + 299 + assert.Len(s.T(), paginatedTags, len(allTags), "pagination should return all tags") 300 + 301 + for i := range allTags { 302 + assert.Equal(s.T(), allTags[i].Name, paginatedTags[i].Name, 303 + "tag at index %d differs", i) 304 + } 305 + } 306 + 307 + func (s *TagSuite) TestTags_VerifyAnnotatedTagFields() { 308 + s.setupRepoWithTags() 309 + 310 + tags, err := s.repo.Tags(nil) 311 + require.NoError(s.T(), err) 312 + 313 + var v1Tag *object.Tag 314 + for i := range tags { 315 + if tags[i].Name == "v1.0.0" { 316 + v1Tag = &tags[i] 317 + break 318 + } 319 + } 320 + 321 + require.NotNil(s.T(), v1Tag, "v1.0.0 tag not found") 322 + 323 + assert.Equal(s.T(), "Tagger One", v1Tag.Tagger.Name, "tagger name should match") 324 + assert.Equal(s.T(), "tagger1@example.com", v1Tag.Tagger.Email, "tagger email should match") 325 + 326 + assert.Equal(s.T(), "Release version 1.0.0\n\nThis is the first stable release.", 327 + v1Tag.Message, "tag message should match") 328 + 329 + assert.Equal(s.T(), plumbing.TagObject, v1Tag.TargetType, 330 + "target type should be CommitObject") 331 + 332 + assert.False(s.T(), v1Tag.Hash.IsZero(), "tag hash should be set") 333 + 334 + assert.False(s.T(), v1Tag.Target.IsZero(), "target hash should be set") 335 + } 336 + 337 + func (s *TagSuite) TestTags_NilOptions() { 338 + s.setupRepoWithTags() 339 + 340 + tags, err := s.repo.Tags(nil) 341 + require.NoError(s.T(), err) 342 + assert.Len(s.T(), tags, 5, "nil options should return all tags") 343 + } 344 + 345 + func (s *TagSuite) TestTags_ZeroLimitAndOffset() { 346 + s.setupRepoWithTags() 347 + 348 + tags, err := s.repo.Tags(&TagsOptions{ 349 + Limit: 0, 350 + Offset: 0, 351 + }) 352 + require.NoError(s.T(), err) 353 + assert.Len(s.T(), tags, 5, "zero limit should return all tags") 354 + } 355 + 356 + func (s *TagSuite) TestTags_Pattern() { 357 + s.setupRepoWithTags() 358 + 359 + v1tag, err := s.repo.Tags(&TagsOptions{ 360 + Pattern: "refs/tags/v1.0.0", 361 + }) 362 + 363 + require.NoError(s.T(), err) 364 + assert.Len(s.T(), v1tag, 1, "expected 1 tag") 365 + }
+141
knotserver/git/test_common.go
··· 1 + package git 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + 9 + gogit "github.com/go-git/go-git/v5" 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + "github.com/stretchr/testify/require" 13 + ) 14 + 15 + type RepoSuite struct { 16 + t *testing.T 17 + tempDir string 18 + repo *GitRepo 19 + baseTime time.Time 20 + } 21 + 22 + func NewRepoSuite(t *testing.T) *RepoSuite { 23 + tempDir, err := os.MkdirTemp("", "git-test-*") 24 + require.NoError(t, err) 25 + 26 + return &RepoSuite{ 27 + t: t, 28 + tempDir: tempDir, 29 + baseTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 30 + } 31 + } 32 + 33 + func (h *RepoSuite) cleanup() { 34 + if h.tempDir != "" { 35 + os.RemoveAll(h.tempDir) 36 + } 37 + } 38 + 39 + func (h *RepoSuite) init() *GitRepo { 40 + repoPath := filepath.Join(h.tempDir, "test-repo") 41 + 42 + // initialize repository 43 + r, err := gogit.PlainInit(repoPath, false) 44 + require.NoError(h.t, err) 45 + 46 + // configure git user 47 + cfg, err := r.Config() 48 + require.NoError(h.t, err) 49 + cfg.User.Name = "Test User" 50 + cfg.User.Email = "test@example.com" 51 + err = r.SetConfig(cfg) 52 + require.NoError(h.t, err) 53 + 54 + // create initial commit with a file 55 + w, err := r.Worktree() 56 + require.NoError(h.t, err) 57 + 58 + // create initial file 59 + initialFile := filepath.Join(repoPath, "README.md") 60 + err = os.WriteFile(initialFile, []byte("# Test Repository\n\nInitial content.\n"), 0644) 61 + require.NoError(h.t, err) 62 + 63 + _, err = w.Add("README.md") 64 + require.NoError(h.t, err) 65 + 66 + _, err = w.Commit("Initial commit", &gogit.CommitOptions{ 67 + Author: &object.Signature{ 68 + Name: "Test User", 69 + Email: "test@example.com", 70 + When: h.baseTime, 71 + }, 72 + }) 73 + require.NoError(h.t, err) 74 + 75 + gitRepo, err := PlainOpen(repoPath) 76 + require.NoError(h.t, err) 77 + 78 + h.repo = gitRepo 79 + return gitRepo 80 + } 81 + 82 + func (h *RepoSuite) commitFile(filename, content, message string) plumbing.Hash { 83 + filePath := filepath.Join(h.repo.path, filename) 84 + dir := filepath.Dir(filePath) 85 + 86 + err := os.MkdirAll(dir, 0755) 87 + require.NoError(h.t, err) 88 + 89 + err = os.WriteFile(filePath, []byte(content), 0644) 90 + require.NoError(h.t, err) 91 + 92 + w, err := h.repo.r.Worktree() 93 + require.NoError(h.t, err) 94 + 95 + _, err = w.Add(filename) 96 + require.NoError(h.t, err) 97 + 98 + hash, err := w.Commit(message, &gogit.CommitOptions{ 99 + Author: &object.Signature{ 100 + Name: "Test User", 101 + Email: "test@example.com", 102 + }, 103 + }) 104 + require.NoError(h.t, err) 105 + 106 + return hash 107 + } 108 + 109 + func (h *RepoSuite) createAnnotatedTag(name string, commit plumbing.Hash, taggerName, taggerEmail, message string, when time.Time) { 110 + _, err := h.repo.r.CreateTag(name, commit, &gogit.CreateTagOptions{ 111 + Tagger: &object.Signature{ 112 + Name: taggerName, 113 + Email: taggerEmail, 114 + When: when, 115 + }, 116 + Message: message, 117 + }) 118 + require.NoError(h.t, err) 119 + } 120 + 121 + func (h *RepoSuite) createLightweightTag(name string, commit plumbing.Hash) { 122 + ref := plumbing.NewReferenceFromStrings("refs/tags/"+name, commit.String()) 123 + err := h.repo.r.Storer.SetReference(ref) 124 + require.NoError(h.t, err) 125 + } 126 + 127 + func (h *RepoSuite) createBranch(name string, commit plumbing.Hash) { 128 + ref := plumbing.NewReferenceFromStrings("refs/heads/"+name, commit.String()) 129 + err := h.repo.r.Storer.SetReference(ref) 130 + require.NoError(h.t, err) 131 + } 132 + 133 + func (h *RepoSuite) checkoutBranch(name string) { 134 + w, err := h.repo.r.Worktree() 135 + require.NoError(h.t, err) 136 + 137 + err = w.Checkout(&gogit.CheckoutOptions{ 138 + Branch: plumbing.NewBranchReferenceName(name), 139 + }) 140 + require.NoError(h.t, err) 141 + }
+1 -11
knotserver/git/tree.go
··· 48 48 func (g *GitRepo) makeNiceTree(ctx context.Context, subtree *object.Tree, parent string) []types.NiceTree { 49 49 nts := []types.NiceTree{} 50 50 51 - entries := make([]string, len(subtree.Entries)) 52 - for _, e := range subtree.Entries { 53 - entries = append(entries, e.Name) 54 - } 55 - 56 - lastCommitDir := lastCommitDir{ 57 - dir: parent, 58 - entries: entries, 59 - } 60 - 61 - times, err := g.lastCommitDirIn(ctx, lastCommitDir, 2*time.Second) 51 + times, err := g.calculateCommitTimeIn(ctx, subtree, parent, 2*time.Second) 62 52 if err != nil { 63 53 return nts 64 54 }
-23
knotserver/xrpc/repo_blob.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 - "context" 5 4 "crypto/sha256" 6 5 "encoding/base64" 7 6 "fmt" ··· 9 8 "path/filepath" 10 9 "slices" 11 10 "strings" 12 - "time" 13 11 14 12 "tangled.org/core/api/tangled" 15 13 "tangled.org/core/knotserver/git" ··· 142 140 143 141 if mimeType != "" { 144 142 response.MimeType = &mimeType 145 - } 146 - 147 - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) 148 - defer cancel() 149 - 150 - lastCommit, err := gr.LastCommitFile(ctx, treePath) 151 - if err == nil && lastCommit != nil { 152 - response.LastCommit = &tangled.RepoBlob_LastCommit{ 153 - Hash: lastCommit.Hash.String(), 154 - Message: lastCommit.Message, 155 - When: lastCommit.When.Format(time.RFC3339), 156 - } 157 - 158 - // try to get author information 159 - commit, err := gr.Commit(lastCommit.Hash) 160 - if err == nil { 161 - response.LastCommit.Author = &tangled.RepoBlob_Signature{ 162 - Name: commit.Author.Name, 163 - Email: commit.Author.Email, 164 - } 165 - } 166 143 } 167 144 168 145 writeJson(w, response)
+14 -21
knotserver/xrpc/repo_branches.go
··· 17 17 return 18 18 } 19 19 20 - cursor := r.URL.Query().Get("cursor") 20 + // default 21 + limit := 50 22 + offset := 0 21 23 22 - // limit := 50 // default 23 - // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 24 - // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 25 - // limit = l 26 - // } 27 - // } 24 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { 25 + limit = l 26 + } 28 27 29 - limit := 500 28 + if o, err := strconv.Atoi(r.URL.Query().Get("cursor")); err == nil && o > 0 { 29 + offset = o 30 + } 30 31 31 32 gr, err := git.PlainOpen(repoPath) 32 33 if err != nil { ··· 34 35 return 35 36 } 36 37 37 - branches, _ := gr.Branches() 38 - 39 - offset := 0 40 - if cursor != "" { 41 - if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 42 - offset = o 43 - } 44 - } 45 - 46 - end := min(offset+limit, len(branches)) 47 - 48 - paginatedBranches := branches[offset:end] 38 + branches, _ := gr.Branches(&git.BranchesOptions{ 39 + Limit: limit, 40 + Offset: offset, 41 + }) 49 42 50 43 // Create response using existing types.RepoBranchesResponse 51 44 response := types.RepoBranchesResponse{ 52 - Branches: paginatedBranches, 45 + Branches: branches, 53 46 } 54 47 55 48 writeJson(w, response)
+85
knotserver/xrpc/repo_tag.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTag(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + tagName := r.URL.Query().Get("tag") 24 + if tagName == "" { 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("missing name parameter"), 28 + ), http.StatusBadRequest) 29 + return 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 + return 37 + } 38 + 39 + // if this is not already formatted as refs/tags/v0.1.0, then format it 40 + if !plumbing.ReferenceName(tagName).IsTag() { 41 + tagName = plumbing.NewTagReferenceName(tagName).String() 42 + } 43 + 44 + tags, err := gr.Tags(&git.TagsOptions{ 45 + Pattern: tagName, 46 + }) 47 + 48 + if len(tags) != 1 { 49 + writeError(w, xrpcerr.NewXrpcError( 50 + xrpcerr.WithTag("TagNotFound"), 51 + xrpcerr.WithMessage(fmt.Sprintf("expected 1 tag to be returned, got %d tags", len(tags))), 52 + ), http.StatusBadRequest) 53 + return 54 + } 55 + 56 + tag := tags[0] 57 + 58 + if err != nil { 59 + x.Logger.Warn("getting tags", "error", err.Error()) 60 + tags = []object.Tag{} 61 + } 62 + 63 + var target *object.Tag 64 + if tag.Target != plumbing.ZeroHash { 65 + target = &tag 66 + } 67 + tr := types.TagReference{ 68 + Tag: target, 69 + } 70 + 71 + tr.Reference = types.Reference{ 72 + Name: tag.Name, 73 + Hash: tag.Hash.String(), 74 + } 75 + 76 + if tag.Message != "" { 77 + tr.Message = tag.Message 78 + } 79 + 80 + response := types.RepoTagResponse{ 81 + Tag: &tr, 82 + } 83 + 84 + writeJson(w, response) 85 + }
+15 -22
knotserver/xrpc/repo_tags.go
··· 20 20 return 21 21 } 22 22 23 - cursor := r.URL.Query().Get("cursor") 23 + // default 24 + limit := 50 25 + offset := 0 24 26 25 - limit := 50 // default 26 - if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 - limit = l 29 - } 27 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + 31 + if o, err := strconv.Atoi(r.URL.Query().Get("cursor")); err == nil && o > 0 { 32 + offset = o 30 33 } 31 34 32 35 gr, err := git.PlainOpen(repoPath) ··· 36 39 return 37 40 } 38 41 39 - tags, err := gr.Tags() 42 + tags, err := gr.Tags(&git.TagsOptions{ 43 + Limit: limit, 44 + Offset: offset, 45 + }) 46 + 40 47 if err != nil { 41 48 x.Logger.Warn("getting tags", "error", err.Error()) 42 49 tags = []object.Tag{} ··· 64 71 rtags = append(rtags, &tr) 65 72 } 66 73 67 - // apply pagination manually 68 - offset := 0 69 - if cursor != "" { 70 - if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 71 - offset = o 72 - } 73 - } 74 - 75 - // calculate end index 76 - end := min(offset+limit, len(rtags)) 77 - 78 - paginatedTags := rtags[offset:end] 79 - 80 - // Create response using existing types.RepoTagsResponse 81 74 response := types.RepoTagsResponse{ 82 - Tags: paginatedTags, 75 + Tags: rtags, 83 76 } 84 77 85 78 writeJson(w, response)
-35
knotserver/xrpc/repo_tree.go
··· 9 9 "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/pages/markup" 11 11 "tangled.org/core/knotserver/git" 12 - "tangled.org/core/types" 13 12 xrpcerr "tangled.org/core/xrpc/errors" 14 13 ) 15 14 ··· 106 105 Filename: readmeFileName, 107 106 Contents: readmeContents, 108 107 }, 109 - } 110 - 111 - // calculate lastCommit for the directory as a whole 112 - var lastCommitTree *types.LastCommitInfo 113 - for _, e := range files { 114 - if e.LastCommit == nil { 115 - continue 116 - } 117 - 118 - if lastCommitTree == nil { 119 - lastCommitTree = e.LastCommit 120 - continue 121 - } 122 - 123 - if lastCommitTree.When.After(e.LastCommit.When) { 124 - lastCommitTree = e.LastCommit 125 - } 126 - } 127 - 128 - if lastCommitTree != nil { 129 - response.LastCommit = &tangled.RepoTree_LastCommit{ 130 - Hash: lastCommitTree.Hash.String(), 131 - Message: lastCommitTree.Message, 132 - When: lastCommitTree.When.Format(time.RFC3339), 133 - } 134 - 135 - // try to get author information 136 - commit, err := gr.Commit(lastCommitTree.Hash) 137 - if err == nil { 138 - response.LastCommit.Author = &tangled.RepoTree_Signature{ 139 - Name: commit.Author.Name, 140 - Email: commit.Author.Email, 141 - } 142 - } 143 108 } 144 109 145 110 writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
··· 59 59 r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 60 r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 61 61 r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 + r.Get("/"+tangled.RepoTagNSID, x.RepoTag) 62 63 r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 63 64 r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 64 65 r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
+4
lexicons/repo/blob.json
··· 115 115 "type": "string", 116 116 "description": "Commit hash" 117 117 }, 118 + "shortHash": { 119 + "type": "string", 120 + "description": "Short commit hash" 121 + }, 118 122 "message": { 119 123 "type": "string", 120 124 "description": "Commit message"
+43
lexicons/repo/tag.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tag", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo", 11 + "tag" 12 + ], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "description": "Repository identifier in format 'did:plc:.../repoName'" 17 + }, 18 + "tag": { 19 + "type": "string", 20 + "description": "Name of tag, such as v1.3.0" 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "*/*" 26 + }, 27 + "errors": [ 28 + { 29 + "name": "RepoNotFound", 30 + "description": "Repository not found or access denied" 31 + }, 32 + { 33 + "name": "TagNotFound", 34 + "description": "Tag not found" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+5 -53
lexicons/repo/tree.json
··· 6 6 "type": "query", 7 7 "parameters": { 8 8 "type": "params", 9 - "required": [ 10 - "repo", 11 - "ref" 12 - ], 9 + "required": ["repo", "ref"], 13 10 "properties": { 14 11 "repo": { 15 12 "type": "string", ··· 30 27 "encoding": "application/json", 31 28 "schema": { 32 29 "type": "object", 33 - "required": [ 34 - "ref", 35 - "files" 36 - ], 30 + "required": ["ref", "files"], 37 31 "properties": { 38 32 "ref": { 39 33 "type": "string", ··· 51 45 "type": "ref", 52 46 "ref": "#readme", 53 47 "description": "Readme for this file tree" 54 - }, 55 - "lastCommit": { 56 - "type": "ref", 57 - "ref": "#lastCommit" 58 48 }, 59 49 "files": { 60 50 "type": "array", ··· 87 77 }, 88 78 "readme": { 89 79 "type": "object", 90 - "required": [ 91 - "filename", 92 - "contents" 93 - ], 80 + "required": ["filename", "contents"], 94 81 "properties": { 95 82 "filename": { 96 83 "type": "string", ··· 104 91 }, 105 92 "treeEntry": { 106 93 "type": "object", 107 - "required": [ 108 - "name", 109 - "mode", 110 - "size" 111 - ], 94 + "required": ["name", "mode", "size"], 112 95 "properties": { 113 96 "name": { 114 97 "type": "string", ··· 130 113 }, 131 114 "lastCommit": { 132 115 "type": "object", 133 - "required": [ 134 - "hash", 135 - "message", 136 - "when" 137 - ], 116 + "required": ["hash", "message", "when"], 138 117 "properties": { 139 118 "hash": { 140 119 "type": "string", ··· 144 123 "type": "string", 145 124 "description": "Commit message" 146 125 }, 147 - "author": { 148 - "type": "ref", 149 - "ref": "#signature" 150 - }, 151 126 "when": { 152 127 "type": "string", 153 128 "format": "datetime", 154 129 "description": "Commit timestamp" 155 - } 156 - } 157 - }, 158 - "signature": { 159 - "type": "object", 160 - "required": [ 161 - "name", 162 - "email", 163 - "when" 164 - ], 165 - "properties": { 166 - "name": { 167 - "type": "string", 168 - "description": "Author name" 169 - }, 170 - "email": { 171 - "type": "string", 172 - "description": "Author email" 173 - }, 174 - "when": { 175 - "type": "string", 176 - "format": "datetime", 177 - "description": "Author timestamp" 178 130 } 179 131 } 180 132 }
+14 -16
spindle/engine/engine.go
··· 30 30 } 31 31 } 32 32 33 - secretValues := make([]string, len(allSecrets)) 34 - for i, s := range allSecrets { 35 - secretValues[i] = s.Value 36 - } 37 - 38 33 var wg sync.WaitGroup 39 34 for eng, wfs := range pipeline.Workflows { 40 35 workflowTimeout := eng.WorkflowTimeout() ··· 50 45 Name: w.Name, 51 46 } 52 47 53 - wfLogger, err := models.NewFileWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 54 - if err != nil { 55 - l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 56 - wfLogger = models.NullLogger{} 57 - } else { 58 - l.Info("setup step logger; logs will be persisted", "logDir", cfg.Server.LogDir, "wid", wid) 59 - defer wfLogger.Close() 60 - } 61 - 62 - err = db.StatusRunning(wid, n) 48 + err := db.StatusRunning(wid, n) 63 49 if err != nil { 64 50 l.Error("failed to set workflow status to running", "wid", wid, "err", err) 65 51 return 66 52 } 67 53 68 - err = eng.SetupWorkflow(ctx, wid, &w, wfLogger) 54 + err = eng.SetupWorkflow(ctx, wid, &w) 69 55 if err != nil { 70 56 // TODO(winter): Should this always set StatusFailed? 71 57 // In the original, we only do in a subset of cases. ··· 83 69 return 84 70 } 85 71 defer eng.DestroyWorkflow(ctx, wid) 72 + 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 78 + if err != nil { 79 + l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 80 + wfLogger = nil 81 + } else { 82 + defer wfLogger.Close() 83 + } 86 84 87 85 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 88 86 defer cancel()
+9 -49
spindle/engines/nixery/engine.go
··· 1 1 package nixery 2 2 3 3 import ( 4 - "bufio" 5 4 "context" 6 5 "errors" 7 6 "fmt" 8 7 "io" 9 8 "log/slog" 9 + "os" 10 10 "path" 11 11 "runtime" 12 12 "sync" ··· 169 169 return e, nil 170 170 } 171 171 172 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error { 173 - /// -------------------------INITIAL SETUP------------------------------------------ 174 - l := e.l.With("workflow", wid) 175 - l.Info("setting up workflow") 176 - 177 - setupStep := Step{ 178 - name: "nixery image pull", 179 - kind: models.StepKindSystem, 180 - } 181 - setupStepIdx := -1 172 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 173 + e.l.Info("setting up workflow", "workflow", wid) 182 174 183 - wfLogger.ControlWriter(setupStepIdx, setupStep, models.StepStatusStart).Write([]byte{0}) 184 - defer wfLogger.ControlWriter(setupStepIdx, setupStep, models.StepStatusEnd).Write([]byte{0}) 185 - 186 - /// -------------------------NETWORK CREATION--------------------------------------- 187 175 _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 188 176 Driver: "bridge", 189 177 }) 190 178 if err != nil { 191 179 return err 192 180 } 193 - 194 181 e.registerCleanup(wid, func(ctx context.Context) error { 195 182 if err := e.docker.NetworkRemove(ctx, networkName(wid)); err != nil { 196 183 return fmt.Errorf("removing network: %w", err) ··· 198 185 return nil 199 186 }) 200 187 201 - /// -------------------------IMAGE PULL--------------------------------------------- 202 188 addl := wf.Data.(addlFields) 203 - l.Info("pulling image", "image", addl.image) 204 - fmt.Fprintf( 205 - wfLogger.DataWriter(setupStepIdx, "stdout"), 206 - "pulling image: %s", 207 - addl.image, 208 - ) 209 189 210 190 reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 211 191 if err != nil { 212 - l.Error("pipeline image pull failed!", "error", err.Error()) 213 - fmt.Fprintf(wfLogger.DataWriter(setupStepIdx, "stderr"), "image pull failed: %s", err) 192 + e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 193 + 214 194 return fmt.Errorf("pulling image: %w", err) 215 195 } 216 196 defer reader.Close() 217 - 218 - scanner := bufio.NewScanner(reader) 219 - for scanner.Scan() { 220 - line := scanner.Text() 221 - wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte(line)) 222 - l.Info("image pull progress", "stdout", line) 223 - } 224 - 225 - /// -------------------------CONTAINER CREATION------------------------------------- 226 - l.Info("creating container") 227 - wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte("creating container...")) 197 + io.Copy(os.Stdout, reader) 228 198 229 199 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 230 200 Image: addl.image, ··· 259 229 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 260 230 }, nil, nil, "") 261 231 if err != nil { 262 - fmt.Fprintf( 263 - wfLogger.DataWriter(setupStepIdx, "stderr"), 264 - "container creation failed: %s", 265 - err, 266 - ) 267 232 return fmt.Errorf("creating container: %w", err) 268 233 } 269 - 270 234 e.registerCleanup(wid, func(ctx context.Context) error { 271 235 if err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}); err != nil { 272 236 return fmt.Errorf("stopping container: %w", err) ··· 280 244 if err != nil { 281 245 return fmt.Errorf("removing container: %w", err) 282 246 } 283 - 284 247 return nil 285 248 }) 286 249 287 - /// -------------------------CONTAINER START---------------------------------------- 288 - wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte("starting container...")) 289 250 if err := e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { 290 251 return fmt.Errorf("starting container: %w", err) 291 252 } ··· 312 273 return err 313 274 } 314 275 315 - /// -----------------------------------FINISH--------------------------------------- 316 276 execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 317 277 if err != nil { 318 278 return err ··· 330 290 return nil 331 291 } 332 292 333 - func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error { 293 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 334 294 addl := w.Data.(addlFields) 335 295 workflowEnvs := ConstructEnvs(w.Environment) 336 296 // TODO(winter): should SetupWorkflow also have secret access? ··· 371 331 // start tailing logs in background 372 332 tailDone := make(chan error, 1) 373 333 go func() { 374 - tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, idx) 334 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 375 335 }() 376 336 377 337 select { ··· 417 377 return nil 418 378 } 419 379 420 - func (e *Engine) tailStep(ctx context.Context, wfLogger models.WorkflowLogger, execID string, stepIdx int) error { 380 + func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 421 381 if wfLogger == nil { 422 382 return nil 423 383 }
+2 -2
spindle/models/engine.go
··· 10 10 11 11 type Engine interface { 12 12 InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error) 13 - SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow, wfLogger WorkflowLogger) error 13 + SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error 14 14 WorkflowTimeout() time.Duration 15 15 DestroyWorkflow(ctx context.Context, wid WorkflowId) error 16 - RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger WorkflowLogger) error 16 + RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error 17 17 }
+10 -22
spindle/models/logger.go
··· 9 9 "strings" 10 10 ) 11 11 12 - type WorkflowLogger interface { 13 - Close() error 14 - DataWriter(idx int, stream string) io.Writer 15 - ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer 16 - } 17 - 18 - type NullLogger struct{} 19 - 20 - func (l NullLogger) Close() error { return nil } 21 - func (l NullLogger) DataWriter(idx int, stream string) io.Writer { return io.Discard } 22 - func (l NullLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 23 - return io.Discard 24 - } 25 - 26 - type FileWorkflowLogger struct { 12 + type WorkflowLogger struct { 27 13 file *os.File 28 14 encoder *json.Encoder 29 15 mask *SecretMask 30 16 } 31 17 32 - func NewFileWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (WorkflowLogger, error) { 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 33 19 path := LogFilePath(baseDir, wid) 20 + 34 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 35 22 if err != nil { 36 23 return nil, fmt.Errorf("creating log file: %w", err) 37 24 } 38 - return &FileWorkflowLogger{ 25 + 26 + return &WorkflowLogger{ 39 27 file: file, 40 28 encoder: json.NewEncoder(file), 41 29 mask: NewSecretMask(secretValues), ··· 47 35 return logFilePath 48 36 } 49 37 50 - func (l *FileWorkflowLogger) Close() error { 38 + func (l *WorkflowLogger) Close() error { 51 39 return l.file.Close() 52 40 } 53 41 54 - func (l *FileWorkflowLogger) DataWriter(idx int, stream string) io.Writer { 42 + func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer { 55 43 return &dataWriter{ 56 44 logger: l, 57 45 idx: idx, ··· 59 47 } 60 48 } 61 49 62 - func (l *FileWorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 50 + func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 63 51 return &controlWriter{ 64 52 logger: l, 65 53 idx: idx, ··· 69 57 } 70 58 71 59 type dataWriter struct { 72 - logger *FileWorkflowLogger 60 + logger *WorkflowLogger 73 61 idx int 74 62 stream string 75 63 } ··· 87 75 } 88 76 89 77 type controlWriter struct { 90 - logger *FileWorkflowLogger 78 + logger *WorkflowLogger 91 79 idx int 92 80 step Step 93 81 stepStatus StepStatus
+14
types/repo.go
··· 94 94 Tags []*TagReference `json:"tags,omitempty"` 95 95 } 96 96 97 + type RepoTagResponse struct { 98 + Tag *TagReference `json:"tag,omitempty"` 99 + } 100 + 97 101 type RepoBranchesResponse struct { 98 102 Branches []Branch `json:"branches,omitempty"` 99 103 } ··· 104 108 105 109 type RepoDefaultBranchResponse struct { 106 110 Branch string `json:"branch,omitempty"` 111 + } 112 + 113 + type RepoBlobResponse struct { 114 + Contents string `json:"contents,omitempty"` 115 + Ref string `json:"ref,omitempty"` 116 + Path string `json:"path,omitempty"` 117 + IsBinary bool `json:"is_binary,omitempty"` 118 + 119 + Lines int `json:"lines,omitempty"` 120 + SizeHint uint64 `json:"size_hint,omitempty"` 107 121 } 108 122 109 123 type ForkStatus int
-5
types/tree.go
··· 105 105 Hash plumbing.Hash 106 106 Message string 107 107 When time.Time 108 - Author struct { 109 - Email string 110 - Name string 111 - When time.Time 112 - } 113 108 }