This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

legacy PR #1

Open opened by alice.pds.demo.boltless.dev targeting master from sl/ref-based-pr

two rounds, each a gzipped format-patch blob

Labels

None yet.

Participants 1
AT URI
at://did:plc:3e3arfbizb2pj4gi3a26iqom/sh.tangled.repo.pull/3lzg7tkxvq222
+10958 -5357
Diff #1
+92
api/tangled/gitkeepCommit.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.git.keepCommit 6 + 7 + import ( 8 + "context" 9 + "encoding/json" 10 + "fmt" 11 + 12 + "github.com/bluesky-social/indigo/lex/util" 13 + ) 14 + 15 + const ( 16 + GitKeepCommitNSID = "sh.tangled.git.keepCommit" 17 + ) 18 + 19 + // GitKeepCommit_Commit is a "commit" in the sh.tangled.git.keepCommit schema. 20 + // 21 + // RECORDTYPE: GitKeepCommit_Commit 22 + type GitKeepCommit_Commit struct { 23 + LexiconTypeID string `json:"$type,const=sh.tangled.git.keepCommit#commit" cborgen:"$type,const=sh.tangled.git.keepCommit#commit"` 24 + Oid string `json:"oid" cborgen:"oid"` 25 + Repo string `json:"repo" cborgen:"repo"` 26 + } 27 + 28 + // GitKeepCommit_Input is the input argument to a sh.tangled.git.keepCommit call. 29 + type GitKeepCommit_Input struct { 30 + // record: at-uri of target record. rkey is required 31 + Record string `json:"record" cborgen:"record"` 32 + Repo string `json:"repo" cborgen:"repo"` 33 + Source *GitKeepCommit_Input_Source `json:"source" cborgen:"source"` 34 + } 35 + 36 + type GitKeepCommit_Input_Source struct { 37 + GitKeepCommit_Commit *GitKeepCommit_Commit 38 + GitKeepCommit_Patches *GitKeepCommit_Patches 39 + } 40 + 41 + func (t *GitKeepCommit_Input_Source) MarshalJSON() ([]byte, error) { 42 + if t.GitKeepCommit_Commit != nil { 43 + t.GitKeepCommit_Commit.LexiconTypeID = "sh.tangled.git.keepCommit#commit" 44 + return json.Marshal(t.GitKeepCommit_Commit) 45 + } 46 + if t.GitKeepCommit_Patches != nil { 47 + t.GitKeepCommit_Patches.LexiconTypeID = "sh.tangled.git.keepCommit#patches" 48 + return json.Marshal(t.GitKeepCommit_Patches) 49 + } 50 + return nil, fmt.Errorf("cannot marshal empty enum") 51 + } 52 + func (t *GitKeepCommit_Input_Source) UnmarshalJSON(b []byte) error { 53 + typ, err := util.TypeExtract(b) 54 + if err != nil { 55 + return err 56 + } 57 + 58 + switch typ { 59 + case "sh.tangled.git.keepCommit#commit": 60 + t.GitKeepCommit_Commit = new(GitKeepCommit_Commit) 61 + return json.Unmarshal(b, t.GitKeepCommit_Commit) 62 + case "sh.tangled.git.keepCommit#patches": 63 + t.GitKeepCommit_Patches = new(GitKeepCommit_Patches) 64 + return json.Unmarshal(b, t.GitKeepCommit_Patches) 65 + 66 + default: 67 + return nil 68 + } 69 + } 70 + 71 + // GitKeepCommit_Output is the output of a sh.tangled.git.keepCommit call. 72 + type GitKeepCommit_Output struct { 73 + Commit string `json:"commit" cborgen:"commit"` 74 + } 75 + 76 + // GitKeepCommit_Patches is a "patches" in the sh.tangled.git.keepCommit schema. 77 + // 78 + // RECORDTYPE: GitKeepCommit_Patches 79 + type GitKeepCommit_Patches struct { 80 + LexiconTypeID string `json:"$type,const=sh.tangled.git.keepCommit#patches" cborgen:"$type,const=sh.tangled.git.keepCommit#patches"` 81 + Patches []string `json:"patches,omitempty" cborgen:"patches,omitempty"` 82 + } 83 + 84 + // GitKeepCommit calls the XRPC method "sh.tangled.git.keepCommit". 85 + func GitKeepCommit(ctx context.Context, c util.LexClient, input *GitKeepCommit_Input) (*GitKeepCommit_Output, error) { 86 + var out GitKeepCommit_Output 87 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.git.keepCommit", nil, input, &out); err != nil { 88 + return nil, err 89 + } 90 + 91 + return &out, nil 92 + }
+89
lexicons/git/keepCommit.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.git.keepCommit", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Keep commit for an atproto record.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["repo", "source", "record"], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "format": "did" 17 + }, 18 + "source": { 19 + "type": "union", 20 + "refs": ["#commit", "#patches"] 21 + }, 22 + "record": { 23 + "type": "string", 24 + "format": "at-uri", 25 + "description": "at-uri of target record. rkey is required" 26 + } 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["commit"], 35 + "properties": { 36 + "commit": { 37 + "type": "string", 38 + "minLength": 40, 39 + "maxLength": 128 40 + } 41 + } 42 + } 43 + }, 44 + "errors": [ 45 + { 46 + "name": "InvalidRequest", 47 + "description": "Invalid request parameters" 48 + }, 49 + { 50 + "name": "RepoNotFound", 51 + "description": "Repo is not registered on this knot" 52 + }, 53 + { 54 + "name": "CommitNotFound", 55 + "description": "Commit is not found from source Repo" 56 + }, 57 + { 58 + "name": "PatchApplyFailed", 59 + "description": "Failed to apply patches" 60 + } 61 + ] 62 + }, 63 + "commit": { 64 + "type": "object", 65 + "required": ["repo", "oid"], 66 + "properties": { 67 + "repo": { 68 + "type": "string", 69 + "format": "did" 70 + }, 71 + "oid": { 72 + "type": "string", 73 + "minLength": 40, 74 + "maxLength": 128 75 + } 76 + } 77 + }, 78 + "patches": { 79 + "type": "object", 80 + "required": ["patch"], 81 + "properties": { 82 + "patches": { 83 + "type": "array", 84 + "items": { "type": "string" } 85 + } 86 + } 87 + } 88 + } 89 + }
+2
appview/oauth/scopes.go
··· 33 33 "rpc:sh.tangled.knot.removeMember?aud=*", 34 34 "rpc:sh.tangled.ci.triggerPipeline?aud=*", 35 35 "rpc:sh.tangled.ci.cancelPipeline?aud=*", 36 + "rpc:sh.tangled.git.keepCommit?aud=*", 37 + "rpc:sh.tangled.git.mergeCommit?aud=*", 36 38 "rpc:sh.tangled.repo.addCollaborator?aud=*", 37 39 "rpc:sh.tangled.repo.addSecret?aud=*", 38 40 "rpc:sh.tangled.repo.create?aud=*",
+1
consts/consts.go
··· 11 11 type Capability string 12 12 13 13 const CapKnotACL Capability = "knot-acl" 14 + const CapKeepCommit Capability = "knot-keepcommit"
+149
knotserver/xrpc/git_keep_commit.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/hex" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "os/exec" 10 + "strings" 11 + 12 + "github.com/bluesky-social/indigo/atproto/atclient" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.org/core/api/tangled" 15 + ) 16 + 17 + func (x *Xrpc) KeepCommit(w http.ResponseWriter, r *http.Request) { 18 + var input tangled.GitKeepCommit_Input 19 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 20 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"}) 21 + return 22 + } 23 + 24 + if err := gitKeepCommit_Input_Validate(input); err != nil { 25 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: err.Error()}) 26 + return 27 + } 28 + if syntax.ATURI(input.Record).RecordKey() == "" { 29 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "record at-uri should have rkey"}) 30 + return 31 + } 32 + 33 + output, status, apierr := x.keepCommit(r.Context(), input) 34 + if apierr != nil { 35 + writeJson(w, status, apierr) 36 + return 37 + } 38 + writeJson(w, status, output) 39 + } 40 + 41 + func (x *Xrpc) keepCommit(ctx context.Context, input tangled.GitKeepCommit_Input) (*tangled.GitKeepCommit_Output, int, *atclient.ErrorBody) { 42 + repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo) 43 + if err != nil { 44 + return nil, http.StatusNotFound, &atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %q", input.Repo)} 45 + } 46 + 47 + record := syntax.ATURI(input.Record) 48 + recordIdent, err := x.Resolver.Directory().Lookup(ctx, record.Authority()) 49 + if err != nil { 50 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "Failed to resolve record authority"} 51 + } 52 + recordDID := recordIdent.DID 53 + 54 + var commitID string 55 + 56 + switch { 57 + case input.Source.GitKeepCommit_Commit != nil: 58 + source := input.Source.GitKeepCommit_Commit 59 + if input.Repo == source.Repo { 60 + // no-op. we already have that commit 61 + } else { 62 + // TODO: target repo should own the source commit 63 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "source repo should match the target repo"} 64 + // // fetch commit from source repo 65 + // if err := x.fetchCommitFrom(ctx, syntax.DID(source.Repo), source.Oid); err != nil { 66 + // return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "CommitNotFound", Message: "Failed to fetch commit from source repo"} 67 + // } 68 + } 69 + commitID = source.Oid 70 + 71 + case input.Source.GitKeepCommit_Patches != nil: 72 + // TODO: apply patches to target commit and bring tip commit ID 73 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "patches source is not supported"} 74 + 75 + default: 76 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "source should be one of: [commit, patches]"} 77 + } 78 + 79 + // create refs/tngl/keep/{did}/{collection}/{rkey}/{oid} 80 + refName := EscapeGitRef(fmt.Sprintf("refs/tngl/keep/%s/%s/%s/%s", recordDID.String(), record.Collection(), record.RecordKey(), commitID)) 81 + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "update-ref", refName, commitID) 82 + if out, err := cmd.CombinedOutput(); err != nil { 83 + x.Logger.Error("failed to keep commit", "err", err, "out", string(out)) 84 + return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalServerError", Message: "Failed to keep commit"} 85 + } 86 + 87 + return &tangled.GitKeepCommit_Output{ 88 + Commit: commitID, 89 + }, http.StatusOK, nil 90 + } 91 + 92 + // lexgen doesn't give Validate() method... 93 + func gitKeepCommit_Input_Validate(input tangled.GitKeepCommit_Input) error { 94 + if _, err := syntax.ParseDID(input.Repo); err != nil { 95 + return fmt.Errorf("repo: invalid repo DID: %w", err) 96 + } 97 + if _, err := syntax.ParseATURI(input.Record); err != nil { 98 + return fmt.Errorf("repo: invalid record at-uri: %w", err) 99 + } 100 + switch { 101 + case input.Source.GitKeepCommit_Commit != nil: 102 + if _, err := syntax.ParseDID(input.Source.GitKeepCommit_Commit.Repo); err != nil { 103 + return fmt.Errorf("source: commit: invalid repo DID: %w", err) 104 + } 105 + if ok := IsHash(input.Source.GitKeepCommit_Commit.Oid); !ok { 106 + return fmt.Errorf("source: commit: invalid commit OID: %q", input.Source.GitKeepCommit_Commit.Oid) 107 + } 108 + case input.Source.GitKeepCommit_Patches != nil: 109 + return fmt.Errorf("source: patches: patch is not supported yet") 110 + // for i, patch := range input.Source.GitKeepCommit_Patches.Patches { 111 + // if err := validatePatch(patch); err != nil { 112 + // return fmt.Errorf("source: patches: invalid patches at [%d]: %w", i, err) 113 + // } 114 + // } 115 + default: 116 + return fmt.Errorf("source should be one of: [commit, patches]") 117 + } 118 + return nil 119 + } 120 + 121 + func EscapeGitRef(s string) string { 122 + var b strings.Builder 123 + b.Grow(len(s) * 4 / 3) 124 + for i := 0; i < len(s); i++ { 125 + c := s[i] 126 + if (c >= 'a' && c <= 'z') || 127 + (c >= 'A' && c <= 'Z') || 128 + (c >= '0' && c <= '9') || 129 + c == '/' || c == '-' || c == '_' || c == '.' { 130 + b.WriteByte(c) 131 + continue 132 + } 133 + b.WriteByte('%') 134 + b.WriteByte("0123456789ABCDEF"[c>>4]) 135 + b.WriteByte("0123456789ABCDEF"[c&15]) 136 + } 137 + return strings.ToLower(b.String()) 138 + } 139 + 140 + func IsHash(s string) bool { 141 + switch len(s) { 142 + case 40: // SHA1 143 + case 64: // SHA2 144 + default: 145 + return false 146 + } 147 + _, err := hex.DecodeString(s) 148 + return err == nil 149 + }
+17
knotserver/xrpc/git_keep_commit_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "testing" 6 + 7 + "github.com/alecthomas/assert/v2" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + func TestEscapeGitRef(t *testing.T) { 12 + recordDID := syntax.DID("did:plc:alice") 13 + record := syntax.ATURI("at://did:plc:alice/sh.tangled.repo.pull/something") 14 + commitID := "what" 15 + refName := EscapeGitRef(fmt.Sprintf("refs/tngl/keep/%s/%s/%s/%s", recordDID.String(), record.Collection(), record.RecordKey(), commitID)) 16 + assert.Equal(t, "refs/tngl/keep/did%3aplc%3aalice/sh.tangled.repo.pull/something/what", refName) 17 + }
+4 -1
knotserver/xrpc/version.go
··· 12 12 // version is set during build time. 13 13 var version string 14 14 15 - var knotCapabilities = []string{string(consts.CapKnotACL)} 15 + var knotCapabilities = []string{ 16 + string(consts.CapKnotACL), 17 + string(consts.CapKeepCommit), 18 + } 16 19 17 20 func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 18 21 if version == "" {
+17 -5
knotserver/xrpc/xrpc.go
··· 49 49 r.Group(func(r chi.Router) { 50 50 r.Use(x.ServiceAuth.VerifyServiceAuth) 51 51 52 + r.Post("/"+tangled.GitKeepCommitNSID, x.KeepCommit) 53 + r.Post("/"+tangled.GitMergeCommitNSID, x.MergeCommit) 52 54 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 53 55 r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 54 56 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) ··· 66 68 67 69 68 70 71 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 72 + // - use ETags on clients to keep requests to a minimum 73 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 74 + r.Post("/"+tangled.GitMergeCheckNSID, x.GitMergeCheck) 69 75 76 + // repo query endpoints (no auth required) 77 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 70 78 71 79 72 80 ··· 187 195 188 196 189 197 190 - 191 - 192 - 193 - 194 - 195 198 w.Header().Set("Content-Type", "application/json") 196 199 w.Write(lw.buf.Bytes()) 200 + } 201 + 202 + func writeJson(w http.ResponseWriter, status int, response any) error { 203 + w.Header().Set("Content-Type", "application/json") 204 + w.WriteHeader(status) 205 + if err := json.NewEncoder(w).Encode(response); err != nil { 206 + return err 207 + } 208 + return nil 197 209 }
+39
api/tangled/tempgetMergeBase.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.git.temp.getMergeBase 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + GitTempGetMergeBaseNSID = "sh.tangled.git.temp.getMergeBase" 15 + ) 16 + 17 + // GitTempGetMergeBase_Output is the output of a sh.tangled.git.temp.getMergeBase call. 18 + type GitTempGetMergeBase_Output struct { 19 + Commit string `json:"commit" cborgen:"commit"` 20 + } 21 + 22 + // GitTempGetMergeBase calls the XRPC method "sh.tangled.git.temp.getMergeBase". 23 + // 24 + // base: Ref used to find the common base. 25 + // head: Ref to list history for. 26 + // repo: DID of the repository 27 + func GitTempGetMergeBase(ctx context.Context, c util.LexClient, base string, head string, repo string) (*GitTempGetMergeBase_Output, error) { 28 + var out GitTempGetMergeBase_Output 29 + 30 + params := map[string]interface{}{} 31 + params["base"] = base 32 + params["head"] = head 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getMergeBase", params, nil, &out); err != nil { 35 + return nil, err 36 + } 37 + 38 + return &out, nil 39 + }
+54
lexicons/git/temp/getMergeBase.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.git.temp.getMergeBase", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "base", "head"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "format": "did", 14 + "description": "DID of the repository" 15 + }, 16 + "base": { 17 + "type": "string", 18 + "description": "Ref used to find the common base." 19 + }, 20 + "head": { 21 + "type": "string", 22 + "description": "Ref to list history for." 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["commit"], 31 + "properties": { 32 + "commit": { 33 + "type": "string" 34 + } 35 + } 36 + } 37 + }, 38 + "errors": [ 39 + { 40 + "name": "RepoNotFound", 41 + "description": "Repository not found or access denied" 42 + }, 43 + { 44 + "name": "RefNotFound", 45 + "description": "Git reference not found" 46 + }, 47 + { 48 + "name": "InvalidRequest", 49 + "description": "Invalid request parameters" 50 + } 51 + ] 52 + } 53 + } 54 + }
+57
knotmirror/xrpc/git_get_merge_base.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "os/exec" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/atclient" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + ) 13 + 14 + func (x *Xrpc) GetMergeBase(w http.ResponseWriter, r *http.Request) { 15 + var ( 16 + repoQuery = r.URL.Query().Get("repo") 17 + base = r.URL.Query().Get("base") 18 + head = r.URL.Query().Get("head") 19 + ) 20 + 21 + repo, err := syntax.ParseDID(repoQuery) 22 + if err != nil { 23 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 24 + return 25 + } 26 + 27 + if base == "" { 28 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing base parameter"}) 29 + return 30 + } 31 + if head == "" { 32 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing head parameter"}) 33 + return 34 + } 35 + 36 + l := x.logger.With("method", "git.getMergeBase", "repo", repo, "base", base, "head", head) 37 + l.Debug("request") 38 + 39 + ctx := r.Context() 40 + 41 + repoPath, err := x.makeRepoPath(ctx, repo) 42 + if err != nil { 43 + writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %q", repo)}) 44 + return 45 + } 46 + 47 + cmd := exec.Command("git", "-C", repoPath, "merge-base", head, base) 48 + out, err := cmd.Output() 49 + if err != nil { 50 + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalError", Message: "failed to compute merge-base"}) 51 + return 52 + } 53 + 54 + writeJson(w, http.StatusOK, tangled.GitTempGetMergeBase_Output{ 55 + Commit: strings.TrimSpace(string(out)), 56 + }) 57 + }
+3 -1
knotmirror/xrpc/xrpc.go
··· 63 63 64 64 65 65 66 - 66 + // r.Get("/"+tangled.GitTempGetCommitNSID, x.GetCommit) // todo 67 67 // r.Get("/"+tangled.GitTempGetDiffNSID, x.GetDiff) // todo 68 68 r.Get("/"+tangled.GitTempGetEntryNSID, x.GetEntry) // todo 69 + r.Get("/"+tangled.GitTempFormatPatchNSID, x.FormatPatch) 69 70 // r.Get("/"+tangled.GitTempGetHeadNSID, x.GetHead) // todo 71 + r.Get("/"+tangled.GitTempGetMergeBaseNSID, x.GetMergeBase) 70 72 r.Get("/"+tangled.GitTempGetTagNSID, x.GetTag) // using types.Response 71 73 r.Get("/"+tangled.GitTempGetTreeNSID, x.GetTree) 72 74 r.Get("/"+tangled.GitTempListBranchesNSID, x.ListBranches) // wip, unknown output
+17
api/tangled/gitdefs.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.git.defs 6 + 7 + const () 8 + 9 + // GitDefs_Signature is a "signature" in the sh.tangled.git.defs schema. 10 + type GitDefs_Signature struct { 11 + // email: Person email 12 + Email string `json:"email" cborgen:"email"` 13 + // name: Person name 14 + Name string `json:"name" cborgen:"name"` 15 + // when: Timestamp of the signature 16 + When string `json:"when" cborgen:"when"` 17 + }
+59
api/tangled/gitmergeCheck.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.git.mergeCheck 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + GitMergeCheckNSID = "sh.tangled.git.mergeCheck" 15 + ) 16 + 17 + // GitMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.git.mergeCheck schema. 18 + type GitMergeCheck_ConflictInfo struct { 19 + // filename: Name of the conflicted file 20 + Filename string `json:"filename" cborgen:"filename"` 21 + // reason: Reason for the conflict 22 + Reason string `json:"reason" cborgen:"reason"` 23 + } 24 + 25 + // GitMergeCheck_Input is the input argument to a sh.tangled.git.mergeCheck call. 26 + type GitMergeCheck_Input struct { 27 + // branch: Target branch to merge into 28 + Branch string `json:"branch" cborgen:"branch"` 29 + // repo: DID of Git repository 30 + Repo string `json:"repo" cborgen:"repo"` 31 + Source *GitMergeCheck_Input_Source `json:"source" cborgen:"source"` 32 + } 33 + 34 + type GitMergeCheck_Input_Source struct { 35 + // commit: Head commit ID of PR to merge 36 + Commit string `json:"commit" cborgen:"commit"` 37 + // repo: DID of source git repository 38 + Repo string `json:"repo" cborgen:"repo"` 39 + } 40 + 41 + // GitMergeCheck_Output is the output of a sh.tangled.git.mergeCheck call. 42 + type GitMergeCheck_Output struct { 43 + // conflicts: List of files with merge conflicts 44 + Conflicts []*GitMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 45 + // isConflicted: Whether the merge has conflicts 46 + IsConflicted bool `json:"isConflicted" cborgen:"isConflicted"` 47 + // message: Additional message about the merge check 48 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 49 + } 50 + 51 + // GitMergeCheck calls the XRPC method "sh.tangled.git.mergeCheck". 52 + func GitMergeCheck(ctx context.Context, c util.LexClient, input *GitMergeCheck_Input) (*GitMergeCheck_Output, error) { 53 + var out GitMergeCheck_Output 54 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.git.mergeCheck", nil, input, &out); err != nil { 55 + return nil, err 56 + } 57 + 58 + return &out, nil 59 + }
+53
api/tangled/gitmergeCommit.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.git.mergeCommit 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + GitMergeCommitNSID = "sh.tangled.git.mergeCommit" 15 + ) 16 + 17 + // GitMergeCommit_Input is the input argument to a sh.tangled.git.mergeCommit call. 18 + type GitMergeCommit_Input struct { 19 + // mergeCommit: Merge commit content. Used when merge commit is created. 20 + MergeCommit *GitMergeCommit_Input_MergeCommit `json:"mergeCommit,omitempty" cborgen:"mergeCommit,omitempty"` 21 + Source *GitMergeCommit_Input_Source `json:"source" cborgen:"source"` 22 + Style string `json:"style" cborgen:"style"` 23 + Target *GitMergeCommit_Input_Target `json:"target" cborgen:"target"` 24 + } 25 + 26 + // Merge commit content. Used when merge commit is created. 27 + type GitMergeCommit_Input_MergeCommit struct { 28 + Author *GitDefs_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 29 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 30 + } 31 + 32 + type GitMergeCommit_Input_Source struct { 33 + // commit: Head commit ID to merge 34 + Commit string `json:"commit" cborgen:"commit"` 35 + // repo: DID of source git repository 36 + Repo string `json:"repo" cborgen:"repo"` 37 + } 38 + 39 + type GitMergeCommit_Input_Target struct { 40 + // branch: Target branch to merge into 41 + Branch string `json:"branch" cborgen:"branch"` 42 + // repo: DID of target git repository 43 + Repo string `json:"repo" cborgen:"repo"` 44 + } 45 + 46 + // GitMergeCommit calls the XRPC method "sh.tangled.git.mergeCommit". 47 + func GitMergeCommit(ctx context.Context, c util.LexClient, input *GitMergeCommit_Input) error { 48 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.git.mergeCommit", nil, input, nil); err != nil { 49 + return err 50 + } 51 + 52 + return nil 53 + }
+25
lexicons/git/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.git.defs", 4 + "defs": { 5 + "signature": { 6 + "type": "object", 7 + "required": ["name", "email", "when"], 8 + "properties": { 9 + "name": { 10 + "type": "string", 11 + "description": "Person name" 12 + }, 13 + "email": { 14 + "type": "string", 15 + "description": "Person email" 16 + }, 17 + "when": { 18 + "type": "string", 19 + "format": "datetime", 20 + "description": "Timestamp of the signature" 21 + } 22 + } 23 + } 24 + } 25 + }
+96
lexicons/git/mergeCheck.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.git.mergeCheck", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check if a merge is possible between two branches. Performs 3-way merge check", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["repo", "branch", "source"], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of Git repository" 18 + }, 19 + "branch": { 20 + "type": "string", 21 + "description": "Target branch to merge into" 22 + }, 23 + "source": { 24 + "type": "object", 25 + "required": ["repo", "commit"], 26 + "properties": { 27 + "repo": { 28 + "type": "string", 29 + "format": "did", 30 + "description": "DID of source git repository" 31 + }, 32 + "commit": { 33 + "type": "string", 34 + "description": "Head commit ID of PR to merge" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }, 41 + "output": { 42 + "encoding": "application/json", 43 + "schema": { 44 + "type": "object", 45 + "required": ["isConflicted"], 46 + "properties": { 47 + "isConflicted": { 48 + "type": "boolean", 49 + "description": "Whether the merge has conflicts" 50 + }, 51 + "conflicts": { 52 + "type": "array", 53 + "description": "List of files with merge conflicts", 54 + "items": { 55 + "type": "ref", 56 + "ref": "#conflictInfo" 57 + } 58 + }, 59 + "message": { 60 + "type": "string", 61 + "description": "Additional message about the merge check" 62 + } 63 + } 64 + } 65 + }, 66 + "errors": [ 67 + { 68 + "name": "InvalidRequest", 69 + "description": "Invalid request parameters" 70 + }, 71 + { 72 + "name": "RepoNotFound", 73 + "description": "Repo is not registered on this knot" 74 + }, 75 + { 76 + "name": "CommitNotFound", 77 + "description": "Commit is not found from source Repo" 78 + } 79 + ] 80 + }, 81 + "conflictInfo": { 82 + "type": "object", 83 + "required": ["filename", "reason"], 84 + "properties": { 85 + "filename": { 86 + "type": "string", 87 + "description": "Name of the conflicted file" 88 + }, 89 + "reason": { 90 + "type": "string", 91 + "description": "Reason for the conflict" 92 + } 93 + } 94 + } 95 + } 96 + }
+68
lexicons/git/mergeCommit.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.git.mergeCommit", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Merge a commit into repository's branch", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["target", "source", "style"], 13 + "properties": { 14 + "target": { 15 + "type": "object", 16 + "required": ["repo", "branch"], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of target git repository" 22 + }, 23 + "branch": { 24 + "type": "string", 25 + "description": "Target branch to merge into" 26 + } 27 + } 28 + }, 29 + "source": { 30 + "type": "object", 31 + "required": ["repo", "commit"], 32 + "properties": { 33 + "repo": { 34 + "type": "string", 35 + "format": "did", 36 + "description": "DID of source git repository" 37 + }, 38 + "commit": { 39 + "type": "string", 40 + "description": "Head commit ID to merge" 41 + } 42 + } 43 + }, 44 + "style": { 45 + "type": "string", 46 + "enum": [ 47 + "rebase", 48 + "merge", 49 + "rebase-merge", 50 + "squash-rebase", 51 + "squash-rebase-merge", 52 + "fast-forward-only" 53 + ] 54 + }, 55 + "mergeCommit": { 56 + "type": "object", 57 + "description": "Merge commit content. Used when merge commit is created.", 58 + "properties": { 59 + "author": { "type": "ref", "ref": "sh.tangled.git.defs#signature" }, 60 + "message": { "type": "string", "maxLength": 1000000 } 61 + } 62 + } 63 + } 64 + } 65 + } 66 + } 67 + } 68 + }
+1 -1
lexicons/repo/merge.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "procedure", 7 - "description": "Merge a patch into a repository branch", 7 + "description": "DEPRECATED: Merge a patch into a repository branch", 8 8 "input": { 9 9 "encoding": "application/json", 10 10 "schema": {
+225
knotserver/xrpc/git_merge_check.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "crypto/sha256" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "net/url" 10 + "os" 11 + "strings" 12 + 13 + "github.com/bluesky-social/indigo/atproto/atclient" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/dgraph-io/ristretto" 16 + "tangled.org/core/api/tangled" 17 + ) 18 + 19 + type MergeInput struct { 20 + TargetRepo syntax.DID 21 + TargetBranch string 22 + SourceRepo syntax.DID 23 + SourceCommit string 24 + } 25 + 26 + type MergeCheckCache struct { 27 + cache *ristretto.Cache 28 + } 29 + 30 + func (m *MergeCheckCache) cacheKey(input MergeInput) string { 31 + raw := strings.Join([]string{ 32 + input.TargetRepo.String(), 33 + input.TargetBranch, 34 + input.SourceRepo.String(), 35 + input.SourceCommit, 36 + }, "\x00") 37 + sum := sha256.Sum256([]byte(raw)) 38 + return fmt.Sprintf("%x", sum) 39 + } 40 + 41 + func (m *MergeCheckCache) cacheVal(out *tangled.GitMergeCheck_Output) any { 42 + return *out 43 + } 44 + 45 + func (m *MergeCheckCache) Set(input MergeInput, mergeCheck *tangled.GitMergeCheck_Output) { 46 + key := m.cacheKey(input) 47 + val := m.cacheVal(mergeCheck) 48 + m.cache.Set(key, val, 0) 49 + } 50 + 51 + func (m *MergeCheckCache) Get(input MergeInput) (tangled.GitMergeCheck_Output, bool) { 52 + key := m.cacheKey(input) 53 + if val, ok := m.cache.Get(key); ok { 54 + if out, ok := val.(tangled.GitMergeCheck_Output); ok { 55 + // cache hit 56 + return out, true 57 + } 58 + } 59 + 60 + // cache miss 61 + return tangled.GitMergeCheck_Output{}, false 62 + } 63 + 64 + var mergeCheckCache MergeCheckCache 65 + 66 + func init() { 67 + cache, _ := ristretto.NewCache(&ristretto.Config{ 68 + NumCounters: 1e7, 69 + MaxCost: 1 << 30, 70 + BufferItems: 64, 71 + TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days 72 + }) 73 + mergeCheckCache = MergeCheckCache{cache} 74 + } 75 + 76 + func (x *Xrpc) GitMergeCheck(w http.ResponseWriter, r *http.Request) { 77 + var input tangled.GitMergeCheck_Input 78 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 79 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"}) 80 + return 81 + } 82 + l := x.Logger.With("handler", "MergeCheck2", "input", input) 83 + l.Debug("request") 84 + 85 + if err := gitMergeCheck_Input_Validate(input); err != nil { 86 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "InvalidRequest", Message: err.Error()}) 87 + return 88 + } 89 + 90 + mergeInput := MergeInput{ 91 + TargetRepo: syntax.DID(input.Repo), 92 + TargetBranch: input.Branch, 93 + SourceRepo: syntax.DID(input.Source.Repo), 94 + SourceCommit: input.Source.Commit, 95 + } 96 + 97 + // check cache 98 + if cached, ok := mergeCheckCache.Get(mergeInput); ok { 99 + l.Debug("cache hit") 100 + writeJson(w, http.StatusOK, cached) 101 + return 102 + } 103 + 104 + output, status, apierr := x.mergeCheck(r.Context(), input) 105 + if apierr != nil { 106 + l.Error("failed", "kind", apierr.Name, "error", apierr.Message) 107 + writeJson(w, status, apierr) 108 + return 109 + } 110 + 111 + // update cache 112 + mergeCheckCache.Set(mergeInput, &output) 113 + 114 + writeJson(w, status, output) 115 + } 116 + 117 + func (x *Xrpc) mergeCheck(ctx context.Context, input tangled.GitMergeCheck_Input) (tangled.GitMergeCheck_Output, int, *atclient.ErrorBody) { 118 + l := x.Logger.With("handler", "mergeCheck") 119 + 120 + fail := func(status int, name, clientMsg string, detail ...any) (tangled.GitMergeCheck_Output, int, *atclient.ErrorBody) { 121 + l.Error(clientMsg, append([]any{"name", name}, detail...)...) 122 + return tangled.GitMergeCheck_Output{}, status, &atclient.ErrorBody{Name: name, Message: clientMsg} 123 + } 124 + 125 + baseRepoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo) 126 + if err != nil { 127 + return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "repo", input.Repo, "err", err) 128 + } 129 + sourceRepoDid := syntax.DID(input.Source.Repo) 130 + 131 + var sourceRepoUrl string 132 + var sourceRepoPath string // non-empty only when the source is local to this knot 133 + if p, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Source.Repo); err == nil { 134 + sourceRepoPath = p 135 + sourceRepoUrl = "file://" + p 136 + } else { 137 + ident, err := x.Resolver.Directory().LookupDID(ctx, sourceRepoDid) 138 + if err != nil { 139 + return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "err", err) 140 + } 141 + sourceKnot := ident.GetServiceEndpoint("atproto_pds") 142 + u, err := url.Parse(sourceKnot) 143 + if err != nil { 144 + return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "knot", sourceKnot, "err", err) 145 + } 146 + sourceRepoUrl = u.JoinPath(sourceRepoDid.String()).String() 147 + } 148 + 149 + env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") 150 + 151 + // 1. create temp repo with git alternate to the base repo's objects. 152 + tmpRepoPath, cleanup, err := createTemporaryRepoForMerge(ctx, x.Sandbox, baseRepoPath, input.Branch) 153 + if err != nil { 154 + return fail(http.StatusInternalServerError, "InternalError", "failed to prepare merge check", "err", err) 155 + } 156 + defer cleanup() 157 + 158 + runGit := func(args ...string) ([]byte, []byte, error) { 159 + args = append([]string{"-C", tmpRepoPath}, args...) 160 + return gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath}, args...) 161 + } 162 + 163 + // 2. fetch source commit and pin it to a "tracking" branch. 164 + fetchPaths := []string{tmpRepoPath} 165 + if sourceRepoPath != "" { 166 + fetchPaths = append(fetchPaths, sourceRepoPath) 167 + } 168 + if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, fetchPaths, "-C", tmpRepoPath, "fetch", sourceRepoUrl, input.Source.Commit); err != nil { 169 + return fail(http.StatusNotFound, "CommitNotFound", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) 170 + } 171 + if _, stderr, err := runGit("branch", "tracking", input.Source.Commit); err != nil { 172 + return fail(http.StatusNotFound, "CommitNotFound", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) 173 + } 174 + 175 + // 3. populate the working tree on the base branch. 176 + if _, stderr, err := runGit("checkout", "-f", "base"); err != nil { 177 + return fail(http.StatusInternalServerError, "InternalError", "failed to perform merge check", "step", "checkout base", "err", err, "stderr", strings.TrimSpace(string(stderr))) 178 + } 179 + 180 + // 4. attempt a 3-way merge without committing. 181 + if _, stderr, err := runGit("merge", "--no-commit", "--no-ff", "tracking"); err != nil { 182 + lsOut, _, _ := runGit("ls-files", "--unmerged") 183 + files := parseUnmergedFiles(lsOut) 184 + if len(files) == 0 { 185 + return fail(http.StatusInternalServerError, "InternalError", "failed to perform merge check", "step", "merge", "err", err, "stderr", strings.TrimSpace(string(stderr))) 186 + } 187 + 188 + conflicts := make([]*tangled.GitMergeCheck_ConflictInfo, 0, len(files)) 189 + for _, f := range files { 190 + conflicts = append(conflicts, &tangled.GitMergeCheck_ConflictInfo{ 191 + Filename: f, 192 + Reason: "merge conflict", 193 + }) 194 + } 195 + msg := strings.TrimSpace(string(stderr)) 196 + l.Debug("merge check found conflicts", "files", files) 197 + return tangled.GitMergeCheck_Output{ 198 + IsConflicted: true, 199 + Conflicts: conflicts, 200 + Message: &msg, 201 + }, http.StatusOK, nil 202 + } 203 + 204 + return tangled.GitMergeCheck_Output{IsConflicted: false}, http.StatusOK, nil 205 + } 206 + 207 + // lexgen doesn't give Validate() method... 208 + func gitMergeCheck_Input_Validate(input tangled.GitMergeCheck_Input) error { 209 + if _, err := syntax.ParseDID(input.Repo); err != nil { 210 + return fmt.Errorf("repo: invalid DID: %w", err) 211 + } 212 + if input.Branch == "" { 213 + return fmt.Errorf("branch: required") 214 + } 215 + if input.Source == nil { 216 + return fmt.Errorf("source: required") 217 + } 218 + if _, err := syntax.ParseDID(input.Source.Repo); err != nil { 219 + return fmt.Errorf("source.repo: invalid DID: %w", err) 220 + } 221 + if input.Source.Commit == "" { 222 + return fmt.Errorf("source.commit: required") 223 + } 224 + return nil 225 + }
+374
knotserver/xrpc/git_merge_commit.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "net/url" 11 + "os" 12 + "os/exec" 13 + "path/filepath" 14 + "strings" 15 + 16 + "github.com/bluesky-social/indigo/atproto/atclient" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + "tangled.org/core/api/tangled" 19 + "tangled.org/core/knotserver/sandbox" 20 + "tangled.org/core/rbac" 21 + ) 22 + 23 + func (x *Xrpc) MergeCommit(w http.ResponseWriter, r *http.Request) { 24 + var input tangled.GitMergeCommit_Input 25 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 26 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"}) 27 + return 28 + } 29 + l := x.Logger.With("handler", "MergeCommit", "input", input) 30 + l.Debug("request") 31 + 32 + if err := gitMergeCommit_Input_Validate(input); err != nil { 33 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: err.Error()}) 34 + return 35 + } 36 + 37 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 38 + if !ok { 39 + writeJson(w, http.StatusUnauthorized, &atclient.ErrorBody{Name: "Unauthorized", Message: "missing actor DID"}) 40 + return 41 + } 42 + if allowed, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, input.Target.Repo); err != nil || !allowed { 43 + writeJson(w, http.StatusUnauthorized, &atclient.ErrorBody{Name: "Forbidden", Message: fmt.Sprintf("%s is not allowed to merge into this repository", actorDid.String())}) 44 + return 45 + } 46 + 47 + output, status, apierr := x.mergeCommit(r.Context(), input) 48 + if apierr != nil { 49 + l.Error("failed", "kind", apierr.Name, "error", apierr.Message) 50 + writeJson(w, status, apierr) 51 + return 52 + } 53 + 54 + writeJson(w, status, output) 55 + } 56 + 57 + func (x *Xrpc) mergeCommit(ctx context.Context, input tangled.GitMergeCommit_Input) (any, int, *atclient.ErrorBody) { 58 + l := x.Logger.With("handler", "mergePullRequest") 59 + 60 + fail := func(status int, name, clientMsg string, detail ...any) (any, int, *atclient.ErrorBody) { 61 + l.Error(clientMsg, append([]any{"name", name}, detail...)...) 62 + return nil, status, &atclient.ErrorBody{Name: name, Message: clientMsg} 63 + } 64 + 65 + baseRepoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Target.Repo) 66 + if err != nil { 67 + return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "repo", input.Target.Repo, "err", err) 68 + } 69 + sourceRepoDid := syntax.DID(input.Source.Repo) 70 + 71 + var sourceRepoUrl string 72 + var sourceRepoPath string // non-empty only when the source is local to this knot 73 + if p, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Source.Repo); err == nil { 74 + sourceRepoPath = p 75 + sourceRepoUrl = "file://" + p 76 + } else { 77 + ident, err := x.Resolver.Directory().LookupDID(ctx, sourceRepoDid) 78 + if err != nil { 79 + return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "err", err) 80 + } 81 + sourceKnot := ident.GetServiceEndpoint("atproto_pds") 82 + u, err := url.Parse(sourceKnot) 83 + if err != nil { 84 + return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "knot", sourceKnot, "err", err) 85 + } 86 + sourceRepoUrl = u.JoinPath(sourceRepoDid.String()).String() 87 + } 88 + 89 + var authorName, authorEmail, authorDate string 90 + if input.MergeCommit != nil && input.MergeCommit.Author != nil { 91 + authorName = input.MergeCommit.Author.Name 92 + authorEmail = input.MergeCommit.Author.Email 93 + authorDate = input.MergeCommit.Author.When 94 + } else { 95 + authorName = x.Config.Git.UserName 96 + authorEmail = x.Config.Git.UserEmail 97 + } 98 + 99 + var message string 100 + if input.MergeCommit != nil && input.MergeCommit.Message != nil { 101 + message = *input.MergeCommit.Message 102 + } else { 103 + shortSha := input.Source.Commit 104 + if len(shortSha) > 8 { 105 + shortSha = shortSha[:8] 106 + } 107 + message = fmt.Sprintf("Merge %s into %s", shortSha, input.Target.Branch) 108 + } 109 + 110 + env := append(os.Environ(), 111 + "GIT_TERMINAL_PROMPT=0", 112 + "GIT_AUTHOR_NAME="+authorName, 113 + "GIT_AUTHOR_EMAIL="+authorEmail, 114 + "GIT_COMMITTER_NAME="+x.Config.Git.UserName, 115 + "GIT_COMMITTER_EMAIL="+x.Config.Git.UserEmail, 116 + ) 117 + if authorDate != "" { 118 + env = append(env, "GIT_AUTHOR_DATE="+authorDate) 119 + } 120 + 121 + // 1. create temp repo with git alternate 122 + tmpRepoPath, cleanup, err := createTemporaryRepoForMerge(ctx, x.Sandbox, baseRepoPath, input.Target.Branch) 123 + if err != nil { 124 + return fail(http.StatusInternalServerError, "InternalError", "failed to prepare merge", "err", err) 125 + } 126 + defer cleanup() 127 + 128 + runGit := func(args ...string) ([]byte, []byte, error) { 129 + args = append([]string{"-C", tmpRepoPath}, args...) 130 + return gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath}, args...) 131 + } 132 + 133 + // 2. fetch source as "tracking" branch 134 + fetchPaths := []string{tmpRepoPath} 135 + if sourceRepoPath != "" { 136 + fetchPaths = append(fetchPaths, sourceRepoPath) 137 + } 138 + if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, fetchPaths, "-C", tmpRepoPath, "fetch", sourceRepoUrl, input.Source.Commit); err != nil { 139 + return fail(http.StatusInternalServerError, "InternalError", "failed to fetch source commit", "err", err, "stderr", strings.TrimSpace(string(stderr))) 140 + } 141 + if _, stderr, err := runGit("branch", "tracking", input.Source.Commit); err != nil { 142 + return fail(http.StatusBadRequest, "InvalidCommit", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) 143 + } 144 + 145 + // populate the working tree on the base branch. 146 + if _, stderr, err := runGit("checkout", "-f", "base"); err != nil { 147 + return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base", "err", err, "stderr", strings.TrimSpace(string(stderr))) 148 + } 149 + 150 + // 3. merge 151 + switch input.Style { 152 + case "rebase": 153 + if status, apierr := rebaseTrackingOntoBase(runGit, l, tmpRepoPath); apierr != nil { 154 + return nil, status, apierr 155 + } 156 + if _, stderr, err := runGit("checkout", "base"); err != nil { 157 + return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base after rebase", "err", err, "stderr", strings.TrimSpace(string(stderr))) 158 + } 159 + if _, stderr, err := runGit("merge", "--ff-only", "staging"); err != nil { 160 + return mergeConflictError(runGit, l, stderr) 161 + } 162 + 163 + case "merge": 164 + if _, stderr, err := runGit("merge", "--no-ff", "--no-commit", "tracking"); err != nil { 165 + return mergeConflictError(runGit, l, stderr) 166 + } 167 + if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil { 168 + return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "merge commit", "err", err, "stderr", strings.TrimSpace(string(stderr))) 169 + } 170 + 171 + case "rebase-merge": 172 + if status, apierr := rebaseTrackingOntoBase(runGit, l, tmpRepoPath); apierr != nil { 173 + return nil, status, apierr 174 + } 175 + if _, stderr, err := runGit("checkout", "base"); err != nil { 176 + return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base after rebase", "err", err, "stderr", strings.TrimSpace(string(stderr))) 177 + } 178 + if _, stderr, err := runGit("merge", "--no-ff", "--no-commit", "staging"); err != nil { 179 + return mergeConflictError(runGit, l, stderr) 180 + } 181 + if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil { 182 + return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "merge commit", "err", err, "stderr", strings.TrimSpace(string(stderr))) 183 + } 184 + 185 + case "squash-rebase": 186 + if _, stderr, err := runGit("merge", "--squash", "tracking"); err != nil { 187 + return mergeConflictError(runGit, l, stderr) 188 + } 189 + if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil { 190 + return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "squash commit", "err", err, "stderr", strings.TrimSpace(string(stderr))) 191 + } 192 + 193 + case "squash-merge": 194 + // TODO: implement this 195 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("unknown merge style: %q", input.Style)} 196 + 197 + case "squash-rebase-merge": 198 + return fail(http.StatusInternalServerError, "InternalError", "squash-rebase-merge is not yet supported") 199 + 200 + case "fast-forward-only": 201 + if _, stderr, err := runGit("merge", "--ff-only", "tracking"); err != nil { 202 + return fail(http.StatusConflict, "MergeConflict", "cannot fast-forward: source and target have diverged", "commit", input.Source.Commit, "branch", input.Target.Branch, "err", err, "stderr", strings.TrimSpace(string(stderr))) 203 + } 204 + 205 + default: 206 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("unknown merge style: %q", input.Style)} 207 + } 208 + 209 + // 4. push the merged "base" back to the real base repo's branch. this goes through 210 + // receive-pack so the base repo's hooks fire (ref-update notification). 211 + if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath, baseRepoPath}, "-C", tmpRepoPath, "push", "origin", "base:refs/heads/"+input.Target.Branch); err != nil { 212 + msg := strings.TrimSpace(string(stderr)) 213 + if strings.Contains(msg, "non-fast-forward") || strings.Contains(msg, "rejected") { 214 + return fail(http.StatusConflict, "PushRejected", "target branch changed; retry the merge", "branch", input.Target.Branch, "err", err, "stderr", msg) 215 + } 216 + return fail(http.StatusInternalServerError, "InternalError", "failed to complete merge", "step", "push", "err", err, "stderr", msg) 217 + } 218 + 219 + return nil, http.StatusOK, nil 220 + } 221 + 222 + // rebaseTrackingOntoBase checks out "tracking" as "staging" and rebases it onto "base". 223 + func rebaseTrackingOntoBase(run func(...string) ([]byte, []byte, error), l *slog.Logger, tmpRepoPath string) (int, *atclient.ErrorBody) { 224 + if _, stderr, err := run("checkout", "-b", "staging", "tracking"); err != nil { 225 + l.Error("failed to merge commit", "step", "checkout staging", "err", err, "stderr", strings.TrimSpace(string(stderr))) 226 + return http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"} 227 + } 228 + if _, stderr, err := run("rebase", "base"); err != nil { 229 + if _, statErr := os.Stat(filepath.Join(tmpRepoPath, ".git", "REBASE_HEAD")); statErr == nil { 230 + l.Error("rebase produced conflicts", "err", err, "stderr", strings.TrimSpace(string(stderr))) 231 + return http.StatusConflict, &atclient.ErrorBody{Name: "MergeConflict", Message: "rebase produced conflicts"} 232 + } 233 + l.Error("failed to merge commit", "step", "rebase", "err", err, "stderr", strings.TrimSpace(string(stderr))) 234 + return http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"} 235 + } 236 + return 0, nil 237 + } 238 + 239 + // mergeConflictError inspects the temp repo for unmerged paths after a failed merge and 240 + // returns a 409 listing them, falling back to a 500 when the failure is not a conflict. 241 + func mergeConflictError(runGit func(...string) ([]byte, []byte, error), l *slog.Logger, mergeStderr []byte) (any, int, *atclient.ErrorBody) { 242 + stdout, _, _ := runGit("ls-files", "--unmerged") 243 + files := parseUnmergedFiles(stdout) 244 + if len(files) > 0 { 245 + l.Error("merge produced conflicts", "files", files, "stderr", strings.TrimSpace(string(mergeStderr))) 246 + return nil, http.StatusConflict, &atclient.ErrorBody{Name: "MergeConflict", Message: "merge produced conflicts"} 247 + } 248 + l.Error("failed to merge commit", "step", "merge", "stderr", strings.TrimSpace(string(mergeStderr))) 249 + return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"} 250 + } 251 + 252 + // parseUnmergedFiles parses `git ls-files --unmerged` output into a deduplicated list of paths. 253 + // Each line looks like: "<mode> <sha> <stage>\t<path>". 254 + func parseUnmergedFiles(out []byte) []string { 255 + seen := make(map[string]struct{}) 256 + var files []string 257 + for line := range strings.SplitSeq(string(out), "\n") { 258 + _, path, ok := strings.Cut(line, "\t") 259 + if !ok { 260 + continue 261 + } 262 + if _, ok := seen[path]; ok { 263 + continue 264 + } 265 + seen[path] = struct{}{} 266 + files = append(files, path) 267 + } 268 + return files 269 + } 270 + 271 + // lexgen doesn't give Validate() method... 272 + func gitMergeCommit_Input_Validate(input tangled.GitMergeCommit_Input) error { 273 + if input.Target == nil { 274 + return fmt.Errorf("target: required") 275 + } 276 + if input.Source == nil { 277 + return fmt.Errorf("source: required") 278 + } 279 + if _, err := syntax.ParseDID(input.Target.Repo); err != nil { 280 + return fmt.Errorf("target.repo: invalid DID: %w", err) 281 + } 282 + if input.Target.Branch == "" { 283 + return fmt.Errorf("target.branch: required") 284 + } 285 + if _, err := syntax.ParseDID(input.Source.Repo); err != nil { 286 + return fmt.Errorf("source.repo: invalid DID: %w", err) 287 + } 288 + if input.Source.Commit == "" { 289 + return fmt.Errorf("source.commit: required") 290 + } 291 + switch input.Style { 292 + case "merge", "rebase", "rebase-merge", "squash", "fast-forward-only": 293 + default: 294 + return fmt.Errorf("style: unknown merge style %q", input.Style) 295 + } 296 + return nil 297 + } 298 + 299 + // createTemporaryRepoForMerge creates a temporary non-bare repo with the base repo's 300 + // "base" branch (and a copy "original_base") checked out via a git alternate to the base 301 + // repo's object store. Returns the temp repo path and a cleanup func that removes it. 302 + func createTemporaryRepoForMerge(ctx context.Context, sb sandbox.Backend, baseRepoPath string, baseBranch string) (path string, cleanup context.CancelFunc, err error) { 303 + tmp, err := os.MkdirTemp("", "merge-*") 304 + if err != nil { 305 + return "", nil, fmt.Errorf("create temp dir: %w", err) 306 + } 307 + cleanup = func() { os.RemoveAll(tmp) } 308 + 309 + env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") 310 + run := func(paths []string, args ...string) ([]byte, []byte, error) { 311 + return gitWithSandbox(ctx, sb, env, paths, args...) 312 + } 313 + 314 + // git init needs a working dir (non-bare). 315 + if _, stderr, err := run([]string{tmp}, "-C", tmp, "init"); err != nil { 316 + cleanup() 317 + return "", nil, fmt.Errorf("git init: %s", strings.TrimSpace(string(stderr))) 318 + } 319 + 320 + // borrow the base repo's objects via alternates. 321 + if err := func(repoPath, srcRepoPath string) error { 322 + p := filepath.Join(repoPath, ".git", "objects", "info", "alternates") 323 + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) 324 + if err != nil { 325 + return err 326 + } 327 + defer f.Close() 328 + _, err = fmt.Fprintln(f, filepath.Join(srcRepoPath, "objects")) 329 + return err 330 + }(tmp, baseRepoPath); err != nil { 331 + cleanup() 332 + return "", nil, fmt.Errorf("add base objects: %w", err) 333 + } 334 + 335 + if _, stderr, err := run([]string{tmp}, "-C", tmp, "remote", "add", "origin", baseRepoPath); err != nil { 336 + cleanup() 337 + return "", nil, fmt.Errorf("git remote add: %s", strings.TrimSpace(string(stderr))) 338 + } 339 + 340 + if _, stderr, err := run([]string{tmp, baseRepoPath}, "-C", tmp, "fetch", "--no-tags", "origin", baseBranch+":base", baseBranch+":original_base"); err != nil { 341 + cleanup() 342 + return "", nil, fmt.Errorf("git fetch base branch %q: %s", baseBranch, strings.TrimSpace(string(stderr))) 343 + } 344 + 345 + if _, stderr, err := run([]string{tmp}, "-C", tmp, "symbolic-ref", "HEAD", "refs/heads/base"); err != nil { 346 + cleanup() 347 + return "", nil, fmt.Errorf("git symbolic-ref: %s", strings.TrimSpace(string(stderr))) 348 + } 349 + 350 + return tmp, cleanup, nil 351 + } 352 + 353 + func gitWithSandbox(ctx context.Context, sb sandbox.Backend, env []string, paths []string, args ...string) (stdout, stderr []byte, err error) { 354 + cmd := exec.CommandContext(ctx, "git", args...) 355 + var outBuf, errBuf bytes.Buffer 356 + // set stdout/stderr/env before wrapping: the landlock backend copies these into a 357 + // fresh *exec.Cmd, so mutating the original afterwards would be lost. 358 + cmd.Stdout = &outBuf 359 + cmd.Stderr = &errBuf 360 + cmd.Env = env 361 + 362 + if sb != nil { 363 + wrapped, werr := sb.WrapMulti(paths, cmd) 364 + if werr != nil { 365 + return nil, nil, fmt.Errorf("sandbox wrap: %w", werr) 366 + } 367 + cmd = wrapped 368 + } else if len(paths) > 0 { 369 + cmd.Dir = paths[0] 370 + } 371 + 372 + err = cmd.Run() 373 + return outBuf.Bytes(), errBuf.Bytes(), err 374 + }
+33
api/tangled/tempformatPatch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.git.temp.formatPatch 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + GitTempFormatPatchNSID = "sh.tangled.git.temp.formatPatch" 16 + ) 17 + 18 + // GitTempFormatPatch calls the XRPC method "sh.tangled.git.temp.formatPatch". 19 + // 20 + // repo: DID of the repository 21 + func GitTempFormatPatch(ctx context.Context, c util.LexClient, from string, repo string, to string) ([]byte, error) { 22 + buf := new(bytes.Buffer) 23 + 24 + params := map[string]interface{}{} 25 + params["from"] = from 26 + params["repo"] = repo 27 + params["to"] = to 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.formatPatch", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+43
lexicons/git/temp/formatPatch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.git.temp.formatPatch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "from", "to"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "format": "did", 14 + "description": "DID of the repository" 15 + }, 16 + "from": { 17 + "type": "string" 18 + }, 19 + "to": { 20 + "type": "string" 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": "RevisionNotFound", 34 + "description": "One or both revisions not found" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+54
knotmirror/xrpc/git_format_patch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "net/http" 7 + "os/exec" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/atclient" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + func (x *Xrpc) FormatPatch(w http.ResponseWriter, r *http.Request) { 15 + var ( 16 + repoQuery = r.URL.Query().Get("repo") 17 + from = r.URL.Query().Get("from") 18 + to = r.URL.Query().Get("to") 19 + ) 20 + 21 + repo, err := syntax.ParseDID(repoQuery) 22 + if err != nil { 23 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 24 + return 25 + } 26 + 27 + // reject leading dashes so user input can't be parsed as git flags 28 + if from == "" || to == "" || strings.HasPrefix(from, "-") || strings.HasPrefix(to, "-") { 29 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing or invalid from/to parameter"}) 30 + return 31 + } 32 + 33 + l := x.logger.With("method", "git.formatPatch", "repo", repo, "from", from, "to", to) 34 + l.Debug("request") 35 + 36 + ctx := r.Context() 37 + 38 + repoPath, err := x.makeRepoPath(ctx, repo) 39 + if err != nil { 40 + writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %q", repo)}) 41 + return 42 + } 43 + 44 + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "format-patch", "--stdout", fmt.Sprintf("%s..%s", from, to)) 45 + stderr := new(bytes.Buffer) 46 + cmd.Stderr = stderr 47 + 48 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 49 + cmd.Stdout = w 50 + if err := cmd.Run(); err != nil { 51 + l.Error("running format-patch", "err", err, "stderr", stderr.String()) 52 + w.WriteHeader(http.StatusInternalServerError) 53 + } 54 + }
+268 -1
api/tangled/cbor_gen.go
··· 12416 12416 } 12417 12417 12418 12418 cw := cbg.NewCborWriter(w) 12419 - fieldCount := 10 12419 + fieldCount := 11 12420 12420 12421 12421 if t.Body == nil { 12422 12422 fieldCount-- ··· 12613 12613 } 12614 12614 } 12615 12615 12616 + // t.Versions ([]*tangled.RepoPull_Version) (slice) 12617 + if len("versions") > 1000000 { 12618 + return xerrors.Errorf("Value in field \"versions\" was too long") 12619 + } 12620 + 12621 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("versions"))); err != nil { 12622 + return err 12623 + } 12624 + if _, err := cw.WriteString(string("versions")); err != nil { 12625 + return err 12626 + } 12627 + 12628 + if len(t.Versions) > 8192 { 12629 + return xerrors.Errorf("Slice value in field t.Versions was too long") 12630 + } 12631 + 12632 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Versions))); err != nil { 12633 + return err 12634 + } 12635 + for _, v := range t.Versions { 12636 + if err := v.MarshalCBOR(cw); err != nil { 12637 + return err 12638 + } 12639 + 12640 + } 12641 + 12616 12642 // t.CreatedAt (string) (string) 12617 12643 if len("createdAt") > 1000000 { 12618 12644 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 12919 12945 12920 12946 } 12921 12947 } 12948 + // t.Versions ([]*tangled.RepoPull_Version) (slice) 12949 + case "versions": 12950 + 12951 + maj, extra, err = cr.ReadHeader() 12952 + if err != nil { 12953 + return err 12954 + } 12955 + 12956 + if extra > 8192 { 12957 + return fmt.Errorf("t.Versions: array too large (%d)", extra) 12958 + } 12959 + 12960 + if maj != cbg.MajArray { 12961 + return fmt.Errorf("expected cbor array") 12962 + } 12963 + 12964 + if extra > 0 { 12965 + t.Versions = make([]*RepoPull_Version, extra) 12966 + } 12967 + 12968 + for i := 0; i < int(extra); i++ { 12969 + { 12970 + var maj byte 12971 + var extra uint64 12972 + var err error 12973 + _ = maj 12974 + _ = extra 12975 + _ = err 12976 + 12977 + { 12978 + 12979 + b, err := cr.ReadByte() 12980 + if err != nil { 12981 + return err 12982 + } 12983 + if b != cbg.CborNull[0] { 12984 + if err := cr.UnreadByte(); err != nil { 12985 + return err 12986 + } 12987 + t.Versions[i] = new(RepoPull_Version) 12988 + if err := t.Versions[i].UnmarshalCBOR(cr); err != nil { 12989 + return xerrors.Errorf("unmarshaling t.Versions[i] pointer: %w", err) 12990 + } 12991 + } 12992 + 12993 + } 12994 + 12995 + } 12996 + } 12922 12997 // t.CreatedAt (string) (string) 12923 12998 case "createdAt": 12924 12999 ··· 13987 14062 13988 14063 return nil 13989 14064 } 14065 + func (t *RepoPull_Version) MarshalCBOR(w io.Writer) error { 14066 + if t == nil { 14067 + _, err := w.Write(cbg.CborNull) 14068 + return err 14069 + } 14070 + 14071 + cw := cbg.NewCborWriter(w) 14072 + fieldCount := 3 14073 + 14074 + if t.Base == nil { 14075 + fieldCount-- 14076 + } 14077 + 14078 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 14079 + return err 14080 + } 14081 + 14082 + // t.Base (string) (string) 14083 + if t.Base != nil { 14084 + 14085 + if len("base") > 1000000 { 14086 + return xerrors.Errorf("Value in field \"base\" was too long") 14087 + } 14088 + 14089 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("base"))); err != nil { 14090 + return err 14091 + } 14092 + if _, err := cw.WriteString(string("base")); err != nil { 14093 + return err 14094 + } 14095 + 14096 + if t.Base == nil { 14097 + if _, err := cw.Write(cbg.CborNull); err != nil { 14098 + return err 14099 + } 14100 + } else { 14101 + if len(*t.Base) > 1000000 { 14102 + return xerrors.Errorf("Value in field t.Base was too long") 14103 + } 14104 + 14105 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Base))); err != nil { 14106 + return err 14107 + } 14108 + if _, err := cw.WriteString(string(*t.Base)); err != nil { 14109 + return err 14110 + } 14111 + } 14112 + } 14113 + 14114 + // t.Head (string) (string) 14115 + if len("head") > 1000000 { 14116 + return xerrors.Errorf("Value in field \"head\" was too long") 14117 + } 14118 + 14119 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("head"))); err != nil { 14120 + return err 14121 + } 14122 + if _, err := cw.WriteString(string("head")); err != nil { 14123 + return err 14124 + } 14125 + 14126 + if len(t.Head) > 1000000 { 14127 + return xerrors.Errorf("Value in field t.Head was too long") 14128 + } 14129 + 14130 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Head))); err != nil { 14131 + return err 14132 + } 14133 + if _, err := cw.WriteString(string(t.Head)); err != nil { 14134 + return err 14135 + } 14136 + 14137 + // t.CreatedAt (string) (string) 14138 + if len("createdAt") > 1000000 { 14139 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 14140 + } 14141 + 14142 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 14143 + return err 14144 + } 14145 + if _, err := cw.WriteString(string("createdAt")); err != nil { 14146 + return err 14147 + } 14148 + 14149 + if len(t.CreatedAt) > 1000000 { 14150 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 14151 + } 14152 + 14153 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 14154 + return err 14155 + } 14156 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 14157 + return err 14158 + } 14159 + return nil 14160 + } 14161 + 14162 + func (t *RepoPull_Version) UnmarshalCBOR(r io.Reader) (err error) { 14163 + *t = RepoPull_Version{} 14164 + 14165 + cr := cbg.NewCborReader(r) 14166 + 14167 + maj, extra, err := cr.ReadHeader() 14168 + if err != nil { 14169 + return err 14170 + } 14171 + defer func() { 14172 + if err == io.EOF { 14173 + err = io.ErrUnexpectedEOF 14174 + } 14175 + }() 14176 + 14177 + if maj != cbg.MajMap { 14178 + return fmt.Errorf("cbor input should be of type map") 14179 + } 14180 + 14181 + if extra > cbg.MaxLength { 14182 + return fmt.Errorf("RepoPull_Version: map struct too large (%d)", extra) 14183 + } 14184 + 14185 + n := extra 14186 + 14187 + nameBuf := make([]byte, 9) 14188 + for i := uint64(0); i < n; i++ { 14189 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 14190 + if err != nil { 14191 + return err 14192 + } 14193 + 14194 + if !ok { 14195 + // Field doesn't exist on this type, so ignore it 14196 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 14197 + return err 14198 + } 14199 + continue 14200 + } 14201 + 14202 + switch string(nameBuf[:nameLen]) { 14203 + // t.Base (string) (string) 14204 + case "base": 14205 + 14206 + { 14207 + b, err := cr.ReadByte() 14208 + if err != nil { 14209 + return err 14210 + } 14211 + if b != cbg.CborNull[0] { 14212 + if err := cr.UnreadByte(); err != nil { 14213 + return err 14214 + } 14215 + 14216 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 14217 + if err != nil { 14218 + return err 14219 + } 14220 + 14221 + t.Base = (*string)(&sval) 14222 + } 14223 + } 14224 + // t.Head (string) (string) 14225 + case "head": 14226 + 14227 + { 14228 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 14229 + if err != nil { 14230 + return err 14231 + } 14232 + 14233 + t.Head = string(sval) 14234 + } 14235 + // t.CreatedAt (string) (string) 14236 + case "createdAt": 14237 + 14238 + { 14239 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 14240 + if err != nil { 14241 + return err 14242 + } 14243 + 14244 + t.CreatedAt = string(sval) 14245 + } 14246 + 14247 + default: 14248 + // Field doesn't exist on this type, so ignore it 14249 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 14250 + return err 14251 + } 14252 + } 14253 + } 14254 + 14255 + return nil 14256 + } 13990 14257 func (t *Spindle) MarshalCBOR(w io.Writer) error { 13991 14258 if t == nil { 13992 14259 _, err := w.Write(cbg.CborNull)
+22 -11
api/tangled/repopull.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPull 19 19 type RepoPull struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - DependentOn *string `json:"dependentOn,omitempty" cborgen:"dependentOn,omitempty"` 24 - Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 25 - References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 - Rounds []*RepoPull_Round `json:"rounds" cborgen:"rounds"` 27 - Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 28 - Target *RepoPull_Target `json:"target" cborgen:"target"` 29 - Title string `json:"title" cborgen:"title"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + DependentOn *string `json:"dependentOn,omitempty" cborgen:"dependentOn,omitempty"` 24 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 + // rounds: DEPRECATED: always empty 27 + Rounds []*RepoPull_Round `json:"rounds" cborgen:"rounds"` 28 + Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 29 + Target *RepoPull_Target `json:"target" cborgen:"target"` 30 + Title string `json:"title" cborgen:"title"` 31 + Versions []*RepoPull_Version `json:"versions" cborgen:"versions"` 30 32 } 31 33 32 34 // RepoPull_Round is a "round" in the sh.tangled.repo.pull schema. 33 35 // 34 - // revisions of this pull request, newer rounds are appended to this array. appviews may reject records do not treat this field as append-only. the blob format is gzipped text-based git-format-patches. 36 + // DEPRECATED: revisions of this pull request, newer rounds are appended to this array. appviews may reject records do not treat this field as append-only. the blob format is gzipped text-based git-format-patches. 35 37 type RepoPull_Round struct { 36 38 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 37 39 PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"` ··· 48 50 Branch string `json:"branch" cborgen:"branch"` 49 51 Repo string `json:"repo" cborgen:"repo"` 50 52 } 53 + 54 + // RepoPull_Version is a "version" in the sh.tangled.repo.pull schema. 55 + type RepoPull_Version struct { 56 + // base: merge-base commit ID for combined interdiff 57 + Base *string `json:"base,omitempty" cborgen:"base,omitempty"` 58 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 59 + // head: HEAD commit ID 60 + Head string `json:"head" cborgen:"head"` 61 + }
+1
cmd/cborgen/cborgen.go
··· 67 67 tangled.RepoPull_Source{}, 68 68 tangled.RepoPullStatus{}, 69 69 tangled.RepoPull_Target{}, 70 + tangled.RepoPull_Version{}, 70 71 tangled.Spindle{}, 71 72 tangled.SpindleMember{}, 72 73 tangled.String{},
+32 -2
lexicons/pulls/pull.json
··· 13 13 "target", 14 14 "title", 15 15 "createdAt", 16 - "rounds" 16 + "rounds", 17 + "versions" 17 18 ], 18 19 "properties": { 19 20 "title": { ··· 24 25 }, 25 26 "rounds": { 26 27 "type": "array", 28 + "description": "DEPRECATED: always empty", 27 29 "items": { 28 30 "type": "ref", 29 31 "ref": "#round" 30 32 } 31 33 }, 34 + "versions": { 35 + "type": "array", 36 + "items": { 37 + "type": "ref", 38 + "ref": "#version" 39 + } 40 + }, 32 41 "source": { 33 42 "type": "ref", 34 43 "ref": "#source" ··· 99 108 "patchBlob", 100 109 "createdAt" 101 110 ], 102 - "description": "revisions of this pull request, newer rounds are appended to this array. appviews may reject records do not treat this field as append-only. the blob format is gzipped text-based git-format-patches.", 111 + "description": "DEPRECATED: revisions of this pull request, newer rounds are appended to this array. appviews may reject records do not treat this field as append-only. the blob format is gzipped text-based git-format-patches.", 103 112 "properties": { 104 113 "createdAt": { 105 114 "type": "string", ··· 112 121 ] 113 122 } 114 123 } 124 + }, 125 + "version": { 126 + "type": "object", 127 + "required": [ 128 + "head", 129 + "createdAt" 130 + ], 131 + "properties": { 132 + "createdAt": { 133 + "type": "string", 134 + "format": "datetime" 135 + }, 136 + "base": { 137 + "type": "string", 138 + "description": "merge-base commit ID for combined interdiff" 139 + }, 140 + "head": { 141 + "type": "string", 142 + "description": "HEAD commit ID" 143 + } 144 + } 115 145 } 116 146 } 117 147 }
+2
.cargo/config.toml
··· 1 + [target.aarch64-apple-darwin] 2 + linker = "arm64-apple-darwin-cc"
+2
.gitattributes
··· 1 1 api/tangled/** linguist-generated -diff 2 2 api/tangled/*_ext.go -linguist-generated diff 3 3 flake.lock -diff 4 + Cargo.lock -diff 5 + gitmirror/proto/gen/** linguist-generated -diff
+1455 -35
Cargo.lock
··· 148 148 "rustversion", 149 149 ] 150 150 151 + [[package]] 152 + name = "arrayvec" 153 + version = "0.7.7" 154 + source = "registry+https://github.com/rust-lang/crates.io-index" 155 + checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" 156 + 151 157 [[package]] 152 158 name = "assert-json-diff" 153 159 version = "2.0.2" ··· 309 315 source = "registry+https://github.com/rust-lang/crates.io-index" 310 316 checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" 311 317 318 + [[package]] 319 + name = "bitflags" 320 + version = "1.3.2" 321 + source = "registry+https://github.com/rust-lang/crates.io-index" 322 + checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 323 + 312 324 [[package]] 313 325 name = "bitflags" 314 326 version = "2.11.1" ··· 333 345 "generic-array", 334 346 ] 335 347 348 + [[package]] 349 + name = "block-buffer" 350 + version = "0.12.1" 351 + source = "registry+https://github.com/rust-lang/crates.io-index" 352 + checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" 353 + dependencies = [ 354 + "hybrid-array", 355 + ] 356 + 336 357 [[package]] 337 358 name = "bobbin" 338 359 version = "0.0.1" ··· 647 668 "tinyvec", 648 669 ] 649 670 671 + [[package]] 672 + name = "bstr" 673 + version = "1.12.3" 674 + source = "registry+https://github.com/rust-lang/crates.io-index" 675 + checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" 676 + dependencies = [ 677 + "memchr", 678 + "regex-automata", 679 + "serde_core", 680 + ] 681 + 650 682 [[package]] 651 683 name = "bumpalo" 652 684 version = "3.20.3" ··· 668 700 "serde", 669 701 ] 670 702 703 + [[package]] 704 + name = "bytesize" 705 + version = "2.4.0" 706 + source = "registry+https://github.com/rust-lang/crates.io-index" 707 + checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" 708 + 671 709 [[package]] 672 710 name = "cast" 673 711 version = "0.3.0" ··· 825 863 source = "registry+https://github.com/rust-lang/crates.io-index" 826 864 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 827 865 866 + [[package]] 867 + name = "clru" 868 + version = "0.6.3" 869 + source = "registry+https://github.com/rust-lang/crates.io-index" 870 + checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" 871 + dependencies = [ 872 + "hashbrown 0.16.1", 873 + ] 874 + 828 875 [[package]] 829 876 name = "cobs" 830 877 version = "0.3.0" ··· 937 984 "libc", 938 985 ] 939 986 987 + [[package]] 988 + name = "cpufeatures" 989 + version = "0.3.0" 990 + source = "registry+https://github.com/rust-lang/crates.io-index" 991 + checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" 992 + dependencies = [ 993 + "libc", 994 + ] 995 + 940 996 [[package]] 941 997 name = "crc32fast" 942 998 version = "1.5.0" ··· 1047 1103 "typenum", 1048 1104 ] 1049 1105 1106 + [[package]] 1107 + name = "crypto-common" 1108 + version = "0.2.2" 1109 + source = "registry+https://github.com/rust-lang/crates.io-index" 1110 + checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" 1111 + dependencies = [ 1112 + "hybrid-array", 1113 + ] 1114 + 1050 1115 [[package]] 1051 1116 name = "darling" 1052 1117 version = "0.23.0" ··· 1145 1210 source = "registry+https://github.com/rust-lang/crates.io-index" 1146 1211 checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" 1147 1212 1213 + [[package]] 1214 + name = "defmt" 1215 + version = "1.1.0" 1216 + source = "registry+https://github.com/rust-lang/crates.io-index" 1217 + checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" 1218 + dependencies = [ 1219 + "bitflags 1.3.2", 1220 + "defmt-macros", 1221 + ] 1222 + 1223 + [[package]] 1224 + name = "defmt-macros" 1225 + version = "1.1.0" 1226 + source = "registry+https://github.com/rust-lang/crates.io-index" 1227 + checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" 1228 + dependencies = [ 1229 + "defmt-parser", 1230 + "proc-macro-error2", 1231 + "proc-macro2", 1232 + "quote", 1233 + "syn", 1234 + ] 1235 + 1236 + [[package]] 1237 + name = "defmt-parser" 1238 + version = "1.0.0" 1239 + source = "registry+https://github.com/rust-lang/crates.io-index" 1240 + checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" 1241 + dependencies = [ 1242 + "thiserror 2.0.18", 1243 + ] 1244 + 1148 1245 [[package]] 1149 1246 name = "der" 1150 1247 version = "0.7.10" ··· 1199 1296 source = "registry+https://github.com/rust-lang/crates.io-index" 1200 1297 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 1201 1298 dependencies = [ 1202 - "block-buffer", 1299 + "block-buffer 0.10.4", 1203 1300 "const-oid", 1204 - "crypto-common", 1301 + "crypto-common 0.1.6", 1205 1302 "subtle", 1206 1303 ] 1207 1304 1305 + [[package]] 1306 + name = "digest" 1307 + version = "0.11.3" 1308 + source = "registry+https://github.com/rust-lang/crates.io-index" 1309 + checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" 1310 + dependencies = [ 1311 + "block-buffer 0.12.1", 1312 + "crypto-common 0.2.2", 1313 + ] 1314 + 1208 1315 [[package]] 1209 1316 name = "displaydoc" 1210 1317 version = "0.2.6" ··· 1222 1329 source = "registry+https://github.com/rust-lang/crates.io-index" 1223 1330 checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" 1224 1331 1332 + [[package]] 1333 + name = "dunce" 1334 + version = "1.0.5" 1335 + source = "registry+https://github.com/rust-lang/crates.io-index" 1336 + checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 1337 + 1225 1338 [[package]] 1226 1339 name = "ecdsa" 1227 1340 version = "0.16.9" ··· 1229 1342 checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 1230 1343 dependencies = [ 1231 1344 "der", 1232 - "digest", 1345 + "digest 0.10.7", 1233 1346 "elliptic-curve", 1234 1347 "rfc6979", 1235 1348 "signature", ··· 1250 1363 dependencies = [ 1251 1364 "base16ct", 1252 1365 "crypto-bigint", 1253 - "digest", 1366 + "digest 0.10.7", 1254 1367 "ff", 1255 1368 "generic-array", 1256 1369 "group", ··· 1316 1429 source = "registry+https://github.com/rust-lang/crates.io-index" 1317 1430 checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" 1318 1431 1432 + [[package]] 1433 + name = "faster-hex" 1434 + version = "0.10.0" 1435 + source = "registry+https://github.com/rust-lang/crates.io-index" 1436 + checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" 1437 + dependencies = [ 1438 + "heapless 0.8.0", 1439 + "serde", 1440 + ] 1441 + 1319 1442 [[package]] 1320 1443 name = "fastrand" 1321 1444 version = "2.4.1" ··· 1332 1455 "subtle", 1333 1456 ] 1334 1457 1458 + [[package]] 1459 + name = "filetime" 1460 + version = "0.2.29" 1461 + source = "registry+https://github.com/rust-lang/crates.io-index" 1462 + checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" 1463 + dependencies = [ 1464 + "cfg-if", 1465 + "libc", 1466 + ] 1467 + 1335 1468 [[package]] 1336 1469 name = "find-msvc-tools" 1337 1470 version = "0.1.9" ··· 1573 1706 name = "getrandom" 1574 1707 version = "0.4.2" 1575 1708 source = "registry+https://github.com/rust-lang/crates.io-index" 1576 - checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 1709 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 1710 + dependencies = [ 1711 + "cfg-if", 1712 + "libc", 1713 + "r-efi 6.0.0", 1714 + "wasip2", 1715 + "wasip3", 1716 + ] 1717 + 1718 + [[package]] 1719 + name = "gitmirror" 1720 + version = "0.1.0" 1721 + dependencies = [ 1722 + "anyhow", 1723 + "clap", 1724 + "gix", 1725 + "line-numbers", 1726 + "prost", 1727 + "prost-types", 1728 + "rustc-hash", 1729 + "tempfile", 1730 + "tokio", 1731 + "tokio-stream", 1732 + "tonic", 1733 + "tonic-prost", 1734 + "tonic-prost-build", 1735 + "tracing", 1736 + "tracing-subscriber", 1737 + ] 1738 + 1739 + [[package]] 1740 + name = "gix" 1741 + version = "0.84.0" 1742 + source = "registry+https://github.com/rust-lang/crates.io-index" 1743 + checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce" 1744 + dependencies = [ 1745 + "gix-actor", 1746 + "gix-archive", 1747 + "gix-attributes", 1748 + "gix-blame", 1749 + "gix-command", 1750 + "gix-commitgraph", 1751 + "gix-config", 1752 + "gix-credentials", 1753 + "gix-date", 1754 + "gix-diff", 1755 + "gix-dir", 1756 + "gix-discover", 1757 + "gix-error", 1758 + "gix-features", 1759 + "gix-filter", 1760 + "gix-fs", 1761 + "gix-glob", 1762 + "gix-hash", 1763 + "gix-hashtable", 1764 + "gix-ignore", 1765 + "gix-index", 1766 + "gix-lock", 1767 + "gix-mailmap", 1768 + "gix-merge", 1769 + "gix-negotiate", 1770 + "gix-object", 1771 + "gix-odb", 1772 + "gix-pack", 1773 + "gix-path", 1774 + "gix-pathspec", 1775 + "gix-prompt", 1776 + "gix-protocol", 1777 + "gix-ref", 1778 + "gix-refspec", 1779 + "gix-revision", 1780 + "gix-revwalk", 1781 + "gix-sec", 1782 + "gix-shallow", 1783 + "gix-status", 1784 + "gix-submodule", 1785 + "gix-tempfile", 1786 + "gix-trace", 1787 + "gix-traverse", 1788 + "gix-url", 1789 + "gix-utils", 1790 + "gix-validate", 1791 + "gix-worktree", 1792 + "gix-worktree-state", 1793 + "gix-worktree-stream", 1794 + "nonempty", 1795 + "parking_lot", 1796 + "regex", 1797 + "signal-hook", 1798 + "smallvec", 1799 + "thiserror 2.0.18", 1800 + ] 1801 + 1802 + [[package]] 1803 + name = "gix-actor" 1804 + version = "0.41.1" 1805 + source = "registry+https://github.com/rust-lang/crates.io-index" 1806 + checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" 1807 + dependencies = [ 1808 + "bstr", 1809 + "gix-date", 1810 + "gix-error", 1811 + ] 1812 + 1813 + [[package]] 1814 + name = "gix-archive" 1815 + version = "0.33.0" 1816 + source = "registry+https://github.com/rust-lang/crates.io-index" 1817 + checksum = "16909cacc78936ab96f6c3be08379d0a2e88bfa3a7527972d2ed75c7517ef31e" 1818 + dependencies = [ 1819 + "bstr", 1820 + "gix-date", 1821 + "gix-error", 1822 + "gix-object", 1823 + "gix-worktree-stream", 1824 + ] 1825 + 1826 + [[package]] 1827 + name = "gix-attributes" 1828 + version = "0.33.2" 1829 + source = "registry+https://github.com/rust-lang/crates.io-index" 1830 + checksum = "39b40888d0ed415c0744a6cdc61eebf0304c9d26ab726725b718443c322e5ba4" 1831 + dependencies = [ 1832 + "bstr", 1833 + "gix-glob", 1834 + "gix-path", 1835 + "gix-quote", 1836 + "gix-trace", 1837 + "kstring", 1838 + "smallvec", 1839 + "thiserror 2.0.18", 1840 + "unicode-bom", 1841 + ] 1842 + 1843 + [[package]] 1844 + name = "gix-bitmap" 1845 + version = "0.3.2" 1846 + source = "registry+https://github.com/rust-lang/crates.io-index" 1847 + checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" 1848 + dependencies = [ 1849 + "gix-error", 1850 + ] 1851 + 1852 + [[package]] 1853 + name = "gix-blame" 1854 + version = "0.14.0" 1855 + source = "registry+https://github.com/rust-lang/crates.io-index" 1856 + checksum = "4d39a0c14af94c2edaa5eefe06d5ef2cdea55316ae9a9321314288e3f55fa4c0" 1857 + dependencies = [ 1858 + "gix-commitgraph", 1859 + "gix-date", 1860 + "gix-diff", 1861 + "gix-error", 1862 + "gix-hash", 1863 + "gix-object", 1864 + "gix-revwalk", 1865 + "gix-trace", 1866 + "gix-traverse", 1867 + "gix-worktree", 1868 + "smallvec", 1869 + "thiserror 2.0.18", 1870 + ] 1871 + 1872 + [[package]] 1873 + name = "gix-chunk" 1874 + version = "0.7.2" 1875 + source = "registry+https://github.com/rust-lang/crates.io-index" 1876 + checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" 1877 + dependencies = [ 1878 + "gix-error", 1879 + ] 1880 + 1881 + [[package]] 1882 + name = "gix-command" 1883 + version = "0.9.1" 1884 + source = "registry+https://github.com/rust-lang/crates.io-index" 1885 + checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" 1886 + dependencies = [ 1887 + "bstr", 1888 + "gix-path", 1889 + "gix-quote", 1890 + "gix-trace", 1891 + "shell-words", 1892 + ] 1893 + 1894 + [[package]] 1895 + name = "gix-commitgraph" 1896 + version = "0.37.1" 1897 + source = "registry+https://github.com/rust-lang/crates.io-index" 1898 + checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb" 1899 + dependencies = [ 1900 + "bstr", 1901 + "gix-chunk", 1902 + "gix-error", 1903 + "gix-hash", 1904 + "memmap2", 1905 + "nonempty", 1906 + ] 1907 + 1908 + [[package]] 1909 + name = "gix-config" 1910 + version = "0.57.0" 1911 + source = "registry+https://github.com/rust-lang/crates.io-index" 1912 + checksum = "4f2372d4b49ca28431e7d150cab9d25edc1890f0184bd57eb0e917c7799e63de" 1913 + dependencies = [ 1914 + "bstr", 1915 + "gix-config-value", 1916 + "gix-features", 1917 + "gix-glob", 1918 + "gix-path", 1919 + "gix-ref", 1920 + "gix-sec", 1921 + "smallvec", 1922 + "thiserror 2.0.18", 1923 + "unicode-bom", 1924 + ] 1925 + 1926 + [[package]] 1927 + name = "gix-config-value" 1928 + version = "0.18.1" 1929 + source = "registry+https://github.com/rust-lang/crates.io-index" 1930 + checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" 1931 + dependencies = [ 1932 + "bitflags 2.11.1", 1933 + "bstr", 1934 + "gix-path", 1935 + "libc", 1936 + "thiserror 2.0.18", 1937 + ] 1938 + 1939 + [[package]] 1940 + name = "gix-credentials" 1941 + version = "0.38.1" 1942 + source = "registry+https://github.com/rust-lang/crates.io-index" 1943 + checksum = "f40cd22f0dd71988be12d6e78b1709de2370e1957c5f107ff31e56caeba3745d" 1944 + dependencies = [ 1945 + "bstr", 1946 + "gix-command", 1947 + "gix-config-value", 1948 + "gix-date", 1949 + "gix-path", 1950 + "gix-prompt", 1951 + "gix-sec", 1952 + "gix-trace", 1953 + "gix-url", 1954 + "thiserror 2.0.18", 1955 + ] 1956 + 1957 + [[package]] 1958 + name = "gix-date" 1959 + version = "0.15.5" 1960 + source = "registry+https://github.com/rust-lang/crates.io-index" 1961 + checksum = "3d63f9e28b59ddeb1a1eb9e5cf986a9222b5d484947445edbc20473939cc7fd0" 1962 + dependencies = [ 1963 + "bstr", 1964 + "gix-error", 1965 + "itoa", 1966 + "jiff", 1967 + ] 1968 + 1969 + [[package]] 1970 + name = "gix-diff" 1971 + version = "0.64.0" 1972 + source = "registry+https://github.com/rust-lang/crates.io-index" 1973 + checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b" 1974 + dependencies = [ 1975 + "bstr", 1976 + "gix-attributes", 1977 + "gix-command", 1978 + "gix-filter", 1979 + "gix-fs", 1980 + "gix-hash", 1981 + "gix-imara-diff", 1982 + "gix-index", 1983 + "gix-object", 1984 + "gix-path", 1985 + "gix-pathspec", 1986 + "gix-tempfile", 1987 + "gix-trace", 1988 + "gix-traverse", 1989 + "gix-worktree", 1990 + "thiserror 2.0.18", 1991 + ] 1992 + 1993 + [[package]] 1994 + name = "gix-dir" 1995 + version = "0.26.0" 1996 + source = "registry+https://github.com/rust-lang/crates.io-index" 1997 + checksum = "21bb2a53a6fd917ec499ed0bfb5b6887de7a15bd79197dcea7c987938749a9f1" 1998 + dependencies = [ 1999 + "bstr", 2000 + "gix-discover", 2001 + "gix-fs", 2002 + "gix-ignore", 2003 + "gix-index", 2004 + "gix-object", 2005 + "gix-path", 2006 + "gix-pathspec", 2007 + "gix-trace", 2008 + "gix-utils", 2009 + "gix-worktree", 2010 + "thiserror 2.0.18", 2011 + ] 2012 + 2013 + [[package]] 2014 + name = "gix-discover" 2015 + version = "0.52.0" 2016 + source = "registry+https://github.com/rust-lang/crates.io-index" 2017 + checksum = "77bacdd12b7879d2178a80c58c2f319995e4654e1a7a23e3181e5c8a12b824f7" 2018 + dependencies = [ 2019 + "bstr", 2020 + "dunce", 2021 + "gix-fs", 2022 + "gix-path", 2023 + "gix-ref", 2024 + "gix-sec", 2025 + "thiserror 2.0.18", 2026 + ] 2027 + 2028 + [[package]] 2029 + name = "gix-error" 2030 + version = "0.2.4" 2031 + source = "registry+https://github.com/rust-lang/crates.io-index" 2032 + checksum = "e57831e199be480af90dcd7e459abed8a174c09ec9a6e2cc8f7ca6c54598b06b" 2033 + dependencies = [ 2034 + "bstr", 2035 + ] 2036 + 2037 + [[package]] 2038 + name = "gix-features" 2039 + version = "0.48.1" 2040 + source = "registry+https://github.com/rust-lang/crates.io-index" 2041 + checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" 2042 + dependencies = [ 2043 + "bytes", 2044 + "bytesize", 2045 + "crc32fast", 2046 + "crossbeam-channel", 2047 + "gix-path", 2048 + "gix-trace", 2049 + "gix-utils", 2050 + "libc", 2051 + "once_cell", 2052 + "parking_lot", 2053 + "prodash", 2054 + "thiserror 2.0.18", 2055 + "walkdir", 2056 + "zlib-rs", 2057 + ] 2058 + 2059 + [[package]] 2060 + name = "gix-filter" 2061 + version = "0.31.0" 2062 + source = "registry+https://github.com/rust-lang/crates.io-index" 2063 + checksum = "ecf74b7d16f6694ce4a3049074c41be0c7987105743674f1671807bd6dce09fa" 2064 + dependencies = [ 2065 + "bstr", 2066 + "encoding_rs", 2067 + "gix-attributes", 2068 + "gix-command", 2069 + "gix-hash", 2070 + "gix-object", 2071 + "gix-packetline", 2072 + "gix-path", 2073 + "gix-quote", 2074 + "gix-trace", 2075 + "gix-utils", 2076 + "smallvec", 2077 + "thiserror 2.0.18", 2078 + ] 2079 + 2080 + [[package]] 2081 + name = "gix-fs" 2082 + version = "0.21.2" 2083 + source = "registry+https://github.com/rust-lang/crates.io-index" 2084 + checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" 2085 + dependencies = [ 2086 + "bstr", 2087 + "fastrand", 2088 + "gix-features", 2089 + "gix-path", 2090 + "gix-utils", 2091 + "thiserror 2.0.18", 2092 + ] 2093 + 2094 + [[package]] 2095 + name = "gix-glob" 2096 + version = "0.26.1" 2097 + source = "registry+https://github.com/rust-lang/crates.io-index" 2098 + checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" 2099 + dependencies = [ 2100 + "bitflags 2.11.1", 2101 + "bstr", 2102 + "gix-features", 2103 + "gix-path", 2104 + ] 2105 + 2106 + [[package]] 2107 + name = "gix-hash" 2108 + version = "0.25.1" 2109 + source = "registry+https://github.com/rust-lang/crates.io-index" 2110 + checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce" 2111 + dependencies = [ 2112 + "faster-hex", 2113 + "gix-features", 2114 + "sha1-checked", 2115 + "sha2 0.11.0", 2116 + "thiserror 2.0.18", 2117 + ] 2118 + 2119 + [[package]] 2120 + name = "gix-hashtable" 2121 + version = "0.15.2" 2122 + source = "registry+https://github.com/rust-lang/crates.io-index" 2123 + checksum = "7e261d54091f0d1c729bc83f54548c071bdec60a697de1e58e88bdfd7a99d24e" 2124 + dependencies = [ 2125 + "gix-hash", 2126 + "hashbrown 0.17.1", 2127 + "parking_lot", 2128 + ] 2129 + 2130 + [[package]] 2131 + name = "gix-ignore" 2132 + version = "0.21.1" 2133 + source = "registry+https://github.com/rust-lang/crates.io-index" 2134 + checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d" 2135 + dependencies = [ 2136 + "bstr", 2137 + "gix-glob", 2138 + "gix-path", 2139 + "gix-trace", 2140 + "unicode-bom", 2141 + ] 2142 + 2143 + [[package]] 2144 + name = "gix-imara-diff" 2145 + version = "0.2.3" 2146 + source = "registry+https://github.com/rust-lang/crates.io-index" 2147 + checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" 2148 + dependencies = [ 2149 + "bstr", 2150 + "hashbrown 0.17.1", 2151 + ] 2152 + 2153 + [[package]] 2154 + name = "gix-index" 2155 + version = "0.52.0" 2156 + source = "registry+https://github.com/rust-lang/crates.io-index" 2157 + checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d" 2158 + dependencies = [ 2159 + "bitflags 2.11.1", 2160 + "bstr", 2161 + "filetime", 2162 + "fnv", 2163 + "gix-bitmap", 2164 + "gix-features", 2165 + "gix-fs", 2166 + "gix-hash", 2167 + "gix-lock", 2168 + "gix-object", 2169 + "gix-traverse", 2170 + "gix-utils", 2171 + "gix-validate", 2172 + "hashbrown 0.17.1", 2173 + "itoa", 2174 + "libc", 2175 + "memmap2", 2176 + "rustix", 2177 + "smallvec", 2178 + "thiserror 2.0.18", 2179 + ] 2180 + 2181 + [[package]] 2182 + name = "gix-lock" 2183 + version = "23.0.1" 2184 + source = "registry+https://github.com/rust-lang/crates.io-index" 2185 + checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" 2186 + dependencies = [ 2187 + "gix-tempfile", 2188 + "gix-utils", 2189 + "thiserror 2.0.18", 2190 + ] 2191 + 2192 + [[package]] 2193 + name = "gix-mailmap" 2194 + version = "0.33.1" 2195 + source = "registry+https://github.com/rust-lang/crates.io-index" 2196 + checksum = "195fd20808055824531be2fd0d34136d900e5fbca3ffb0a3c07e8beeefb9c828" 2197 + dependencies = [ 2198 + "bstr", 2199 + "gix-actor", 2200 + "gix-date", 2201 + "gix-error", 2202 + ] 2203 + 2204 + [[package]] 2205 + name = "gix-merge" 2206 + version = "0.17.0" 2207 + source = "registry+https://github.com/rust-lang/crates.io-index" 2208 + checksum = "7543e1eceb25fbbd1a29459c794148d8bafc1c77555eb386617a2d99b5371971" 2209 + dependencies = [ 2210 + "bstr", 2211 + "gix-command", 2212 + "gix-diff", 2213 + "gix-filter", 2214 + "gix-fs", 2215 + "gix-hash", 2216 + "gix-imara-diff", 2217 + "gix-index", 2218 + "gix-object", 2219 + "gix-path", 2220 + "gix-quote", 2221 + "gix-revision", 2222 + "gix-revwalk", 2223 + "gix-tempfile", 2224 + "gix-trace", 2225 + "gix-worktree", 2226 + "nonempty", 2227 + "thiserror 2.0.18", 2228 + ] 2229 + 2230 + [[package]] 2231 + name = "gix-negotiate" 2232 + version = "0.32.0" 2233 + source = "registry+https://github.com/rust-lang/crates.io-index" 2234 + checksum = "890c936a215bae25818c076cb881cb2e54d2c66ba947ba58b8dd47cff921bf55" 2235 + dependencies = [ 2236 + "bitflags 2.11.1", 2237 + "gix-commitgraph", 2238 + "gix-date", 2239 + "gix-hash", 2240 + "gix-object", 2241 + "gix-revwalk", 2242 + ] 2243 + 2244 + [[package]] 2245 + name = "gix-object" 2246 + version = "0.61.0" 2247 + source = "registry+https://github.com/rust-lang/crates.io-index" 2248 + checksum = "d5cd857e29429c7213bdef3f5aef83f8cc124774fe8ae0d27b1607d218d6d525" 2249 + dependencies = [ 2250 + "bstr", 2251 + "gix-actor", 2252 + "gix-date", 2253 + "gix-features", 2254 + "gix-hash", 2255 + "gix-hashtable", 2256 + "gix-utils", 2257 + "gix-validate", 2258 + "itoa", 2259 + "smallvec", 2260 + "thiserror 2.0.18", 2261 + ] 2262 + 2263 + [[package]] 2264 + name = "gix-odb" 2265 + version = "0.81.0" 2266 + source = "registry+https://github.com/rust-lang/crates.io-index" 2267 + checksum = "7d004c32858b1556f2d7874405edb3c97dc78fc09beaa87d57bb077ee2858a7d" 2268 + dependencies = [ 2269 + "arc-swap", 2270 + "gix-features", 2271 + "gix-fs", 2272 + "gix-hash", 2273 + "gix-hashtable", 2274 + "gix-object", 2275 + "gix-pack", 2276 + "gix-path", 2277 + "gix-quote", 2278 + "memmap2", 2279 + "parking_lot", 2280 + "tempfile", 2281 + "thiserror 2.0.18", 2282 + ] 2283 + 2284 + [[package]] 2285 + name = "gix-pack" 2286 + version = "0.71.0" 2287 + source = "registry+https://github.com/rust-lang/crates.io-index" 2288 + checksum = "e43626f2a27d1033674ec1a196b845614231e6bbd949d5e21c133045ff56b174" 2289 + dependencies = [ 2290 + "clru", 2291 + "gix-chunk", 2292 + "gix-error", 2293 + "gix-features", 2294 + "gix-hash", 2295 + "gix-hashtable", 2296 + "gix-object", 2297 + "gix-path", 2298 + "memmap2", 2299 + "smallvec", 2300 + "thiserror 2.0.18", 2301 + "uluru", 2302 + ] 2303 + 2304 + [[package]] 2305 + name = "gix-packetline" 2306 + version = "0.21.5" 2307 + source = "registry+https://github.com/rust-lang/crates.io-index" 2308 + checksum = "b217dd0ee0c4021ecf169a4a519b1b4f80d15e3f3765f3dc466223dc0ac891d7" 2309 + dependencies = [ 2310 + "bstr", 2311 + "faster-hex", 2312 + "gix-trace", 2313 + "thiserror 2.0.18", 2314 + ] 2315 + 2316 + [[package]] 2317 + name = "gix-path" 2318 + version = "0.12.1" 2319 + source = "registry+https://github.com/rust-lang/crates.io-index" 2320 + checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6" 2321 + dependencies = [ 2322 + "bstr", 2323 + "gix-trace", 2324 + "gix-validate", 2325 + "thiserror 2.0.18", 2326 + ] 2327 + 2328 + [[package]] 2329 + name = "gix-pathspec" 2330 + version = "0.18.1" 2331 + source = "registry+https://github.com/rust-lang/crates.io-index" 2332 + checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" 2333 + dependencies = [ 2334 + "bitflags 2.11.1", 2335 + "bstr", 2336 + "gix-attributes", 2337 + "gix-config-value", 2338 + "gix-glob", 2339 + "gix-path", 2340 + "thiserror 2.0.18", 2341 + ] 2342 + 2343 + [[package]] 2344 + name = "gix-prompt" 2345 + version = "0.15.1" 2346 + source = "registry+https://github.com/rust-lang/crates.io-index" 2347 + checksum = "3ee604d7746080ae7e1023bf47204bcc2c5f307bfbe2306a3c90b1bfd1a2c6d8" 2348 + dependencies = [ 2349 + "gix-command", 2350 + "gix-config-value", 2351 + "parking_lot", 2352 + "rustix", 2353 + "thiserror 2.0.18", 2354 + ] 2355 + 2356 + [[package]] 2357 + name = "gix-protocol" 2358 + version = "0.62.0" 2359 + source = "registry+https://github.com/rust-lang/crates.io-index" 2360 + checksum = "51dea3acb390707ab868f1f9584f18449eb95d869deffae96768e47d303595ee" 2361 + dependencies = [ 2362 + "bstr", 2363 + "gix-date", 2364 + "gix-features", 2365 + "gix-hash", 2366 + "gix-ref", 2367 + "gix-shallow", 2368 + "gix-transport", 2369 + "gix-utils", 2370 + "maybe-async", 2371 + "nonempty", 2372 + "thiserror 2.0.18", 2373 + ] 2374 + 2375 + [[package]] 2376 + name = "gix-quote" 2377 + version = "0.7.2" 2378 + source = "registry+https://github.com/rust-lang/crates.io-index" 2379 + checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" 2380 + dependencies = [ 2381 + "bstr", 2382 + "gix-error", 2383 + "gix-utils", 2384 + ] 2385 + 2386 + [[package]] 2387 + name = "gix-ref" 2388 + version = "0.64.0" 2389 + source = "registry+https://github.com/rust-lang/crates.io-index" 2390 + checksum = "4c04f64c37eb7e6feb73c7060f8dc6f381cc5de5d53249bfd450bc48a86b2e8b" 2391 + dependencies = [ 2392 + "gix-actor", 2393 + "gix-features", 2394 + "gix-fs", 2395 + "gix-hash", 2396 + "gix-lock", 2397 + "gix-object", 2398 + "gix-path", 2399 + "gix-tempfile", 2400 + "gix-utils", 2401 + "gix-validate", 2402 + "memmap2", 2403 + "thiserror 2.0.18", 2404 + ] 2405 + 2406 + [[package]] 2407 + name = "gix-refspec" 2408 + version = "0.42.0" 2409 + source = "registry+https://github.com/rust-lang/crates.io-index" 2410 + checksum = "b216ae06ec74b5f24ad0142026a997fb0a935b7410eaf9c1616fc3f0e6c5a6d3" 2411 + dependencies = [ 2412 + "bstr", 2413 + "gix-error", 2414 + "gix-glob", 2415 + "gix-hash", 2416 + "gix-revision", 2417 + "gix-validate", 2418 + "smallvec", 2419 + "thiserror 2.0.18", 2420 + ] 2421 + 2422 + [[package]] 2423 + name = "gix-revision" 2424 + version = "0.46.0" 2425 + source = "registry+https://github.com/rust-lang/crates.io-index" 2426 + checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f" 2427 + dependencies = [ 2428 + "bitflags 2.11.1", 2429 + "bstr", 2430 + "gix-commitgraph", 2431 + "gix-date", 2432 + "gix-error", 2433 + "gix-hash", 2434 + "gix-hashtable", 2435 + "gix-object", 2436 + "gix-revwalk", 2437 + "gix-trace", 2438 + "nonempty", 2439 + ] 2440 + 2441 + [[package]] 2442 + name = "gix-revwalk" 2443 + version = "0.32.0" 2444 + source = "registry+https://github.com/rust-lang/crates.io-index" 2445 + checksum = "85f5756abffe0917827aac683b13684ed99875bc398fa1f9b8f479b0681ef9e6" 2446 + dependencies = [ 2447 + "gix-commitgraph", 2448 + "gix-date", 2449 + "gix-error", 2450 + "gix-hash", 2451 + "gix-hashtable", 2452 + "gix-object", 2453 + "smallvec", 2454 + "thiserror 2.0.18", 2455 + ] 2456 + 2457 + [[package]] 2458 + name = "gix-sec" 2459 + version = "0.14.1" 2460 + source = "registry+https://github.com/rust-lang/crates.io-index" 2461 + checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" 2462 + dependencies = [ 2463 + "bitflags 2.11.1", 2464 + "gix-path", 2465 + "libc", 2466 + "windows-sys 0.61.2", 2467 + ] 2468 + 2469 + [[package]] 2470 + name = "gix-shallow" 2471 + version = "0.12.1" 2472 + source = "registry+https://github.com/rust-lang/crates.io-index" 2473 + checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97" 2474 + dependencies = [ 2475 + "bstr", 2476 + "gix-hash", 2477 + "gix-lock", 2478 + "nonempty", 2479 + "thiserror 2.0.18", 2480 + ] 2481 + 2482 + [[package]] 2483 + name = "gix-status" 2484 + version = "0.31.0" 2485 + source = "registry+https://github.com/rust-lang/crates.io-index" 2486 + checksum = "22042e385d28a34275e029d98f4970285045be14b9073658ca897923f2ed8700" 2487 + dependencies = [ 2488 + "bstr", 2489 + "filetime", 2490 + "gix-diff", 2491 + "gix-dir", 2492 + "gix-features", 2493 + "gix-filter", 2494 + "gix-fs", 2495 + "gix-hash", 2496 + "gix-index", 2497 + "gix-object", 2498 + "gix-path", 2499 + "gix-pathspec", 2500 + "gix-worktree", 2501 + "portable-atomic", 2502 + "thiserror 2.0.18", 2503 + ] 2504 + 2505 + [[package]] 2506 + name = "gix-submodule" 2507 + version = "0.31.0" 2508 + source = "registry+https://github.com/rust-lang/crates.io-index" 2509 + checksum = "3059890ef054066c22a94bfc6a3eaba0d806aedcd630a0bc9e5783fd88884781" 2510 + dependencies = [ 2511 + "bstr", 2512 + "gix-config", 2513 + "gix-path", 2514 + "gix-pathspec", 2515 + "gix-refspec", 2516 + "gix-url", 2517 + "thiserror 2.0.18", 2518 + ] 2519 + 2520 + [[package]] 2521 + name = "gix-tempfile" 2522 + version = "23.0.2" 2523 + source = "registry+https://github.com/rust-lang/crates.io-index" 2524 + checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" 2525 + dependencies = [ 2526 + "dashmap", 2527 + "gix-fs", 2528 + "libc", 2529 + "parking_lot", 2530 + "signal-hook", 2531 + "signal-hook-registry", 2532 + "tempfile", 2533 + ] 2534 + 2535 + [[package]] 2536 + name = "gix-trace" 2537 + version = "0.1.20" 2538 + source = "registry+https://github.com/rust-lang/crates.io-index" 2539 + checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" 2540 + 2541 + [[package]] 2542 + name = "gix-transport" 2543 + version = "0.57.2" 2544 + source = "registry+https://github.com/rust-lang/crates.io-index" 2545 + checksum = "186874f7ad1fb2f9a2f2aa9c2dabc7f9dd087bef74c1a0eee2b4a9cf0248fcb3" 2546 + dependencies = [ 2547 + "bstr", 2548 + "gix-command", 2549 + "gix-features", 2550 + "gix-packetline", 2551 + "gix-quote", 2552 + "gix-sec", 2553 + "gix-url", 2554 + "thiserror 2.0.18", 2555 + ] 2556 + 2557 + [[package]] 2558 + name = "gix-traverse" 2559 + version = "0.58.0" 2560 + source = "registry+https://github.com/rust-lang/crates.io-index" 2561 + checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed" 2562 + dependencies = [ 2563 + "bitflags 2.11.1", 2564 + "gix-commitgraph", 2565 + "gix-date", 2566 + "gix-hash", 2567 + "gix-hashtable", 2568 + "gix-object", 2569 + "gix-revwalk", 2570 + "smallvec", 2571 + "thiserror 2.0.18", 2572 + ] 2573 + 2574 + [[package]] 2575 + name = "gix-url" 2576 + version = "0.36.1" 2577 + source = "registry+https://github.com/rust-lang/crates.io-index" 2578 + checksum = "65bb01ec69d55e82ccb7a19e264501ead4e6aac38463a8cebfdd81e22bb67ab2" 2579 + dependencies = [ 2580 + "bstr", 2581 + "gix-path", 2582 + "percent-encoding", 2583 + "thiserror 2.0.18", 2584 + ] 2585 + 2586 + [[package]] 2587 + name = "gix-utils" 2588 + version = "0.3.3" 2589 + source = "registry+https://github.com/rust-lang/crates.io-index" 2590 + checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771" 2591 + dependencies = [ 2592 + "bstr", 2593 + "fastrand", 2594 + "unicode-normalization", 2595 + ] 2596 + 2597 + [[package]] 2598 + name = "gix-validate" 2599 + version = "0.11.2" 2600 + source = "registry+https://github.com/rust-lang/crates.io-index" 2601 + checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" 2602 + dependencies = [ 2603 + "bstr", 2604 + ] 2605 + 2606 + [[package]] 2607 + name = "gix-worktree" 2608 + version = "0.53.0" 2609 + source = "registry+https://github.com/rust-lang/crates.io-index" 2610 + checksum = "cef414ed275e8407cd5d53d301e83be19700b0dd3f859d2434417b58f454a2d1" 2611 + dependencies = [ 2612 + "bstr", 2613 + "gix-attributes", 2614 + "gix-fs", 2615 + "gix-glob", 2616 + "gix-hash", 2617 + "gix-ignore", 2618 + "gix-index", 2619 + "gix-object", 2620 + "gix-path", 2621 + "gix-validate", 2622 + ] 2623 + 2624 + [[package]] 2625 + name = "gix-worktree-state" 2626 + version = "0.31.0" 2627 + source = "registry+https://github.com/rust-lang/crates.io-index" 2628 + checksum = "4bffae8b3ca258fdd50370cd51f06deb4c76a3b43db3868bc28dde45ffa77d69" 2629 + dependencies = [ 2630 + "bstr", 2631 + "gix-features", 2632 + "gix-filter", 2633 + "gix-fs", 2634 + "gix-index", 2635 + "gix-object", 2636 + "gix-path", 2637 + "gix-worktree", 2638 + "io-close", 2639 + "thiserror 2.0.18", 2640 + ] 2641 + 2642 + [[package]] 2643 + name = "gix-worktree-stream" 2644 + version = "0.33.0" 2645 + source = "registry+https://github.com/rust-lang/crates.io-index" 2646 + checksum = "d25e9ed30100c63f7590bc581c225e53f731a53e06aa79a245739c07f7dcc557" 1577 2647 dependencies = [ 1578 - "cfg-if", 1579 - "libc", 1580 - "r-efi 6.0.0", 1581 - "wasip2", 1582 - "wasip3", 2648 + "gix-attributes", 2649 + "gix-error", 2650 + "gix-features", 2651 + "gix-filter", 2652 + "gix-fs", 2653 + "gix-hash", 2654 + "gix-object", 2655 + "gix-path", 2656 + "gix-traverse", 2657 + "parking_lot", 1583 2658 ] 1584 2659 1585 2660 [[package]] ··· 1632 2707 "byteorder", 1633 2708 ] 1634 2709 2710 + [[package]] 2711 + name = "hash32" 2712 + version = "0.3.1" 2713 + source = "registry+https://github.com/rust-lang/crates.io-index" 2714 + checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" 2715 + dependencies = [ 2716 + "byteorder", 2717 + ] 2718 + 1635 2719 [[package]] 1636 2720 name = "hashbrown" 1637 2721 version = "0.14.5" ··· 1669 2753 version = "0.17.1" 1670 2754 source = "registry+https://github.com/rust-lang/crates.io-index" 1671 2755 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 2756 + dependencies = [ 2757 + "allocator-api2", 2758 + "equivalent", 2759 + "foldhash 0.2.0", 2760 + ] 1672 2761 1673 2762 [[package]] 1674 2763 name = "heapless" ··· 1677 2766 checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" 1678 2767 dependencies = [ 1679 2768 "atomic-polyfill", 1680 - "hash32", 2769 + "hash32 0.2.1", 1681 2770 "rustc_version", 1682 2771 "serde", 1683 2772 "spin 0.9.8", 1684 2773 "stable_deref_trait", 1685 2774 ] 1686 2775 2776 + [[package]] 2777 + name = "heapless" 2778 + version = "0.8.0" 2779 + source = "registry+https://github.com/rust-lang/crates.io-index" 2780 + checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" 2781 + dependencies = [ 2782 + "hash32 0.3.1", 2783 + "stable_deref_trait", 2784 + ] 2785 + 1687 2786 [[package]] 1688 2787 name = "heck" 1689 2788 version = "0.4.1" ··· 1714 2813 source = "registry+https://github.com/rust-lang/crates.io-index" 1715 2814 checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 1716 2815 dependencies = [ 1717 - "digest", 2816 + "digest 0.10.7", 1718 2817 ] 1719 2818 1720 2819 [[package]] ··· 1768 2867 source = "registry+https://github.com/rust-lang/crates.io-index" 1769 2868 checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1770 2869 2870 + [[package]] 2871 + name = "human_format" 2872 + version = "1.2.1" 2873 + source = "registry+https://github.com/rust-lang/crates.io-index" 2874 + checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" 2875 + 2876 + [[package]] 2877 + name = "hybrid-array" 2878 + version = "0.4.12" 2879 + source = "registry+https://github.com/rust-lang/crates.io-index" 2880 + checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" 2881 + dependencies = [ 2882 + "typenum", 2883 + ] 2884 + 1771 2885 [[package]] 1772 2886 name = "hyper" 1773 2887 version = "1.10.0" ··· 1806 2920 "webpki-roots 1.0.7", 1807 2921 ] 1808 2922 2923 + [[package]] 2924 + name = "hyper-timeout" 2925 + version = "0.5.2" 2926 + source = "registry+https://github.com/rust-lang/crates.io-index" 2927 + checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" 2928 + dependencies = [ 2929 + "hyper", 2930 + "hyper-util", 2931 + "pin-project-lite", 2932 + "tokio", 2933 + "tower-service", 2934 + ] 2935 + 1809 2936 [[package]] 1810 2937 name = "hyper-util" 1811 2938 version = "0.1.20" ··· 1991 3118 "rustversion", 1992 3119 ] 1993 3120 3121 + [[package]] 3122 + name = "io-close" 3123 + version = "0.3.7" 3124 + source = "registry+https://github.com/rust-lang/crates.io-index" 3125 + checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" 3126 + dependencies = [ 3127 + "libc", 3128 + "winapi", 3129 + ] 3130 + 1994 3131 [[package]] 1995 3132 name = "ipld-core" 1996 3133 version = "0.4.3" ··· 2126 3263 "serde_path_to_error", 2127 3264 "serde_repr", 2128 3265 "serde_with", 2129 - "sha2", 3266 + "sha2 0.10.9", 2130 3267 "syn", 2131 3268 "thiserror 2.0.18", 2132 3269 "unicode-segmentation", 2133 3270 ] 2134 3271 3272 + [[package]] 3273 + name = "jiff" 3274 + version = "0.2.29" 3275 + source = "registry+https://github.com/rust-lang/crates.io-index" 3276 + checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" 3277 + dependencies = [ 3278 + "defmt", 3279 + "jiff-static", 3280 + "jiff-tzdb-platform", 3281 + "log", 3282 + "portable-atomic", 3283 + "portable-atomic-util", 3284 + "serde_core", 3285 + "windows-link", 3286 + ] 3287 + 3288 + [[package]] 3289 + name = "jiff-static" 3290 + version = "0.2.29" 3291 + source = "registry+https://github.com/rust-lang/crates.io-index" 3292 + checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" 3293 + dependencies = [ 3294 + "proc-macro2", 3295 + "quote", 3296 + "syn", 3297 + ] 3298 + 3299 + [[package]] 3300 + name = "jiff-tzdb" 3301 + version = "0.1.6" 3302 + source = "registry+https://github.com/rust-lang/crates.io-index" 3303 + checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" 3304 + 3305 + [[package]] 3306 + name = "jiff-tzdb-platform" 3307 + version = "0.1.3" 3308 + source = "registry+https://github.com/rust-lang/crates.io-index" 3309 + checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" 3310 + dependencies = [ 3311 + "jiff-tzdb", 3312 + ] 3313 + 2135 3314 [[package]] 2136 3315 name = "jobserver" 2137 3316 version = "0.1.34" ··· 2163 3342 "cfg-if", 2164 3343 "ecdsa", 2165 3344 "elliptic-curve", 2166 - "sha2", 3345 + "sha2 0.10.9", 3346 + ] 3347 + 3348 + [[package]] 3349 + name = "kstring" 3350 + version = "2.0.2" 3351 + source = "registry+https://github.com/rust-lang/crates.io-index" 3352 + checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" 3353 + dependencies = [ 3354 + "static_assertions", 2167 3355 ] 2168 3356 2169 3357 [[package]] ··· 2200 3388 source = "registry+https://github.com/rust-lang/crates.io-index" 2201 3389 checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 2202 3390 3391 + [[package]] 3392 + name = "line-numbers" 3393 + version = "0.4.0" 3394 + source = "registry+https://github.com/rust-lang/crates.io-index" 3395 + checksum = "24ab6409a2f9ffae158ae823a012a7d7d68386663a14bedc260ca66eacf65c2c" 3396 + 2203 3397 [[package]] 2204 3398 name = "linux-raw-sys" 2205 3399 version = "0.12.1" ··· 2300 3494 source = "registry+https://github.com/rust-lang/crates.io-index" 2301 3495 checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 2302 3496 3497 + [[package]] 3498 + name = "maybe-async" 3499 + version = "0.2.11" 3500 + source = "registry+https://github.com/rust-lang/crates.io-index" 3501 + checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" 3502 + dependencies = [ 3503 + "proc-macro2", 3504 + "quote", 3505 + "syn", 3506 + ] 3507 + 2303 3508 [[package]] 2304 3509 name = "measure_time" 2305 3510 version = "0.9.0" ··· 2464 3669 source = "registry+https://github.com/rust-lang/crates.io-index" 2465 3670 checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" 2466 3671 dependencies = [ 2467 - "bitflags", 3672 + "bitflags 2.11.1", 2468 3673 "cfg-if", 2469 3674 "cfg_aliases", 2470 3675 "libc", ··· 2481 3686 "minimal-lexical", 2482 3687 ] 2483 3688 3689 + [[package]] 3690 + name = "nonempty" 3691 + version = "0.12.0" 3692 + source = "registry+https://github.com/rust-lang/crates.io-index" 3693 + checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" 3694 + 2484 3695 [[package]] 2485 3696 name = "nu-ansi-term" 2486 3697 version = "0.50.3" ··· 2614 3825 "ecdsa", 2615 3826 "elliptic-curve", 2616 3827 "primeorder", 2617 - "sha2", 3828 + "sha2 0.10.9", 2618 3829 ] 2619 3830 2620 3831 [[package]] ··· 2778 3989 source = "registry+https://github.com/rust-lang/crates.io-index" 2779 3990 checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" 2780 3991 3992 + [[package]] 3993 + name = "portable-atomic-util" 3994 + version = "0.2.7" 3995 + source = "registry+https://github.com/rust-lang/crates.io-index" 3996 + checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" 3997 + dependencies = [ 3998 + "portable-atomic", 3999 + ] 4000 + 2781 4001 [[package]] 2782 4002 name = "postcard" 2783 4003 version = "1.1.3" ··· 2787 4007 "cobs", 2788 4008 "embedded-io 0.4.0", 2789 4009 "embedded-io 0.6.1", 2790 - "heapless", 4010 + "heapless 0.7.17", 2791 4011 "serde", 2792 4012 ] 2793 4013 ··· 2834 4054 "elliptic-curve", 2835 4055 ] 2836 4056 4057 + [[package]] 4058 + name = "proc-macro-error-attr2" 4059 + version = "2.0.0" 4060 + source = "registry+https://github.com/rust-lang/crates.io-index" 4061 + checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" 4062 + dependencies = [ 4063 + "proc-macro2", 4064 + "quote", 4065 + ] 4066 + 4067 + [[package]] 4068 + name = "proc-macro-error2" 4069 + version = "2.0.1" 4070 + source = "registry+https://github.com/rust-lang/crates.io-index" 4071 + checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" 4072 + dependencies = [ 4073 + "proc-macro-error-attr2", 4074 + "proc-macro2", 4075 + "quote", 4076 + "syn", 4077 + ] 4078 + 2837 4079 [[package]] 2838 4080 name = "proc-macro2" 2839 4081 version = "1.0.106" ··· 2856 4098 "yansi", 2857 4099 ] 2858 4100 4101 + [[package]] 4102 + name = "prodash" 4103 + version = "31.0.0" 4104 + source = "registry+https://github.com/rust-lang/crates.io-index" 4105 + checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" 4106 + dependencies = [ 4107 + "bytesize", 4108 + "human_format", 4109 + "parking_lot", 4110 + ] 4111 + 2859 4112 [[package]] 2860 4113 name = "prost" 2861 - version = "0.14.3" 4114 + version = "0.14.4" 2862 4115 source = "registry+https://github.com/rust-lang/crates.io-index" 2863 - checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" 4116 + checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" 2864 4117 dependencies = [ 2865 4118 "bytes", 2866 4119 "prost-derive", ··· 2880 4133 "prettyplease", 2881 4134 "prost", 2882 4135 "prost-types", 4136 + "pulldown-cmark", 4137 + "pulldown-cmark-to-cmark", 2883 4138 "regex", 2884 4139 "syn", 2885 4140 "tempfile", ··· 2887 4142 2888 4143 [[package]] 2889 4144 name = "prost-derive" 2890 - version = "0.14.3" 4145 + version = "0.14.4" 2891 4146 source = "registry+https://github.com/rust-lang/crates.io-index" 2892 - checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" 4147 + checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" 2893 4148 dependencies = [ 2894 4149 "anyhow", 2895 4150 "itertools 0.14.0", ··· 2967 4222 2968 4223 [[package]] 2969 4224 name = "prost-types" 2970 - version = "0.14.3" 4225 + version = "0.14.4" 2971 4226 source = "registry+https://github.com/rust-lang/crates.io-index" 2972 - checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" 4227 + checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" 2973 4228 dependencies = [ 2974 4229 "prost", 2975 4230 ] 2976 4231 4232 + [[package]] 4233 + name = "pulldown-cmark" 4234 + version = "0.13.4" 4235 + source = "registry+https://github.com/rust-lang/crates.io-index" 4236 + checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" 4237 + dependencies = [ 4238 + "bitflags 2.11.1", 4239 + "memchr", 4240 + "unicase", 4241 + ] 4242 + 4243 + [[package]] 4244 + name = "pulldown-cmark-to-cmark" 4245 + version = "22.0.0" 4246 + source = "registry+https://github.com/rust-lang/crates.io-index" 4247 + checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" 4248 + dependencies = [ 4249 + "pulldown-cmark", 4250 + ] 4251 + 2977 4252 [[package]] 2978 4253 name = "quick_cache" 2979 4254 version = "0.6.22" ··· 3147 4422 source = "registry+https://github.com/rust-lang/crates.io-index" 3148 4423 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 3149 4424 dependencies = [ 3150 - "bitflags", 4425 + "bitflags 2.11.1", 3151 4426 ] 3152 4427 3153 4428 [[package]] ··· 3304 4579 source = "registry+https://github.com/rust-lang/crates.io-index" 3305 4580 checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 3306 4581 dependencies = [ 3307 - "bitflags", 4582 + "bitflags 2.11.1", 3308 4583 "errno", 3309 4584 "libc", 3310 4585 "linux-raw-sys", ··· 3445 4720 source = "registry+https://github.com/rust-lang/crates.io-index" 3446 4721 checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" 3447 4722 dependencies = [ 3448 - "bitflags", 4723 + "bitflags 2.11.1", 3449 4724 "core-foundation 0.10.1", 3450 4725 "core-foundation-sys", 3451 4726 "libc", ··· 3639 4914 checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 3640 4915 dependencies = [ 3641 4916 "cfg-if", 3642 - "cpufeatures", 3643 - "digest", 4917 + "cpufeatures 0.2.17", 4918 + "digest 0.10.7", 4919 + ] 4920 + 4921 + [[package]] 4922 + name = "sha1-checked" 4923 + version = "0.10.0" 4924 + source = "registry+https://github.com/rust-lang/crates.io-index" 4925 + checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" 4926 + dependencies = [ 4927 + "digest 0.10.7", 4928 + "sha1", 3644 4929 ] 3645 4930 3646 4931 [[package]] ··· 3650 4935 checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 3651 4936 dependencies = [ 3652 4937 "cfg-if", 3653 - "cpufeatures", 3654 - "digest", 4938 + "cpufeatures 0.2.17", 4939 + "digest 0.10.7", 4940 + ] 4941 + 4942 + [[package]] 4943 + name = "sha2" 4944 + version = "0.11.0" 4945 + source = "registry+https://github.com/rust-lang/crates.io-index" 4946 + checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" 4947 + dependencies = [ 4948 + "cfg-if", 4949 + "cpufeatures 0.3.0", 4950 + "digest 0.11.3", 3655 4951 ] 3656 4952 3657 4953 [[package]] ··· 3663 4959 "lazy_static", 3664 4960 ] 3665 4961 4962 + [[package]] 4963 + name = "shell-words" 4964 + version = "1.1.1" 4965 + source = "registry+https://github.com/rust-lang/crates.io-index" 4966 + checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" 4967 + 3666 4968 [[package]] 3667 4969 name = "shlex" 3668 4970 version = "1.3.0" ··· 3689 4991 "tracing-subscriber", 3690 4992 ] 3691 4993 4994 + [[package]] 4995 + name = "signal-hook" 4996 + version = "0.4.4" 4997 + source = "registry+https://github.com/rust-lang/crates.io-index" 4998 + checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" 4999 + dependencies = [ 5000 + "libc", 5001 + "signal-hook-registry", 5002 + ] 5003 + 3692 5004 [[package]] 3693 5005 name = "signal-hook-registry" 3694 5006 version = "1.4.8" ··· 3705 5017 source = "registry+https://github.com/rust-lang/crates.io-index" 3706 5018 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 3707 5019 dependencies = [ 3708 - "digest", 5020 + "digest 0.10.7", 3709 5021 "rand_core 0.6.4", 3710 5022 ] 3711 5023 ··· 3848 5160 source = "registry+https://github.com/rust-lang/crates.io-index" 3849 5161 checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" 3850 5162 dependencies = [ 3851 - "bitflags", 5163 + "bitflags 2.11.1", 3852 5164 "core-foundation 0.9.4", 3853 5165 "system-configuration-sys", 3854 5166 ] ··· 4216 5528 "futures-core", 4217 5529 "pin-project-lite", 4218 5530 "tokio", 5531 + "tokio-util", 4219 5532 ] 4220 5533 4221 5534 [[package]] ··· 4335 5648 source = "registry+https://github.com/rust-lang/crates.io-index" 4336 5649 checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" 4337 5650 5651 + [[package]] 5652 + name = "tonic" 5653 + version = "0.14.6" 5654 + source = "registry+https://github.com/rust-lang/crates.io-index" 5655 + checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" 5656 + dependencies = [ 5657 + "async-trait", 5658 + "axum", 5659 + "base64", 5660 + "bytes", 5661 + "h2", 5662 + "http", 5663 + "http-body", 5664 + "http-body-util", 5665 + "hyper", 5666 + "hyper-timeout", 5667 + "hyper-util", 5668 + "percent-encoding", 5669 + "pin-project", 5670 + "socket2", 5671 + "sync_wrapper", 5672 + "tokio", 5673 + "tokio-stream", 5674 + "tower", 5675 + "tower-layer", 5676 + "tower-service", 5677 + "tracing", 5678 + ] 5679 + 5680 + [[package]] 5681 + name = "tonic-build" 5682 + version = "0.14.6" 5683 + source = "registry+https://github.com/rust-lang/crates.io-index" 5684 + checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" 5685 + dependencies = [ 5686 + "prettyplease", 5687 + "proc-macro2", 5688 + "quote", 5689 + "syn", 5690 + ] 5691 + 5692 + [[package]] 5693 + name = "tonic-prost" 5694 + version = "0.14.6" 5695 + source = "registry+https://github.com/rust-lang/crates.io-index" 5696 + checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" 5697 + dependencies = [ 5698 + "bytes", 5699 + "prost", 5700 + "tonic", 5701 + ] 5702 + 5703 + [[package]] 5704 + name = "tonic-prost-build" 5705 + version = "0.14.6" 5706 + source = "registry+https://github.com/rust-lang/crates.io-index" 5707 + checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" 5708 + dependencies = [ 5709 + "prettyplease", 5710 + "proc-macro2", 5711 + "prost-build", 5712 + "prost-types", 5713 + "quote", 5714 + "syn", 5715 + "tempfile", 5716 + "tonic-build", 5717 + ] 5718 + 4338 5719 [[package]] 4339 5720 name = "tower" 4340 5721 version = "0.5.3" ··· 4343 5724 dependencies = [ 4344 5725 "futures-core", 4345 5726 "futures-util", 5727 + "indexmap", 4346 5728 "pin-project-lite", 5729 + "slab", 4347 5730 "sync_wrapper", 4348 5731 "tokio", 5732 + "tokio-util", 4349 5733 "tower-layer", 4350 5734 "tower-service", 4351 5735 "tracing", ··· 4358 5742 checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" 4359 5743 dependencies = [ 4360 5744 "async-compression", 4361 - "bitflags", 5745 + "bitflags 2.11.1", 4362 5746 "bytes", 4363 5747 "futures-core", 4364 5748 "futures-util", ··· 4559 5943 "syn", 4560 5944 ] 4561 5945 5946 + [[package]] 5947 + name = "uluru" 5948 + version = "3.1.0" 5949 + source = "registry+https://github.com/rust-lang/crates.io-index" 5950 + checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" 5951 + dependencies = [ 5952 + "arrayvec", 5953 + ] 5954 + 5955 + [[package]] 5956 + name = "unicase" 5957 + version = "2.9.0" 5958 + source = "registry+https://github.com/rust-lang/crates.io-index" 5959 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 5960 + 5961 + [[package]] 5962 + name = "unicode-bom" 5963 + version = "2.0.3" 5964 + source = "registry+https://github.com/rust-lang/crates.io-index" 5965 + checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" 5966 + 4562 5967 [[package]] 4563 5968 name = "unicode-ident" 4564 5969 version = "1.0.24" 4565 5970 source = "registry+https://github.com/rust-lang/crates.io-index" 4566 5971 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 4567 5972 5973 + [[package]] 5974 + name = "unicode-normalization" 5975 + version = "0.1.25" 5976 + source = "registry+https://github.com/rust-lang/crates.io-index" 5977 + checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" 5978 + dependencies = [ 5979 + "tinyvec", 5980 + ] 5981 + 4568 5982 [[package]] 4569 5983 name = "unicode-segmentation" 4570 5984 version = "1.13.2" ··· 4805 6219 source = "registry+https://github.com/rust-lang/crates.io-index" 4806 6220 checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 4807 6221 dependencies = [ 4808 - "bitflags", 6222 + "bitflags 2.11.1", 4809 6223 "hashbrown 0.15.5", 4810 6224 "indexmap", 4811 6225 "semver", ··· 5214 6628 checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 5215 6629 dependencies = [ 5216 6630 "anyhow", 5217 - "bitflags", 6631 + "bitflags 2.11.1", 5218 6632 "indexmap", 5219 6633 "log", 5220 6634 "serde", ··· 5359 6773 "syn", 5360 6774 ] 5361 6775 6776 + [[package]] 6777 + name = "zlib-rs" 6778 + version = "0.6.4" 6779 + source = "registry+https://github.com/rust-lang/crates.io-index" 6780 + checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" 6781 + 5362 6782 [[package]] 5363 6783 name = "zmij" 5364 6784 version = "1.0.21"
+6 -1
Cargo.toml
··· 1 1 [workspace] 2 2 resolver = "2" 3 - members = ["bobbin/crates/*", "shuttle"] 3 + members = ["bobbin/crates/*", "shuttle", "gitmirror"] 4 4 exclude = ["sites"] 5 5 6 6 [workspace.package] ··· 85 85 tracing = "0.1" 86 86 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } 87 87 88 + tonic = "0.14" 89 + tonic-prost = "0.14" 90 + tonic-prost-build = "0.14" 91 + prost = "0.14" 92 + 88 93 thiserror = "2" 89 94 90 95 confique = { version = "0.4", default-features = false, features = ["toml"] }
+29 -18
docker-compose.yml
··· 284 284 condition: service_started 285 285 networks: [tngl] 286 286 287 + gitmirror: 288 + build: 289 + context: . 290 + dockerfile: localinfra/gitmirror.Dockerfile 291 + restart: unless-stopped 292 + environment: 293 + RUST_LOG: gitmirror=debug 294 + GITMIRROR_ADDR: 0.0.0.0:9000 295 + GITMIRROR_REPO_BASE: /data/repos 296 + volumes: 297 + - knotmirror-data:/data 298 + - ./localinfra/certs/root.crt:/usr/local/share/ca-certificates/caddy.crt:ro 299 + networks: [tngl] 300 + 287 301 zoekt-webserver: 288 302 build: 289 303 context: https://tangled.org/boltless.me/zoekt.git#tngl ··· 312 326 313 327 314 328 329 + working_dir: /build 330 + init: true 331 + volumes: 332 + - .:/build:ro 333 + - ./appview/pages/static:/build/appview/pages/static 334 + command: 335 + ["-i", "input.css", "-o", "appview/pages/static/tw.css", "--watch=always"] 315 336 316 337 317 338 ··· 324 345 325 346 326 347 348 + TANGLED_JETSTREAM_ENDPOINT: wss://jetstream.tngl.boltless.dev/subscribe 349 + TANGLED_REDIS_ADDR: redis:6379 350 + TANGLED_KNOTMIRROR_URL: https://mirror.tngl.boltless.dev 351 + TANGLED_KNOTMIRROR_V2_HOST: gitmirror:9000 352 + TANGLED_CODESEARCH_ZOEKT_URL: https://zoekt.tngl.boltless.dev 353 + TANGLED_SSH_ENABLED: "true" 354 + TANGLED_SSH_LISTEN_ADDR: "0.0.0.0:3333" 327 355 328 356 329 357 ··· 376 404 377 405 378 406 379 - 380 - 381 - 382 - 383 - 384 - 385 - 386 - 387 - 388 - 389 - 390 - 391 - 392 - 393 - 394 - 395 - 396 - 397 407 - spindle.tngl.boltless.dev 398 408 - tngl.boltless.dev 399 409 - mirror.tngl.boltless.dev 410 + - gitmirror.tngl.boltless.dev 400 411 - zoekt.tngl.boltless.dev 401 412 - pdsls.tngl.boltless.dev 402 413
+3
flake.nix
··· 362 362 pkgs.protoc-gen-prost-crate 363 363 pkgs.protoc-gen-prost-serde 364 364 pkgs.protoc-gen-go 365 + pkgs.protoc-gen-go-grpc 365 366 (fenix.packages.${system}.combine [ 366 367 fenix.packages.${system}.stable.cargo 367 368 fenix.packages.${system}.stable.rustc ··· 476 477 cd "$rootDir" 477 478 echo ">>> regenerating protobuf files.." 478 479 buf generate 480 + echo ">>> regenerating gitmirror go stubs..." 481 + buf generate gitmirror --template gitmirror/buf.gen.yaml 479 482 echo ">>> generating file descriptor set for shuttle..." 480 483 buf build -o shuttle/src/gen/file_descriptor_set.bin 481 484 echo ">>> done"
+28
gitmirror/Cargo.toml
··· 1 + [package] 2 + name = "gitmirror" 3 + version = "0.1.0" 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + anyhow = { workspace = true } 10 + clap = { workspace = true } 11 + tonic = { workspace = true } 12 + tonic-prost = { workspace = true } 13 + prost = { workspace = true } 14 + tokio = { workspace = true, features = ["net", "rt-multi-thread", "sync"] } 15 + tokio-stream = { workspace = true, features = ["sync"] } 16 + tracing = "0.1" 17 + tracing-subscriber = { version = "0.3", features = ["env-filter"] } 18 + line-numbers = "0.4.0" 19 + rustc-hash = "2.1.2" 20 + gix = { version = "0.84", features = ["parallel", "blob-diff", "merge", "sha1", "sha256", "revision", "tree-editor"] } 21 + prost-types = "0.14.4" 22 + tempfile = "3" 23 + 24 + [build-dependencies] 25 + tonic-prost-build = { workspace = true } 26 + 27 + [lints] 28 + workspace = true
+4
gitmirror/README.md
··· 1 + # Gitmirror 2 + 3 + gitmirror is alternative name of Knotmirror v2. It is designed to be run under the AppView as git syncing service. 4 + Because it's internal sync service like tap or hydrant, it uses grpc over xrpc.
+12
gitmirror/buf.gen.yaml
··· 1 + version: v2 2 + plugins: 3 + - local: protoc-gen-go 4 + out: . 5 + opt: 6 + - paths=import 7 + - module=tangled.org/core 8 + - local: protoc-gen-go-grpc 9 + out: . 10 + opt: 11 + - paths=import 12 + - module=tangled.org/core
+3
gitmirror/buf.yaml
··· 1 + version: v2 2 + modules: 3 + - path: proto
+4
gitmirror/build.rs
··· 1 + fn main() -> Result<(), Box<dyn std::error::Error>> { 2 + tonic_prost_build::compile_protos("proto/gitmirror/v1/gitmirror.proto")?; 3 + Ok(()) 4 + }
+42
gitmirror/example/client.go
··· 1 + //go:build ignore 2 + 3 + // Example: how appview will call gitmirror's GitMirrorService. 4 + // 5 + // This file is build-ignored so it never breaks `go build ./...`. It is a 6 + // copy-ready reference for wiring the client into appview once the Go stubs 7 + // are generated (see gitmirror/buf.gen.yaml). 8 + package main 9 + 10 + import ( 11 + "context" 12 + "log" 13 + "time" 14 + 15 + "google.golang.org/grpc" 16 + "google.golang.org/grpc/credentials/insecure" 17 + 18 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 19 + ) 20 + 21 + func main() { 22 + conn, err := grpc.NewClient("127.0.0.1:9000", 23 + grpc.WithTransportCredentials(insecure.NewCredentials())) 24 + if err != nil { 25 + log.Fatal(err) 26 + } 27 + defer conn.Close() 28 + 29 + client := gitmirrorv1.NewGitMirrorServiceClient(conn) 30 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 31 + defer cancel() 32 + 33 + // appview converts its syntax.DID to a string via did.String(). 34 + resp, err := client.Commits(ctx, &gitmirrorv1.CommitsRequest{ 35 + Repo: "did:plc:example", 36 + Options: &gitmirrorv1.CommitsOptions{}, 37 + }) 38 + if err != nil { 39 + log.Fatal(err) 40 + } 41 + log.Println(resp.GetCommits()) 42 + }
+1470
gitmirror/proto/gen/gitmirror.pb.go
··· 1 + // Code generated by protoc-gen-go. DO NOT EDIT. 2 + // versions: 3 + // protoc-gen-go v1.36.11 4 + // protoc (unknown) 5 + // source: gitmirror/v1/gitmirror.proto 6 + 7 + package gitmirrorv1 8 + 9 + import ( 10 + protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 + protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 + timestamppb "google.golang.org/protobuf/types/known/timestamppb" 13 + reflect "reflect" 14 + sync "sync" 15 + unsafe "unsafe" 16 + ) 17 + 18 + const ( 19 + // Verify that this generated code is sufficiently up-to-date. 20 + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 21 + // Verify that runtime/protoimpl is sufficiently up-to-date. 22 + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 23 + ) 24 + 25 + type DiffRequest_ComparisonType int32 26 + 27 + const ( 28 + DiffRequest_COMPARISON_TYPE_UNSPECIFIED DiffRequest_ComparisonType = 0 29 + // Corresponds to the BASE..HEAD syntax that only returns any commits that 30 + // are in HEAD but not in BASE. 31 + DiffRequest_COMPARISON_TYPE_ONLY_IN_HEAD DiffRequest_ComparisonType = 1 32 + // Corresponds to the BASE...HEAD syntax that returns any commits that are 33 + // not in both BASE and HEAD. 34 + DiffRequest_COMPARISON_TYPE_INTERSECTION DiffRequest_ComparisonType = 2 35 + ) 36 + 37 + // Enum value maps for DiffRequest_ComparisonType. 38 + var ( 39 + DiffRequest_ComparisonType_name = map[int32]string{ 40 + 0: "COMPARISON_TYPE_UNSPECIFIED", 41 + 1: "COMPARISON_TYPE_ONLY_IN_HEAD", 42 + 2: "COMPARISON_TYPE_INTERSECTION", 43 + } 44 + DiffRequest_ComparisonType_value = map[string]int32{ 45 + "COMPARISON_TYPE_UNSPECIFIED": 0, 46 + "COMPARISON_TYPE_ONLY_IN_HEAD": 1, 47 + "COMPARISON_TYPE_INTERSECTION": 2, 48 + } 49 + ) 50 + 51 + func (x DiffRequest_ComparisonType) Enum() *DiffRequest_ComparisonType { 52 + p := new(DiffRequest_ComparisonType) 53 + *p = x 54 + return p 55 + } 56 + 57 + func (x DiffRequest_ComparisonType) String() string { 58 + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) 59 + } 60 + 61 + func (DiffRequest_ComparisonType) Descriptor() protoreflect.EnumDescriptor { 62 + return file_gitmirror_v1_gitmirror_proto_enumTypes[0].Descriptor() 63 + } 64 + 65 + func (DiffRequest_ComparisonType) Type() protoreflect.EnumType { 66 + return &file_gitmirror_v1_gitmirror_proto_enumTypes[0] 67 + } 68 + 69 + func (x DiffRequest_ComparisonType) Number() protoreflect.EnumNumber { 70 + return protoreflect.EnumNumber(x) 71 + } 72 + 73 + // Deprecated: Use DiffRequest_ComparisonType.Descriptor instead. 74 + func (DiffRequest_ComparisonType) EnumDescriptor() ([]byte, []int) { 75 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{10, 0} 76 + } 77 + 78 + type ResolveRevRequest struct { 79 + state protoimpl.MessageState `protogen:"open.v1"` 80 + // repo carries the DID as a string. 81 + Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` 82 + // rev is a SIMPLE revspec: a branch name, tag name, or short/full commit oid. 83 + // HEAD and revspec navigation (HEAD~2, foo^, ref@{...}, a..b, path:file) are rejected. 84 + Rev []byte `protobuf:"bytes,2,opt,name=rev,proto3" json:"rev,omitempty"` 85 + unknownFields protoimpl.UnknownFields 86 + sizeCache protoimpl.SizeCache 87 + } 88 + 89 + func (x *ResolveRevRequest) Reset() { 90 + *x = ResolveRevRequest{} 91 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[0] 92 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 93 + ms.StoreMessageInfo(mi) 94 + } 95 + 96 + func (x *ResolveRevRequest) String() string { 97 + return protoimpl.X.MessageStringOf(x) 98 + } 99 + 100 + func (*ResolveRevRequest) ProtoMessage() {} 101 + 102 + func (x *ResolveRevRequest) ProtoReflect() protoreflect.Message { 103 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[0] 104 + if x != nil { 105 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 106 + if ms.LoadMessageInfo() == nil { 107 + ms.StoreMessageInfo(mi) 108 + } 109 + return ms 110 + } 111 + return mi.MessageOf(x) 112 + } 113 + 114 + // Deprecated: Use ResolveRevRequest.ProtoReflect.Descriptor instead. 115 + func (*ResolveRevRequest) Descriptor() ([]byte, []int) { 116 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{0} 117 + } 118 + 119 + func (x *ResolveRevRequest) GetRepo() string { 120 + if x != nil { 121 + return x.Repo 122 + } 123 + return "" 124 + } 125 + 126 + func (x *ResolveRevRequest) GetRev() []byte { 127 + if x != nil { 128 + return x.Rev 129 + } 130 + return nil 131 + } 132 + 133 + type ResolveRevResponse struct { 134 + state protoimpl.MessageState `protogen:"open.v1"` 135 + // commit is the resolved commit object id, hex-encoded. 136 + Commit string `protobuf:"bytes,1,opt,name=commit,proto3" json:"commit,omitempty"` 137 + unknownFields protoimpl.UnknownFields 138 + sizeCache protoimpl.SizeCache 139 + } 140 + 141 + func (x *ResolveRevResponse) Reset() { 142 + *x = ResolveRevResponse{} 143 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[1] 144 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 145 + ms.StoreMessageInfo(mi) 146 + } 147 + 148 + func (x *ResolveRevResponse) String() string { 149 + return protoimpl.X.MessageStringOf(x) 150 + } 151 + 152 + func (*ResolveRevResponse) ProtoMessage() {} 153 + 154 + func (x *ResolveRevResponse) ProtoReflect() protoreflect.Message { 155 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[1] 156 + if x != nil { 157 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 158 + if ms.LoadMessageInfo() == nil { 159 + ms.StoreMessageInfo(mi) 160 + } 161 + return ms 162 + } 163 + return mi.MessageOf(x) 164 + } 165 + 166 + // Deprecated: Use ResolveRevResponse.ProtoReflect.Descriptor instead. 167 + func (*ResolveRevResponse) Descriptor() ([]byte, []int) { 168 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{1} 169 + } 170 + 171 + func (x *ResolveRevResponse) GetCommit() string { 172 + if x != nil { 173 + return x.Commit 174 + } 175 + return "" 176 + } 177 + 178 + // A commit in a specific repo, addressed by the repo's DID. 179 + type RepoCommit struct { 180 + state protoimpl.MessageState `protogen:"open.v1"` 181 + // repo carries the DID as a string. 182 + Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` 183 + // commit is a full commit object id, hex-encoded (NOT a revspec/branch/tag). 184 + Commit []byte `protobuf:"bytes,2,opt,name=commit,proto3" json:"commit,omitempty"` 185 + unknownFields protoimpl.UnknownFields 186 + sizeCache protoimpl.SizeCache 187 + } 188 + 189 + func (x *RepoCommit) Reset() { 190 + *x = RepoCommit{} 191 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[2] 192 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 193 + ms.StoreMessageInfo(mi) 194 + } 195 + 196 + func (x *RepoCommit) String() string { 197 + return protoimpl.X.MessageStringOf(x) 198 + } 199 + 200 + func (*RepoCommit) ProtoMessage() {} 201 + 202 + func (x *RepoCommit) ProtoReflect() protoreflect.Message { 203 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[2] 204 + if x != nil { 205 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 206 + if ms.LoadMessageInfo() == nil { 207 + ms.StoreMessageInfo(mi) 208 + } 209 + return ms 210 + } 211 + return mi.MessageOf(x) 212 + } 213 + 214 + // Deprecated: Use RepoCommit.ProtoReflect.Descriptor instead. 215 + func (*RepoCommit) Descriptor() ([]byte, []int) { 216 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{2} 217 + } 218 + 219 + func (x *RepoCommit) GetRepo() string { 220 + if x != nil { 221 + return x.Repo 222 + } 223 + return "" 224 + } 225 + 226 + func (x *RepoCommit) GetCommit() []byte { 227 + if x != nil { 228 + return x.Commit 229 + } 230 + return nil 231 + } 232 + 233 + type MergeCheckRequest struct { 234 + state protoimpl.MessageState `protogen:"open.v1"` 235 + // Branch/commit being merged into (the "ours" side of the 3-way merge). 236 + Target *RepoCommit `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` 237 + // PR head being merged (the "theirs" side of the 3-way merge). 238 + Source *RepoCommit `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` 239 + unknownFields protoimpl.UnknownFields 240 + sizeCache protoimpl.SizeCache 241 + } 242 + 243 + func (x *MergeCheckRequest) Reset() { 244 + *x = MergeCheckRequest{} 245 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[3] 246 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 247 + ms.StoreMessageInfo(mi) 248 + } 249 + 250 + func (x *MergeCheckRequest) String() string { 251 + return protoimpl.X.MessageStringOf(x) 252 + } 253 + 254 + func (*MergeCheckRequest) ProtoMessage() {} 255 + 256 + func (x *MergeCheckRequest) ProtoReflect() protoreflect.Message { 257 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[3] 258 + if x != nil { 259 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 260 + if ms.LoadMessageInfo() == nil { 261 + ms.StoreMessageInfo(mi) 262 + } 263 + return ms 264 + } 265 + return mi.MessageOf(x) 266 + } 267 + 268 + // Deprecated: Use MergeCheckRequest.ProtoReflect.Descriptor instead. 269 + func (*MergeCheckRequest) Descriptor() ([]byte, []int) { 270 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{3} 271 + } 272 + 273 + func (x *MergeCheckRequest) GetTarget() *RepoCommit { 274 + if x != nil { 275 + return x.Target 276 + } 277 + return nil 278 + } 279 + 280 + func (x *MergeCheckRequest) GetSource() *RepoCommit { 281 + if x != nil { 282 + return x.Source 283 + } 284 + return nil 285 + } 286 + 287 + type MergeCheckResponse struct { 288 + state protoimpl.MessageState `protogen:"open.v1"` 289 + // Whether the 3-way merge has unresolved conflicts. 290 + IsConflicted bool `protobuf:"varint,1,opt,name=is_conflicted,json=isConflicted,proto3" json:"is_conflicted,omitempty"` 291 + // One entry per conflicted file. 292 + Conflicts []*MergeConflict `protobuf:"bytes,2,rep,name=conflicts,proto3" json:"conflicts,omitempty"` 293 + // Optional human-readable summary of the merge check. 294 + Message *string `protobuf:"bytes,3,opt,name=message,proto3,oneof" json:"message,omitempty"` 295 + unknownFields protoimpl.UnknownFields 296 + sizeCache protoimpl.SizeCache 297 + } 298 + 299 + func (x *MergeCheckResponse) Reset() { 300 + *x = MergeCheckResponse{} 301 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[4] 302 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 303 + ms.StoreMessageInfo(mi) 304 + } 305 + 306 + func (x *MergeCheckResponse) String() string { 307 + return protoimpl.X.MessageStringOf(x) 308 + } 309 + 310 + func (*MergeCheckResponse) ProtoMessage() {} 311 + 312 + func (x *MergeCheckResponse) ProtoReflect() protoreflect.Message { 313 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[4] 314 + if x != nil { 315 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 316 + if ms.LoadMessageInfo() == nil { 317 + ms.StoreMessageInfo(mi) 318 + } 319 + return ms 320 + } 321 + return mi.MessageOf(x) 322 + } 323 + 324 + // Deprecated: Use MergeCheckResponse.ProtoReflect.Descriptor instead. 325 + func (*MergeCheckResponse) Descriptor() ([]byte, []int) { 326 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{4} 327 + } 328 + 329 + func (x *MergeCheckResponse) GetIsConflicted() bool { 330 + if x != nil { 331 + return x.IsConflicted 332 + } 333 + return false 334 + } 335 + 336 + func (x *MergeCheckResponse) GetConflicts() []*MergeConflict { 337 + if x != nil { 338 + return x.Conflicts 339 + } 340 + return nil 341 + } 342 + 343 + func (x *MergeCheckResponse) GetMessage() string { 344 + if x != nil && x.Message != nil { 345 + return *x.Message 346 + } 347 + return "" 348 + } 349 + 350 + type MergeConflict struct { 351 + state protoimpl.MessageState `protogen:"open.v1"` 352 + Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"` 353 + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` 354 + unknownFields protoimpl.UnknownFields 355 + sizeCache protoimpl.SizeCache 356 + } 357 + 358 + func (x *MergeConflict) Reset() { 359 + *x = MergeConflict{} 360 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[5] 361 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 362 + ms.StoreMessageInfo(mi) 363 + } 364 + 365 + func (x *MergeConflict) String() string { 366 + return protoimpl.X.MessageStringOf(x) 367 + } 368 + 369 + func (*MergeConflict) ProtoMessage() {} 370 + 371 + func (x *MergeConflict) ProtoReflect() protoreflect.Message { 372 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[5] 373 + if x != nil { 374 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 375 + if ms.LoadMessageInfo() == nil { 376 + ms.StoreMessageInfo(mi) 377 + } 378 + return ms 379 + } 380 + return mi.MessageOf(x) 381 + } 382 + 383 + // Deprecated: Use MergeConflict.ProtoReflect.Descriptor instead. 384 + func (*MergeConflict) Descriptor() ([]byte, []int) { 385 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{5} 386 + } 387 + 388 + func (x *MergeConflict) GetFilename() string { 389 + if x != nil { 390 + return x.Filename 391 + } 392 + return "" 393 + } 394 + 395 + func (x *MergeConflict) GetReason() string { 396 + if x != nil { 397 + return x.Reason 398 + } 399 + return "" 400 + } 401 + 402 + type CommitLogRequest struct { 403 + state protoimpl.MessageState `protogen:"open.v1"` 404 + // repo DID 405 + Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` 406 + // Ranges to include in the git log (revspec, "A..B", "A...B", etc.). 407 + // At least one range, or all_refs must be specified. 408 + Ranges [][]byte `protobuf:"bytes,2,rep,name=ranges,proto3" json:"ranges,omitempty"` 409 + // If true, all refs are searched for commits. 410 + // Must not be true when ranges are given. 411 + AllRefs bool `protobuf:"varint,3,opt,name=all_refs,json=allRefs,proto3" json:"all_refs,omitempty"` 412 + // After is an optional parameter to specify the earliest commit to consider. 413 + After *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=after,proto3" json:"after,omitempty"` 414 + // Before is an optional parameter to specify the latest commit to consider. 415 + Before *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=before,proto3" json:"before,omitempty"` 416 + // MaxCommits is an optional parameter to specify the maximum number of commits 417 + // to return. If max_commits is 0, all commits that match the criteria will be 418 + // returned. 419 + MaxCommits uint32 `protobuf:"varint,6,opt,name=max_commits,json=maxCommits,proto3" json:"max_commits,omitempty"` 420 + // Skip is an optional parameter to specify the number of commits to skip. 421 + // This can be used to implement a poor mans pagination. 422 + Skip uint32 `protobuf:"varint,7,opt,name=skip,proto3" json:"skip,omitempty"` 423 + unknownFields protoimpl.UnknownFields 424 + sizeCache protoimpl.SizeCache 425 + } 426 + 427 + func (x *CommitLogRequest) Reset() { 428 + *x = CommitLogRequest{} 429 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[6] 430 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 431 + ms.StoreMessageInfo(mi) 432 + } 433 + 434 + func (x *CommitLogRequest) String() string { 435 + return protoimpl.X.MessageStringOf(x) 436 + } 437 + 438 + func (*CommitLogRequest) ProtoMessage() {} 439 + 440 + func (x *CommitLogRequest) ProtoReflect() protoreflect.Message { 441 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[6] 442 + if x != nil { 443 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 444 + if ms.LoadMessageInfo() == nil { 445 + ms.StoreMessageInfo(mi) 446 + } 447 + return ms 448 + } 449 + return mi.MessageOf(x) 450 + } 451 + 452 + // Deprecated: Use CommitLogRequest.ProtoReflect.Descriptor instead. 453 + func (*CommitLogRequest) Descriptor() ([]byte, []int) { 454 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{6} 455 + } 456 + 457 + func (x *CommitLogRequest) GetRepo() string { 458 + if x != nil { 459 + return x.Repo 460 + } 461 + return "" 462 + } 463 + 464 + func (x *CommitLogRequest) GetRanges() [][]byte { 465 + if x != nil { 466 + return x.Ranges 467 + } 468 + return nil 469 + } 470 + 471 + func (x *CommitLogRequest) GetAllRefs() bool { 472 + if x != nil { 473 + return x.AllRefs 474 + } 475 + return false 476 + } 477 + 478 + func (x *CommitLogRequest) GetAfter() *timestamppb.Timestamp { 479 + if x != nil { 480 + return x.After 481 + } 482 + return nil 483 + } 484 + 485 + func (x *CommitLogRequest) GetBefore() *timestamppb.Timestamp { 486 + if x != nil { 487 + return x.Before 488 + } 489 + return nil 490 + } 491 + 492 + func (x *CommitLogRequest) GetMaxCommits() uint32 { 493 + if x != nil { 494 + return x.MaxCommits 495 + } 496 + return 0 497 + } 498 + 499 + func (x *CommitLogRequest) GetSkip() uint32 { 500 + if x != nil { 501 + return x.Skip 502 + } 503 + return 0 504 + } 505 + 506 + type CommitLogResponse struct { 507 + state protoimpl.MessageState `protogen:"open.v1"` 508 + Commits []*GitCommit `protobuf:"bytes,1,rep,name=commits,proto3" json:"commits,omitempty"` 509 + unknownFields protoimpl.UnknownFields 510 + sizeCache protoimpl.SizeCache 511 + } 512 + 513 + func (x *CommitLogResponse) Reset() { 514 + *x = CommitLogResponse{} 515 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[7] 516 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 517 + ms.StoreMessageInfo(mi) 518 + } 519 + 520 + func (x *CommitLogResponse) String() string { 521 + return protoimpl.X.MessageStringOf(x) 522 + } 523 + 524 + func (*CommitLogResponse) ProtoMessage() {} 525 + 526 + func (x *CommitLogResponse) ProtoReflect() protoreflect.Message { 527 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[7] 528 + if x != nil { 529 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 530 + if ms.LoadMessageInfo() == nil { 531 + ms.StoreMessageInfo(mi) 532 + } 533 + return ms 534 + } 535 + return mi.MessageOf(x) 536 + } 537 + 538 + // Deprecated: Use CommitLogResponse.ProtoReflect.Descriptor instead. 539 + func (*CommitLogResponse) Descriptor() ([]byte, []int) { 540 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{7} 541 + } 542 + 543 + func (x *CommitLogResponse) GetCommits() []*GitCommit { 544 + if x != nil { 545 + return x.Commits 546 + } 547 + return nil 548 + } 549 + 550 + type GetBlobRequest struct { 551 + state protoimpl.MessageState `protogen:"open.v1"` 552 + // repo carries the DID as a string. 553 + Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` 554 + // oid is the blob object id (hex). 555 + Oid string `protobuf:"bytes,2,opt,name=oid,proto3" json:"oid,omitempty"` 556 + unknownFields protoimpl.UnknownFields 557 + sizeCache protoimpl.SizeCache 558 + } 559 + 560 + func (x *GetBlobRequest) Reset() { 561 + *x = GetBlobRequest{} 562 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[8] 563 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 564 + ms.StoreMessageInfo(mi) 565 + } 566 + 567 + func (x *GetBlobRequest) String() string { 568 + return protoimpl.X.MessageStringOf(x) 569 + } 570 + 571 + func (*GetBlobRequest) ProtoMessage() {} 572 + 573 + func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { 574 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[8] 575 + if x != nil { 576 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 577 + if ms.LoadMessageInfo() == nil { 578 + ms.StoreMessageInfo(mi) 579 + } 580 + return ms 581 + } 582 + return mi.MessageOf(x) 583 + } 584 + 585 + // Deprecated: Use GetBlobRequest.ProtoReflect.Descriptor instead. 586 + func (*GetBlobRequest) Descriptor() ([]byte, []int) { 587 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{8} 588 + } 589 + 590 + func (x *GetBlobRequest) GetRepo() string { 591 + if x != nil { 592 + return x.Repo 593 + } 594 + return "" 595 + } 596 + 597 + func (x *GetBlobRequest) GetOid() string { 598 + if x != nil { 599 + return x.Oid 600 + } 601 + return "" 602 + } 603 + 604 + type BlobChunk struct { 605 + state protoimpl.MessageState `protogen:"open.v1"` 606 + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` 607 + unknownFields protoimpl.UnknownFields 608 + sizeCache protoimpl.SizeCache 609 + } 610 + 611 + func (x *BlobChunk) Reset() { 612 + *x = BlobChunk{} 613 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[9] 614 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 615 + ms.StoreMessageInfo(mi) 616 + } 617 + 618 + func (x *BlobChunk) String() string { 619 + return protoimpl.X.MessageStringOf(x) 620 + } 621 + 622 + func (*BlobChunk) ProtoMessage() {} 623 + 624 + func (x *BlobChunk) ProtoReflect() protoreflect.Message { 625 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[9] 626 + if x != nil { 627 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 628 + if ms.LoadMessageInfo() == nil { 629 + ms.StoreMessageInfo(mi) 630 + } 631 + return ms 632 + } 633 + return mi.MessageOf(x) 634 + } 635 + 636 + // Deprecated: Use BlobChunk.ProtoReflect.Descriptor instead. 637 + func (*BlobChunk) Descriptor() ([]byte, []int) { 638 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{9} 639 + } 640 + 641 + func (x *BlobChunk) GetData() []byte { 642 + if x != nil { 643 + return x.Data 644 + } 645 + return nil 646 + } 647 + 648 + type DiffRequest struct { 649 + state protoimpl.MessageState `protogen:"open.v1"` 650 + Base *RepoCommit `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` 651 + Head *RepoCommit `protobuf:"bytes,2,opt,name=head,proto3" json:"head,omitempty"` 652 + ComparisonType DiffRequest_ComparisonType `protobuf:"varint,3,opt,name=comparison_type,json=comparisonType,proto3,enum=gitmirror.v1.DiffRequest_ComparisonType" json:"comparison_type,omitempty"` 653 + unknownFields protoimpl.UnknownFields 654 + sizeCache protoimpl.SizeCache 655 + } 656 + 657 + func (x *DiffRequest) Reset() { 658 + *x = DiffRequest{} 659 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[10] 660 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 661 + ms.StoreMessageInfo(mi) 662 + } 663 + 664 + func (x *DiffRequest) String() string { 665 + return protoimpl.X.MessageStringOf(x) 666 + } 667 + 668 + func (*DiffRequest) ProtoMessage() {} 669 + 670 + func (x *DiffRequest) ProtoReflect() protoreflect.Message { 671 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[10] 672 + if x != nil { 673 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 674 + if ms.LoadMessageInfo() == nil { 675 + ms.StoreMessageInfo(mi) 676 + } 677 + return ms 678 + } 679 + return mi.MessageOf(x) 680 + } 681 + 682 + // Deprecated: Use DiffRequest.ProtoReflect.Descriptor instead. 683 + func (*DiffRequest) Descriptor() ([]byte, []int) { 684 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{10} 685 + } 686 + 687 + func (x *DiffRequest) GetBase() *RepoCommit { 688 + if x != nil { 689 + return x.Base 690 + } 691 + return nil 692 + } 693 + 694 + func (x *DiffRequest) GetHead() *RepoCommit { 695 + if x != nil { 696 + return x.Head 697 + } 698 + return nil 699 + } 700 + 701 + func (x *DiffRequest) GetComparisonType() DiffRequest_ComparisonType { 702 + if x != nil { 703 + return x.ComparisonType 704 + } 705 + return DiffRequest_COMPARISON_TYPE_UNSPECIFIED 706 + } 707 + 708 + type InterdiffRequest struct { 709 + state protoimpl.MessageState `protogen:"open.v1"` 710 + // repo carries the DID as a string. 711 + Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` 712 + // Old patch range (from_base..from_head) and new patch range (to_base..to_head). 713 + FromBase []byte `protobuf:"bytes,2,opt,name=from_base,json=fromBase,proto3" json:"from_base,omitempty"` 714 + FromHead []byte `protobuf:"bytes,3,opt,name=from_head,json=fromHead,proto3" json:"from_head,omitempty"` 715 + ToBase []byte `protobuf:"bytes,4,opt,name=to_base,json=toBase,proto3" json:"to_base,omitempty"` 716 + ToHead []byte `protobuf:"bytes,5,opt,name=to_head,json=toHead,proto3" json:"to_head,omitempty"` 717 + unknownFields protoimpl.UnknownFields 718 + sizeCache protoimpl.SizeCache 719 + } 720 + 721 + func (x *InterdiffRequest) Reset() { 722 + *x = InterdiffRequest{} 723 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[11] 724 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 725 + ms.StoreMessageInfo(mi) 726 + } 727 + 728 + func (x *InterdiffRequest) String() string { 729 + return protoimpl.X.MessageStringOf(x) 730 + } 731 + 732 + func (*InterdiffRequest) ProtoMessage() {} 733 + 734 + func (x *InterdiffRequest) ProtoReflect() protoreflect.Message { 735 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[11] 736 + if x != nil { 737 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 738 + if ms.LoadMessageInfo() == nil { 739 + ms.StoreMessageInfo(mi) 740 + } 741 + return ms 742 + } 743 + return mi.MessageOf(x) 744 + } 745 + 746 + // Deprecated: Use InterdiffRequest.ProtoReflect.Descriptor instead. 747 + func (*InterdiffRequest) Descriptor() ([]byte, []int) { 748 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{11} 749 + } 750 + 751 + func (x *InterdiffRequest) GetRepo() string { 752 + if x != nil { 753 + return x.Repo 754 + } 755 + return "" 756 + } 757 + 758 + func (x *InterdiffRequest) GetFromBase() []byte { 759 + if x != nil { 760 + return x.FromBase 761 + } 762 + return nil 763 + } 764 + 765 + func (x *InterdiffRequest) GetFromHead() []byte { 766 + if x != nil { 767 + return x.FromHead 768 + } 769 + return nil 770 + } 771 + 772 + func (x *InterdiffRequest) GetToBase() []byte { 773 + if x != nil { 774 + return x.ToBase 775 + } 776 + return nil 777 + } 778 + 779 + func (x *InterdiffRequest) GetToHead() []byte { 780 + if x != nil { 781 + return x.ToHead 782 + } 783 + return nil 784 + } 785 + 786 + type FileContent struct { 787 + state protoimpl.MessageState `protogen:"open.v1"` 788 + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` 789 + Oid string `protobuf:"bytes,2,opt,name=oid,proto3" json:"oid,omitempty"` 790 + Size uint64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` 791 + IsBinary bool `protobuf:"varint,4,opt,name=is_binary,json=isBinary,proto3" json:"is_binary,omitempty"` 792 + IsSubmodule bool `protobuf:"varint,5,opt,name=is_submodule,json=isSubmodule,proto3" json:"is_submodule,omitempty"` 793 + // Raw file bytes, inlined when the oid is not fetchable by the client 794 + // (e.g. interdiff) 795 + Content []byte `protobuf:"bytes,6,opt,name=content,proto3,oneof" json:"content,omitempty"` 796 + unknownFields protoimpl.UnknownFields 797 + sizeCache protoimpl.SizeCache 798 + } 799 + 800 + func (x *FileContent) Reset() { 801 + *x = FileContent{} 802 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[12] 803 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 804 + ms.StoreMessageInfo(mi) 805 + } 806 + 807 + func (x *FileContent) String() string { 808 + return protoimpl.X.MessageStringOf(x) 809 + } 810 + 811 + func (*FileContent) ProtoMessage() {} 812 + 813 + func (x *FileContent) ProtoReflect() protoreflect.Message { 814 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[12] 815 + if x != nil { 816 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 817 + if ms.LoadMessageInfo() == nil { 818 + ms.StoreMessageInfo(mi) 819 + } 820 + return ms 821 + } 822 + return mi.MessageOf(x) 823 + } 824 + 825 + // Deprecated: Use FileContent.ProtoReflect.Descriptor instead. 826 + func (*FileContent) Descriptor() ([]byte, []int) { 827 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{12} 828 + } 829 + 830 + func (x *FileContent) GetPath() string { 831 + if x != nil { 832 + return x.Path 833 + } 834 + return "" 835 + } 836 + 837 + func (x *FileContent) GetOid() string { 838 + if x != nil { 839 + return x.Oid 840 + } 841 + return "" 842 + } 843 + 844 + func (x *FileContent) GetSize() uint64 { 845 + if x != nil { 846 + return x.Size 847 + } 848 + return 0 849 + } 850 + 851 + func (x *FileContent) GetIsBinary() bool { 852 + if x != nil { 853 + return x.IsBinary 854 + } 855 + return false 856 + } 857 + 858 + func (x *FileContent) GetIsSubmodule() bool { 859 + if x != nil { 860 + return x.IsSubmodule 861 + } 862 + return false 863 + } 864 + 865 + func (x *FileContent) GetContent() []byte { 866 + if x != nil { 867 + return x.Content 868 + } 869 + return nil 870 + } 871 + 872 + // One aligned line pair; a side is absent (None) when unset. 873 + type LinePair struct { 874 + state protoimpl.MessageState `protogen:"open.v1"` 875 + Lhs *uint32 `protobuf:"varint,1,opt,name=lhs,proto3,oneof" json:"lhs,omitempty"` 876 + Rhs *uint32 `protobuf:"varint,2,opt,name=rhs,proto3,oneof" json:"rhs,omitempty"` 877 + unknownFields protoimpl.UnknownFields 878 + sizeCache protoimpl.SizeCache 879 + } 880 + 881 + func (x *LinePair) Reset() { 882 + *x = LinePair{} 883 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[13] 884 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 885 + ms.StoreMessageInfo(mi) 886 + } 887 + 888 + func (x *LinePair) String() string { 889 + return protoimpl.X.MessageStringOf(x) 890 + } 891 + 892 + func (*LinePair) ProtoMessage() {} 893 + 894 + func (x *LinePair) ProtoReflect() protoreflect.Message { 895 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[13] 896 + if x != nil { 897 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 898 + if ms.LoadMessageInfo() == nil { 899 + ms.StoreMessageInfo(mi) 900 + } 901 + return ms 902 + } 903 + return mi.MessageOf(x) 904 + } 905 + 906 + // Deprecated: Use LinePair.ProtoReflect.Descriptor instead. 907 + func (*LinePair) Descriptor() ([]byte, []int) { 908 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{13} 909 + } 910 + 911 + func (x *LinePair) GetLhs() uint32 { 912 + if x != nil && x.Lhs != nil { 913 + return *x.Lhs 914 + } 915 + return 0 916 + } 917 + 918 + func (x *LinePair) GetRhs() uint32 { 919 + if x != nil && x.Rhs != nil { 920 + return *x.Rhs 921 + } 922 + return 0 923 + } 924 + 925 + type Hunk struct { 926 + state protoimpl.MessageState `protogen:"open.v1"` 927 + // LineNumber (u32) sets containing novel content per side. 928 + NovelLhs []uint32 `protobuf:"varint,1,rep,packed,name=novel_lhs,json=novelLhs,proto3" json:"novel_lhs,omitempty"` 929 + NovelRhs []uint32 `protobuf:"varint,2,rep,packed,name=novel_rhs,json=novelRhs,proto3" json:"novel_rhs,omitempty"` 930 + Lines []*LinePair `protobuf:"bytes,3,rep,name=lines,proto3" json:"lines,omitempty"` 931 + unknownFields protoimpl.UnknownFields 932 + sizeCache protoimpl.SizeCache 933 + } 934 + 935 + func (x *Hunk) Reset() { 936 + *x = Hunk{} 937 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[14] 938 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 939 + ms.StoreMessageInfo(mi) 940 + } 941 + 942 + func (x *Hunk) String() string { 943 + return protoimpl.X.MessageStringOf(x) 944 + } 945 + 946 + func (*Hunk) ProtoMessage() {} 947 + 948 + func (x *Hunk) ProtoReflect() protoreflect.Message { 949 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[14] 950 + if x != nil { 951 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 952 + if ms.LoadMessageInfo() == nil { 953 + ms.StoreMessageInfo(mi) 954 + } 955 + return ms 956 + } 957 + return mi.MessageOf(x) 958 + } 959 + 960 + // Deprecated: Use Hunk.ProtoReflect.Descriptor instead. 961 + func (*Hunk) Descriptor() ([]byte, []int) { 962 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{14} 963 + } 964 + 965 + func (x *Hunk) GetNovelLhs() []uint32 { 966 + if x != nil { 967 + return x.NovelLhs 968 + } 969 + return nil 970 + } 971 + 972 + func (x *Hunk) GetNovelRhs() []uint32 { 973 + if x != nil { 974 + return x.NovelRhs 975 + } 976 + return nil 977 + } 978 + 979 + func (x *Hunk) GetLines() []*LinePair { 980 + if x != nil { 981 + return x.Lines 982 + } 983 + return nil 984 + } 985 + 986 + // Number of bytes per side when the files differ (Rust Option<(usize, usize)>). 987 + type ByteChanges struct { 988 + state protoimpl.MessageState `protogen:"open.v1"` 989 + Lhs uint64 `protobuf:"varint,1,opt,name=lhs,proto3" json:"lhs,omitempty"` 990 + Rhs uint64 `protobuf:"varint,2,opt,name=rhs,proto3" json:"rhs,omitempty"` 991 + unknownFields protoimpl.UnknownFields 992 + sizeCache protoimpl.SizeCache 993 + } 994 + 995 + func (x *ByteChanges) Reset() { 996 + *x = ByteChanges{} 997 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[15] 998 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 999 + ms.StoreMessageInfo(mi) 1000 + } 1001 + 1002 + func (x *ByteChanges) String() string { 1003 + return protoimpl.X.MessageStringOf(x) 1004 + } 1005 + 1006 + func (*ByteChanges) ProtoMessage() {} 1007 + 1008 + func (x *ByteChanges) ProtoReflect() protoreflect.Message { 1009 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[15] 1010 + if x != nil { 1011 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1012 + if ms.LoadMessageInfo() == nil { 1013 + ms.StoreMessageInfo(mi) 1014 + } 1015 + return ms 1016 + } 1017 + return mi.MessageOf(x) 1018 + } 1019 + 1020 + // Deprecated: Use ByteChanges.ProtoReflect.Descriptor instead. 1021 + func (*ByteChanges) Descriptor() ([]byte, []int) { 1022 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{15} 1023 + } 1024 + 1025 + func (x *ByteChanges) GetLhs() uint64 { 1026 + if x != nil { 1027 + return x.Lhs 1028 + } 1029 + return 0 1030 + } 1031 + 1032 + func (x *ByteChanges) GetRhs() uint64 { 1033 + if x != nil { 1034 + return x.Rhs 1035 + } 1036 + return 0 1037 + } 1038 + 1039 + type FileDiff struct { 1040 + state protoimpl.MessageState `protogen:"open.v1"` 1041 + LhsSrc *FileContent `protobuf:"bytes,1,opt,name=lhs_src,json=lhsSrc,proto3" json:"lhs_src,omitempty"` 1042 + RhsSrc *FileContent `protobuf:"bytes,2,opt,name=rhs_src,json=rhsSrc,proto3" json:"rhs_src,omitempty"` 1043 + Hunks []*Hunk `protobuf:"bytes,3,rep,name=hunks,proto3" json:"hunks,omitempty"` 1044 + HasByteChanges *ByteChanges `protobuf:"bytes,4,opt,name=has_byte_changes,json=hasByteChanges,proto3,oneof" json:"has_byte_changes,omitempty"` 1045 + HasSyntacticChanges bool `protobuf:"varint,5,opt,name=has_syntactic_changes,json=hasSyntacticChanges,proto3" json:"has_syntactic_changes,omitempty"` // TODO: lhs_positions & rhs_positions 1046 + unknownFields protoimpl.UnknownFields 1047 + sizeCache protoimpl.SizeCache 1048 + } 1049 + 1050 + func (x *FileDiff) Reset() { 1051 + *x = FileDiff{} 1052 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[16] 1053 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1054 + ms.StoreMessageInfo(mi) 1055 + } 1056 + 1057 + func (x *FileDiff) String() string { 1058 + return protoimpl.X.MessageStringOf(x) 1059 + } 1060 + 1061 + func (*FileDiff) ProtoMessage() {} 1062 + 1063 + func (x *FileDiff) ProtoReflect() protoreflect.Message { 1064 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[16] 1065 + if x != nil { 1066 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1067 + if ms.LoadMessageInfo() == nil { 1068 + ms.StoreMessageInfo(mi) 1069 + } 1070 + return ms 1071 + } 1072 + return mi.MessageOf(x) 1073 + } 1074 + 1075 + // Deprecated: Use FileDiff.ProtoReflect.Descriptor instead. 1076 + func (*FileDiff) Descriptor() ([]byte, []int) { 1077 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{16} 1078 + } 1079 + 1080 + func (x *FileDiff) GetLhsSrc() *FileContent { 1081 + if x != nil { 1082 + return x.LhsSrc 1083 + } 1084 + return nil 1085 + } 1086 + 1087 + func (x *FileDiff) GetRhsSrc() *FileContent { 1088 + if x != nil { 1089 + return x.RhsSrc 1090 + } 1091 + return nil 1092 + } 1093 + 1094 + func (x *FileDiff) GetHunks() []*Hunk { 1095 + if x != nil { 1096 + return x.Hunks 1097 + } 1098 + return nil 1099 + } 1100 + 1101 + func (x *FileDiff) GetHasByteChanges() *ByteChanges { 1102 + if x != nil { 1103 + return x.HasByteChanges 1104 + } 1105 + return nil 1106 + } 1107 + 1108 + func (x *FileDiff) GetHasSyntacticChanges() bool { 1109 + if x != nil { 1110 + return x.HasSyntacticChanges 1111 + } 1112 + return false 1113 + } 1114 + 1115 + type GitCommit struct { 1116 + state protoimpl.MessageState `protogen:"open.v1"` 1117 + Oid string `protobuf:"bytes,1,opt,name=oid,proto3" json:"oid,omitempty"` 1118 + Author *GitSignature `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"` 1119 + Committer *GitSignature `protobuf:"bytes,3,opt,name=committer,proto3" json:"committer,omitempty"` 1120 + Message []byte `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` 1121 + Parents []string `protobuf:"bytes,5,rep,name=parents,proto3" json:"parents,omitempty"` 1122 + ExtraHeaders map[string]string `protobuf:"bytes,6,rep,name=extra_headers,json=extraHeaders,proto3" json:"extra_headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` 1123 + unknownFields protoimpl.UnknownFields 1124 + sizeCache protoimpl.SizeCache 1125 + } 1126 + 1127 + func (x *GitCommit) Reset() { 1128 + *x = GitCommit{} 1129 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[17] 1130 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1131 + ms.StoreMessageInfo(mi) 1132 + } 1133 + 1134 + func (x *GitCommit) String() string { 1135 + return protoimpl.X.MessageStringOf(x) 1136 + } 1137 + 1138 + func (*GitCommit) ProtoMessage() {} 1139 + 1140 + func (x *GitCommit) ProtoReflect() protoreflect.Message { 1141 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[17] 1142 + if x != nil { 1143 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1144 + if ms.LoadMessageInfo() == nil { 1145 + ms.StoreMessageInfo(mi) 1146 + } 1147 + return ms 1148 + } 1149 + return mi.MessageOf(x) 1150 + } 1151 + 1152 + // Deprecated: Use GitCommit.ProtoReflect.Descriptor instead. 1153 + func (*GitCommit) Descriptor() ([]byte, []int) { 1154 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{17} 1155 + } 1156 + 1157 + func (x *GitCommit) GetOid() string { 1158 + if x != nil { 1159 + return x.Oid 1160 + } 1161 + return "" 1162 + } 1163 + 1164 + func (x *GitCommit) GetAuthor() *GitSignature { 1165 + if x != nil { 1166 + return x.Author 1167 + } 1168 + return nil 1169 + } 1170 + 1171 + func (x *GitCommit) GetCommitter() *GitSignature { 1172 + if x != nil { 1173 + return x.Committer 1174 + } 1175 + return nil 1176 + } 1177 + 1178 + func (x *GitCommit) GetMessage() []byte { 1179 + if x != nil { 1180 + return x.Message 1181 + } 1182 + return nil 1183 + } 1184 + 1185 + func (x *GitCommit) GetParents() []string { 1186 + if x != nil { 1187 + return x.Parents 1188 + } 1189 + return nil 1190 + } 1191 + 1192 + func (x *GitCommit) GetExtraHeaders() map[string]string { 1193 + if x != nil { 1194 + return x.ExtraHeaders 1195 + } 1196 + return nil 1197 + } 1198 + 1199 + type GitSignature struct { 1200 + state protoimpl.MessageState `protogen:"open.v1"` 1201 + Name []byte `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` 1202 + Email []byte `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"` 1203 + Date *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=date,proto3" json:"date,omitempty"` 1204 + unknownFields protoimpl.UnknownFields 1205 + sizeCache protoimpl.SizeCache 1206 + } 1207 + 1208 + func (x *GitSignature) Reset() { 1209 + *x = GitSignature{} 1210 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[18] 1211 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1212 + ms.StoreMessageInfo(mi) 1213 + } 1214 + 1215 + func (x *GitSignature) String() string { 1216 + return protoimpl.X.MessageStringOf(x) 1217 + } 1218 + 1219 + func (*GitSignature) ProtoMessage() {} 1220 + 1221 + func (x *GitSignature) ProtoReflect() protoreflect.Message { 1222 + mi := &file_gitmirror_v1_gitmirror_proto_msgTypes[18] 1223 + if x != nil { 1224 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1225 + if ms.LoadMessageInfo() == nil { 1226 + ms.StoreMessageInfo(mi) 1227 + } 1228 + return ms 1229 + } 1230 + return mi.MessageOf(x) 1231 + } 1232 + 1233 + // Deprecated: Use GitSignature.ProtoReflect.Descriptor instead. 1234 + func (*GitSignature) Descriptor() ([]byte, []int) { 1235 + return file_gitmirror_v1_gitmirror_proto_rawDescGZIP(), []int{18} 1236 + } 1237 + 1238 + func (x *GitSignature) GetName() []byte { 1239 + if x != nil { 1240 + return x.Name 1241 + } 1242 + return nil 1243 + } 1244 + 1245 + func (x *GitSignature) GetEmail() []byte { 1246 + if x != nil { 1247 + return x.Email 1248 + } 1249 + return nil 1250 + } 1251 + 1252 + func (x *GitSignature) GetDate() *timestamppb.Timestamp { 1253 + if x != nil { 1254 + return x.Date 1255 + } 1256 + return nil 1257 + } 1258 + 1259 + var File_gitmirror_v1_gitmirror_proto protoreflect.FileDescriptor 1260 + 1261 + const file_gitmirror_v1_gitmirror_proto_rawDesc = "" + 1262 + "\n" + 1263 + "\x1cgitmirror/v1/gitmirror.proto\x12\fgitmirror.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"9\n" + 1264 + "\x11ResolveRevRequest\x12\x12\n" + 1265 + "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x10\n" + 1266 + "\x03rev\x18\x02 \x01(\fR\x03rev\",\n" + 1267 + "\x12ResolveRevResponse\x12\x16\n" + 1268 + "\x06commit\x18\x01 \x01(\tR\x06commit\"8\n" + 1269 + "\n" + 1270 + "RepoCommit\x12\x12\n" + 1271 + "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x16\n" + 1272 + "\x06commit\x18\x02 \x01(\fR\x06commit\"w\n" + 1273 + "\x11MergeCheckRequest\x120\n" + 1274 + "\x06target\x18\x01 \x01(\v2\x18.gitmirror.v1.RepoCommitR\x06target\x120\n" + 1275 + "\x06source\x18\x02 \x01(\v2\x18.gitmirror.v1.RepoCommitR\x06source\"\x9f\x01\n" + 1276 + "\x12MergeCheckResponse\x12#\n" + 1277 + "\ris_conflicted\x18\x01 \x01(\bR\fisConflicted\x129\n" + 1278 + "\tconflicts\x18\x02 \x03(\v2\x1b.gitmirror.v1.MergeConflictR\tconflicts\x12\x1d\n" + 1279 + "\amessage\x18\x03 \x01(\tH\x00R\amessage\x88\x01\x01B\n" + 1280 + "\n" + 1281 + "\b_message\"C\n" + 1282 + "\rMergeConflict\x12\x1a\n" + 1283 + "\bfilename\x18\x01 \x01(\tR\bfilename\x12\x16\n" + 1284 + "\x06reason\x18\x02 \x01(\tR\x06reason\"\xf4\x01\n" + 1285 + "\x10CommitLogRequest\x12\x12\n" + 1286 + "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x16\n" + 1287 + "\x06ranges\x18\x02 \x03(\fR\x06ranges\x12\x19\n" + 1288 + "\ball_refs\x18\x03 \x01(\bR\aallRefs\x120\n" + 1289 + "\x05after\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x05after\x122\n" + 1290 + "\x06before\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x06before\x12\x1f\n" + 1291 + "\vmax_commits\x18\x06 \x01(\rR\n" + 1292 + "maxCommits\x12\x12\n" + 1293 + "\x04skip\x18\a \x01(\rR\x04skip\"F\n" + 1294 + "\x11CommitLogResponse\x121\n" + 1295 + "\acommits\x18\x01 \x03(\v2\x17.gitmirror.v1.GitCommitR\acommits\"6\n" + 1296 + "\x0eGetBlobRequest\x12\x12\n" + 1297 + "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x10\n" + 1298 + "\x03oid\x18\x02 \x01(\tR\x03oid\"\x1f\n" + 1299 + "\tBlobChunk\x12\x12\n" + 1300 + "\x04data\x18\x01 \x01(\fR\x04data\"\xb3\x02\n" + 1301 + "\vDiffRequest\x12,\n" + 1302 + "\x04base\x18\x01 \x01(\v2\x18.gitmirror.v1.RepoCommitR\x04base\x12,\n" + 1303 + "\x04head\x18\x02 \x01(\v2\x18.gitmirror.v1.RepoCommitR\x04head\x12Q\n" + 1304 + "\x0fcomparison_type\x18\x03 \x01(\x0e2(.gitmirror.v1.DiffRequest.ComparisonTypeR\x0ecomparisonType\"u\n" + 1305 + "\x0eComparisonType\x12\x1f\n" + 1306 + "\x1bCOMPARISON_TYPE_UNSPECIFIED\x10\x00\x12 \n" + 1307 + "\x1cCOMPARISON_TYPE_ONLY_IN_HEAD\x10\x01\x12 \n" + 1308 + "\x1cCOMPARISON_TYPE_INTERSECTION\x10\x02\"\x92\x01\n" + 1309 + "\x10InterdiffRequest\x12\x12\n" + 1310 + "\x04repo\x18\x01 \x01(\tR\x04repo\x12\x1b\n" + 1311 + "\tfrom_base\x18\x02 \x01(\fR\bfromBase\x12\x1b\n" + 1312 + "\tfrom_head\x18\x03 \x01(\fR\bfromHead\x12\x17\n" + 1313 + "\ato_base\x18\x04 \x01(\fR\x06toBase\x12\x17\n" + 1314 + "\ato_head\x18\x05 \x01(\fR\x06toHead\"\xb2\x01\n" + 1315 + "\vFileContent\x12\x12\n" + 1316 + "\x04path\x18\x01 \x01(\tR\x04path\x12\x10\n" + 1317 + "\x03oid\x18\x02 \x01(\tR\x03oid\x12\x12\n" + 1318 + "\x04size\x18\x03 \x01(\x04R\x04size\x12\x1b\n" + 1319 + "\tis_binary\x18\x04 \x01(\bR\bisBinary\x12!\n" + 1320 + "\fis_submodule\x18\x05 \x01(\bR\visSubmodule\x12\x1d\n" + 1321 + "\acontent\x18\x06 \x01(\fH\x00R\acontent\x88\x01\x01B\n" + 1322 + "\n" + 1323 + "\b_content\"H\n" + 1324 + "\bLinePair\x12\x15\n" + 1325 + "\x03lhs\x18\x01 \x01(\rH\x00R\x03lhs\x88\x01\x01\x12\x15\n" + 1326 + "\x03rhs\x18\x02 \x01(\rH\x01R\x03rhs\x88\x01\x01B\x06\n" + 1327 + "\x04_lhsB\x06\n" + 1328 + "\x04_rhs\"n\n" + 1329 + "\x04Hunk\x12\x1b\n" + 1330 + "\tnovel_lhs\x18\x01 \x03(\rR\bnovelLhs\x12\x1b\n" + 1331 + "\tnovel_rhs\x18\x02 \x03(\rR\bnovelRhs\x12,\n" + 1332 + "\x05lines\x18\x03 \x03(\v2\x16.gitmirror.v1.LinePairR\x05lines\"1\n" + 1333 + "\vByteChanges\x12\x10\n" + 1334 + "\x03lhs\x18\x01 \x01(\x04R\x03lhs\x12\x10\n" + 1335 + "\x03rhs\x18\x02 \x01(\x04R\x03rhs\"\xaf\x02\n" + 1336 + "\bFileDiff\x122\n" + 1337 + "\alhs_src\x18\x01 \x01(\v2\x19.gitmirror.v1.FileContentR\x06lhsSrc\x122\n" + 1338 + "\arhs_src\x18\x02 \x01(\v2\x19.gitmirror.v1.FileContentR\x06rhsSrc\x12(\n" + 1339 + "\x05hunks\x18\x03 \x03(\v2\x12.gitmirror.v1.HunkR\x05hunks\x12H\n" + 1340 + "\x10has_byte_changes\x18\x04 \x01(\v2\x19.gitmirror.v1.ByteChangesH\x00R\x0ehasByteChanges\x88\x01\x01\x122\n" + 1341 + "\x15has_syntactic_changes\x18\x05 \x01(\bR\x13hasSyntacticChangesB\x13\n" + 1342 + "\x11_has_byte_changes\"\xd0\x02\n" + 1343 + "\tGitCommit\x12\x10\n" + 1344 + "\x03oid\x18\x01 \x01(\tR\x03oid\x122\n" + 1345 + "\x06author\x18\x02 \x01(\v2\x1a.gitmirror.v1.GitSignatureR\x06author\x128\n" + 1346 + "\tcommitter\x18\x03 \x01(\v2\x1a.gitmirror.v1.GitSignatureR\tcommitter\x12\x18\n" + 1347 + "\amessage\x18\x04 \x01(\fR\amessage\x12\x18\n" + 1348 + "\aparents\x18\x05 \x03(\tR\aparents\x12N\n" + 1349 + "\rextra_headers\x18\x06 \x03(\v2).gitmirror.v1.GitCommit.ExtraHeadersEntryR\fextraHeaders\x1a?\n" + 1350 + "\x11ExtraHeadersEntry\x12\x10\n" + 1351 + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + 1352 + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"h\n" + 1353 + "\fGitSignature\x12\x12\n" + 1354 + "\x04name\x18\x01 \x01(\fR\x04name\x12\x14\n" + 1355 + "\x05email\x18\x02 \x01(\fR\x05email\x12.\n" + 1356 + "\x04date\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x04date2\xcc\x03\n" + 1357 + "\x10GitMirrorService\x12N\n" + 1358 + "\tCommitLog\x12\x1e.gitmirror.v1.CommitLogRequest\x1a\x1f.gitmirror.v1.CommitLogResponse0\x01\x12B\n" + 1359 + "\aGetBlob\x12\x1c.gitmirror.v1.GetBlobRequest\x1a\x17.gitmirror.v1.BlobChunk0\x01\x12;\n" + 1360 + "\x04Diff\x12\x19.gitmirror.v1.DiffRequest\x1a\x16.gitmirror.v1.FileDiff0\x01\x12E\n" + 1361 + "\tInterdiff\x12\x1e.gitmirror.v1.InterdiffRequest\x1a\x16.gitmirror.v1.FileDiff0\x01\x12O\n" + 1362 + "\n" + 1363 + "MergeCheck\x12\x1f.gitmirror.v1.MergeCheckRequest\x1a .gitmirror.v1.MergeCheckResponse\x12O\n" + 1364 + "\n" + 1365 + "ResolveRev\x12\x1f.gitmirror.v1.ResolveRevRequest\x1a .gitmirror.v1.ResolveRevResponseB2Z0tangled.org/core/gitmirror/proto/gen;gitmirrorv1b\x06proto3" 1366 + 1367 + var ( 1368 + file_gitmirror_v1_gitmirror_proto_rawDescOnce sync.Once 1369 + file_gitmirror_v1_gitmirror_proto_rawDescData []byte 1370 + ) 1371 + 1372 + func file_gitmirror_v1_gitmirror_proto_rawDescGZIP() []byte { 1373 + file_gitmirror_v1_gitmirror_proto_rawDescOnce.Do(func() { 1374 + file_gitmirror_v1_gitmirror_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_gitmirror_v1_gitmirror_proto_rawDesc), len(file_gitmirror_v1_gitmirror_proto_rawDesc))) 1375 + }) 1376 + return file_gitmirror_v1_gitmirror_proto_rawDescData 1377 + } 1378 + 1379 + var file_gitmirror_v1_gitmirror_proto_enumTypes = make([]protoimpl.EnumInfo, 1) 1380 + var file_gitmirror_v1_gitmirror_proto_msgTypes = make([]protoimpl.MessageInfo, 20) 1381 + var file_gitmirror_v1_gitmirror_proto_goTypes = []any{ 1382 + (DiffRequest_ComparisonType)(0), // 0: gitmirror.v1.DiffRequest.ComparisonType 1383 + (*ResolveRevRequest)(nil), // 1: gitmirror.v1.ResolveRevRequest 1384 + (*ResolveRevResponse)(nil), // 2: gitmirror.v1.ResolveRevResponse 1385 + (*RepoCommit)(nil), // 3: gitmirror.v1.RepoCommit 1386 + (*MergeCheckRequest)(nil), // 4: gitmirror.v1.MergeCheckRequest 1387 + (*MergeCheckResponse)(nil), // 5: gitmirror.v1.MergeCheckResponse 1388 + (*MergeConflict)(nil), // 6: gitmirror.v1.MergeConflict 1389 + (*CommitLogRequest)(nil), // 7: gitmirror.v1.CommitLogRequest 1390 + (*CommitLogResponse)(nil), // 8: gitmirror.v1.CommitLogResponse 1391 + (*GetBlobRequest)(nil), // 9: gitmirror.v1.GetBlobRequest 1392 + (*BlobChunk)(nil), // 10: gitmirror.v1.BlobChunk 1393 + (*DiffRequest)(nil), // 11: gitmirror.v1.DiffRequest 1394 + (*InterdiffRequest)(nil), // 12: gitmirror.v1.InterdiffRequest 1395 + (*FileContent)(nil), // 13: gitmirror.v1.FileContent 1396 + (*LinePair)(nil), // 14: gitmirror.v1.LinePair 1397 + (*Hunk)(nil), // 15: gitmirror.v1.Hunk 1398 + (*ByteChanges)(nil), // 16: gitmirror.v1.ByteChanges 1399 + (*FileDiff)(nil), // 17: gitmirror.v1.FileDiff 1400 + (*GitCommit)(nil), // 18: gitmirror.v1.GitCommit 1401 + (*GitSignature)(nil), // 19: gitmirror.v1.GitSignature 1402 + nil, // 20: gitmirror.v1.GitCommit.ExtraHeadersEntry 1403 + (*timestamppb.Timestamp)(nil), // 21: google.protobuf.Timestamp 1404 + } 1405 + var file_gitmirror_v1_gitmirror_proto_depIdxs = []int32{ 1406 + 3, // 0: gitmirror.v1.MergeCheckRequest.target:type_name -> gitmirror.v1.RepoCommit 1407 + 3, // 1: gitmirror.v1.MergeCheckRequest.source:type_name -> gitmirror.v1.RepoCommit 1408 + 6, // 2: gitmirror.v1.MergeCheckResponse.conflicts:type_name -> gitmirror.v1.MergeConflict 1409 + 21, // 3: gitmirror.v1.CommitLogRequest.after:type_name -> google.protobuf.Timestamp 1410 + 21, // 4: gitmirror.v1.CommitLogRequest.before:type_name -> google.protobuf.Timestamp 1411 + 18, // 5: gitmirror.v1.CommitLogResponse.commits:type_name -> gitmirror.v1.GitCommit 1412 + 3, // 6: gitmirror.v1.DiffRequest.base:type_name -> gitmirror.v1.RepoCommit 1413 + 3, // 7: gitmirror.v1.DiffRequest.head:type_name -> gitmirror.v1.RepoCommit 1414 + 0, // 8: gitmirror.v1.DiffRequest.comparison_type:type_name -> gitmirror.v1.DiffRequest.ComparisonType 1415 + 14, // 9: gitmirror.v1.Hunk.lines:type_name -> gitmirror.v1.LinePair 1416 + 13, // 10: gitmirror.v1.FileDiff.lhs_src:type_name -> gitmirror.v1.FileContent 1417 + 13, // 11: gitmirror.v1.FileDiff.rhs_src:type_name -> gitmirror.v1.FileContent 1418 + 15, // 12: gitmirror.v1.FileDiff.hunks:type_name -> gitmirror.v1.Hunk 1419 + 16, // 13: gitmirror.v1.FileDiff.has_byte_changes:type_name -> gitmirror.v1.ByteChanges 1420 + 19, // 14: gitmirror.v1.GitCommit.author:type_name -> gitmirror.v1.GitSignature 1421 + 19, // 15: gitmirror.v1.GitCommit.committer:type_name -> gitmirror.v1.GitSignature 1422 + 20, // 16: gitmirror.v1.GitCommit.extra_headers:type_name -> gitmirror.v1.GitCommit.ExtraHeadersEntry 1423 + 21, // 17: gitmirror.v1.GitSignature.date:type_name -> google.protobuf.Timestamp 1424 + 7, // 18: gitmirror.v1.GitMirrorService.CommitLog:input_type -> gitmirror.v1.CommitLogRequest 1425 + 9, // 19: gitmirror.v1.GitMirrorService.GetBlob:input_type -> gitmirror.v1.GetBlobRequest 1426 + 11, // 20: gitmirror.v1.GitMirrorService.Diff:input_type -> gitmirror.v1.DiffRequest 1427 + 12, // 21: gitmirror.v1.GitMirrorService.Interdiff:input_type -> gitmirror.v1.InterdiffRequest 1428 + 4, // 22: gitmirror.v1.GitMirrorService.MergeCheck:input_type -> gitmirror.v1.MergeCheckRequest 1429 + 1, // 23: gitmirror.v1.GitMirrorService.ResolveRev:input_type -> gitmirror.v1.ResolveRevRequest 1430 + 8, // 24: gitmirror.v1.GitMirrorService.CommitLog:output_type -> gitmirror.v1.CommitLogResponse 1431 + 10, // 25: gitmirror.v1.GitMirrorService.GetBlob:output_type -> gitmirror.v1.BlobChunk 1432 + 17, // 26: gitmirror.v1.GitMirrorService.Diff:output_type -> gitmirror.v1.FileDiff 1433 + 17, // 27: gitmirror.v1.GitMirrorService.Interdiff:output_type -> gitmirror.v1.FileDiff 1434 + 5, // 28: gitmirror.v1.GitMirrorService.MergeCheck:output_type -> gitmirror.v1.MergeCheckResponse 1435 + 2, // 29: gitmirror.v1.GitMirrorService.ResolveRev:output_type -> gitmirror.v1.ResolveRevResponse 1436 + 24, // [24:30] is the sub-list for method output_type 1437 + 18, // [18:24] is the sub-list for method input_type 1438 + 18, // [18:18] is the sub-list for extension type_name 1439 + 18, // [18:18] is the sub-list for extension extendee 1440 + 0, // [0:18] is the sub-list for field type_name 1441 + } 1442 + 1443 + func init() { file_gitmirror_v1_gitmirror_proto_init() } 1444 + func file_gitmirror_v1_gitmirror_proto_init() { 1445 + if File_gitmirror_v1_gitmirror_proto != nil { 1446 + return 1447 + } 1448 + file_gitmirror_v1_gitmirror_proto_msgTypes[4].OneofWrappers = []any{} 1449 + file_gitmirror_v1_gitmirror_proto_msgTypes[12].OneofWrappers = []any{} 1450 + file_gitmirror_v1_gitmirror_proto_msgTypes[13].OneofWrappers = []any{} 1451 + file_gitmirror_v1_gitmirror_proto_msgTypes[16].OneofWrappers = []any{} 1452 + type x struct{} 1453 + out := protoimpl.TypeBuilder{ 1454 + File: protoimpl.DescBuilder{ 1455 + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 1456 + RawDescriptor: unsafe.Slice(unsafe.StringData(file_gitmirror_v1_gitmirror_proto_rawDesc), len(file_gitmirror_v1_gitmirror_proto_rawDesc)), 1457 + NumEnums: 1, 1458 + NumMessages: 20, 1459 + NumExtensions: 0, 1460 + NumServices: 1, 1461 + }, 1462 + GoTypes: file_gitmirror_v1_gitmirror_proto_goTypes, 1463 + DependencyIndexes: file_gitmirror_v1_gitmirror_proto_depIdxs, 1464 + EnumInfos: file_gitmirror_v1_gitmirror_proto_enumTypes, 1465 + MessageInfos: file_gitmirror_v1_gitmirror_proto_msgTypes, 1466 + }.Build() 1467 + File_gitmirror_v1_gitmirror_proto = out.File 1468 + file_gitmirror_v1_gitmirror_proto_goTypes = nil 1469 + file_gitmirror_v1_gitmirror_proto_depIdxs = nil 1470 + }
+324
gitmirror/proto/gen/gitmirror_grpc.pb.go
··· 1 + // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 + // versions: 3 + // - protoc-gen-go-grpc v1.6.2 4 + // - protoc (unknown) 5 + // source: gitmirror/v1/gitmirror.proto 6 + 7 + package gitmirrorv1 8 + 9 + import ( 10 + context "context" 11 + grpc "google.golang.org/grpc" 12 + codes "google.golang.org/grpc/codes" 13 + status "google.golang.org/grpc/status" 14 + ) 15 + 16 + // This is a compile-time assertion to ensure that this generated file 17 + // is compatible with the grpc package it is being compiled against. 18 + // Requires gRPC-Go v1.64.0 or later. 19 + const _ = grpc.SupportPackageIsVersion9 20 + 21 + const ( 22 + GitMirrorService_CommitLog_FullMethodName = "/gitmirror.v1.GitMirrorService/CommitLog" 23 + GitMirrorService_GetBlob_FullMethodName = "/gitmirror.v1.GitMirrorService/GetBlob" 24 + GitMirrorService_Diff_FullMethodName = "/gitmirror.v1.GitMirrorService/Diff" 25 + GitMirrorService_Interdiff_FullMethodName = "/gitmirror.v1.GitMirrorService/Interdiff" 26 + GitMirrorService_MergeCheck_FullMethodName = "/gitmirror.v1.GitMirrorService/MergeCheck" 27 + GitMirrorService_ResolveRev_FullMethodName = "/gitmirror.v1.GitMirrorService/ResolveRev" 28 + ) 29 + 30 + // GitMirrorServiceClient is the client API for GitMirrorService service. 31 + // 32 + // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 33 + type GitMirrorServiceClient interface { 34 + CommitLog(ctx context.Context, in *CommitLogRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CommitLogResponse], error) 35 + GetBlob(ctx context.Context, in *GetBlobRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BlobChunk], error) 36 + Diff(ctx context.Context, in *DiffRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FileDiff], error) 37 + Interdiff(ctx context.Context, in *InterdiffRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FileDiff], error) 38 + MergeCheck(ctx context.Context, in *MergeCheckRequest, opts ...grpc.CallOption) (*MergeCheckResponse, error) 39 + ResolveRev(ctx context.Context, in *ResolveRevRequest, opts ...grpc.CallOption) (*ResolveRevResponse, error) 40 + } 41 + 42 + type gitMirrorServiceClient struct { 43 + cc grpc.ClientConnInterface 44 + } 45 + 46 + func NewGitMirrorServiceClient(cc grpc.ClientConnInterface) GitMirrorServiceClient { 47 + return &gitMirrorServiceClient{cc} 48 + } 49 + 50 + func (c *gitMirrorServiceClient) CommitLog(ctx context.Context, in *CommitLogRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CommitLogResponse], error) { 51 + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 52 + stream, err := c.cc.NewStream(ctx, &GitMirrorService_ServiceDesc.Streams[0], GitMirrorService_CommitLog_FullMethodName, cOpts...) 53 + if err != nil { 54 + return nil, err 55 + } 56 + x := &grpc.GenericClientStream[CommitLogRequest, CommitLogResponse]{ClientStream: stream} 57 + if err := x.ClientStream.SendMsg(in); err != nil { 58 + return nil, err 59 + } 60 + if err := x.ClientStream.CloseSend(); err != nil { 61 + return nil, err 62 + } 63 + return x, nil 64 + } 65 + 66 + // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 67 + type GitMirrorService_CommitLogClient = grpc.ServerStreamingClient[CommitLogResponse] 68 + 69 + func (c *gitMirrorServiceClient) GetBlob(ctx context.Context, in *GetBlobRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BlobChunk], error) { 70 + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 71 + stream, err := c.cc.NewStream(ctx, &GitMirrorService_ServiceDesc.Streams[1], GitMirrorService_GetBlob_FullMethodName, cOpts...) 72 + if err != nil { 73 + return nil, err 74 + } 75 + x := &grpc.GenericClientStream[GetBlobRequest, BlobChunk]{ClientStream: stream} 76 + if err := x.ClientStream.SendMsg(in); err != nil { 77 + return nil, err 78 + } 79 + if err := x.ClientStream.CloseSend(); err != nil { 80 + return nil, err 81 + } 82 + return x, nil 83 + } 84 + 85 + // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 86 + type GitMirrorService_GetBlobClient = grpc.ServerStreamingClient[BlobChunk] 87 + 88 + func (c *gitMirrorServiceClient) Diff(ctx context.Context, in *DiffRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FileDiff], error) { 89 + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 90 + stream, err := c.cc.NewStream(ctx, &GitMirrorService_ServiceDesc.Streams[2], GitMirrorService_Diff_FullMethodName, cOpts...) 91 + if err != nil { 92 + return nil, err 93 + } 94 + x := &grpc.GenericClientStream[DiffRequest, FileDiff]{ClientStream: stream} 95 + if err := x.ClientStream.SendMsg(in); err != nil { 96 + return nil, err 97 + } 98 + if err := x.ClientStream.CloseSend(); err != nil { 99 + return nil, err 100 + } 101 + return x, nil 102 + } 103 + 104 + // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 105 + type GitMirrorService_DiffClient = grpc.ServerStreamingClient[FileDiff] 106 + 107 + func (c *gitMirrorServiceClient) Interdiff(ctx context.Context, in *InterdiffRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FileDiff], error) { 108 + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 109 + stream, err := c.cc.NewStream(ctx, &GitMirrorService_ServiceDesc.Streams[3], GitMirrorService_Interdiff_FullMethodName, cOpts...) 110 + if err != nil { 111 + return nil, err 112 + } 113 + x := &grpc.GenericClientStream[InterdiffRequest, FileDiff]{ClientStream: stream} 114 + if err := x.ClientStream.SendMsg(in); err != nil { 115 + return nil, err 116 + } 117 + if err := x.ClientStream.CloseSend(); err != nil { 118 + return nil, err 119 + } 120 + return x, nil 121 + } 122 + 123 + // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 124 + type GitMirrorService_InterdiffClient = grpc.ServerStreamingClient[FileDiff] 125 + 126 + func (c *gitMirrorServiceClient) MergeCheck(ctx context.Context, in *MergeCheckRequest, opts ...grpc.CallOption) (*MergeCheckResponse, error) { 127 + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 128 + out := new(MergeCheckResponse) 129 + err := c.cc.Invoke(ctx, GitMirrorService_MergeCheck_FullMethodName, in, out, cOpts...) 130 + if err != nil { 131 + return nil, err 132 + } 133 + return out, nil 134 + } 135 + 136 + func (c *gitMirrorServiceClient) ResolveRev(ctx context.Context, in *ResolveRevRequest, opts ...grpc.CallOption) (*ResolveRevResponse, error) { 137 + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 138 + out := new(ResolveRevResponse) 139 + err := c.cc.Invoke(ctx, GitMirrorService_ResolveRev_FullMethodName, in, out, cOpts...) 140 + if err != nil { 141 + return nil, err 142 + } 143 + return out, nil 144 + } 145 + 146 + // GitMirrorServiceServer is the server API for GitMirrorService service. 147 + // All implementations must embed UnimplementedGitMirrorServiceServer 148 + // for forward compatibility. 149 + type GitMirrorServiceServer interface { 150 + CommitLog(*CommitLogRequest, grpc.ServerStreamingServer[CommitLogResponse]) error 151 + GetBlob(*GetBlobRequest, grpc.ServerStreamingServer[BlobChunk]) error 152 + Diff(*DiffRequest, grpc.ServerStreamingServer[FileDiff]) error 153 + Interdiff(*InterdiffRequest, grpc.ServerStreamingServer[FileDiff]) error 154 + MergeCheck(context.Context, *MergeCheckRequest) (*MergeCheckResponse, error) 155 + ResolveRev(context.Context, *ResolveRevRequest) (*ResolveRevResponse, error) 156 + mustEmbedUnimplementedGitMirrorServiceServer() 157 + } 158 + 159 + // UnimplementedGitMirrorServiceServer must be embedded to have 160 + // forward compatible implementations. 161 + // 162 + // NOTE: this should be embedded by value instead of pointer to avoid a nil 163 + // pointer dereference when methods are called. 164 + type UnimplementedGitMirrorServiceServer struct{} 165 + 166 + func (UnimplementedGitMirrorServiceServer) CommitLog(*CommitLogRequest, grpc.ServerStreamingServer[CommitLogResponse]) error { 167 + return status.Error(codes.Unimplemented, "method CommitLog not implemented") 168 + } 169 + func (UnimplementedGitMirrorServiceServer) GetBlob(*GetBlobRequest, grpc.ServerStreamingServer[BlobChunk]) error { 170 + return status.Error(codes.Unimplemented, "method GetBlob not implemented") 171 + } 172 + func (UnimplementedGitMirrorServiceServer) Diff(*DiffRequest, grpc.ServerStreamingServer[FileDiff]) error { 173 + return status.Error(codes.Unimplemented, "method Diff not implemented") 174 + } 175 + func (UnimplementedGitMirrorServiceServer) Interdiff(*InterdiffRequest, grpc.ServerStreamingServer[FileDiff]) error { 176 + return status.Error(codes.Unimplemented, "method Interdiff not implemented") 177 + } 178 + func (UnimplementedGitMirrorServiceServer) MergeCheck(context.Context, *MergeCheckRequest) (*MergeCheckResponse, error) { 179 + return nil, status.Error(codes.Unimplemented, "method MergeCheck not implemented") 180 + } 181 + func (UnimplementedGitMirrorServiceServer) ResolveRev(context.Context, *ResolveRevRequest) (*ResolveRevResponse, error) { 182 + return nil, status.Error(codes.Unimplemented, "method ResolveRev not implemented") 183 + } 184 + func (UnimplementedGitMirrorServiceServer) mustEmbedUnimplementedGitMirrorServiceServer() {} 185 + func (UnimplementedGitMirrorServiceServer) testEmbeddedByValue() {} 186 + 187 + // UnsafeGitMirrorServiceServer may be embedded to opt out of forward compatibility for this service. 188 + // Use of this interface is not recommended, as added methods to GitMirrorServiceServer will 189 + // result in compilation errors. 190 + type UnsafeGitMirrorServiceServer interface { 191 + mustEmbedUnimplementedGitMirrorServiceServer() 192 + } 193 + 194 + func RegisterGitMirrorServiceServer(s grpc.ServiceRegistrar, srv GitMirrorServiceServer) { 195 + // If the following call panics, it indicates UnimplementedGitMirrorServiceServer was 196 + // embedded by pointer and is nil. This will cause panics if an 197 + // unimplemented method is ever invoked, so we test this at initialization 198 + // time to prevent it from happening at runtime later due to I/O. 199 + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 200 + t.testEmbeddedByValue() 201 + } 202 + s.RegisterService(&GitMirrorService_ServiceDesc, srv) 203 + } 204 + 205 + func _GitMirrorService_CommitLog_Handler(srv interface{}, stream grpc.ServerStream) error { 206 + m := new(CommitLogRequest) 207 + if err := stream.RecvMsg(m); err != nil { 208 + return err 209 + } 210 + return srv.(GitMirrorServiceServer).CommitLog(m, &grpc.GenericServerStream[CommitLogRequest, CommitLogResponse]{ServerStream: stream}) 211 + } 212 + 213 + // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 214 + type GitMirrorService_CommitLogServer = grpc.ServerStreamingServer[CommitLogResponse] 215 + 216 + func _GitMirrorService_GetBlob_Handler(srv interface{}, stream grpc.ServerStream) error { 217 + m := new(GetBlobRequest) 218 + if err := stream.RecvMsg(m); err != nil { 219 + return err 220 + } 221 + return srv.(GitMirrorServiceServer).GetBlob(m, &grpc.GenericServerStream[GetBlobRequest, BlobChunk]{ServerStream: stream}) 222 + } 223 + 224 + // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 225 + type GitMirrorService_GetBlobServer = grpc.ServerStreamingServer[BlobChunk] 226 + 227 + func _GitMirrorService_Diff_Handler(srv interface{}, stream grpc.ServerStream) error { 228 + m := new(DiffRequest) 229 + if err := stream.RecvMsg(m); err != nil { 230 + return err 231 + } 232 + return srv.(GitMirrorServiceServer).Diff(m, &grpc.GenericServerStream[DiffRequest, FileDiff]{ServerStream: stream}) 233 + } 234 + 235 + // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 236 + type GitMirrorService_DiffServer = grpc.ServerStreamingServer[FileDiff] 237 + 238 + func _GitMirrorService_Interdiff_Handler(srv interface{}, stream grpc.ServerStream) error { 239 + m := new(InterdiffRequest) 240 + if err := stream.RecvMsg(m); err != nil { 241 + return err 242 + } 243 + return srv.(GitMirrorServiceServer).Interdiff(m, &grpc.GenericServerStream[InterdiffRequest, FileDiff]{ServerStream: stream}) 244 + } 245 + 246 + // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 247 + type GitMirrorService_InterdiffServer = grpc.ServerStreamingServer[FileDiff] 248 + 249 + func _GitMirrorService_MergeCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 250 + in := new(MergeCheckRequest) 251 + if err := dec(in); err != nil { 252 + return nil, err 253 + } 254 + if interceptor == nil { 255 + return srv.(GitMirrorServiceServer).MergeCheck(ctx, in) 256 + } 257 + info := &grpc.UnaryServerInfo{ 258 + Server: srv, 259 + FullMethod: GitMirrorService_MergeCheck_FullMethodName, 260 + } 261 + handler := func(ctx context.Context, req interface{}) (interface{}, error) { 262 + return srv.(GitMirrorServiceServer).MergeCheck(ctx, req.(*MergeCheckRequest)) 263 + } 264 + return interceptor(ctx, in, info, handler) 265 + } 266 + 267 + func _GitMirrorService_ResolveRev_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 268 + in := new(ResolveRevRequest) 269 + if err := dec(in); err != nil { 270 + return nil, err 271 + } 272 + if interceptor == nil { 273 + return srv.(GitMirrorServiceServer).ResolveRev(ctx, in) 274 + } 275 + info := &grpc.UnaryServerInfo{ 276 + Server: srv, 277 + FullMethod: GitMirrorService_ResolveRev_FullMethodName, 278 + } 279 + handler := func(ctx context.Context, req interface{}) (interface{}, error) { 280 + return srv.(GitMirrorServiceServer).ResolveRev(ctx, req.(*ResolveRevRequest)) 281 + } 282 + return interceptor(ctx, in, info, handler) 283 + } 284 + 285 + // GitMirrorService_ServiceDesc is the grpc.ServiceDesc for GitMirrorService service. 286 + // It's only intended for direct use with grpc.RegisterService, 287 + // and not to be introspected or modified (even as a copy) 288 + var GitMirrorService_ServiceDesc = grpc.ServiceDesc{ 289 + ServiceName: "gitmirror.v1.GitMirrorService", 290 + HandlerType: (*GitMirrorServiceServer)(nil), 291 + Methods: []grpc.MethodDesc{ 292 + { 293 + MethodName: "MergeCheck", 294 + Handler: _GitMirrorService_MergeCheck_Handler, 295 + }, 296 + { 297 + MethodName: "ResolveRev", 298 + Handler: _GitMirrorService_ResolveRev_Handler, 299 + }, 300 + }, 301 + Streams: []grpc.StreamDesc{ 302 + { 303 + StreamName: "CommitLog", 304 + Handler: _GitMirrorService_CommitLog_Handler, 305 + ServerStreams: true, 306 + }, 307 + { 308 + StreamName: "GetBlob", 309 + Handler: _GitMirrorService_GetBlob_Handler, 310 + ServerStreams: true, 311 + }, 312 + { 313 + StreamName: "Diff", 314 + Handler: _GitMirrorService_Diff_Handler, 315 + ServerStreams: true, 316 + }, 317 + { 318 + StreamName: "Interdiff", 319 + Handler: _GitMirrorService_Interdiff_Handler, 320 + ServerStreams: true, 321 + }, 322 + }, 323 + Metadata: "gitmirror/v1/gitmirror.proto", 324 + }
+172
gitmirror/proto/gitmirror/v1/gitmirror.proto
··· 1 + syntax = "proto3"; 2 + 3 + package gitmirror.v1; 4 + 5 + import "google/protobuf/timestamp.proto"; 6 + 7 + option go_package = "tangled.org/core/gitmirror/proto/gen;gitmirrorv1"; 8 + 9 + service GitMirrorService { 10 + rpc CommitLog(CommitLogRequest) returns (stream CommitLogResponse); 11 + rpc GetBlob(GetBlobRequest) returns (stream BlobChunk); 12 + rpc Diff(DiffRequest) returns (stream FileDiff); 13 + rpc Interdiff(InterdiffRequest) returns (stream FileDiff); 14 + rpc MergeCheck(MergeCheckRequest) returns (MergeCheckResponse); 15 + rpc ResolveRev(ResolveRevRequest) returns (ResolveRevResponse); 16 + } 17 + 18 + message ResolveRevRequest { 19 + // repo carries the DID as a string. 20 + string repo = 1; 21 + // rev is a SIMPLE revspec: a branch name, tag name, or short/full commit oid. 22 + // HEAD and revspec navigation (HEAD~2, foo^, ref@{...}, a..b, path:file) are rejected. 23 + bytes rev = 2; 24 + } 25 + message ResolveRevResponse { 26 + // commit is the resolved commit object id, hex-encoded. 27 + string commit = 1; 28 + } 29 + 30 + // A commit in a specific repo, addressed by the repo's DID. 31 + message RepoCommit { 32 + // repo carries the DID as a string. 33 + string repo = 1; 34 + // commit is a full commit object id, hex-encoded (NOT a revspec/branch/tag). 35 + bytes commit = 2; 36 + } 37 + 38 + message MergeCheckRequest { 39 + // Branch/commit being merged into (the "ours" side of the 3-way merge). 40 + RepoCommit target = 1; 41 + // PR head being merged (the "theirs" side of the 3-way merge). 42 + RepoCommit source = 2; 43 + } 44 + message MergeCheckResponse { 45 + // Whether the 3-way merge has unresolved conflicts. 46 + bool is_conflicted = 1; 47 + // One entry per conflicted file. 48 + repeated MergeConflict conflicts = 2; 49 + // Optional human-readable summary of the merge check. 50 + optional string message = 3; 51 + } 52 + message MergeConflict { 53 + string filename = 1; 54 + string reason = 2; 55 + } 56 + 57 + message CommitLogRequest { 58 + // repo DID 59 + string repo = 1; 60 + // Ranges to include in the git log (revspec, "A..B", "A...B", etc.). 61 + // At least one range, or all_refs must be specified. 62 + repeated bytes ranges = 2; 63 + // If true, all refs are searched for commits. 64 + // Must not be true when ranges are given. 65 + bool all_refs = 3; 66 + // After is an optional parameter to specify the earliest commit to consider. 67 + google.protobuf.Timestamp after = 4; 68 + // Before is an optional parameter to specify the latest commit to consider. 69 + google.protobuf.Timestamp before = 5; 70 + // MaxCommits is an optional parameter to specify the maximum number of commits 71 + // to return. If max_commits is 0, all commits that match the criteria will be 72 + // returned. 73 + uint32 max_commits = 6; 74 + // Skip is an optional parameter to specify the number of commits to skip. 75 + // This can be used to implement a poor mans pagination. 76 + uint32 skip = 7; 77 + } 78 + message CommitLogResponse { 79 + repeated GitCommit commits = 1; 80 + } 81 + 82 + message GetBlobRequest { 83 + // repo carries the DID as a string. 84 + string repo = 1; 85 + // oid is the blob object id (hex). 86 + string oid = 2; 87 + } 88 + message BlobChunk { 89 + bytes data = 1; 90 + } 91 + 92 + message DiffRequest { 93 + RepoCommit base = 1; 94 + RepoCommit head = 2; 95 + ComparisonType comparison_type = 3; 96 + 97 + enum ComparisonType { 98 + COMPARISON_TYPE_UNSPECIFIED = 0; 99 + // Corresponds to the BASE..HEAD syntax that only returns any commits that 100 + // are in HEAD but not in BASE. 101 + COMPARISON_TYPE_ONLY_IN_HEAD = 1; 102 + // Corresponds to the BASE...HEAD syntax that returns any commits that are 103 + // not in both BASE and HEAD. 104 + COMPARISON_TYPE_INTERSECTION = 2; 105 + } 106 + } 107 + 108 + message InterdiffRequest { 109 + // repo carries the DID as a string. 110 + string repo = 1; 111 + // Old patch range (from_base..from_head) and new patch range (to_base..to_head). 112 + bytes from_base = 2; 113 + bytes from_head = 3; 114 + bytes to_base = 4; 115 + bytes to_head = 5; 116 + } 117 + 118 + // Diff and Interdiff stream one FileDiff per changed file. 119 + 120 + message FileContent { 121 + string path = 1; 122 + string oid = 2; 123 + uint64 size = 3; 124 + bool is_binary = 4; 125 + bool is_submodule = 5; 126 + // Raw file bytes, inlined when the oid is not fetchable by the client 127 + // (e.g. interdiff) 128 + optional bytes content = 6; 129 + } 130 + 131 + // One aligned line pair; a side is absent (None) when unset. 132 + message LinePair { 133 + optional uint32 lhs = 1; 134 + optional uint32 rhs = 2; 135 + } 136 + 137 + message Hunk { 138 + // LineNumber (u32) sets containing novel content per side. 139 + repeated uint32 novel_lhs = 1; 140 + repeated uint32 novel_rhs = 2; 141 + repeated LinePair lines = 3; 142 + } 143 + 144 + // Number of bytes per side when the files differ (Rust Option<(usize, usize)>). 145 + message ByteChanges { 146 + uint64 lhs = 1; 147 + uint64 rhs = 2; 148 + } 149 + 150 + message FileDiff { 151 + FileContent lhs_src = 1; 152 + FileContent rhs_src = 2; 153 + repeated Hunk hunks = 3; 154 + optional ByteChanges has_byte_changes = 4; 155 + bool has_syntactic_changes = 5; 156 + // TODO: lhs_positions & rhs_positions 157 + } 158 + 159 + message GitCommit { 160 + string oid = 1; 161 + GitSignature author = 2; 162 + GitSignature committer = 3; 163 + bytes message = 4; 164 + repeated string parents = 5; 165 + map<string, string> extra_headers = 6; 166 + } 167 + 168 + message GitSignature { 169 + bytes name = 1; 170 + bytes email = 2; 171 + google.protobuf.Timestamp date = 3; 172 + }
+365
gitmirror/src/diff.rs
··· 1 + use gix::ObjectId; 2 + use gix::bstr::ByteSlice as _; 3 + use gix::objs::tree::EntryKind; 4 + use line_numbers::{LineNumber, SingleLineSpan}; 5 + use rustc_hash::FxHashSet; 6 + use tracing::info; 7 + 8 + /// Blobs larger than this are treated as binary. 9 + const LARGE_FILE_THRESHOLD_BYTES: u64 = 1024 * 1024; 10 + 11 + #[derive(Debug)] 12 + pub struct FileContent { 13 + pub(crate) path: String, 14 + pub(crate) oid: String, 15 + pub(crate) size: usize, 16 + pub(crate) is_binary: bool, 17 + pub(crate) is_submodule: bool, 18 + /// Raw file bytes, inlined when the `oid` is not fetchable by the client (interdiff's 19 + /// synthetic rebased tree). `None` for ordinary diffs and for binary/oversized blobs. 20 + pub(crate) content: Option<Vec<u8>>, 21 + } 22 + 23 + impl From<FileContent> for crate::protocol::v1::FileContent { 24 + fn from(f: FileContent) -> Self { 25 + Self { 26 + path: f.path, 27 + oid: f.oid, 28 + size: f.size as u64, 29 + is_binary: f.is_binary, 30 + is_submodule: f.is_submodule, 31 + content: f.content, 32 + } 33 + } 34 + } 35 + 36 + #[derive(Debug, Clone)] 37 + pub struct Hunk { 38 + pub(crate) novel_lhs: FxHashSet<LineNumber>, 39 + pub(crate) novel_rhs: FxHashSet<LineNumber>, 40 + pub(crate) lines: Vec<(Option<LineNumber>, Option<LineNumber>)>, 41 + } 42 + 43 + impl From<Hunk> for crate::protocol::v1::Hunk { 44 + fn from(h: Hunk) -> Self { 45 + // Sort the novel line sets for deterministic output. 46 + let sorted = |set: FxHashSet<LineNumber>| -> Vec<u32> { 47 + let mut v: Vec<u32> = set.into_iter().map(|n| n.0).collect(); 48 + v.sort_unstable(); 49 + v 50 + }; 51 + Self { 52 + novel_lhs: sorted(h.novel_lhs), 53 + novel_rhs: sorted(h.novel_rhs), 54 + lines: h 55 + .lines 56 + .into_iter() 57 + .map(|(lhs, rhs)| crate::protocol::v1::LinePair { 58 + lhs: lhs.map(|n| n.0), 59 + rhs: rhs.map(|n| n.0), 60 + }) 61 + .collect(), 62 + } 63 + } 64 + } 65 + 66 + /// A matched token (an atom, a delimiter, or a comment word). 67 + #[derive(PartialEq, Eq, Debug, Clone)] 68 + pub enum MatchKind { 69 + // TBD 70 + } 71 + 72 + #[derive(Debug, Clone, PartialEq, Eq)] 73 + pub struct MatchedPos { 74 + pub(crate) kind: MatchKind, 75 + pub(crate) pos: SingleLineSpan, 76 + } 77 + 78 + #[derive(Debug)] 79 + pub struct Diff { 80 + pub(crate) lhs_src: FileContent, 81 + pub(crate) rhs_src: FileContent, 82 + pub(crate) hunks: Vec<Hunk>, 83 + 84 + #[allow(unused)] 85 + pub(crate) lhs_positions: Vec<MatchedPos>, 86 + #[allow(unused)] 87 + pub(crate) rhs_positions: Vec<MatchedPos>, 88 + 89 + /// If the two files do not have exactly the same bytes, the 90 + /// number of bytes in each file. 91 + pub(crate) has_byte_changes: Option<(usize, usize)>, 92 + pub(crate) has_syntactic_changes: bool, 93 + } 94 + 95 + impl From<Diff> for crate::protocol::v1::FileDiff { 96 + fn from(d: Diff) -> Self { 97 + Self { 98 + lhs_src: Some(d.lhs_src.into()), 99 + rhs_src: Some(d.rhs_src.into()), 100 + hunks: d.hunks.into_iter().map(Into::into).collect(), 101 + has_byte_changes: d.has_byte_changes.map(|(lhs, rhs)| { 102 + crate::protocol::v1::ByteChanges { 103 + lhs: lhs as u64, 104 + rhs: rhs as u64, 105 + } 106 + }), 107 + has_syntactic_changes: d.has_syntactic_changes, 108 + } 109 + } 110 + } 111 + 112 + /// Diff two trees, yielding one [`Diff`] per changed file (line-level [`Hunk`]s included). 113 + /// 114 + /// Returns a lazy iterator of `anyhow::Result<Diff>`. 115 + pub fn diff<'repo>( 116 + repo: &'repo gix::Repository, 117 + old_tree: &gix::Tree<'_>, 118 + new_tree: &gix::Tree<'_>, 119 + embed_content: bool, 120 + ) -> anyhow::Result<DiffIter<'repo>> { 121 + let changes = repo.diff_tree_to_tree(Some(old_tree), Some(new_tree), None)?; 122 + let mut cache = repo.diff_resource_cache_for_tree_diff()?; 123 + cache.filter.options.large_file_threshold_bytes = LARGE_FILE_THRESHOLD_BYTES; 124 + Ok(DiffIter { 125 + repo, 126 + changes: changes.into_iter(), 127 + cache, 128 + embed_content, 129 + }) 130 + } 131 + 132 + /// Lazy iterator over per-file [`Diff`]s. 133 + pub struct DiffIter<'repo> { 134 + repo: &'repo gix::Repository, 135 + changes: std::vec::IntoIter<gix::object::tree::diff::ChangeDetached>, 136 + cache: gix::diff::blob::Platform, 137 + embed_content: bool, 138 + } 139 + 140 + impl Iterator for DiffIter<'_> { 141 + type Item = anyhow::Result<Diff>; 142 + 143 + fn next(&mut self) -> Option<Self::Item> { 144 + loop { 145 + let change = self.changes.next()?; 146 + let (old, new) = change_sides(&change); 147 + // Trees are recursed into by gix and never diffed directly; skip defensively. 148 + if old.is_kind(EntryKind::Tree) || new.is_kind(EntryKind::Tree) { 149 + continue; 150 + } 151 + // Submodules (gitlinks) can't go through the blob pipeline — their commit lives in 152 + // another repo. Emit the commit oids directly for the caller to compare. 153 + if old.is_kind(EntryKind::Commit) || new.is_kind(EntryKind::Commit) { 154 + return Some(Ok(submodule_diff(self.repo, &change, old, new))); 155 + } 156 + let result = build_diff(self.repo, &change, &mut self.cache, self.embed_content); 157 + self.cache.clear_resource_cache_keep_allocation(); 158 + return Some(result); 159 + } 160 + } 161 + } 162 + 163 + enum Side { 164 + Absent, 165 + Present { oid: ObjectId, kind: EntryKind }, 166 + } 167 + 168 + impl Side { 169 + fn is_kind(&self, wanted: EntryKind) -> bool { 170 + matches!(self, Side::Present { kind, .. } if *kind == wanted) 171 + } 172 + } 173 + 174 + /// Decompose a change into its `(old, new)` sides regardless of variant. 175 + fn change_sides(change: &gix::object::tree::diff::ChangeDetached) -> (Side, Side) { 176 + use gix::object::tree::diff::ChangeDetached as C; 177 + let present = |oid, mode: gix::object::tree::EntryMode| Side::Present { 178 + oid, 179 + kind: mode.kind(), 180 + }; 181 + match change { 182 + C::Addition { id, entry_mode, .. } => (Side::Absent, present(*id, *entry_mode)), 183 + C::Deletion { id, entry_mode, .. } => (present(*id, *entry_mode), Side::Absent), 184 + C::Modification { 185 + previous_id, 186 + previous_entry_mode, 187 + id, 188 + entry_mode, 189 + .. 190 + } => ( 191 + present(*previous_id, *previous_entry_mode), 192 + present(*id, *entry_mode), 193 + ), 194 + C::Rewrite { 195 + source_id, 196 + source_entry_mode, 197 + id, 198 + entry_mode, 199 + .. 200 + } => ( 201 + present(*source_id, *source_entry_mode), 202 + present(*id, *entry_mode), 203 + ), 204 + } 205 + } 206 + 207 + /// Build a [`Diff`] for a submodule (gitlink) change: no hunks, both sides carry their commit 208 + /// oid with `is_submodule` set. The caller compares the oids as submodule pointers. 209 + fn submodule_diff( 210 + repo: &gix::Repository, 211 + change: &gix::object::tree::diff::ChangeDetached, 212 + old: Side, 213 + new: Side, 214 + ) -> Diff { 215 + let path = change.location().to_string(); 216 + let side = |s: Side| FileContent { 217 + path: path.clone(), 218 + oid: match s { 219 + Side::Absent => ObjectId::null(repo.object_hash()).to_string(), 220 + Side::Present { oid, .. } => oid.to_string(), 221 + }, 222 + size: 0, 223 + is_binary: false, 224 + is_submodule: true, 225 + content: None, 226 + }; 227 + Diff { 228 + lhs_src: side(old), 229 + rhs_src: side(new), 230 + hunks: Vec::new(), 231 + lhs_positions: Vec::new(), 232 + rhs_positions: Vec::new(), 233 + has_byte_changes: None, 234 + has_syntactic_changes: false, 235 + } 236 + } 237 + 238 + /// Build the [`Diff`] for a single changed file. 239 + fn build_diff( 240 + repo: &gix::Repository, 241 + change: &gix::object::tree::diff::ChangeDetached, 242 + cache: &mut gix::diff::blob::Platform, 243 + embed_content: bool, 244 + ) -> anyhow::Result<Diff> { 245 + use gix::diff::blob::platform::prepare_diff::Operation; 246 + use gix::diff::blob::platform::resource::Data; 247 + use gix::diff::blob::{Diff as BlobDiff, InternedInput}; 248 + use gix::prelude::TreeDiffChangeExt as _; 249 + 250 + let change = change.attach(repo, repo); 251 + let blob = change.diff(cache)?; 252 + let out = blob.resource_cache.prepare_diff()?; 253 + 254 + // Per-side file metadata, derived from the prepared resources. 255 + let side = 256 + |res: &gix::diff::blob::platform::Resource<'_>, embed_content: bool| -> FileContent { 257 + let (size, is_binary, content) = match res.data { 258 + // Only textual buffers carry inlinable bytes; binary/missing sides fall back to oid. 259 + Data::Buffer { buf, .. } => (buf.len(), false, embed_content.then(|| buf.to_vec())), 260 + Data::Binary { size } => (size as usize, true, None), 261 + Data::Missing => (0, false, None), 262 + }; 263 + FileContent { 264 + path: res.rela_path.to_string(), 265 + oid: res.id.to_string(), 266 + size, 267 + is_binary, 268 + is_submodule: false, 269 + content, 270 + } 271 + }; 272 + let mut lhs_src = side(&out.old, embed_content); 273 + let mut rhs_src = side(&out.new, false); 274 + 275 + let has_byte_changes = if lhs_src.size == rhs_src.size && out.old.id == out.new.id { 276 + None 277 + } else { 278 + Some((lhs_src.size, rhs_src.size)) 279 + }; 280 + 281 + let hunks = match out.operation { 282 + Operation::InternalDiff { algorithm } => { 283 + let input = InternedInput::new(out.old.intern_source(), out.new.intern_source()); 284 + let mut d = BlobDiff::compute(algorithm, &input); 285 + d.postprocess_lines(&input); 286 + d.hunks() 287 + .map(|h| { 288 + let before: Vec<LineNumber> = h.before.clone().map(LineNumber).collect(); 289 + let after: Vec<LineNumber> = h.after.clone().map(LineNumber).collect(); 290 + let n = before.len().max(after.len()); 291 + let lines = (0..n) 292 + .map(|i| (before.get(i).copied(), after.get(i).copied())) 293 + .collect(); 294 + Hunk { 295 + novel_lhs: before.iter().copied().collect(), 296 + novel_rhs: after.iter().copied().collect(), 297 + lines, 298 + } 299 + }) 300 + .collect() 301 + } 302 + Operation::SourceOrDestinationIsBinary => { 303 + lhs_src.is_binary = true; 304 + rhs_src.is_binary = true; 305 + Vec::new() 306 + } 307 + Operation::ExternalCommand { .. } => unreachable!("we disabled that"), 308 + }; 309 + 310 + Ok(Diff { 311 + lhs_src, 312 + rhs_src, 313 + hunks, 314 + lhs_positions: Vec::new(), 315 + rhs_positions: Vec::new(), 316 + has_byte_changes, 317 + has_syntactic_changes: false, 318 + }) 319 + } 320 + 321 + /// Compute the two trees whose diff is the interdiff between an old patch 322 + /// version (`from_base..from_head`) and a new one (`to_base..to_head`). 323 + /// 324 + /// Rebase `from_base..from_head` onto `to_base` and return rebased tree so 325 + /// caller can diff between `rebased_tree` and `to_head.tree` 326 + pub fn prepare_interdiff<'repo>( 327 + repo: &'repo gix::Repository, 328 + (from_base_id, from_head_id): (ObjectId, ObjectId), 329 + to_base_id: ObjectId, 330 + ) -> anyhow::Result<gix::Tree<'repo>> { 331 + let from_head = repo.find_commit(from_head_id)?; 332 + 333 + let from_base = repo.find_commit(from_base_id)?; 334 + let to_base = repo.find_commit(to_base_id)?; 335 + 336 + // Endpoint trees for the 3-way merge. 337 + let from_base_tree = from_base.tree_id()?.detach(); 338 + let from_head_tree = from_head.tree_id()?.detach(); 339 + let to_base_tree = to_base.tree_id()?.detach(); 340 + 341 + let options = repo.tree_merge_options()?; 342 + let labels = gix::merge::blob::builtin_driver::text::Labels { 343 + ancestor: Some("from.base".as_bytes().as_bstr()), 344 + current: Some("to.base".as_bytes().as_bstr()), 345 + other: Some("from.head".as_bytes().as_bstr()), 346 + }; 347 + let mut outcome = repo.merge_trees( 348 + from_base_tree, 349 + to_base_tree, 350 + from_head_tree, 351 + labels, 352 + options, 353 + )?; 354 + 355 + if !outcome.conflicts.is_empty() { 356 + info!( 357 + conflicts = outcome.conflicts.len(), 358 + "interdiff rebase produced conflicts; tree contains conflict markers" 359 + ); 360 + } 361 + 362 + let rebased_id = outcome.tree.write()?.detach(); 363 + let rebased_tree = repo.find_tree(rebased_id)?; 364 + Ok(rebased_tree) 365 + }
+46
gitmirror/src/main.rs
··· 1 + use std::net::SocketAddr; 2 + use std::path::PathBuf; 3 + 4 + use clap::{Parser, Subcommand}; 5 + 6 + mod diff; 7 + mod merge; 8 + mod protocol; 9 + mod service; 10 + 11 + #[derive(Parser)] 12 + #[command(name = "gitmirror", about = "Git mirror gRPC service")] 13 + struct Cli { 14 + #[command(subcommand)] 15 + cmd: Command, 16 + } 17 + 18 + #[derive(Subcommand)] 19 + enum Command { 20 + /// Run the gRPC server. 21 + Serve(ServeArgs), 22 + } 23 + 24 + #[derive(clap::Args)] 25 + struct ServeArgs { 26 + /// Address to bind the gRPC server to. 27 + #[arg(long, env = "GITMIRROR_ADDR", default_value = "127.0.0.1:9000")] 28 + addr: SocketAddr, 29 + 30 + /// Base directory holding bare mirror repos, one per DID (<base>/<did>). 31 + #[arg(long, env = "GITMIRROR_REPO_BASE", default_value = "repos")] 32 + repo_base: PathBuf, 33 + } 34 + 35 + #[tokio::main] 36 + async fn main() -> anyhow::Result<()> { 37 + tracing_subscriber::fmt() 38 + .with_env_filter( 39 + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), 40 + ) 41 + .init(); 42 + 43 + match Cli::parse().cmd { 44 + Command::Serve(args) => service::serve(args.addr, args.repo_base).await, 45 + } 46 + }
+289
gitmirror/src/merge.rs
··· 1 + use gix::bstr::ByteSlice as _; 2 + use gix::diff::tree_with_rewrites::Change; 3 + use gix::merge::tree::{Conflict, TreatAsUnresolved}; 4 + 5 + use crate::protocol::v1::{MergeCheckResponse, MergeConflict}; 6 + 7 + /// Perform a 3-way merge check between `target` (ours) and `source` (theirs), using their 8 + /// merge base as the common ancestor. 9 + /// 10 + /// This is read-only: it inspects only the conflicts of the in-memory merge and never persists 11 + /// the merged tree. The caller must have opened `repo` with [`gix::Repository::with_object_memory`] 12 + /// so that any objects `merge_trees` writes while merging land in memory rather than on disk — 13 + /// the mirrored repositories (reached read-only via alternates) are never mutated. 14 + pub fn merge_check( 15 + repo: &gix::Repository, 16 + target_oid: gix::ObjectId, 17 + source_oid: gix::ObjectId, 18 + ) -> anyhow::Result<MergeCheckResponse> { 19 + let our_tree = repo.find_commit(target_oid)?.tree_id()?.detach(); 20 + let their_tree = repo.find_commit(source_oid)?.tree_id()?.detach(); 21 + 22 + // Common ancestor for the 3-way merge. When the histories are unrelated (no merge base), 23 + // fall back to the empty tree — the same behaviour as `git merge --allow-unrelated-histories`, 24 + // which surfaces add/add conflicts with real filenames instead of erroring out. 25 + let (ancestor_tree, unrelated) = match repo.merge_base(target_oid, source_oid) { 26 + Ok(base) => (repo.find_commit(base.detach())?.tree_id()?.detach(), false), 27 + Err(_) => (gix::ObjectId::empty_tree(repo.object_hash()), true), 28 + }; 29 + 30 + let options = repo.tree_merge_options()?; 31 + let labels = gix::merge::blob::builtin_driver::text::Labels { 32 + ancestor: Some("ancestor".as_bytes().as_bstr()), 33 + current: Some("target".as_bytes().as_bstr()), 34 + other: Some("source".as_bytes().as_bstr()), 35 + }; 36 + 37 + let outcome = repo.merge_trees(ancestor_tree, our_tree, their_tree, labels, options)?; 38 + 39 + // Report only genuinely-unresolved conflicts (matching git's notion), so gix's 40 + // auto-resolutions don't show up as false positives. The merged tree is intentionally 41 + // never written. 42 + let mut conflicts = Vec::new(); 43 + for conflict in &outcome.conflicts { 44 + if !conflict.is_unresolved(TreatAsUnresolved::git()) { 45 + continue; 46 + } 47 + conflicts.push(MergeConflict { 48 + filename: conflict.ours.location().to_string(), 49 + reason: conflict_reason(conflict).to_string(), 50 + }); 51 + } 52 + 53 + let is_conflicted = !conflicts.is_empty(); 54 + let message = match (is_conflicted, unrelated) { 55 + (true, true) => Some(format!( 56 + "{} conflicting file(s); unrelated histories (no common ancestor)", 57 + conflicts.len() 58 + )), 59 + (true, false) => Some(format!("{} conflicting file(s)", conflicts.len())), 60 + (false, true) => Some("no common ancestor (unrelated histories)".to_string()), 61 + (false, false) => None, 62 + }; 63 + 64 + Ok(MergeCheckResponse { 65 + is_conflicted, 66 + conflicts, 67 + message, 68 + }) 69 + } 70 + 71 + /// Classify a conflict into a short, human-readable reason from the shape of the two changes. 72 + /// Derived from the change variants rather than the resolution failure so it stays stable 73 + /// across gix versions. 74 + fn conflict_reason(conflict: &Conflict) -> &'static str { 75 + match (&conflict.ours, &conflict.theirs) { 76 + (Change::Deletion { .. }, _) | (_, Change::Deletion { .. }) => "modify/delete conflict", 77 + (Change::Addition { .. }, Change::Addition { .. }) => "add/add conflict", 78 + (Change::Rewrite { .. }, _) | (_, Change::Rewrite { .. }) => "rename conflict", 79 + _ => "content conflict", 80 + } 81 + } 82 + 83 + #[cfg(test)] 84 + mod tests { 85 + use super::*; 86 + use std::path::Path; 87 + use std::process::Command; 88 + 89 + fn git(dir: &Path, args: &[&str]) { 90 + let status = Command::new("git") 91 + .args(args) 92 + .current_dir(dir) 93 + .env("GIT_AUTHOR_NAME", "t") 94 + .env("GIT_AUTHOR_EMAIL", "t@t") 95 + .env("GIT_COMMITTER_NAME", "t") 96 + .env("GIT_COMMITTER_EMAIL", "t@t") 97 + .status() 98 + .expect("run git"); 99 + assert!(status.success(), "git {args:?} failed"); 100 + } 101 + 102 + fn write(dir: &Path, name: &str, content: &str) { 103 + std::fs::write(dir.join(name), content).unwrap(); 104 + } 105 + 106 + fn rev_parse(dir: &Path, rev: &str) -> gix::ObjectId { 107 + let out = Command::new("git") 108 + .args(["rev-parse", rev]) 109 + .current_dir(dir) 110 + .output() 111 + .expect("rev-parse"); 112 + let hex = String::from_utf8(out.stdout).unwrap(); 113 + gix::ObjectId::from_hex(hex.trim().as_bytes()).unwrap() 114 + } 115 + 116 + /// Count loose object files under `<repo>/.git/objects`, ignoring `info` and `pack`. 117 + fn count_loose_objects(git_dir: &Path) -> usize { 118 + let objects = git_dir.join(".git").join("objects"); 119 + let mut n = 0; 120 + for shard in std::fs::read_dir(&objects).unwrap() { 121 + let shard = shard.unwrap(); 122 + let name = shard.file_name(); 123 + if name == "info" || name == "pack" || !shard.file_type().unwrap().is_dir() { 124 + continue; 125 + } 126 + n += std::fs::read_dir(shard.path()).unwrap().count(); 127 + } 128 + n 129 + } 130 + 131 + /// Build a repo with a shared base commit, a `main` (target/ours) tip, and a `source` 132 + /// (theirs) tip built by the two closures. 133 + fn fixture( 134 + target: impl FnOnce(&Path), 135 + source: impl FnOnce(&Path), 136 + ) -> (tempfile::TempDir, gix::ObjectId, gix::ObjectId) { 137 + let dir = tempfile::tempdir().unwrap(); 138 + let p = dir.path(); 139 + git(p, &["init", "-q", "-b", "main"]); 140 + write(p, "a.txt", "line1\nline2\nline3\n"); 141 + write(p, "b.txt", "keep\n"); 142 + git(p, &["add", "."]); 143 + git(p, &["commit", "-q", "-m", "base"]); 144 + 145 + git(p, &["branch", "source"]); 146 + // target edits on main 147 + target(p); 148 + git(p, &["add", "-A"]); 149 + git(p, &["commit", "-q", "-m", "target"]); 150 + let target_oid = rev_parse(p, "main"); 151 + 152 + git(p, &["checkout", "-q", "source"]); 153 + source(p); 154 + git(p, &["add", "-A"]); 155 + git(p, &["commit", "-q", "-m", "source"]); 156 + let source_oid = rev_parse(p, "source"); 157 + 158 + (dir, target_oid, source_oid) 159 + } 160 + 161 + fn open_in_memory(dir: &Path) -> gix::Repository { 162 + gix::open(dir).unwrap().with_object_memory() 163 + } 164 + 165 + /// Mirror of `GitMirror::open_merge_scratch`: a throwaway repo whose alternates point at the 166 + /// given object dirs, opened in-memory. 167 + fn scratch_with_alternates(object_dirs: &[std::path::PathBuf]) -> (tempfile::TempDir, gix::Repository) { 168 + let scratch = tempfile::tempdir().unwrap(); 169 + gix::init_bare(scratch.path()).unwrap(); 170 + let info = scratch.path().join("objects").join("info"); 171 + std::fs::create_dir_all(&info).unwrap(); 172 + let mut alternates = String::new(); 173 + for d in object_dirs { 174 + alternates.push_str(&std::fs::canonicalize(d).unwrap().display().to_string()); 175 + alternates.push('\n'); 176 + } 177 + std::fs::write(info.join("alternates"), alternates).unwrap(); 178 + let repo = gix::open(scratch.path()).unwrap().with_object_memory(); 179 + (scratch, repo) 180 + } 181 + 182 + #[test] 183 + fn cross_repo_via_alternates_never_touches_mirrors() { 184 + // Two separate repos sharing a base commit: `a` holds the target tip, `b` the source 185 + // tip. This is the real gitmirror shape (target and source live in different mirrors). 186 + let a = tempfile::tempdir().unwrap(); 187 + let ap = a.path(); 188 + git(ap, &["init", "-q", "-b", "main"]); 189 + write(ap, "a.txt", "line1\nline2\nline3\n"); 190 + git(ap, &["add", "."]); 191 + git(ap, &["commit", "-q", "-m", "base"]); 192 + // target edit in repo `a` 193 + write(ap, "a.txt", "line1\nOURS\nline3\n"); 194 + git(ap, &["add", "-A"]); 195 + git(ap, &["commit", "-q", "-m", "target"]); 196 + let target = rev_parse(ap, "main"); 197 + 198 + // Clone `a` → `b`, reset to the shared base, and make a conflicting source edit. 199 + let b = tempfile::tempdir().unwrap(); 200 + let bp = b.path(); 201 + git(bp, &["clone", "-q", ap.to_str().unwrap(), "."]); 202 + git(bp, &["checkout", "-q", "-B", "source", &target.to_string()]); 203 + git(bp, &["reset", "-q", "--hard", "HEAD~1"]); // back to base 204 + write(bp, "a.txt", "line1\nTHEIRS\nline3\n"); 205 + git(bp, &["add", "-A"]); 206 + git(bp, &["commit", "-q", "-m", "source"]); 207 + let source = rev_parse(bp, "source"); 208 + 209 + let a_objs = ap.join(".git").join("objects"); 210 + let b_objs = bp.join(".git").join("objects"); 211 + let before_a = count_loose_objects(ap); 212 + let before_b = count_loose_objects(bp); 213 + 214 + let (_scratch, repo) = scratch_with_alternates(&[a_objs, b_objs]); 215 + let out = merge_check(&repo, target, source).unwrap(); 216 + 217 + assert!(out.is_conflicted); 218 + assert_eq!(out.conflicts.len(), 1); 219 + assert_eq!(out.conflicts[0].filename, "a.txt"); 220 + // Neither mirror gained loose objects — the merge was read-only against both. 221 + assert_eq!(count_loose_objects(ap), before_a, "target mirror mutated"); 222 + assert_eq!(count_loose_objects(bp), before_b, "source mirror mutated"); 223 + } 224 + 225 + #[test] 226 + fn non_overlapping_edits_are_clean() { 227 + let (dir, t, s) = fixture( 228 + |p| write(p, "a.txt", "line1\nCHANGED\nline3\n"), 229 + |p| write(p, "b.txt", "keep\nappended\n"), 230 + ); 231 + let repo = open_in_memory(dir.path()); 232 + let before = count_loose_objects(dir.path()); 233 + let out = merge_check(&repo, t, s).unwrap(); 234 + assert!(!out.is_conflicted, "expected clean, got {out:?}"); 235 + assert!(out.conflicts.is_empty()); 236 + // never-touch-the-mirror: the in-memory merge writes no loose objects to disk. 237 + assert_eq!(count_loose_objects(dir.path()), before); 238 + } 239 + 240 + #[test] 241 + fn same_line_edits_conflict() { 242 + let (dir, t, s) = fixture( 243 + |p| write(p, "a.txt", "line1\nOURS\nline3\n"), 244 + |p| write(p, "a.txt", "line1\nTHEIRS\nline3\n"), 245 + ); 246 + let repo = open_in_memory(dir.path()); 247 + let before = count_loose_objects(dir.path()); 248 + let out = merge_check(&repo, t, s).unwrap(); 249 + assert!(out.is_conflicted); 250 + assert_eq!(out.conflicts.len(), 1); 251 + assert_eq!(out.conflicts[0].filename, "a.txt"); 252 + assert_eq!(out.conflicts[0].reason, "content conflict"); 253 + assert_eq!(count_loose_objects(dir.path()), before); 254 + } 255 + 256 + #[test] 257 + fn modify_delete_conflict() { 258 + let (dir, t, s) = fixture( 259 + |p| write(p, "a.txt", "line1\nOURS\nline3\n"), 260 + |p| std::fs::remove_file(p.join("a.txt")).unwrap(), 261 + ); 262 + let repo = open_in_memory(dir.path()); 263 + let out = merge_check(&repo, t, s).unwrap(); 264 + assert!(out.is_conflicted); 265 + assert_eq!(out.conflicts[0].filename, "a.txt"); 266 + assert_eq!(out.conflicts[0].reason, "modify/delete conflict"); 267 + } 268 + 269 + #[test] 270 + fn source_is_ancestor_is_clean() { 271 + // Source is an ancestor of target (i.e. already merged / up to date) → no conflicts. 272 + let dir = tempfile::tempdir().unwrap(); 273 + let p = dir.path(); 274 + git(p, &["init", "-q", "-b", "main"]); 275 + write(p, "a.txt", "line1\nline2\n"); 276 + git(p, &["add", "."]); 277 + git(p, &["commit", "-q", "-m", "base"]); 278 + let base = rev_parse(p, "main"); 279 + write(p, "a.txt", "line1\nline2\nline3\n"); 280 + git(p, &["add", "-A"]); 281 + git(p, &["commit", "-q", "-m", "target"]); 282 + let target = rev_parse(p, "main"); 283 + 284 + let repo = open_in_memory(p); 285 + let out = merge_check(&repo, target, base).unwrap(); 286 + assert!(!out.is_conflicted, "expected clean, got {out:?}"); 287 + assert!(out.conflicts.is_empty()); 288 + } 289 + }
+3
gitmirror/src/protocol.rs
··· 1 + pub mod v1 { 2 + tonic::include_proto!("gitmirror.v1"); 3 + }
+523
gitmirror/src/service.rs
··· 1 + use std::net::SocketAddr; 2 + use std::path::PathBuf; 3 + use std::pin::Pin; 4 + 5 + use gix::bstr::ByteSlice as _; 6 + use gix::revision::plumbing::Spec as RevSpec; 7 + use gix::revision::walk::Sorting; 8 + use tempfile::TempDir; 9 + use tokio::sync::mpsc; 10 + use tokio_stream::wrappers::ReceiverStream; 11 + use tokio_stream::Stream; 12 + use tonic::transport::Server; 13 + use tonic::{Request, Response, Status}; 14 + use tracing::{debug, info}; 15 + 16 + use crate::diff::{self, prepare_interdiff}; 17 + use crate::protocol::v1::diff_request::ComparisonType; 18 + use crate::protocol::v1::git_mirror_service_server::{GitMirrorService, GitMirrorServiceServer}; 19 + use crate::protocol::v1::{ 20 + BlobChunk, CommitLogRequest, CommitLogResponse, DiffRequest, FileDiff, GetBlobRequest, 21 + GitCommit, GitSignature, InterdiffRequest, MergeCheckRequest, MergeCheckResponse, 22 + ResolveRevRequest, ResolveRevResponse, 23 + }; 24 + 25 + type FileDiffStream = Pin<Box<dyn Stream<Item = Result<FileDiff, Status>> + Send>>; 26 + type BlobChunkStream = Pin<Box<dyn Stream<Item = Result<BlobChunk, Status>> + Send>>; 27 + type CommitLogResponseStream = 28 + Pin<Box<dyn Stream<Item = Result<CommitLogResponse, Status>> + Send>>; 29 + 30 + const BLOB_CHUNK_SIZE: usize = 64 * 1024; 31 + const COMMIT_LOG_BATCH_SIZE: usize = 128; 32 + 33 + struct GitMirror { 34 + repo_base: PathBuf, 35 + } 36 + 37 + /// A [`gix::Repository`] backed by a temporary directory that is removed on drop. Derefs to 38 + /// `gix::Repository`, so it is used just like one; keeping it alive keeps the scratch dir alive. 39 + struct TempRepository { 40 + repo: gix::Repository, 41 + // Declared AFTER `repo` so `repo` drops first: any file handles into the scratch dir close 42 + // before the dir itself is removed (Rust drops struct fields in declaration order). 43 + _dir: TempDir, 44 + } 45 + 46 + impl TempRepository { 47 + fn new(repo: gix::Repository, dir: TempDir) -> Self { 48 + Self { repo, _dir: dir } 49 + } 50 + } 51 + 52 + impl std::ops::Deref for TempRepository { 53 + type Target = gix::Repository; 54 + fn deref(&self) -> &gix::Repository { 55 + &self.repo 56 + } 57 + } 58 + 59 + impl GitMirror { 60 + fn open_repo(&self, did: &str) -> Result<gix::Repository, Status> { 61 + if !did.starts_with("did:") { 62 + return Err(Status::invalid_argument("repo must be a DID")); 63 + } 64 + gix::open(self.repo_base.join(did)) 65 + .map_err(|e| Status::not_found(format!("repo not found: {e}"))) 66 + } 67 + 68 + /// Build a throwaway bare repo whose `objects/info/alternates` points read-only at each of the 69 + /// given git repositories, so a single `gix::Repository` can see objects from all of them 70 + /// without ever mutating them. 71 + fn open_scratch(&self, dids: &[&str]) -> Result<TempRepository, Status> { 72 + let mut seen: Vec<&str> = Vec::new(); 73 + let mut object_dirs = Vec::new(); 74 + for &did in dids { 75 + if seen.contains(&did) { 76 + continue 77 + } 78 + if !did.starts_with("did:") { 79 + return Err(Status::invalid_argument("repo must be a DID")); 80 + } 81 + seen.push(did); 82 + let object_dir = std::fs::canonicalize(self.repo_base.join(did).join("objects")) 83 + .map_err(|_| Status::not_found(format!("repo not found: {did}")))?; 84 + object_dirs.push(object_dir); 85 + } 86 + if object_dirs.is_empty() { 87 + return Err(Status::internal("open_scratch requires at least one repo")); 88 + } 89 + 90 + let scratch = tempfile::tempdir().map_err(|e| Status::internal(e.to_string()))?; 91 + gix::init_bare(scratch.path()).map_err(|e| Status::internal(e.to_string()))?; 92 + 93 + let info_dir = scratch.path().join("objects").join("info"); 94 + std::fs::create_dir_all(&info_dir).map_err(|e| Status::internal(e.to_string()))?; 95 + let alternates = object_dirs 96 + .iter() 97 + .map(|p| p.display().to_string()) 98 + .collect::<Vec<_>>() 99 + .join("\n"); 100 + std::fs::write(info_dir.join("alternates"), format!("{alternates}\n")) 101 + .map_err(|e| Status::internal(e.to_string()))?; 102 + 103 + let repo = gix::open(scratch.path()) 104 + .map_err(|e| Status::internal(e.to_string()))? 105 + .with_object_memory(); 106 + Ok(TempRepository::new(repo, scratch)) 107 + } 108 + } 109 + 110 + #[tonic::async_trait] 111 + impl GitMirrorService for GitMirror { 112 + type DiffStream = FileDiffStream; 113 + 114 + async fn diff( 115 + &self, 116 + request: Request<DiffRequest>, 117 + ) -> Result<Response<Self::DiffStream>, Status> { 118 + let req = request.into_inner(); 119 + let head = req 120 + .head 121 + .ok_or_else(|| Status::invalid_argument("head is required"))?; 122 + let base = req.base; 123 + debug!( 124 + base_repo = ?base.as_ref().map(|b| b.repo.as_str()), 125 + base_commit = ?base.as_ref().map(|b| b.commit.as_bstr()), 126 + head_repo = %head.repo, 127 + head_commit = %head.commit.as_bstr(), 128 + "diff request", 129 + ); 130 + let scratch = match &base { 131 + Some(base) => self.open_scratch(&[head.repo.as_str(), base.repo.as_str()])?, 132 + None => self.open_scratch(&[head.repo.as_str()])?, 133 + }; 134 + let head_commit_id = find_commit_by_sha(&scratch, &head.commit)?; 135 + let base_commit_id = base 136 + .as_ref() 137 + .map(|b| find_commit_by_sha(&scratch, &b.commit)) 138 + .transpose()?; 139 + let comparison_type = 140 + ComparisonType::try_from(req.comparison_type).unwrap_or(ComparisonType::Unspecified); 141 + 142 + let (tx, rx) = mpsc::channel::<Result<FileDiff, Status>>(16); 143 + tokio::task::spawn_blocking(move || { 144 + let repo = &*scratch; 145 + 146 + let run = || -> anyhow::Result<()> { 147 + let head_commit = repo.find_commit(head_commit_id)?; 148 + let old = match base_commit_id { 149 + Some(base_commit_id) => match comparison_type { 150 + ComparisonType::OnlyInHead => { 151 + repo.find_tree(repo.find_commit(base_commit_id)?.tree_id()?)? 152 + } 153 + ComparisonType::Unspecified | ComparisonType::Intersection => { 154 + let merge_base = 155 + repo.merge_base(base_commit_id, head_commit_id)?.detach(); 156 + repo.find_tree(repo.find_commit(merge_base)?.tree_id()?)? 157 + } 158 + }, 159 + None => match head_commit.parent_ids().next() { 160 + Some(parent_id) => { 161 + repo.find_tree(repo.find_commit(parent_id.detach())?.tree_id()?)? 162 + } 163 + None => repo.empty_tree(), 164 + }, 165 + }; 166 + let new = repo.find_tree(head_commit.tree_id()?)?; 167 + for item in diff::diff(repo, &old, &new, false)? { 168 + if tx.blocking_send(Ok(item?.into())).is_err() { 169 + break; 170 + } 171 + } 172 + Ok(()) 173 + }; 174 + if let Err(e) = run() { 175 + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); 176 + } 177 + }); 178 + 179 + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) 180 + } 181 + 182 + type InterdiffStream = FileDiffStream; 183 + 184 + async fn interdiff( 185 + &self, 186 + request: Request<InterdiffRequest>, 187 + ) -> Result<Response<Self::InterdiffStream>, Status> { 188 + let req = request.into_inner(); 189 + let repo = self.open_repo(&req.repo)?; 190 + let from_base_id = resolve_commit_oid(&repo, &req.from_base)?; 191 + let from_head_id = resolve_commit_oid(&repo, &req.from_head)?; 192 + let to_base_id = resolve_commit_oid(&repo, &req.to_base)?; 193 + let to_head_id = resolve_commit_oid(&repo, &req.to_head)?; 194 + let safe = repo.into_sync(); 195 + 196 + let (tx, rx) = mpsc::channel::<Result<FileDiff, Status>>(16); 197 + tokio::task::spawn_blocking(move || { 198 + let repo = safe.to_thread_local(); 199 + let run = || -> anyhow::Result<()> { 200 + let to_head = repo.find_commit(to_head_id)?; 201 + let to_head_tree = to_head.tree()?; 202 + let rebased_tree = 203 + prepare_interdiff(&repo, (from_base_id, from_head_id), to_base_id)?; 204 + for item in diff::diff(&repo, &rebased_tree, &to_head_tree, true)? { 205 + if tx.blocking_send(Ok(item?.into())).is_err() { 206 + break; 207 + } 208 + } 209 + Ok(()) 210 + }; 211 + if let Err(e) = run() { 212 + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); 213 + } 214 + }); 215 + 216 + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) 217 + } 218 + 219 + type GetBlobStream = BlobChunkStream; 220 + 221 + async fn get_blob( 222 + &self, 223 + request: Request<GetBlobRequest>, 224 + ) -> Result<Response<Self::GetBlobStream>, Status> { 225 + let req = request.into_inner(); 226 + let repo = self.open_repo(&req.repo)?; 227 + let oid = gix::ObjectId::from_hex(req.oid.as_bytes()) 228 + .map_err(|e| Status::invalid_argument(format!("bad oid '{}': {e}", req.oid)))?; 229 + let safe = repo.into_sync(); 230 + 231 + let (tx, rx) = mpsc::channel::<Result<BlobChunk, Status>>(16); 232 + tokio::task::spawn_blocking(move || { 233 + let repo = safe.to_thread_local(); 234 + let run = || -> anyhow::Result<()> { 235 + let blob = repo.find_object(oid)?.try_into_blob()?; 236 + for chunk in blob.data.chunks(BLOB_CHUNK_SIZE) { 237 + let msg = BlobChunk { 238 + data: chunk.to_vec(), 239 + }; 240 + if tx.blocking_send(Ok(msg)).is_err() { 241 + break; 242 + } 243 + } 244 + Ok(()) 245 + }; 246 + if let Err(e) = run() { 247 + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); 248 + } 249 + }); 250 + 251 + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) 252 + } 253 + 254 + type CommitLogStream = CommitLogResponseStream; 255 + 256 + async fn commit_log( 257 + &self, 258 + request: Request<CommitLogRequest>, 259 + ) -> Result<Response<Self::CommitLogStream>, Status> { 260 + let req = request.into_inner(); 261 + debug!( 262 + repo = %req.repo, 263 + all_refs = req.all_refs, 264 + ranges = ?req.ranges, 265 + after = ?req.after, 266 + before = ?req.before, 267 + max_commits = req.max_commits, 268 + skip = req.skip, 269 + "commit_log request", 270 + ); 271 + if req.all_refs != req.ranges.is_empty() { 272 + return Err(Status::invalid_argument( 273 + "specify exactly one of `ranges` or `all_refs`", 274 + )); 275 + } 276 + let repo = self.open_repo(&req.repo)?; 277 + let safe = repo.into_sync(); 278 + 279 + let (tx, rx) = mpsc::channel::<Result<CommitLogResponse, Status>>(16); 280 + tokio::task::spawn_blocking(move || { 281 + let repo = safe.to_thread_local(); 282 + let run = || -> anyhow::Result<()> { 283 + let after = req.after.map(|t| t.seconds); 284 + let before = req.before.map(|t| t.seconds); 285 + let max_commits = req.max_commits as usize; 286 + let mut skip = req.skip as usize; 287 + let mut sent = 0usize; 288 + let mut batch = Vec::with_capacity(COMMIT_LOG_BATCH_SIZE); 289 + 290 + for info in commit_log_walk(&repo, &req)? { 291 + let info = info?; 292 + let commit_time = info.commit_time(); 293 + if before.is_some_and(|b| commit_time > b) { 294 + continue; 295 + } 296 + if after.is_some_and(|a| commit_time < a) { 297 + break; 298 + } 299 + if skip > 0 { 300 + skip -= 1; 301 + continue; 302 + } 303 + if max_commits != 0 && sent >= max_commits { 304 + break; 305 + } 306 + batch.push(GitCommit::try_from(&info.object()?)?); 307 + sent += 1; 308 + if batch.len() == COMMIT_LOG_BATCH_SIZE { 309 + let msg = CommitLogResponse { 310 + commits: std::mem::take(&mut batch), 311 + }; 312 + if tx.blocking_send(Ok(msg)).is_err() { 313 + return Ok(()); // client hung up 314 + } 315 + } 316 + } 317 + if !batch.is_empty() { 318 + let _ = tx.blocking_send(Ok(CommitLogResponse { commits: batch })); 319 + } 320 + Ok(()) 321 + }; 322 + if let Err(e) = run() { 323 + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); 324 + } 325 + }); 326 + 327 + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) 328 + } 329 + 330 + async fn merge_check( 331 + &self, 332 + request: Request<MergeCheckRequest>, 333 + ) -> Result<Response<MergeCheckResponse>, Status> { 334 + let req = request.into_inner(); 335 + let target = req 336 + .target 337 + .ok_or_else(|| Status::invalid_argument("target is required"))?; 338 + let source = req 339 + .source 340 + .ok_or_else(|| Status::invalid_argument("source is required"))?; 341 + debug!( 342 + target_repo = %target.repo, 343 + target_commit = %target.commit.as_bstr(), 344 + source_repo = %source.repo, 345 + source_commit = %source.commit.as_bstr(), 346 + "merge_check request", 347 + ); 348 + 349 + let scratch = self.open_scratch(&[target.repo.as_str(), source.repo.as_str()])?; 350 + 351 + let response = 352 + tokio::task::spawn_blocking(move || -> Result<MergeCheckResponse, Status> { 353 + let target_oid = find_commit_by_sha(&scratch, &target.commit)?; 354 + let source_oid = find_commit_by_sha(&scratch, &source.commit)?; 355 + crate::merge::merge_check(&scratch, target_oid, source_oid) 356 + .map_err(|e| Status::internal(e.to_string())) 357 + }) 358 + .await 359 + .map_err(|e| Status::internal(format!("merge task panicked: {e}")))??; 360 + 361 + Ok(Response::new(response)) 362 + } 363 + 364 + async fn resolve_rev( 365 + &self, 366 + request: Request<ResolveRevRequest>, 367 + ) -> Result<Response<ResolveRevResponse>, Status> { 368 + let req = request.into_inner(); 369 + debug!(repo = %req.repo, rev = %req.rev.as_bstr(), "resolve_rev request"); 370 + let repo = self.open_repo(&req.repo)?; 371 + let commit = tokio::task::spawn_blocking(move || -> Result<String, Status> { 372 + Ok(resolve_simple_rev(&repo, &req.rev)?.to_string()) 373 + }) 374 + .await 375 + .map_err(|e| Status::internal(format!("resolve task panicked: {e}")))??; 376 + Ok(Response::new(ResolveRevResponse { commit })) 377 + } 378 + } 379 + 380 + fn resolve_commit_oid(repo: &gix::Repository, spec: &[u8]) -> Result<gix::ObjectId, Status> { 381 + let commit = repo 382 + .rev_parse_single(spec.as_bstr()) 383 + .map_err(|e| { 384 + Status::invalid_argument(format!("bad rev '{}': {e}", String::from_utf8_lossy(spec))) 385 + })? 386 + .object() 387 + .map_err(|e| Status::internal(e.to_string()))? 388 + .peel_to_commit() 389 + .map_err(|e| Status::internal(e.to_string()))?; 390 + Ok(commit.id) 391 + } 392 + 393 + fn resolve_simple_rev(repo: &gix::Repository, rev: &[u8]) -> Result<gix::ObjectId, Status> { 394 + let unsupported = rev == b"HEAD" 395 + || rev == b"@" 396 + || rev.windows(2).any(|w| w == b"@{") 397 + || rev.iter().any(|&b| matches!(b, b'~' | b'^' | b':')); 398 + if unsupported { 399 + return Err(Status::invalid_argument(format!( 400 + "unsupported rev '{}': only a branch, tag, or commit oid is allowed", 401 + rev.as_bstr() 402 + ))); 403 + } 404 + resolve_commit_oid(repo, rev) 405 + } 406 + 407 + fn find_commit_by_sha(repo: &gix::Repository, sha: &[u8]) -> Result<gix::ObjectId, Status> { 408 + let oid = gix::ObjectId::from_hex(sha).map_err(|e| { 409 + Status::invalid_argument(format!("bad commit sha '{}': {e}", sha.as_bstr())) 410 + })?; 411 + repo.find_commit(oid) 412 + .map_err(|_| Status::not_found(format!("commit not found: {oid}")))?; 413 + Ok(oid) 414 + } 415 + 416 + fn commit_log_walk<'repo>( 417 + repo: &'repo gix::Repository, 418 + req: &CommitLogRequest, 419 + ) -> anyhow::Result<gix::revision::Walk<'repo>> { 420 + let mut tips = Vec::new(); 421 + let mut hidden = Vec::new(); 422 + 423 + if req.all_refs { 424 + for r in repo.references()?.all()? { 425 + let mut r = r.map_err(|e| anyhow::anyhow!(e))?; 426 + if let Ok(commit) = r.peel_to_commit() { 427 + tips.push(commit.id); 428 + } 429 + } 430 + } else { 431 + for range in &req.ranges { 432 + let revspec = repo.rev_parse(range.as_bstr())?; 433 + let spec = revspec.detach(); 434 + match spec { 435 + RevSpec::Include(id) => tips.push(id), 436 + RevSpec::Range { from, to } => { 437 + tips.push(to); 438 + hidden.push(from); 439 + } 440 + _ => { 441 + anyhow::bail!("The spec isn't currently supported: {spec:?}") 442 + } 443 + } 444 + } 445 + } 446 + 447 + Ok(repo 448 + .rev_walk(tips) 449 + .sorting(Sorting::ByCommitTime(Default::default())) 450 + .with_hidden(hidden) 451 + .all()?) 452 + } 453 + 454 + impl TryFrom<&gix::Commit<'_>> for GitCommit { 455 + type Error = anyhow::Error; 456 + 457 + fn try_from(commit: &gix::Commit<'_>) -> anyhow::Result<Self> { 458 + let decoded = commit.decode()?; 459 + Ok(GitCommit { 460 + oid: commit.id.to_string(), 461 + author: Some(decoded.author()?.try_into()?), 462 + committer: Some(decoded.committer()?.try_into()?), 463 + message: decoded.message.to_vec(), 464 + parents: decoded 465 + .parents 466 + .into_iter() 467 + .map(|id| id.to_string()) 468 + .collect(), 469 + extra_headers: decoded 470 + .extra_headers 471 + .iter() 472 + .map(|(k, v)| (k.to_string(), v.to_string())) 473 + .collect(), 474 + }) 475 + } 476 + } 477 + 478 + impl TryFrom<gix::actor::SignatureRef<'_>> for GitSignature { 479 + type Error = anyhow::Error; 480 + 481 + fn try_from(sig: gix::actor::SignatureRef<'_>) -> anyhow::Result<Self> { 482 + let time = sig.time()?; 483 + Ok(GitSignature { 484 + name: sig.name.to_vec(), 485 + email: sig.email.to_vec(), 486 + date: Some(prost_types::Timestamp { 487 + seconds: time.seconds, 488 + nanos: 0, 489 + }), 490 + }) 491 + } 492 + } 493 + 494 + pub async fn serve(addr: SocketAddr, repo_base: PathBuf) -> anyhow::Result<()> { 495 + let service = GitMirror { repo_base }; 496 + info!(addr = %addr, "gitmirror gRPC server listening"); 497 + 498 + Server::builder() 499 + .add_service(GitMirrorServiceServer::new(service)) 500 + .serve(addr) 501 + .await?; 502 + 503 + Ok(()) 504 + } 505 + 506 + #[cfg(test)] 507 + mod tests { 508 + use super::*; 509 + 510 + #[test] 511 + fn temp_repository_removes_scratch_dir_on_drop() { 512 + let dir = tempfile::tempdir().unwrap(); 513 + gix::init_bare(dir.path()).unwrap(); 514 + let repo = gix::open(dir.path()).unwrap().with_object_memory(); 515 + let temp = TempRepository::new(repo, dir); 516 + let path = temp.git_dir().to_path_buf(); 517 + assert!(path.exists()); 518 + // Derefs to gix::Repository. 519 + assert!(temp.object_hash() == gix::hash::Kind::Sha1); 520 + drop(temp); 521 + assert!(!path.exists(), "scratch dir should be gone after drop"); 522 + } 523 + }
+10
localinfra/Caddyfile
··· 53 53 reverse_proxy knotmirror:7000 54 54 } 55 55 56 + gitmirror.tngl.boltless.dev { 57 + tls internal 58 + # gitmirror (tonic) serves h2c; Caddy defaults to HTTP/1.1 to the backend. 59 + reverse_proxy gitmirror:9000 { 60 + transport http { 61 + versions h2c 2 62 + } 63 + } 64 + } 65 + 56 66 # zoekt (internal service) 57 67 zoekt.tngl.boltless.dev { 58 68 tls internal
+29
localinfra/gitmirror.Dockerfile
··· 1 + # Development only. Not for production use. 2 + 3 + FROM rust:1.96-bookworm AS build 4 + 5 + RUN apt-get update && apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev \ 6 + && rm -rf /var/lib/apt/lists/* 7 + 8 + WORKDIR /src 9 + COPY . . 10 + 11 + # Cache mounts on the cargo registry + target dir. The binary is copied out within the same 12 + # RUN because the target cache mount is unmounted afterwards (a later COPY --from could not 13 + # see it). 14 + RUN --mount=type=cache,target=/usr/local/cargo/registry \ 15 + --mount=type=cache,target=/src/target \ 16 + cargo build --release -p gitmirror \ 17 + && cp /src/target/release/gitmirror /usr/local/bin/gitmirror 18 + 19 + FROM debian:bookworm-slim 20 + 21 + RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates tini \ 22 + && rm -rf /var/lib/apt/lists/* 23 + 24 + COPY --from=build /usr/local/bin/gitmirror /usr/local/bin/gitmirror 25 + 26 + EXPOSE 9000 27 + 28 + ENTRYPOINT ["/usr/bin/tini", "--"] 29 + CMD ["sh", "-c", "if [ -f /usr/local/share/ca-certificates/caddy.crt ]; then update-ca-certificates; fi && exec /usr/local/bin/gitmirror serve"]
+5 -2
localinfra/tailwind.Dockerfile
··· 1 1 FROM node:22-alpine 2 2 3 - WORKDIR /build 3 + WORKDIR /deps 4 4 5 5 RUN npm install --no-save \ 6 6 tailwindcss@3.4.17 \ ··· 8 8 tailwindcss-animated@1.1.2 9 9 10 10 ENV BROWSERSLIST_IGNORE_OLD_DATA=true 11 + ENV NODE_PATH=/deps/node_modules 12 + 13 + WORKDIR /build 11 14 12 - ENTRYPOINT ["node_modules/.bin/tailwindcss"] 15 + ENTRYPOINT ["/deps/node_modules/.bin/tailwindcss"]
+2 -1
appview/config/config.go
··· 60 60 } 61 61 62 62 type KnotMirrorConfig struct { 63 - Url string `env:"URL, default=https://mirror.tangled.network"` 63 + Url string `env:"URL, default=https://mirror.tangled.network"` 64 + V2Host string `env:"V2_HOST, required"` 64 65 } 65 66 66 67 type JetstreamConfig struct {
+294 -4
appview/models/pull.go
··· 1 1 2 2 3 + import ( 4 + "bytes" 5 + "compress/gzip" 6 + "encoding/hex" 7 + "fmt" 8 + "io" 9 + "maps" 10 + "slices" 11 + "strings" 12 + "time" 3 13 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/pages/markup/sanitizer" 16 + "tangled.org/core/patchutil" 4 17 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + ) 5 20 21 + type PullState int 6 22 7 23 8 24 ··· 37 53 38 54 39 55 56 + } 40 57 58 + type Pull struct { 59 + ID int64 // appview-local PR id. Used for quick referencing 60 + OwnerDid syntax.DID 61 + Rkey syntax.RecordKey 62 + Cid syntax.CID 63 + RepoDid syntax.DID 64 + PullId int64 41 65 66 + Title string 67 + Body string 68 + TargetBranch string 69 + SourceRepo syntax.DID 70 + SourceBranch *string 71 + Versions []PullVersion 72 + Created time.Time 42 73 74 + State PullState 43 75 76 + // optionally, populate this when querying for reverse mappings 77 + Labels LabelState 78 + Repo *Repo 79 + } 44 80 81 + func (p *Pull) AtUri() syntax.ATURI { 82 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 83 + } 45 84 85 + func (p *Pull) AsRecord() tangled.RepoPull { 86 + sourceRepo := p.SourceRepo.String() 87 + var sourceBranch string 88 + if p.SourceBranch != nil { 89 + sourceBranch = *p.SourceBranch 90 + } 91 + versions := make([]*tangled.RepoPull_Version, len(p.Versions)) 92 + for i, v := range p.Versions { 93 + var base *string 94 + if v.Base != "" { 95 + base = &v.Base 96 + } 97 + versions[i] = &tangled.RepoPull_Version{ 98 + Base: base, 99 + Head: v.Head, 100 + CreatedAt: v.Created.Format(time.RFC3339), 101 + } 102 + } 103 + return tangled.RepoPull{ 104 + Title: p.Title, 105 + Body: &p.Body, 106 + Target: &tangled.RepoPull_Target{ 107 + Repo: p.RepoDid.String(), 108 + Branch: p.TargetBranch, 109 + }, 110 + Source: &tangled.RepoPull_Source{ 111 + Repo: &sourceRepo, 112 + Branch: sourceBranch, 113 + }, 114 + Versions: versions, 115 + CreatedAt: p.Created.Format(time.RFC3339), 116 + } 117 + } 46 118 119 + func (p *Pull) Validate() error { 120 + if len(p.Versions) == 0 { 121 + return fmt.Errorf("pull must have at least one version") 122 + } 47 123 124 + if p.Title == "" { 125 + return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") 126 + } 127 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(p.Title)); st == "" { 128 + return fmt.Errorf("title is empty after HTML sanitization") 129 + } 48 130 131 + for i, version := range p.Versions { 132 + if err := version.Validate(); err != nil { 133 + return fmt.Errorf("versions[%d]: %w", i, err) 134 + } 135 + } 136 + return nil 137 + } 49 138 139 + func (v *PullVersion) Validate() error { 140 + if v.Base != "" && !IsHash(v.Base) { 141 + return fmt.Errorf("invalid base commit id: %q", v.Base) 142 + } 143 + if !IsHash(v.Head) { 144 + return fmt.Errorf("invalid head commit id: %q", v.Head) 145 + } 146 + return nil 147 + } 50 148 149 + func PullFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.RepoPull, versions []PullVersion) (*Pull, error) { 150 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 151 + if err != nil { 152 + return nil, fmt.Errorf("invalid createdAt: %w", err) 51 153 52 154 53 155 54 156 157 + body = *record.Body 158 + } 55 159 160 + // var mentions []syntax.DID 161 + // for _, m := range record.Mentions { 162 + // if did, err := syntax.ParseDID(m); err == nil { 163 + // mentions = append(mentions, did) 164 + // } 165 + // } 56 166 167 + var targetRepoDid syntax.DID 168 + var targetBranch string 57 169 58 170 59 171 60 172 61 173 62 174 175 + targetBranch = record.Target.Branch 176 + } 63 177 178 + var sourceRepo syntax.DID 179 + var sourceBranch *string 180 + if record.Source != nil { 181 + if record.Source.Repo != nil { 182 + did, err := syntax.ParseDID(*record.Source.Repo) 183 + if err != nil { 184 + return nil, fmt.Errorf("invalid source.repo did: %w", err) 185 + } 186 + sourceRepo = did 187 + } 188 + if record.Source.Branch != "" { 189 + sourceBranch = new(string) 190 + *sourceBranch = record.Source.Branch 191 + } 192 + } 64 193 194 + return &Pull{ 195 + ID: -1, // uninitialized 196 + OwnerDid: did, 197 + Rkey: rkey, 198 + Cid: cid, 199 + RepoDid: targetRepoDid, 200 + PullId: 0, // uninitialized 65 201 202 + Title: record.Title, 203 + Body: body, 204 + TargetBranch: targetBranch, 205 + SourceRepo: sourceRepo, 206 + SourceBranch: sourceBranch, 207 + Versions: versions, 208 + Created: created, 209 + State: PullOpen, // default to open 210 + }, nil 211 + } 66 212 213 + func PullVersionFromRecord(idx int, record *tangled.RepoPull_Version) (PullVersion, error) { 214 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 215 + if err != nil { 216 + return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err) 217 + } 67 218 219 + var base string 220 + if record.Base != nil { 221 + base = *record.Base 222 + } 223 + return PullVersion{ 224 + ID: idx, 225 + Base: base, 226 + Head: record.Head, 227 + Created: created, 228 + }, nil 229 + } 68 230 231 + func PullVersionFromLegacy(idx int, record *tangled.RepoPull_Round, reader io.Reader) (PullVersion, error) { 232 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 233 + if err != nil { 234 + return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err) 235 + } 69 236 237 + patch, err := extractGzip(reader) 238 + if err != nil { 239 + return PullVersion{}, fmt.Errorf("failed to extract gzip: %w", err) 240 + } 241 + if !patchutil.IsFormatPatch(patch) { 242 + return PullVersion{}, fmt.Errorf("only format-patch patch is supported") 243 + } 70 244 245 + var sourceRev string 246 + patches, err := patchutil.ExtractPatches(patch) 247 + if err != nil { 248 + return PullVersion{}, fmt.Errorf("failed to extract patches: %w", err) 249 + } 250 + for _, part := range patches { 251 + sourceRev = part.SHA 252 + } 253 + if sourceRev == "" { 254 + return PullVersion{}, fmt.Errorf("source rev is missing") 255 + } 256 + return PullVersion{ 257 + ID: idx, 258 + Base: "", 259 + Head: sourceRev, 260 + Created: created, 261 + }, nil 262 + } 71 263 72 264 73 265 ··· 84 276 85 277 86 278 87 - Repo *Repo 279 + 280 + 281 + 282 + 283 + 284 + } 88 285 } 89 286 90 - // NOTE: This method does not include patch blob in returned atproto record 91 - func (p Pull) AsRecord() tangled.RepoPull { 92 - mentions := make([]string, len(p.Mentions)) 287 + type PullVersion struct { 288 + ID int 289 + Head string // head commit ID 290 + Base string // base commit ID (for combined interdiff) 291 + Created time.Time 292 + 293 + // reverse mappings 294 + Comments []Comment 295 + } 296 + 297 + func (p *Pull) TotalComments() int { 298 + total := 0 299 + for _, s := range p.Versions { 300 + total += len(s.Comments) 301 + } 302 + return total 303 + } 304 + 305 + func (p *Pull) GetVersion(id int) (PullVersion, bool) { 306 + for _, version := range p.Versions { 307 + if version.ID == id { 308 + return version, true 309 + } 310 + } 311 + return PullVersion{}, false 312 + } 313 + 314 + func (p *Pull) LatestVersionNumber() int { 315 + return len(p.Versions) - 1 316 + } 317 + 318 + func (p *Pull) LatestVersion() PullVersion { 319 + return p.Versions[p.LatestVersionNumber()] 320 + } 321 + 322 + func (p *Pull) LatestSha() string { 323 + return p.LatestVersion().Head 324 + } 325 + 326 + func (p *Pull) IsForkBased() bool { 327 + return p.RepoDid != p.SourceRepo 328 + } 329 + 330 + func (p *Pull) Participants() []syntax.DID { 331 + participants := make(map[syntax.DID]struct{}) 332 + 333 + participants[p.OwnerDid] = struct{}{} 334 + 335 + for _, v := range p.Versions { 336 + for _, sp := range v.Participants() { 337 + participants[sp] = struct{}{} 338 + } 339 + } 340 + 341 + return slices.Collect(maps.Keys(participants)) 342 + } 343 + 344 + func (s *PullVersion) Participants() []syntax.DID { 345 + participants := make(map[syntax.DID]struct{}) 346 + 347 + for _, c := range s.Comments { 348 + participants[c.Did] = struct{}{} 349 + } 350 + 351 + return slices.Collect(maps.Keys(participants)) 352 + } 353 + 354 + func extractGzip(blob io.Reader) (string, error) { 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + return b.String(), nil 371 + } 372 + 373 + func IsHash(s string) bool { 374 + switch len(s) { 375 + case 40: // SHA1 376 + case 64: // SHA2 377 + default: 378 + return false 379 + } 380 + _, err := hex.DecodeString(s) 381 + return err == nil 382 + }
+289 -37
appview/pages/pages.go
··· 19 19 20 20 21 21 22 + "tangled.org/core/appview/commitverify" 23 + "tangled.org/core/appview/config" 24 + "tangled.org/core/appview/db" 25 + "tangled.org/core/appview/filetree" 26 + "tangled.org/core/appview/models" 27 + "tangled.org/core/appview/oauth" 28 + "tangled.org/core/appview/pages/markup" 29 + "tangled.org/core/appview/pages/markup/sanitizer" 30 + "tangled.org/core/appview/pages/repoinfo" 31 + "tangled.org/core/appview/pagination" 32 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 33 + "tangled.org/core/idresolver" 34 + "tangled.org/core/types" 22 35 23 36 24 37 ··· 1362 1375 1363 1376 1364 1377 1378 + type PullSubscribeParams struct { 1379 + RepoInfo repoinfo.RepoInfo 1380 + PullId int64 1381 + IsSubscribed *bool 1382 + } 1365 1383 1366 1384 1367 1385 ··· 1411 1429 1412 1430 1413 1431 1432 + type RepoNewPullParams struct { 1433 + BaseParams 1434 + RepoInfo repoinfo.RepoInfo 1435 + Active string 1436 + PrefillError string 1414 1437 1438 + // step 1. choose source 1439 + // TODO: replace to RepoNewPull_StepSourceParams 1440 + Branches []types.Branch 1441 + SourceBranches []types.Branch 1442 + ForkBranches []types.Branch 1443 + Forks []models.Repo 1444 + // selected values 1445 + Source Source // source kind 1446 + TargetBranch string 1447 + Fork string // fork repo DID 1448 + SourceBranch string 1449 + Patch string 1415 1450 1451 + // step 2. review changes 1452 + StepReviewParams *RepoNewPull_StepReviewParams // optional step 2 params 1416 1453 1454 + // step 3. fill details 1455 + // TODO: replace to RepoNewPull_StepDetailsParams 1456 + Title string 1457 + Body string 1458 + TitleDirty bool // flag to avoid overwriting users input 1459 + BodyDirty bool 1460 + MergeCheck MergeCheckParams 1461 + LabelDefs map[string]*models.LabelDefinition 1462 + LabelState models.LabelState 1463 + } 1417 1464 1465 + func (p RepoNewPullParams) SourceRepo() string { 1466 + if p.Fork != "" { 1467 + return p.Fork 1468 + } 1469 + return p.RepoInfo.RepoDid 1470 + } 1418 1471 1472 + type RepoNewPull_StepSourceParams struct { 1473 + Branches []types.Branch 1474 + SourceBranches []types.Branch 1475 + ForkBranches []types.Branch 1476 + Forks []models.Repo 1477 + ErrorMsg string 1478 + // selected values 1479 + Source Source // source kind 1480 + TargetBranch string 1481 + Fork string // fork repo DID 1482 + SourceBranch string 1483 + Patch string 1484 + } 1419 1485 1486 + type RepoNewPull_StepReviewParams struct { 1487 + Commits []types.Commit 1488 + } 1420 1489 1490 + type RepoNewPull_StepDetailsParams struct { 1491 + Title string 1492 + Body string 1493 + TitleDirty bool 1494 + BodyDirty bool 1495 + } 1421 1496 1497 + type MergeCheckParams struct { 1498 + IsConflicted bool 1499 + Conflicts []*gitmirrorv1.MergeConflict 1500 + Error string 1501 + } 1422 1502 1503 + func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 1423 1504 1424 1505 1425 1506 ··· 1447 1528 1448 1529 1449 1530 1531 + FilterState string 1532 + FilterQuery string 1533 + BaseFilterQuery string 1534 + Pipelines map[string]types.Pipeline 1535 + LabelDefs map[string]*models.LabelDefinition 1536 + Page pagination.Page 1450 1537 1451 1538 1452 1539 ··· 1471 1558 1472 1559 1473 1560 1561 + return r == Unknown 1562 + } 1474 1563 1564 + type BranchDeleteStatus struct { 1565 + Repo *models.Repo 1566 + Branch string 1567 + } 1475 1568 1569 + type PullPageBaseParams struct { 1570 + BaseParams 1571 + RepoInfo repoinfo.RepoInfo 1572 + Pull *models.Pull 1476 1573 1574 + Backlinks []models.RichReferenceLink 1575 + Commits []types.Commit // all commits between <target>..<pr/head> 1576 + Pipelines map[string]types.Pipeline 1477 1577 1578 + MergeCheck MergeCheckParams 1579 + ResubmitCheck ResubmitResult 1580 + BranchDeleteStatus *BranchDeleteStatus 1478 1581 1582 + LabelDefs map[string]*models.LabelDefinition 1583 + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1584 + UserReacted map[syntax.ATURI]map[models.ReactionKind]bool 1585 + VouchRelationships map[syntax.DID]*models.VouchRelationship 1586 + VouchSkips map[syntax.DID]bool 1479 1587 1588 + // IsSubscribed is nil when not logged in, true when subscribed, false when explicitly unsubscribed. 1589 + IsSubscribed *bool 1480 1590 1591 + // diff, branch-delete-status, merge-check, resubmit-check, pipelines will be lazy-loaded. 1592 + } 1481 1593 1594 + // /pulls/123/2/1a2b3c..d4e5f6 1595 + type PullDiffParams struct { 1596 + PullPageBaseParams 1597 + VersionId int 1482 1598 1599 + DiffParams DiffParams_Diff 1600 + IsDiffBase bool 1601 + IsDiffHead bool 1483 1602 1603 + ErrorMsg string 1604 + } 1484 1605 1606 + func (p PullDiffParams) ActiveVersionId() int { 1607 + return p.VersionId 1608 + } 1485 1609 1610 + func (p PullDiffParams) ActiveCommitId() string { 1611 + return p.DiffParams.Head 1612 + } 1486 1613 1614 + func (p PullDiffParams) IsInterdiff() bool { 1615 + return false 1616 + } 1487 1617 1618 + func (p PullDiffParams) DisplayDiffBase() string { 1619 + if p.IsDiffBase { 1620 + return "base" 1621 + } 1622 + return shortId(p.DiffParams.Base) 1623 + } 1488 1624 1625 + func (p PullDiffParams) DisplayDiffHead() string { 1626 + if p.IsDiffHead { 1627 + return "head" 1628 + } 1629 + return shortId(p.DiffParams.Head) 1630 + } 1489 1631 1632 + // /pulls/123/1..2/abcdef 1633 + type PullInterdiffParams struct { 1634 + PullPageBaseParams 1635 + Version1 int 1636 + Version2 int 1637 + ChangeId string // optional change-id filter 1490 1638 1639 + DiffParams DiffParams 1640 + ActiveCommitId string 1491 1641 1642 + ErrorMsg string 1643 + } 1492 1644 1645 + func (p PullInterdiffParams) ActiveVersionId() int { 1646 + return p.Version2 1647 + } 1493 1648 1649 + func (p PullInterdiffParams) IsInterdiff() bool { 1650 + return true 1651 + } 1494 1652 1653 + type DiffParams struct { 1654 + Diff *DiffParams_Diff 1655 + Interdiff *DiffParams_Interdiff 1656 + } 1495 1657 1658 + type DiffParams_Diff struct { 1659 + Base string 1660 + Head string 1661 + } 1496 1662 1663 + type DiffParams_Interdiff struct { 1664 + From DiffParams_Diff 1665 + To DiffParams_Diff 1666 + } 1497 1667 1668 + // DiffLine is one row of a unified (inline) diff. Old/New are 1-based line numbers into the 1669 + // base/head blob, or 0 when that side has no line here. Content is pre-rendered, safe HTML. 1670 + type DiffLine struct { 1671 + Op string // " " context, "-" removed, "+" added 1672 + Old int 1673 + New int 1674 + Content template.HTML 1675 + } 1498 1676 1677 + // DiffCell is one side of a side-by-side row. Kind is "ctx", "del", "add", or "empty" (a 1678 + // blank padding cell). Num is the 1-based line number, or 0 when empty. 1679 + type DiffCell struct { 1680 + Kind string 1681 + Num int 1682 + Content template.HTML 1683 + } 1499 1684 1685 + // DiffRow is one side-by-side row: the left (base) and right (head) cells. 1686 + type DiffRow struct { 1687 + Left DiffCell 1688 + Right DiffCell 1689 + } 1500 1690 1691 + // DiffHunk holds a hunk's rows; exactly one of Lines (unified) / Rows (split) is populated, 1692 + // depending on PullDiffFragmentParams.Split. 1693 + type DiffHunk struct { 1694 + Lines []DiffLine 1695 + Rows []DiffRow 1696 + } 1501 1697 1698 + func (h DiffHunk) AtFileStart() bool { 1699 + if len(h.Rows) > 0 { 1700 + r := h.Rows[0] 1701 + return r.Left.Num == 1 || r.Right.Num == 1 1702 + } 1703 + if len(h.Lines) > 0 { 1704 + l := h.Lines[0] 1705 + return l.Old == 1 || l.New == 1 1706 + } 1707 + return false 1708 + } 1502 1709 1710 + // DiffFile is one changed file. Note is set (and Hunks empty) for binary/submodule files. 1711 + type DiffFile struct { 1712 + Path string 1713 + Note string 1714 + Hunks []DiffHunk 1715 + } 1503 1716 1717 + type PullDiffFragmentParams struct { 1718 + BaseRepo syntax.DID 1719 + HeadRepo syntax.DID 1720 + DiffBase string 1721 + DiffHead string 1722 + DiffUrl string 1723 + Unified bool 1724 + Files []DiffFile 1504 1725 1726 + ErrorMsg string 1727 + } 1505 1728 1729 + func (f *DiffFile) Id() string { 1730 + return f.Path 1731 + } 1506 1732 1733 + func (f *DiffFile) Stats() types.DiffFileStat { 1734 + var ins, del int64 1735 + for _, hunk := range f.Hunks { 1736 + for _, line := range hunk.Lines { 1737 + switch line.Op { 1738 + case "+": 1739 + ins++ 1740 + case "-": 1741 + del++ 1742 + } 1743 + } 1744 + for _, row := range hunk.Rows { 1745 + if row.Left.Kind == "del" { 1746 + del++ 1747 + } 1748 + if row.Right.Kind == "add" { 1749 + ins++ 1750 + } 1751 + } 1752 + } 1753 + return types.DiffFileStat{ 1754 + Insertions: ins, 1755 + Deletions: del, 1756 + } 1757 + } 1507 1758 1759 + func (p PullDiffFragmentParams) FileTree() *filetree.FileTreeNode { 1760 + fs := make([]string, len(p.Files)) 1761 + for i, s := range p.Files { 1762 + fs[i] = s.Id() 1763 + } 1764 + return filetree.FileTree(fs) 1765 + } 1508 1766 1767 + func (p PullDiffFragmentParams) Stats() types.DiffStat { 1768 + var stat types.DiffStat 1769 + for _, df := range p.Files { 1770 + fileStats := df.Stats() 1771 + stat.Insertions += fileStats.Insertions 1772 + stat.Deletions += fileStats.Deletions 1773 + } 1774 + stat.FilesChanged = len(p.Files) 1775 + return stat 1776 + } 1509 1777 1778 + func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error { 1779 + return p.executeRepo("repo/pulls/single", w, params) 1780 + } 1510 1781 1782 + func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error { 1783 + return p.executeRepo("repo/pulls/single", w, params) 1784 + } 1511 1785 1786 + func (p *Pages) PullDiffFragment(w io.Writer, params PullDiffFragmentParams) error { 1787 + return p.executePlain("repo/pulls/fragments/diff", w, params) 1788 + } 1512 1789 1790 + func (p *Pages) PullComposeDiffFragment(w io.Writer, params PullDiffFragmentParams) error { 1791 + return p.executePlain("repo/pulls/fragments/composediff", w, params) 1792 + } 1513 1793 1794 + type PullActionsParams struct { 1514 1795 1796 + RepoInfo repoinfo.RepoInfo 1797 + Pull *models.Pull 1798 + RoundNumber int 1799 + MergeCheck MergeCheckParams 1800 + ResubmitCheck ResubmitResult 1801 + BranchDeleteStatus *BranchDeleteStatus 1515 1802 1516 - 1517 - 1518 - 1519 - 1520 - 1521 - 1522 - 1523 - 1524 - 1525 - 1526 - 1527 - 1528 - 1529 - 1530 - 1531 - 1532 - 1533 - 1534 - 1535 - 1536 - 1537 - 1538 - 1539 - 1540 - 1541 - 1542 - 1543 - 1544 - 1545 - 1546 - 1547 - IsSubscribed *bool 1548 - } 1549 - 1550 - func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 1551 - params.Active = "pulls" 1552 - return p.executeRepo("repo/pulls/pull", w, params) 1803 + // Workflow warning state for fork-based pulls without a pipeline on the 1804 + // latest commit. WorkflowsChanged and ChangedWorkflowFiles are computed
+22 -15
appview/pulls/pulls.go
··· 1 + package pulls 1 2 3 + import ( 4 + "log/slog" 5 + "time" 2 6 7 + "tangled.org/core/appview/config" 3 8 4 9 5 10 6 11 7 12 8 13 9 - 10 - 11 - 12 - 13 - 14 - 15 - 16 - 17 - 18 - 19 14 "tangled.org/core/appview/oauth" 20 15 "tangled.org/core/appview/pages" 21 16 "tangled.org/core/appview/reporesolver" 17 + knotmirror "tangled.org/core/gitmirror/proto/gen" 22 18 "tangled.org/core/idresolver" 23 19 "tangled.org/core/ogre" 24 - "tangled.org/core/patchutil" 20 + "tangled.org/core/types" 25 21 22 + "github.com/bluesky-social/indigo/util" 23 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 24 + "github.com/hashicorp/golang-lru/v2/expirable" 25 + ) 26 26 27 27 28 28 ··· 42 42 43 43 44 44 45 - 46 - 47 - 48 - 49 45 indexer *pulls_indexer.Indexer 50 46 ogreClient *ogre.Client 51 47 diffCache *expirable.LRU[string, types.DiffRenderer] 48 + gitmirror knotmirror.GitMirrorServiceClient 49 + 50 + knotMirrorXRPC *indigoxrpc.Client 52 51 } 53 52 54 53 func New( ··· 63 62 acl *knotacl.Service, 64 63 indexer *pulls_indexer.Indexer, 65 64 logger *slog.Logger, 65 + gitmirror knotmirror.GitMirrorServiceClient, 66 66 ) *Pulls { 67 67 return &Pulls{ 68 68 oauth: oauth, ··· 78 78 indexer: indexer, 79 79 ogreClient: ogre.NewClient(config.Ogre.Host), 80 80 diffCache: expirable.NewLRU[string, types.DiffRenderer](diffCacheSize, nil, diffCacheTTL), 81 + gitmirror: gitmirror, 82 + 83 + knotMirrorXRPC: &indigoxrpc.Client{ 84 + Host: config.KnotMirror.Url, 85 + Client: util.RobustHTTPClient(), 86 + }, 81 87 } 82 88 } 83 89 90 + func ptrPullState(s models.PullState) *models.PullState { return &s }
+1
appview/state/router.go
··· 416 416 s.aclService, 417 417 s.indexer.Pulls, 418 418 log.SubLogger(s.logger, "pulls"), 419 + s.gitmirror, 419 420 ) 420 421 return pulls.Router(mw) 421 422 }
+11
appview/state/state.go
··· 11 11 "strings" 12 12 "time" 13 13 14 + "google.golang.org/grpc" 15 + "google.golang.org/grpc/credentials/insecure" 14 16 "tangled.org/core/api/tangled" 15 17 "tangled.org/core/appview" 16 18 "tangled.org/core/appview/bsky" ··· 37 39 "tangled.org/core/appview/reporesolver" 38 40 "tangled.org/core/consts" 39 41 "tangled.org/core/eventconsumer" 42 + knotmirror "tangled.org/core/gitmirror/proto/gen" 40 43 "tangled.org/core/idresolver" 41 44 "tangled.org/core/jetstream" 42 45 "tangled.org/core/log" ··· 75 78 logger *slog.Logger 76 79 cfClient *cloudflare.Client 77 80 codesearch *codesearch.CodeSearch 81 + gitmirror knotmirror.GitMirrorServiceClient 78 82 } 79 83 80 84 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 112 116 return nil, fmt.Errorf("failed to create posthog client: %w", err) 113 117 } 114 118 119 + conn, err := grpc.NewClient(config.KnotMirror.V2Host, grpc.WithTransportCredentials(insecure.NewCredentials())) 120 + if err != nil { 121 + return nil, fmt.Errorf("failed to create gitmirror client: %w", err) 122 + } 123 + gitmirror := knotmirror.NewGitMirrorServiceClient(conn) 124 + 115 125 pages := pages.NewPages(config, res, d, rdb, log.SubLogger(logger, "pages")) 116 126 knotcompat.UseNativeLatch(knotacl.NewLatch(d, log.SubLogger(logger, "knotacl-latch"))) 117 127 aclService := knotacl.NewService(enforcer, d, config.Core.Dev, log.SubLogger(logger, "knotacl")) ··· 243 253 logger: logger, 244 254 cfClient: cfClient, 245 255 codesearch: &codesearch.CodeSearch{Host: config.CodeSearch.ZoektUrl}, 256 + gitmirror: gitmirror, 246 257 } 247 258 248 259 // fetch initial bluesky posts if configured
+1 -1
go.mod
··· 84 84 golang.org/x/sync v0.20.0 85 85 golang.org/x/sys v0.45.0 86 86 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 87 + google.golang.org/grpc v1.80.0 87 88 google.golang.org/protobuf v1.36.11 88 89 gopkg.in/yaml.v3 v3.0.1 89 90 ) ··· 313 314 golang.org/x/tools v0.44.0 // indirect 314 315 google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect 315 316 google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect 316 - google.golang.org/grpc v1.80.0 // indirect 317 317 gopkg.in/fsnotify.v1 v1.4.7 // indirect 318 318 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 319 319 gopkg.in/warnings.v0 v0.1.2 // indirect
+42
appview/db/db.go
··· 2501 2501 return err 2502 2502 }) 2503 2503 2504 + orm.RunMigration(conn, logger, "ref-based-pr", func(tx *sql.Tx) error { 2505 + _, err := tx.Exec(` 2506 + ALTER TABLE pulls ADD COLUMN cid TEXT NOT NULL DEFAULT ''; 2507 + 2508 + -- TODO(boltless): set source_repo_did to '' 2509 + 2510 + CREATE TABLE pull_versions ( 2511 + pull_at TEXT NOT NULL, 2512 + id INTEGER NOT NULL, -- PR local version id 2513 + head TEXT NOT NULL, -- head commit ID 2514 + base TEXT NOT NULL, -- base commit ID (used on interdiff) 2515 + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 2516 + legacy INTEGER NOT NULL DEFAULT 0, -- 2517 + 2518 + UNIQUE(pull_at, id), 2519 + FOREIGN KEY (pull_at) REFERENCES pulls(at_uri) ON DELETE CASCADE 2520 + ); 2521 + 2522 + -- set source_repo_did to repo_did for all branch based PRs 2523 + UPDATE pulls 2524 + SET source_repo_did = repo_did 2525 + WHERE coalesce(source_branch, '') <> ''; 2526 + 2527 + INSERT INTO pull_versions ( 2528 + pull_at, 2529 + id, 2530 + head, 2531 + base, 2532 + created 2533 + ) 2534 + SELECT 2535 + pull_at, 2536 + round_number, 2537 + coalesce(source_rev, ''), 2538 + '', 2539 + created 2540 + FROM pull_submissions; 2541 + -- we keep 'pull_submissions' table just in case. 2542 + `) 2543 + return err 2544 + }) 2545 + 2504 2546 return &DB{ 2505 2547 db, 2506 2548 logger,
+10 -7
appview/db/entity_state_test.go
··· 39 39 } 40 40 pull := &models.Pull{ 41 41 RepoDid: syntax.DID(repo.RepoDid), 42 - OwnerDid: did, 43 - Rkey: rkey, 42 + OwnerDid: syntax.DID(did), 43 + Rkey: syntax.RecordKey(rkey), 44 44 Title: "title", 45 45 Body: "body", 46 46 TargetBranch: "main", 47 47 State: models.PullOpen, 48 + Versions: []models.PullVersion{ 49 + {Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, 50 + }, 48 51 } 49 - if err := PutPull(tx, pull); err != nil { 52 + if err := PutPull(t.Context(), tx, pull, nil); err != nil { 50 53 t.Fatalf("PutPull: %v", err) 51 54 } 52 55 if err := tx.Commit(); err != nil { ··· 120 123 121 124 func pullStateOf(t *testing.T, d *DB, subject syntax.ATURI) models.PullState { 122 125 t.Helper() 123 - pulls, err := GetPulls(d, orm.FilterEq("at_uri", subject)) 124 - if err != nil || len(pulls) != 1 { 125 - t.Fatalf("GetPulls: %v len %d", err, len(pulls)) 126 + pull, err := GetPull(t.Context(), d, orm.FilterEq("at_uri", subject)) 127 + if err != nil { 128 + t.Fatalf("GetPulls: %v", err) 126 129 } 127 - return pulls[0].State 130 + return pull.State 128 131 } 129 132 130 133 func issueRec(did, rkey string, subject syntax.ATURI, v models.StateValue, micros int64) models.StateRecord {
+4 -3
appview/db/focus.go
··· 7 7 "strings" 8 8 "time" 9 9 10 + "github.com/bluesky-social/indigo/atproto/syntax" 10 11 "tangled.org/core/appview/models" 11 12 ) 12 13 ··· 170 171 } 171 172 172 173 if pId.Valid { 173 - pull.ID = int(pId.Int64) 174 + pull.ID = pId.Int64 174 175 if pOwnerDid.Valid { 175 - pull.OwnerDid = pOwnerDid.String 176 + pull.OwnerDid = syntax.DID(pOwnerDid.String) 176 177 } 177 178 if pPullId.Valid { 178 - pull.PullId = int(pPullId.Int64) 179 + pull.PullId = pPullId.Int64 179 180 } 180 181 if pTitle.Valid { 181 182 pull.Title = pTitle.String
+6 -6
appview/db/notifications.go
··· 251 251 252 252 // populate pull if present 253 253 if pId.Valid { 254 - pull.ID = int(pId.Int64) 254 + pull.ID = pId.Int64 255 255 if pOwnerDid.Valid { 256 - pull.OwnerDid = pOwnerDid.String 256 + pull.OwnerDid = syntax.DID(pOwnerDid.String) 257 257 } 258 258 if pPullId.Valid { 259 - pull.PullId = int(pPullId.Int64) 259 + pull.PullId = pPullId.Int64 260 260 } 261 261 if pTitle.Valid { 262 262 pull.Title = pTitle.String ··· 738 738 } 739 739 740 740 if pId.Valid { 741 - pull.ID = int(pId.Int64) 741 + pull.ID = pId.Int64 742 742 if pOwnerDid.Valid { 743 - pull.OwnerDid = pOwnerDid.String 743 + pull.OwnerDid = syntax.DID(pOwnerDid.String) 744 744 } 745 745 if pPullId.Valid { 746 - pull.PullId = int(pPullId.Int64) 746 + pull.PullId = pPullId.Int64 747 747 } 748 748 if pTitle.Valid { 749 749 pull.Title = pTitle.String
+1 -1
appview/db/profile.go
··· 23 23 now := time.Now() 24 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 25 26 - pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) 26 + pulls, err := GetPullsByOwnerDid(e, syntax.DID(forDid), timeframe) 27 27 if err != nil { 28 28 return nil, fmt.Errorf("error getting pulls by owner did: %w", err) 29 29 }
+206 -586
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 - "cmp" 4 + "context" 5 5 "database/sql" 6 - "errors" 7 6 "fmt" 8 7 "maps" 9 8 "slices" ··· 12 11 "time" 13 12 14 13 "github.com/bluesky-social/indigo/atproto/syntax" 15 - lexutil "github.com/bluesky-social/indigo/lex/util" 16 - "github.com/ipfs/go-cid" 17 14 "tangled.org/core/appview/models" 18 15 "tangled.org/core/appview/pagination" 19 16 "tangled.org/core/orm" 20 - "tangled.org/core/sets" 21 17 ) 22 18 23 - func comparePullSource(existing, new *models.PullSource) bool { 24 - if existing == nil && new == nil { 25 - return true 26 - } 27 - if existing == nil || new == nil { 28 - return false 29 - } 30 - if existing.Branch != new.Branch { 31 - return false 32 - } 33 - if existing.RepoDid == nil && new.RepoDid == nil { 34 - return true 35 - } 36 - if existing.RepoDid == nil || new.RepoDid == nil { 37 - return false 38 - } 39 - return *existing.RepoDid == *new.RepoDid 40 - } 41 - 42 - func compareSubmissions(existing, new []*models.PullSubmission) bool { 43 - if len(existing) != len(new) { 44 - return false 45 - } 46 - for i := range existing { 47 - if existing[i].Blob.Ref.String() != new[i].Blob.Ref.String() { 48 - return false 49 - } 50 - if existing[i].Blob.MimeType != new[i].Blob.MimeType { 51 - return false 52 - } 53 - if existing[i].Blob.Size != new[i].Blob.Size { 54 - return false 55 - } 56 - } 57 - return true 58 - } 59 - 60 - func PutPull(tx *sql.Tx, pull *models.Pull) error { 19 + func PutPull(ctx context.Context, tx *sql.Tx, pull *models.Pull, references []syntax.ATURI) error { 61 20 // ensure sequence exists 62 - _, err := tx.Exec(` 21 + _, err := tx.ExecContext(ctx, ` 63 22 insert or ignore into repo_pull_seqs (repo_did, next_pull_id) 64 23 values (?, 1) 65 24 `, pull.RepoDid) ··· 67 26 return err 68 27 } 69 28 70 - pulls, err := GetPulls( 71 - tx, 72 - orm.FilterEq("owner_did", pull.OwnerDid), 73 - orm.FilterEq("rkey", pull.Rkey), 74 - ) 75 - switch { 76 - case err != nil: 29 + var exists bool 30 + if err := tx.QueryRowContext(ctx, 31 + `select exists (select 1 from pulls where at_uri = ?)`, 32 + pull.AtUri(), 33 + ).Scan(&exists); err != nil { 77 34 return err 78 - case len(pulls) == 0: 79 - return createNewPull(tx, pull) 80 - case len(pulls) != 1: // should be unreachable 81 - return fmt.Errorf("invalid number of pulls returned: %d", len(pulls)) 82 - default: 83 - existingPull := pulls[0] 84 - if existingPull.State == models.PullMerged { 85 - return nil 86 - } 87 - 88 - dependentOnEqual := (existingPull.DependentOn == nil && pull.DependentOn == nil) || 89 - (existingPull.DependentOn != nil && pull.DependentOn != nil && *existingPull.DependentOn == *pull.DependentOn) 90 - 91 - pullSourceEqual := comparePullSource(existingPull.PullSource, pull.PullSource) 92 - submissionsEqual := compareSubmissions(existingPull.Submissions, pull.Submissions) 93 - 94 - if existingPull.Title == pull.Title && 95 - existingPull.Body == pull.Body && 96 - existingPull.TargetBranch == pull.TargetBranch && 97 - existingPull.RepoDid == pull.RepoDid && 98 - dependentOnEqual && 99 - pullSourceEqual && 100 - submissionsEqual { 101 - return nil 102 - } 103 - 104 - isLonger := len(existingPull.Submissions) < len(pull.Submissions) 105 - if isLonger { 106 - isAppendOnly := compareSubmissions(existingPull.Submissions, pull.Submissions[:len(existingPull.Submissions)]) 107 - if !isAppendOnly { 108 - return fmt.Errorf("the new pull does not treat submissions as append-only") 109 - } 110 - } else if !submissionsEqual { 111 - return fmt.Errorf("the new pull does not treat submissions as append-only") 112 - } 113 - 114 - pull.ID = existingPull.ID 115 - pull.PullId = existingPull.PullId 116 - return updatePull(tx, pull, existingPull) 117 35 } 118 - } 119 36 120 - func createNewPull(tx *sql.Tx, pull *models.Pull) error { 121 - _, err := tx.Exec(` 122 - insert or ignore into repo_pull_seqs (repo_did, next_pull_id) 123 - values (?, 1) 124 - `, pull.RepoDid) 125 - if err != nil { 126 - return err 127 - } 128 - 129 - var nextId int 130 - err = tx.QueryRow(` 131 - update repo_pull_seqs 132 - set next_pull_id = next_pull_id + 1 133 - where repo_did = ? 134 - returning next_pull_id - 1 135 - `, pull.RepoDid).Scan(&nextId) 136 - if err != nil { 137 - return err 138 - } 139 - 140 - pull.PullId = nextId 141 - pull.State = models.PullOpen 142 - 143 - var sourceBranch, sourceRepoDid *string 144 - if pull.PullSource != nil { 145 - sourceBranch = &pull.PullSource.Branch 146 - if pull.PullSource.RepoDid != nil { 147 - x := string(*pull.PullSource.RepoDid) 148 - sourceRepoDid = &x 37 + if !exists { 38 + // assign new ID for a PR 39 + if err := tx.QueryRowContext(ctx, 40 + `update repo_pull_seqs 41 + set next_pull_id = next_pull_id + 1 42 + where repo_did = ? 43 + returning next_pull_id - 1`, 44 + pull.RepoDid, 45 + ).Scan(&pull.PullId); err != nil { 46 + return err 149 47 } 150 48 } 151 49 152 - result, err := tx.Exec( 153 - ` 154 - insert into pulls ( 155 - repo_did, 50 + result, err := tx.ExecContext(ctx, 51 + `insert into pulls ( 156 52 owner_did, 53 + rkey, 54 + cid, 55 + repo_did, 157 56 pull_id, 158 57 title, 159 - target_branch, 160 58 body, 161 - rkey, 162 - state, 163 - dependent_on, 59 + target_branch, 60 + source_repo_did, 164 61 source_branch, 165 - source_repo_did 62 + created, 63 + state 166 64 ) 167 - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 168 - pull.RepoDid, 65 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 66 + on conflict(at_uri) do update set 67 + cid = excluded.cid, 68 + repo_did = excluded.repo_did, 69 + title = excluded.title, 70 + body = excluded.body, 71 + target_branch = excluded.target_branch, 72 + source_repo_did = excluded.source_repo_did, 73 + source_branch = excluded.source_branch, 74 + created = excluded.created, 75 + state = excluded.state 76 + where pulls.cid is not excluded.cid`, 169 77 pull.OwnerDid, 78 + pull.Rkey, 79 + pull.Cid, 80 + pull.RepoDid, 170 81 pull.PullId, 171 82 pull.Title, 172 - pull.TargetBranch, 173 83 pull.Body, 174 - pull.Rkey, 84 + pull.TargetBranch, 85 + pull.SourceRepo, 86 + pull.SourceBranch, 87 + pull.Created.Format(time.RFC3339), 175 88 pull.State, 176 - pull.DependentOn, 177 - sourceBranch, 178 - sourceRepoDid, 179 89 ) 180 90 if err != nil { 181 - return err 91 + return fmt.Errorf("inserting pr: %w", err) 182 92 } 183 93 184 - // Set the database primary key ID 185 94 id, err := result.LastInsertId() 186 95 if err != nil { 187 96 return err 188 97 } 189 - pull.ID = int(id) 190 - 191 - for i, s := range pull.Submissions { 192 - _, err = tx.Exec(` 193 - insert into pull_submissions ( 194 - pull_at, 195 - round_number, 196 - patch, 197 - combined, 198 - source_rev, 199 - patch_blob_ref, 200 - patch_blob_mime, 201 - patch_blob_size 202 - ) 203 - values (?, ?, ?, ?, ?, ?, ?, ?) 204 - `, 205 - pull.AtUri(), 206 - i, 207 - s.Patch, 208 - s.Combined, 209 - s.SourceRev, 210 - s.Blob.Ref.String(), 211 - s.Blob.MimeType, 212 - s.Blob.Size, 213 - ) 214 - if err != nil { 215 - return err 98 + pull.ID = id 99 + 100 + // delete all existing versions 101 + if _, err := tx.ExecContext(ctx, 102 + `delete from pull_versions where pull_at = ?`, 103 + pull.AtUri(), 104 + ); err != nil { 105 + return fmt.Errorf("deleting old pr versions: %w", err) 106 + } 107 + 108 + // re-create all versions 109 + if len(pull.Versions) > 0 { 110 + pullAt := pull.AtUri() 111 + var sb strings.Builder 112 + sb.WriteString(`insert into pull_versions (pull_at, id, head, base, created) values `) 113 + args := make([]any, 0, len(pull.Versions)*5) 114 + for i, v := range pull.Versions { 115 + if i > 0 { 116 + sb.WriteString(", ") 117 + } 118 + sb.WriteString("(?, ?, ?, ?, ?)") 119 + args = append(args, pullAt, v.ID, v.Head, v.Base, v.Created.Format(time.RFC3339)) 120 + } 121 + if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil { 122 + return fmt.Errorf("inserting pr versions: %w", err) 216 123 } 217 124 } 218 125 219 - if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 126 + // update references when comment is updated 127 + if err := putReferences(tx, pull.AtUri(), references); err != nil { 220 128 return fmt.Errorf("put reference_links: %w", err) 221 129 } 222 130 223 131 return nil 224 132 } 225 133 226 - func updatePull(tx *sql.Tx, pull *models.Pull, existingPull *models.Pull) error { 227 - var sourceBranch, sourceRepoDid *string 228 - if pull.PullSource != nil { 229 - sourceBranch = &pull.PullSource.Branch 230 - if pull.PullSource.RepoDid != nil { 231 - x := string(*pull.PullSource.RepoDid) 232 - sourceRepoDid = &x 233 - } 234 - } 134 + func SubmitPullVersion(ctx context.Context, q Execer, pullAt syntax.ATURI, version models.PullVersion) error { 135 + _, err := q.ExecContext(ctx, 136 + `insert into pull_versions (pull_at, id, head, base, created) 137 + values (?, ?, ?, ?, ?)`, 138 + pullAt, 139 + version.ID, 140 + version.Head, 141 + version.Base, 142 + version.Created.Format(time.RFC3339), 143 + ) 144 + return err 145 + } 235 146 236 - _, err := tx.Exec(` 237 - update pulls set 238 - title = ?, 239 - body = ?, 240 - target_branch = ?, 241 - dependent_on = ?, 242 - source_branch = ?, 243 - source_repo_did = ? 244 - where owner_did = ? and rkey = ? 245 - `, pull.Title, pull.Body, pull.TargetBranch, pull.DependentOn, sourceBranch, sourceRepoDid, pull.OwnerDid, pull.Rkey) 147 + func GetPull(ctx context.Context, q Execer, filters ...orm.Filter) (*models.Pull, error) { 148 + pulls, err := GetPullsPaginated(ctx, q, pagination.Page{Limit: 1}, filters...) 246 149 if err != nil { 247 - return err 248 - } 249 - 250 - // insert new submissions (append-only) 251 - for i := len(existingPull.Submissions); i < len(pull.Submissions); i++ { 252 - s := pull.Submissions[i] 253 - _, err = tx.Exec(` 254 - insert into pull_submissions ( 255 - pull_at, 256 - round_number, 257 - patch, 258 - combined, 259 - source_rev, 260 - patch_blob_ref, 261 - patch_blob_mime, 262 - patch_blob_size 263 - ) 264 - values (?, ?, ?, ?, ?, ?, ?, ?) 265 - `, 266 - pull.AtUri(), 267 - i, 268 - s.Patch, 269 - s.Combined, 270 - s.SourceRev, 271 - s.Blob.Ref.String(), 272 - s.Blob.MimeType, 273 - s.Blob.Size, 274 - ) 275 - if err != nil { 276 - return err 277 - } 150 + return nil, err 278 151 } 279 - 280 - if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 281 - return fmt.Errorf("put reference_links: %w", err) 152 + if len(pulls) == 0 { 153 + return nil, sql.ErrNoRows 282 154 } 283 - return nil 284 - } 285 - 286 - func NextPullId(e Execer, repoDid string) (int, error) { 287 - var pullId int 288 - err := e.QueryRow(`select next_pull_id from repo_pull_seqs where repo_did = ?`, repoDid).Scan(&pullId) 289 - return pullId - 1, err 155 + return pulls[0], nil 290 156 } 291 157 292 - func GetPullsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Pull, error) { 158 + func GetPullsPaginated(ctx context.Context, q Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Pull, error) { 293 159 pulls := make(map[syntax.ATURI]*models.Pull) 294 160 295 161 var conditions []string ··· 316 182 select 317 183 id, 318 184 owner_did, 185 + rkey, 186 + cid, 319 187 repo_did, 320 188 pull_id, 321 - created, 322 189 title, 323 - state, 324 - target_branch, 325 190 body, 326 - rkey, 327 - source_branch, 191 + target_branch, 328 192 source_repo_did, 329 - dependent_on 193 + source_branch, 194 + created, 195 + state 330 196 from 331 197 pulls 332 198 %s ··· 335 201 %s 336 202 `, whereClause, pageClause) 337 203 338 - rows, err := e.Query(query, args...) 204 + rows, err := q.QueryContext(ctx, query, args...) 339 205 if err != nil { 340 206 return nil, err 341 207 } ··· 344 210 for rows.Next() { 345 211 var pull models.Pull 346 212 var createdAt string 347 - var sourceBranch, sourceRepoDid, dependentOn sql.NullString 213 + var sourceRepo, sourceBranch sql.NullString 348 214 err := rows.Scan( 349 215 &pull.ID, 350 216 &pull.OwnerDid, 217 + &pull.Rkey, 218 + &pull.Cid, 351 219 &pull.RepoDid, 352 220 &pull.PullId, 353 - &createdAt, 354 221 &pull.Title, 355 - &pull.State, 356 - &pull.TargetBranch, 357 222 &pull.Body, 358 - &pull.Rkey, 223 + &pull.TargetBranch, 224 + &sourceRepo, 359 225 &sourceBranch, 360 - &sourceRepoDid, 361 - &dependentOn, 226 + &createdAt, 227 + &pull.State, 362 228 ) 363 229 if err != nil { 364 - return nil, err 230 + return nil, fmt.Errorf("scanning row: %w", err) 365 231 } 366 232 367 233 createdTime, err := time.Parse(time.RFC3339, createdAt) 368 234 if err != nil { 369 - return nil, err 235 + return nil, fmt.Errorf("parsing created: %w", err) 370 236 } 371 237 pull.Created = createdTime 372 238 373 - if sourceBranch.Valid { 374 - pull.PullSource = &models.PullSource{ 375 - Branch: sourceBranch.String, 376 - } 377 - if sourceRepoDid.Valid { 378 - sourceRepoDidParsed, err := syntax.ParseDID(sourceRepoDid.String) 379 - if err != nil { 380 - return nil, err 381 - } 382 - pull.PullSource.RepoDid = &sourceRepoDidParsed 383 - } 239 + if sourceRepo.Valid { 240 + pull.SourceRepo = syntax.DID(sourceRepo.String) 241 + } else { 242 + // fallback to pull.target.repo 243 + pull.SourceRepo = pull.RepoDid 384 244 } 385 245 386 - if dependentOn.Valid { 387 - x := syntax.ATURI(dependentOn.String) 388 - pull.DependentOn = &x 246 + if sourceBranch.Valid { 247 + pull.SourceBranch = &sourceBranch.String 389 248 } 390 249 391 250 pulls[pull.AtUri()] = &pull 392 251 } 393 - 394 - var pullAts []syntax.ATURI 395 - for _, p := range pulls { 396 - pullAts = append(pullAts, p.AtUri()) 397 - } 398 - submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts)) 399 - if err != nil { 400 - return nil, fmt.Errorf("failed to get submissions: %w", err) 252 + if err := rows.Err(); err != nil { 253 + return nil, fmt.Errorf("scanning rows: %w", err) 401 254 } 402 255 403 - for pullAt, submissions := range submissionsMap { 404 - if p, ok := pulls[pullAt]; ok { 405 - p.Submissions = submissions 406 - } 407 - } 256 + pullAts := slices.Collect(maps.Keys(pulls)) 408 257 409 - // collect allLabels for each issue 410 - allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts)) 258 + versionsMap, err := ListVersions(ctx, q, pullAts) 411 259 if err != nil { 412 - return nil, fmt.Errorf("failed to query labels: %w", err) 413 - } 414 - for pullAt, labels := range allLabels { 415 - if p, ok := pulls[pullAt]; ok { 416 - p.Labels = labels 417 - } 260 + return nil, fmt.Errorf("querying versions: %w", err) 418 261 } 419 262 420 - // build up reverse mappings: p.Repo and p.PullSource.Repo 421 - var repoDids []syntax.DID 422 - for _, p := range pulls { 423 - repoDids = append(repoDids, p.RepoDid) 424 - if p.PullSource != nil && p.PullSource.RepoDid != nil { 425 - repoDids = append(repoDids, *p.PullSource.RepoDid) 263 + for pullAt, p := range pulls { 264 + if versions, ok := versionsMap[pullAt]; ok { 265 + p.Versions = versions 266 + } else { 267 + return nil, fmt.Errorf("find 0 versions for PR %s", pullAt) 426 268 } 427 269 } 428 270 429 - repos, err := GetRepos(e, orm.FilterIn("repo_did", repoDids)) 430 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 431 - return nil, fmt.Errorf("failed to get repos: %w", err) 432 - } 433 - 434 - repoMap := make(map[syntax.DID]*models.Repo) 435 - for _, r := range repos { 436 - repoMap[syntax.DID(r.RepoDid)] = &r 437 - } 271 + // collect reverse repos 272 + { 273 + repoDids := make([]string, 0, len(pulls)) 274 + for _, issue := range pulls { 275 + repoDids = append(repoDids, string(issue.RepoDid)) 276 + } 438 277 439 - for _, p := range pulls { 440 - if repo, ok := repoMap[p.RepoDid]; ok { 441 - p.Repo = repo 278 + repos, err := GetRepos(q, orm.FilterIn("repo_did", repoDids)) 279 + if err != nil { 280 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 281 + } 282 + repoMap := make(map[syntax.DID]*models.Repo) 283 + for i := range repos { 284 + repoMap[syntax.DID(repos[i].RepoDid)] = &repos[i] 442 285 } 443 - if p.PullSource != nil && p.PullSource.RepoDid != nil { 444 - if sourceRepo, ok := repoMap[*p.PullSource.RepoDid]; ok { 445 - p.PullSource.Repo = sourceRepo 286 + 287 + for pullAt, p := range pulls { 288 + if r, ok := repoMap[p.RepoDid]; ok { 289 + p.Repo = r 290 + } else { 291 + delete(pulls, pullAt) 446 292 } 447 293 } 448 294 } 449 295 450 - allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", pullAts)) 451 - if err != nil { 452 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 453 - } 454 - for pullAt, references := range allReferences { 455 - if pull, ok := pulls[pullAt]; ok { 456 - pull.References = references 296 + // collect allLabels for each PR 297 + { 298 + allLabels, err := GetLabels(q, orm.FilterIn("subject", pullAts)) 299 + if err != nil { 300 + return nil, fmt.Errorf("failed to query labels: %w", err) 301 + } 302 + for pullAt, labels := range allLabels { 303 + if pull, ok := pulls[pullAt]; ok { 304 + pull.Labels = labels 305 + } 457 306 } 458 307 } 459 308 460 - orderedByPullId := []*models.Pull{} 309 + orderedById := []*models.Pull{} 461 310 for _, p := range pulls { 462 - orderedByPullId = append(orderedByPullId, p) 311 + orderedById = append(orderedById, p) 463 312 } 464 - sort.Slice(orderedByPullId, func(i, j int) bool { 465 - return orderedByPullId[i].PullId > orderedByPullId[j].PullId 313 + sort.Slice(orderedById, func(i, j int) bool { 314 + return orderedById[i].PullId > orderedById[j].PullId 466 315 }) 467 316 468 - return orderedByPullId, nil 469 - } 470 - 471 - func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) { 472 - return GetPullsPaginated(e, pagination.Page{}, filters...) 473 - } 474 - 475 - func GetPull(e Execer, filters ...orm.Filter) (*models.Pull, error) { 476 - pulls, err := GetPullsPaginated(e, pagination.Page{Limit: 1}, filters...) 477 - if err != nil { 478 - return nil, err 479 - } 480 - if len(pulls) == 0 { 481 - return nil, sql.ErrNoRows 482 - } 483 - 484 - return pulls[0], nil 317 + return orderedById, nil 485 318 } 486 319 487 320 // mapping from pull -> pull submissions 488 - func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 489 - var conditions []string 490 - var args []any 491 - for _, filter := range filters { 492 - conditions = append(conditions, filter.Condition()) 493 - args = append(args, filter.Arg()...) 494 - } 495 - 496 - whereClause := "" 497 - if conditions != nil { 498 - whereClause = " where " + strings.Join(conditions, " and ") 499 - } 321 + func ListVersions(ctx context.Context, q Execer, pullAts []syntax.ATURI) (map[syntax.ATURI][]models.PullVersion, error) { 322 + filter := orm.FilterIn("pull_at", pullAts) 500 323 501 324 query := fmt.Sprintf(` 502 325 select 503 - id, 504 326 pull_at, 505 - round_number, 506 - patch, 507 - combined, 508 - created, 509 - source_rev, 510 - patch_blob_ref, 511 - patch_blob_mime, 512 - patch_blob_size 513 - from 514 - pull_submissions 515 - %s 516 - order by 517 - round_number asc 518 - `, whereClause) 519 - 520 - rows, err := e.Query(query, args...) 327 + id, 328 + head, 329 + base, 330 + created 331 + from pull_versions 332 + where %s 333 + order by id asc 334 + `, filter.Condition()) 335 + 336 + rows, err := q.QueryContext(ctx, query, filter.Arg()...) 521 337 if err != nil { 522 - return nil, err 338 + return nil, fmt.Errorf("failed to query: %w", err) 523 339 } 524 340 defer rows.Close() 525 341 526 - pullMap := make(map[syntax.ATURI][]*models.PullSubmission) 342 + versionsMap := make(map[syntax.ATURI][]models.PullVersion) 527 343 528 344 for rows.Next() { 529 - var submission models.PullSubmission 530 - var submissionCreatedStr string 531 - var submissionSourceRev, submissionCombined sql.Null[string] 532 - var patchBlobRef, patchBlobMime sql.Null[string] 533 - var patchBlobSize sql.Null[int64] 345 + var version models.PullVersion 346 + var pullAt syntax.ATURI 347 + var createdAt string 534 348 err := rows.Scan( 535 - &submission.ID, 536 - &submission.PullAt, 537 - &submission.RoundNumber, 538 - &submission.Patch, 539 - &submissionCombined, 540 - &submissionCreatedStr, 541 - &submissionSourceRev, 542 - &patchBlobRef, 543 - &patchBlobMime, 544 - &patchBlobSize, 349 + &pullAt, 350 + &version.ID, 351 + &version.Head, 352 + &version.Base, 353 + &createdAt, 545 354 ) 546 355 if err != nil { 547 - return nil, err 548 - } 549 - 550 - if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 551 - submission.Created = t 552 - } 553 - 554 - if submissionSourceRev.Valid { 555 - submission.SourceRev = submissionSourceRev.V 556 - } 557 - 558 - if submissionCombined.Valid { 559 - submission.Combined = submissionCombined.V 356 + return nil, fmt.Errorf("scanning row: %w", err) 560 357 } 561 358 562 - if patchBlobRef.Valid { 563 - submission.Blob.Ref = lexutil.LexLink(cid.MustParse(patchBlobRef.V)) 564 - } 565 - 566 - if patchBlobMime.Valid { 567 - submission.Blob.MimeType = patchBlobMime.V 568 - } 569 - 570 - if patchBlobSize.Valid { 571 - submission.Blob.Size = patchBlobSize.V 359 + createdTime, err := time.Parse(time.RFC3339, createdAt) 360 + if err != nil { 361 + return nil, fmt.Errorf("parsing created: %w", err) 572 362 } 363 + version.Created = createdTime 573 364 574 - pullMap[submission.PullAt] = append(pullMap[submission.PullAt], &submission) 365 + versionsMap[pullAt] = append(versionsMap[pullAt], version) 575 366 } 576 - 577 367 if err := rows.Err(); err != nil { 578 - return nil, err 368 + return nil, fmt.Errorf("scanning rows: %w", err) 579 369 } 580 370 581 - // Get comments for all submissions using GetComments 582 - pullAts := slices.Collect(maps.Keys(pullMap)) 583 - comments, err := GetComments(e, orm.FilterIn("subject_uri", pullAts)) 371 + comments, err := GetComments(q, orm.FilterIn("subject_uri", pullAts)) 584 372 if err != nil { 585 373 return nil, fmt.Errorf("failed to get pull comments: %w", err) 586 374 } 587 375 for _, comment := range comments { 588 - if comment.PullRoundIdx != nil { 589 - roundIdx := *comment.PullRoundIdx 590 - if submissions, ok := pullMap[syntax.ATURI(comment.Subject.Uri)]; ok { 591 - if roundIdx < len(submissions) { 592 - submission := submissions[roundIdx] 593 - submission.Comments = append(submission.Comments, comment) 594 - } 376 + if comment.PullRoundIdx == nil { 377 + continue 378 + } 379 + versionIdx := *comment.PullRoundIdx 380 + if versions, ok := versionsMap[syntax.ATURI(comment.Subject.Uri)]; ok { 381 + if versionIdx >= len(versions) { 382 + continue 595 383 } 384 + versions[versionIdx].Comments = append(versions[versionIdx].Comments, comment) 596 385 } 597 386 } 598 387 599 - // sort each one by round number 600 - for _, s := range pullMap { 601 - slices.SortFunc(s, func(a, b *models.PullSubmission) int { 602 - return cmp.Compare(a.RoundNumber, b.RoundNumber) 603 - }) 604 - } 388 + // TODO: reverse-map version.Comments 605 389 606 - return pullMap, nil 390 + return versionsMap, nil 607 391 } 608 392 609 393 // timeframe here is directly passed into the sql query filter, and any 610 394 // timeframe in the past should be negative; e.g.: "-3 months" 611 - func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { 395 + func GetPullsByOwnerDid(e Execer, did syntax.DID, timeframe string) ([]models.Pull, error) { 612 396 var pulls []models.Pull 613 397 614 398 rows, err := e.Query(` ··· 683 467 } 684 468 685 469 // use with transaction 686 - func SetPullsState(e Execer, pullState models.PullState, filters ...orm.Filter) error { 470 + func setPullsState(e Execer, pullState models.PullState, filters ...orm.Filter) error { 687 471 var conditions []string 688 472 var args []any 689 473 ··· 707 491 } 708 492 709 493 func ClosePulls(e Execer, filters ...orm.Filter) error { 710 - return SetPullsState(e, models.PullClosed, filters...) 494 + return setPullsState(e, models.PullClosed, filters...) 711 495 } 712 496 713 497 func ReopenPulls(e Execer, filters ...orm.Filter) error { 714 - return SetPullsState(e, models.PullOpen, filters...) 498 + return setPullsState(e, models.PullOpen, filters...) 715 499 } 716 500 717 501 func MergePulls(e Execer, filters ...orm.Filter) error { 718 - return SetPullsState(e, models.PullMerged, filters...) 502 + return setPullsState(e, models.PullMerged, filters...) 719 503 } 720 504 721 505 func AbandonPulls(e Execer, filters ...orm.Filter) error { 722 - return SetPullsState(e, models.PullAbandoned, filters...) 723 - } 724 - 725 - func ResubmitPull( 726 - e Execer, 727 - pullAt syntax.ATURI, 728 - newRoundNumber int, 729 - newPatch string, 730 - combinedPatch string, 731 - newSourceRev string, 732 - blob *lexutil.LexBlob, 733 - ) error { 734 - _, err := e.Exec(` 735 - insert into pull_submissions ( 736 - pull_at, 737 - round_number, 738 - patch, 739 - combined, 740 - source_rev, 741 - patch_blob_ref, 742 - patch_blob_mime, 743 - patch_blob_size 744 - ) 745 - values (?, ?, ?, ?, ?, ?, ?, ?) 746 - `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Ref.String(), blob.MimeType, blob.Size) 747 - 748 - return err 749 - } 750 - 751 - func SetDependentOn(e Execer, dependentOn syntax.ATURI, filters ...orm.Filter) error { 752 - var conditions []string 753 - var args []any 754 - 755 - args = append(args, dependentOn) 756 - 757 - for _, filter := range filters { 758 - conditions = append(conditions, filter.Condition()) 759 - args = append(args, filter.Arg()...) 760 - } 761 - 762 - whereClause := "" 763 - if conditions != nil { 764 - whereClause = " where " + strings.Join(conditions, " and ") 765 - } 766 - 767 - query := fmt.Sprintf("update pulls set dependent_on = ? %s", whereClause) 768 - _, err := e.Exec(query, args...) 769 - 770 - return err 506 + return setPullsState(e, models.PullAbandoned, filters...) 771 507 } 772 508 773 509 func GetPullCount(e Execer, repoDid string) (models.PullCount, error) { ··· 793 529 794 530 return count, nil 795 531 } 796 - 797 - // change-id dependent_on 798 - // 799 - // 4 w ,-------- at_uri(z) (TOP) 800 - // 3 z <----',------- at_uri(y) 801 - // 2 y <-----',------ at_uri(x) 802 - // 1 x <------' nil (BOT) 803 - // 804 - // `w` has no dependents, so it is the top of the stack 805 - // 806 - // this unfortunately does a db query for *each* pull of the stack, 807 - // ideally this would be a recursive query, but in the interest of implementation simplicity, 808 - // we took the less performant route 809 - // 810 - // TODO: make this less bad 811 - func GetStack(e Execer, atUri syntax.ATURI) (models.Stack, error) { 812 - // first get the pull for the given at-uri 813 - pull, err := GetPull(e, orm.FilterEq("at_uri", atUri)) 814 - if err != nil { 815 - return nil, err 816 - } 817 - 818 - // Collect all pulls in the stack by traversing up and down 819 - allPulls := []*models.Pull{pull} 820 - visited := sets.New[syntax.ATURI]() 821 - 822 - // Traverse up to find all dependents 823 - current := pull 824 - for { 825 - dependent, err := GetPull(e, 826 - orm.FilterEq("dependent_on", current.AtUri()), 827 - orm.FilterNotEq("state", models.PullAbandoned), 828 - ) 829 - if err != nil || dependent == nil { 830 - break 831 - } 832 - if visited.Contains(dependent.AtUri()) { 833 - return allPulls, fmt.Errorf("circular dependency detected in stack") 834 - } 835 - allPulls = append(allPulls, dependent) 836 - visited.Insert(dependent.AtUri()) 837 - current = dependent 838 - } 839 - 840 - // Traverse down to find all dependencies 841 - current = pull 842 - for current.DependentOn != nil { 843 - dependency, err := GetPull( 844 - e, 845 - orm.FilterEq("at_uri", current.DependentOn), 846 - orm.FilterNotEq("state", models.PullAbandoned), 847 - ) 848 - 849 - if err != nil { 850 - return allPulls, fmt.Errorf("failed to find parent pull request, stack is malformed, missing PR: %s", current.DependentOn) 851 - } 852 - if visited.Contains(dependency.AtUri()) { 853 - return allPulls, fmt.Errorf("circular dependency detected in stack") 854 - } 855 - allPulls = append(allPulls, dependency) 856 - visited.Insert(dependency.AtUri()) 857 - current = dependency 858 - } 859 - 860 - // sort the list: find the top and build ordered list 861 - atUriMap := make(map[syntax.ATURI]*models.Pull, len(allPulls)) 862 - dependentMap := make(map[syntax.ATURI]*models.Pull, len(allPulls)) 863 - 864 - for _, p := range allPulls { 865 - atUriMap[p.AtUri()] = p 866 - if p.DependentOn != nil { 867 - dependentMap[*p.DependentOn] = p 868 - } 869 - } 870 - 871 - // the top of the stack is the pull that no other pull depends on 872 - var topPull *models.Pull 873 - for _, maybeTop := range allPulls { 874 - if _, ok := dependentMap[maybeTop.AtUri()]; !ok { 875 - topPull = maybeTop 876 - break 877 - } 878 - } 879 - 880 - pulls := []*models.Pull{} 881 - for { 882 - pulls = append(pulls, topPull) 883 - if topPull.DependentOn != nil { 884 - if next, ok := atUriMap[*topPull.DependentOn]; ok { 885 - topPull = next 886 - } else { 887 - return pulls, fmt.Errorf("failed to find parent pull request, stack is malformed") 888 - } 889 - } else { 890 - break 891 - } 892 - } 893 - 894 - return pulls, nil 895 - } 896 - 897 - func GetAbandonedPulls(e Execer, atUri syntax.ATURI) ([]*models.Pull, error) { 898 - stack, err := GetStack(e, atUri) 899 - if err != nil { 900 - return nil, err 901 - } 902 - 903 - var abandoned []*models.Pull 904 - for _, p := range stack { 905 - if p.State == models.PullAbandoned { 906 - abandoned = append(abandoned, p) 907 - } 908 - } 909 - 910 - return abandoned, nil 911 - }
+15 -14
appview/indexer/pulls/indexer.go
··· 20 20 "tangled.org/core/appview/indexer/base36" 21 21 bleveutil "tangled.org/core/appview/indexer/bleve" 22 22 "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/pagination" 23 24 tlog "tangled.org/core/log" 24 25 ) 25 26 ··· 164 165 165 166 func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { 166 167 l := tlog.FromContext(ctx) 167 - 168 - pulls, err := db.GetPulls(e) 169 - if err != nil { 170 - return err 171 - } 172 - count := len(pulls) 173 - err = ix.Index(ctx, pulls...) 174 - if err != nil { 175 - return err 176 - } 168 + count := 0 169 + err := pagination.IterateAll( 170 + func(page pagination.Page) ([]*models.Pull, error) { 171 + return db.GetPullsPaginated(ctx, e, page) 172 + }, 173 + func(pulls []*models.Pull) error { 174 + count += len(pulls) 175 + return ix.Index(ctx, pulls...) 176 + }, 177 + ) 177 178 l.Info("pulls indexed", "count", count) 178 179 return err 179 180 } ··· 194 195 195 196 func makePullData(pull *models.Pull) *pullData { 196 197 return &pullData{ 197 - ID: int64(pull.ID), 198 - RepoDid: string(pull.RepoDid), 199 - PullID: pull.PullId, 198 + ID: pull.ID, 199 + RepoDid: pull.RepoDid.String(), 200 + PullID: int(pull.PullId), 200 201 Title: pull.Title, 201 202 Body: pull.Body, 202 203 State: pull.State.String(), 203 - AuthorDid: pull.OwnerDid, 204 + AuthorDid: pull.OwnerDid.String(), 204 205 Labels: pull.Labels.LabelNames(), 205 206 LabelValues: pull.Labels.LabelNameValues(), 206 207 }
+89 -80
appview/ingester.go
··· 1 1 package appview 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 "database/sql" 7 6 "encoding/json" 8 7 "errors" 9 8 "fmt" 10 - "io" 11 9 "log/slog" 12 10 "net/http" 13 11 "net/url" 14 12 "slices" 15 13 "strings" 14 + "sync" 16 15 17 16 "time" 18 17 ··· 1449 1448 } 1450 1449 1451 1450 func (i *Ingester) ingestPull(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { 1452 - did := e.Did 1453 - rkey := e.Commit.RKey 1451 + did := syntax.DID(e.Did) 1452 + rkey := syntax.RecordKey(e.Commit.RKey) 1453 + cid := syntax.CID(e.Commit.CID) 1454 1454 1455 1455 var err error 1456 1456 ··· 1466 1466 return err 1467 1467 } 1468 1468 1469 - ownerId, err := i.IdResolver.ResolveIdent(ctx, did) 1470 - if err != nil { 1471 - l.Error("failed to resolve did", "err", err) 1472 - return err 1473 - } 1474 - 1475 - // go through and fetch all blobs in parallel 1476 - blobs := make([]io.Reader, len(record.Rounds)) 1477 - 1478 - g, gctx := errgroup.WithContext(ctx) 1479 - 1480 - for idx, b := range record.Rounds { 1481 - g.Go(func() error { 1482 - // for some reason, a blob is empty 1483 - if b.PatchBlob == nil { 1484 - return fmt.Errorf("missing patchBlob in round %d", idx) 1469 + versions, err := func() ([]models.PullVersion, error) { 1470 + if len(record.Versions) > 0 { 1471 + versions := make([]models.PullVersion, len(record.Versions)) 1472 + var err error 1473 + for i, v := range record.Versions { 1474 + versions[i], err = models.PullVersionFromRecord(i, v) 1475 + if err != nil { 1476 + return nil, fmt.Errorf("versions[%d]: %w", i, err) 1477 + } 1485 1478 } 1479 + return versions, nil 1480 + } 1486 1481 1487 - ownerPds := ownerId.PDSEndpoint() 1488 - url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 1489 - q := url.Query() 1490 - q.Set("cid", b.PatchBlob.Ref.String()) 1491 - q.Set("did", did) 1492 - url.RawQuery = q.Encode() 1493 - 1494 - req, err := http.NewRequestWithContext(gctx, http.MethodGet, url.String(), nil) 1495 - if err != nil { 1496 - l.Error("failed to create request") 1497 - return err 1498 - } 1499 - req.Header.Set("Content-Type", "application/json") 1482 + if len(record.Rounds) == 0 { 1483 + return nil, nil 1484 + } 1500 1485 1501 - resp, err := http.DefaultClient.Do(req) 1502 - if err != nil { 1503 - l.Error("failed to make request") 1504 - return err 1505 - } 1506 - defer resp.Body.Close() 1486 + ownerId, err := i.IdResolver.Directory().LookupDID(ctx, did) 1487 + if err != nil { 1488 + return nil, fmt.Errorf("failed to resolve did: %w", err) 1489 + } 1507 1490 1508 - var buf bytes.Buffer 1509 - if _, err := io.Copy(&buf, io.LimitReader(resp.Body, 16<<20)); err != nil { 1510 - return fmt.Errorf("failed to read blob in round %d: %w", idx, err) 1511 - } 1512 - blobs[idx] = &buf 1491 + // go through and fetch all blobs in parallel 1492 + versions := make([]models.PullVersion, len(record.Rounds)) 1493 + var mu sync.Mutex 1494 + 1495 + g, gctx := errgroup.WithContext(ctx) 1496 + 1497 + for idx, b := range record.Rounds { 1498 + g.Go(func() error { 1499 + // for some reason, a blob is empty 1500 + if b.PatchBlob == nil { 1501 + return fmt.Errorf("missing patchBlob in round %d", idx) 1502 + } 1503 + 1504 + ownerPds := ownerId.PDSEndpoint() 1505 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 1506 + q := url.Query() 1507 + q.Set("cid", b.PatchBlob.Ref.String()) 1508 + q.Set("did", did.String()) 1509 + url.RawQuery = q.Encode() 1510 + 1511 + req, err := http.NewRequestWithContext(gctx, http.MethodGet, url.String(), nil) 1512 + if err != nil { 1513 + return fmt.Errorf("versions[%d]: failed to create request: %w", idx, err) 1514 + } 1515 + req.Header.Set("Content-Type", "application/json") 1516 + 1517 + resp, err := http.DefaultClient.Do(req) 1518 + if err != nil { 1519 + return fmt.Errorf("versions[%d]: failed to make request: %w", idx, err) 1520 + } 1521 + defer resp.Body.Close() 1522 + 1523 + version, err := models.PullVersionFromLegacy(idx, b, resp.Body) 1524 + if err != nil { 1525 + return fmt.Errorf("versions[%d]: %w", idx, err) 1526 + } 1527 + 1528 + mu.Lock() 1529 + versions[idx] = version 1530 + mu.Unlock() 1513 1531 1514 - return nil 1515 - }) 1516 - } 1532 + return nil 1533 + }) 1534 + } 1517 1535 1518 - if err := g.Wait(); err != nil { 1519 - return err 1536 + if err := g.Wait(); err != nil { 1537 + return nil, err 1538 + } 1539 + 1540 + return versions, nil 1541 + }() 1542 + if err != nil { 1543 + return fmt.Errorf("failed pares pull versions: %w", err) 1520 1544 } 1521 1545 1522 - pull, err := models.PullFromRecord(did, rkey, record, blobs) 1546 + pull, err := models.PullFromRecord(did, rkey, cid, record, versions) 1523 1547 if err != nil { 1524 1548 return fmt.Errorf("failed to parse pull from record: %w", err) 1525 1549 } 1526 1550 if err := pull.Validate(); err != nil { 1527 1551 return fmt.Errorf("failed to validate pull: %w", err) 1528 1552 } 1529 - if pull.DependentOn != nil { 1530 - if err := func() error { 1531 - dependentPull, err := db.GetPull( 1532 - i.Db, 1533 - orm.FilterEq("dependent_on", pull.DependentOn.String()), 1534 - ) 1535 - if errors.Is(err, sql.ErrNoRows) { 1536 - return nil 1537 - } 1538 - if err != nil { 1539 - return fmt.Errorf("failed to fetch pulls with same dependency: %w", err) 1540 - } 1541 - if dependentPull.AtUri() == pull.AtUri() { 1542 - return nil 1543 - } 1544 - return fmt.Errorf("another pull already depends on %s, which would form a DAG, this is presently disallowed", pull.DependentOn.String()) 1545 - }(); err != nil { 1546 - return fmt.Errorf("failed to validate pull stack: %w", err) 1547 - } 1553 + 1554 + var references []syntax.ATURI 1555 + if pull.Body != "" { 1556 + _, references = i.MentionsResolver.Resolve(ctx, pull.Body) 1548 1557 } 1549 1558 1550 1559 tx, err := i.Db.BeginTx(ctx, nil) ··· 1554 1563 } 1555 1564 defer tx.Rollback() 1556 1565 1557 - err = db.PutPull(tx, pull) 1566 + err = db.PutPull(ctx, tx, pull, references) 1558 1567 if err != nil { 1559 1568 l.Error("failed to create pull", "err", err) 1560 1569 return err ··· 1626 1635 type stateIngestSpec struct { 1627 1636 subjectNSID string 1628 1637 parse func(did, rkey string, raw json.RawMessage) (models.StateRecord, error) 1629 - findSubject func(e db.Execer, subject syntax.ATURI) (repo *models.Repo, authorDid string, found bool, err error) 1638 + findSubject func(ctx context.Context, e db.Execer, subject syntax.ATURI) (repo *models.Repo, authorDid string, found bool, err error) 1630 1639 put func(tx *sql.Tx, rec models.StateRecord) (syntax.ATURI, error) 1631 1640 resolve func(tx *sql.Tx, subject syntax.ATURI) error 1632 1641 recompute func(tx *sql.Tx, subject syntax.ATURI) error ··· 1642 1651 } 1643 1652 return models.IssueStateFromRecord(did, rkey, record) 1644 1653 }, 1645 - findSubject: func(e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1654 + findSubject: func(ctx context.Context, e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1646 1655 issues, err := db.GetIssues(e, orm.FilterEq("at_uri", subject)) 1647 1656 if err != nil { 1648 1657 return nil, "", false, err ··· 1667 1676 } 1668 1677 return models.PullStatusFromRecord(did, rkey, record) 1669 1678 }, 1670 - findSubject: func(e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1671 - pulls, err := db.GetPulls(e, orm.FilterEq("at_uri", subject)) 1679 + findSubject: func(ctx context.Context, e db.Execer, subject syntax.ATURI) (*models.Repo, string, bool, error) { 1680 + pull, err := db.GetPull(ctx, e, orm.FilterEq("at_uri", subject)) 1672 1681 if err != nil { 1673 1682 return nil, "", false, err 1674 1683 } 1675 - if len(pulls) != 1 || pulls[0].Repo == nil { 1684 + if pull.Repo == nil { 1676 1685 return nil, "", false, nil 1677 1686 } 1678 - return pulls[0].Repo, pulls[0].OwnerDid, true, nil 1687 + return pull.Repo, pull.OwnerDid.String(), true, nil 1679 1688 }, 1680 1689 put: db.PutPullStatus, 1681 1690 resolve: db.ResolvePullStatus, ··· 1709 1718 return fmt.Errorf("state subject is not %s: %s", spec.subjectNSID, rec.Subject) 1710 1719 } 1711 1720 1712 - repo, authorDid, found, err := spec.findSubject(i.Db, rec.Subject) 1721 + repo, authorDid, found, err := spec.findSubject(ctx, i.Db, rec.Subject) 1713 1722 if err != nil { 1714 1723 return fmt.Errorf("failed to look up state subject: %w", err) 1715 1724 } ··· 2123 2132 return nil 2124 2133 } 2125 2134 2126 - func (i *Ingester) findLabelSubjectRepo(subject syntax.ATURI) (*models.Repo, bool, error) { 2135 + func (i *Ingester) findLabelSubjectRepo(ctx context.Context, subject syntax.ATURI) (*models.Repo, bool, error) { 2127 2136 var spec stateIngestSpec 2128 2137 switch subject.Collection() { 2129 2138 case tangled.RepoIssueNSID: ··· 2133 2142 default: 2134 2143 return nil, false, fmt.Errorf("unsupported label subject: %s", subject.Collection()) 2135 2144 } 2136 - repo, _, found, err := spec.findSubject(i.Db, subject) 2145 + repo, _, found, err := spec.findSubject(ctx, i.Db, subject) 2137 2146 return repo, found, err 2138 2147 } 2139 2148 ··· 2148 2157 return i.parkStateRecord(ctx, did, rkey, tangled.LabelOpNSID, subject, raw, l) 2149 2158 } 2150 2159 2151 - repo, found, err := i.findLabelSubjectRepo(subject) 2160 + repo, found, err := i.findLabelSubjectRepo(ctx, subject) 2152 2161 if err != nil { 2153 2162 return err 2154 2163 }
+3 -3
appview/labels/labels.go
··· 282 282 } 283 283 } 284 284 if subject.Collection() == tangled.RepoPullNSID { 285 - pulls, err := db.GetPulls(l.db, orm.FilterEq("at_uri", subjectUri)) 286 - if err == nil && len(pulls) == 1 { 287 - l.notifier.NewPullLabelOp(r.Context(), syntax.DID(did), pulls[0], validLabelOps) 285 + pull, err := db.GetPull(r.Context(), l.db, orm.FilterEq("at_uri", subjectUri)) 286 + if err == nil { 287 + l.notifier.NewPullLabelOp(r.Context(), syntax.DID(did), pull, validLabelOps) 288 288 } 289 289 } 290 290
-44
appview/middleware/middleware.go
··· 368 368 } 369 369 } 370 370 371 - // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 372 - func (mw Middleware) ResolvePull() middlewareFunc { 373 - return func(next http.Handler) http.Handler { 374 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 375 - l := mw.logger.With("middleware", "ResolvePull") 376 - f, err := mw.repoResolver.Resolve(r) 377 - if err != nil { 378 - l.Error("failed to fully resolve repo", "err", err) 379 - w.WriteHeader(http.StatusNotFound) 380 - mw.pages.ErrorKnot404(w) 381 - return 382 - } 383 - 384 - prId := chi.URLParam(r, "pull") 385 - prIdInt, err := strconv.Atoi(prId) 386 - if err != nil { 387 - l.Error("failed to parse pr id", "err", err) 388 - mw.pages.Error404(w) 389 - return 390 - } 391 - 392 - pr, err := db.GetPull(mw.db, orm.FilterEq("repo_did", f.RepoDid), orm.FilterEq("pull_id", prIdInt)) 393 - if err != nil { 394 - l.Error("failed to get pull and comments", "err", err) 395 - mw.pages.Error404(w) 396 - return 397 - } 398 - 399 - ctx := context.WithValue(r.Context(), "pull", pr) 400 - 401 - stack, err := db.GetStack(mw.db, pr.AtUri()) 402 - if err != nil { 403 - l.Error("failed to get stack", "err", err) 404 - mw.pages.Error404(w) 405 - return 406 - } 407 - 408 - ctx = context.WithValue(ctx, "stack", stack) 409 - 410 - next.ServeHTTP(w, r.WithContext(ctx)) 411 - }) 412 - } 413 - } 414 - 415 371 // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 416 372 func (mw Middleware) ResolveIssue(next http.Handler) http.Handler { 417 373 l := mw.logger.With("middleware", "ResolveIssue")
+3 -3
appview/migration/backfill_entity_state_test.go
··· 214 214 } 215 215 pull := &models.Pull{ 216 216 RepoDid: syntax.DID(repoDid), 217 - OwnerDid: author, 218 - Rkey: rkey, 217 + OwnerDid: syntax.DID(author), 218 + Rkey: syntax.RecordKey(rkey), 219 219 Title: "title", 220 220 Body: "body", 221 221 TargetBranch: "main", 222 222 State: models.PullOpen, 223 223 } 224 - if err := db.PutPull(tx, pull); err != nil { 224 + if err := db.PutPull(t.Context(), tx, pull, nil); err != nil { 225 225 t.Fatalf("PutPull: %v", err) 226 226 } 227 227 if err := tx.Commit(); err != nil {
+19
appview/models/entity_state.go
··· 90 90 }, nil 91 91 } 92 92 93 + func AsPullStatusRecord(subject syntax.ATURI, value StateValue, createdAt time.Time) (tangled.RepoPullStatus, error) { 94 + var variant string 95 + switch value { 96 + case StateOpen: 97 + variant = tangled.RepoPullStatusOpen 98 + case StateClosed: 99 + variant = tangled.RepoPullStatusClosed 100 + case StateMerged: 101 + variant = tangled.RepoPullStatusMerged 102 + default: 103 + return tangled.RepoPullStatus{}, fmt.Errorf("invalid pull status: %q", value) 104 + } 105 + return tangled.RepoPullStatus{ 106 + Pull: subject.String(), 107 + Status: variant, 108 + CreatedAt: createdAt.UTC().Format(syntax.AtprotoDatetimeLayout), 109 + }, nil 110 + } 111 + 93 112 func AsPullStatusRecords(subjects []syntax.ATURI, value StateValue, createdAt time.Time) ([]tangled.RepoPullStatus, error) { 94 113 var variant string 95 114 switch value {
+2 -3
appview/notify/db/db.go
··· 158 158 ) 159 159 160 160 case tangled.RepoPullNSID: 161 - pull, err := db.GetPull( 162 - n.db, 161 + pull, err := db.GetPull(ctx, n.db, 163 162 orm.FilterEq("owner_did", subjectAt.Authority()), 164 163 orm.FilterEq("rkey", subjectAt.RecordKey()), 165 164 ) ··· 381 380 recipients.Insert(c.SubjectDid) 382 381 } 383 382 384 - actorDid := syntax.DID(pull.OwnerDid) 383 + actorDid := pull.OwnerDid 385 384 eventType := models.NotificationTypePullCreated 386 385 entityType := "pull" 387 386 entityId := pull.AtUri().String()
+2 -2
appview/notify/db/db_test.go
··· 129 129 t.Helper() 130 130 pull := &models.Pull{ 131 131 RepoDid: syntax.DID(repoDid), 132 - OwnerDid: authorDid, 132 + OwnerDid: syntax.DID(authorDid), 133 133 Rkey: "pullrkey", 134 134 Title: "test", 135 135 Body: "body", ··· 140 140 if err != nil { 141 141 t.Fatalf("Begin: %v", err) 142 142 } 143 - if err := appviewdb.PutPull(tx, pull); err != nil { 143 + if err := appviewdb.PutPull(t.Context(), tx, pull, nil); err != nil { 144 144 t.Fatalf("PutPull: %v", err) 145 145 } 146 146 if err := tx.Commit(); err != nil {
+2 -16
appview/notify/posthog/notifier.go
··· 96 96 97 97 func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 98 98 err := n.client.Enqueue(posthog.Capture{ 99 - DistinctId: pull.OwnerDid, 99 + DistinctId: pull.OwnerDid.String(), 100 100 Event: "new_pull", 101 101 Properties: posthog.Properties{ 102 102 "repo_did": string(pull.RepoDid), ··· 108 108 } 109 109 } 110 110 111 - func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 112 - err := n.client.Enqueue(posthog.Capture{ 113 - DistinctId: pull.OwnerDid, 114 - Event: "pull_closed", 115 - Properties: posthog.Properties{ 116 - "repo_did": string(pull.RepoDid), 117 - "pull_id": pull.PullId, 118 - }, 119 - }) 120 - if err != nil { 121 - log.Println("failed to enqueue posthog event:", err) 122 - } 123 - } 124 - 125 111 func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 126 112 err := n.client.Enqueue(posthog.Capture{ 127 113 DistinctId: follow.UserDid, ··· 247 233 return 248 234 } 249 235 err := n.client.Enqueue(posthog.Capture{ 250 - DistinctId: pull.OwnerDid, 236 + DistinctId: pull.OwnerDid.String(), 251 237 Event: event, 252 238 Properties: posthog.Properties{ 253 239 "repo_did": string(pull.RepoDid),
+13 -13
appview/notify/webhook/notifier.go
··· 108 108 if !ok { 109 109 return 110 110 } 111 - w.pullRequestEvent(ctx, event, action, actor.String(), pull) 111 + w.pullRequestEvent(ctx, event, action, actor, pull) 112 112 } 113 113 114 114 // pullStateEvent maps a pull's state to the webhook event announcing the ··· 126 126 } 127 127 } 128 128 129 - func (w *Notifier) pullRequestEvent(ctx context.Context, event models.WebhookEvent, action, sender string, pull *models.Pull) { 129 + func (w *Notifier) pullRequestEvent(ctx context.Context, event models.WebhookEvent, action string, sender syntax.DID, pull *models.Pull) { 130 130 // pull request events originate from http handlers, whose context is 131 131 // canceled as soon as the handler returns; detach so in-flight 132 132 // deliveries are not cut short ··· 160 160 } 161 161 } 162 162 163 - func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull, sender, baseUrl string) *models.WebhookPullRequestPayload { 163 + func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull, sender syntax.DID, baseUrl string) *models.WebhookPullRequestPayload { 164 164 htmlUrl := fmt.Sprintf("%s/%s/%s/pulls/%d", baseUrl, repo.Did, repo.Slug(), pull.PullId) 165 165 166 166 pullRequest := models.WebhookPullRequest{ 167 - Number: pull.PullId, 167 + Number: int(pull.PullId), 168 168 Title: pull.Title, 169 169 Body: pull.Body, 170 170 State: pull.State.String(), 171 171 TargetBranch: pull.TargetBranch, 172 - Owner: models.WebhookUser{Did: pull.OwnerDid}, 172 + Owner: models.WebhookUser{Did: pull.OwnerDid.String()}, 173 173 HtmlUrl: htmlUrl, 174 174 CreatedAt: pull.Created.Format(time.RFC3339), 175 175 } 176 - if len(pull.Submissions) > 0 { 177 - pullRequest.RoundNumber = pull.LastRoundNumber() 178 - pullRequest.PatchUrl = fmt.Sprintf("%s/round/%d.patch", htmlUrl, pull.LastRoundNumber()) 176 + if len(pull.Versions) > 0 { 177 + pullRequest.RoundNumber = pull.LatestVersionNumber() 178 + pullRequest.PatchUrl = fmt.Sprintf("%s/%d.patch", htmlUrl, pull.LatestVersionNumber()) 179 179 } 180 - if pull.PullSource != nil { 180 + if pull.SourceBranch != nil { 181 181 source := &models.WebhookPullRequestSource{ 182 - Branch: pull.PullSource.Branch, 182 + Branch: *pull.SourceBranch, 183 183 } 184 - if len(pull.Submissions) > 0 { 184 + if len(pull.Versions) > 0 { 185 185 source.Sha = pull.LatestSha() 186 186 } 187 187 if pull.IsForkBased() { 188 - source.Repo = pull.PullSource.RepoDid.String() 188 + source.Repo = pull.SourceRepo.String() 189 189 } 190 190 pullRequest.Source = source 191 191 } ··· 194 194 Action: action, 195 195 PullRequest: pullRequest, 196 196 Repository: buildWebhookRepository(repo), 197 - Sender: models.WebhookUser{Did: sender}, 197 + Sender: models.WebhookUser{Did: sender.String()}, 198 198 } 199 199 } 200 200
-324
appview/pages/compose_parse_test.go
··· 1 - package pages 2 - 3 - import ( 4 - "bytes" 5 - "io" 6 - "log/slog" 7 - "strings" 8 - "testing" 9 - 10 - "tangled.org/core/appview/config" 11 - "tangled.org/core/appview/models" 12 - "tangled.org/core/appview/pages/repoinfo" 13 - "tangled.org/core/patchutil" 14 - "tangled.org/core/types" 15 - ) 16 - 17 - func TestPullComposeTemplatesParse(t *testing.T) { 18 - cfg := &config.Config{} 19 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 20 - 21 - cases := []struct { 22 - name string 23 - stack []string 24 - }{ 25 - {"new.html via repo base", []string{"layouts/base", "layouts/repobase", "repo/pulls/new"}}, 26 - {"pullComposeHost", []string{"repo/pulls/fragments/pullComposeHost"}}, 27 - {"pullStepSource", []string{"repo/pulls/fragments/pullStepSource"}}, 28 - {"pullStepReview", []string{"repo/pulls/fragments/pullStepReview"}}, 29 - {"pullStepDetails", []string{"repo/pulls/fragments/pullStepDetails"}}, 30 - {"pullCompareForks", []string{"repo/pulls/fragments/pullCompareForks"}}, 31 - {"pullCompareBranches", []string{"repo/pulls/fragments/pullCompareBranches"}}, 32 - {"pullCompareForksBranches", []string{"repo/pulls/fragments/pullCompareForksBranches"}}, 33 - {"pull.html via repo base", []string{"layouts/base", "layouts/repobase", "repo/pulls/pull"}}, 34 - {"pullComment", []string{"fragments/comment/pullComment"}}, 35 - } 36 - 37 - for _, c := range cases { 38 - t.Run(c.name, func(t *testing.T) { 39 - if _, err := p.rawParse(c.stack...); err != nil { 40 - t.Fatalf("parse %v: %v", c.stack, err) 41 - } 42 - }) 43 - } 44 - } 45 - 46 - func TestPullComposeHostRender(t *testing.T) { 47 - cfg := &config.Config{} 48 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 49 - 50 - base := RepoNewPullParams{ 51 - RepoInfo: repoinfo.RepoInfo{ 52 - OwnerDid: "did:plc:test", 53 - Name: "test-repo", 54 - }, 55 - } 56 - 57 - for _, source := range []Source{"", SourceBranch, SourceFork, SourcePatch} { 58 - for _, stacked := range []bool{false, true} { 59 - if source == SourcePatch && stacked { 60 - continue 61 - } 62 - params := base 63 - params.Source = source 64 - params.IsStacked = stacked 65 - name := string(source) 66 - if name == "" { 67 - name = "default" 68 - } 69 - if stacked { 70 - name += "-stacked" 71 - } 72 - t.Run(name, func(t *testing.T) { 73 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 74 - t.Fatalf("render source=%q stacked=%v: %v", source, stacked, err) 75 - } 76 - }) 77 - } 78 - } 79 - } 80 - 81 - func TestPullComposeHostRenderWithData(t *testing.T) { 82 - cfg := &config.Config{} 83 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 84 - 85 - sampleBranches := []types.Branch{ 86 - {Reference: types.Reference{Name: "feature"}}, 87 - {Reference: types.Reference{Name: "main"}, IsDefault: true}, 88 - } 89 - 90 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 91 - From: Test <test@best.fest> 92 - Date: Tue, 1 Jan 2020 00:00:00 +0000 93 - Subject: [PATCH] example commit 94 - 95 - --- 96 - a.txt | 1 + 97 - 1 file changed, 1 insertion(+) 98 - 99 - diff --git a/a.txt b/a.txt 100 - index 0000000..1111111 100644 101 - --- a/a.txt 102 - +++ b/a.txt 103 - @@ -0,0 +1 @@ 104 - +hello 105 - ` 106 - patches, err := patchutil.ExtractPatches(formatPatch) 107 - if err != nil { 108 - t.Fatalf("extract patches: %v", err) 109 - } 110 - comparison := &types.RepoFormatPatchResponse{ 111 - FormatPatchRaw: formatPatch, 112 - FormatPatch: patches, 113 - } 114 - diff := patchutil.AsNiceDiff(formatPatch, "main") 115 - 116 - params := RepoNewPullParams{ 117 - RepoInfo: repoinfo.RepoInfo{ 118 - OwnerDid: "did:plc:test", 119 - Name: "test-repo", 120 - }, 121 - Branches: sampleBranches, 122 - SourceBranches: []types.Branch{sampleBranches[0]}, 123 - ForkBranches: []types.Branch{sampleBranches[0]}, 124 - Source: SourceBranch, 125 - SourceBranch: "feature", 126 - TargetBranch: "main", 127 - Comparison: comparison, 128 - Diff: &diff, 129 - } 130 - 131 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 132 - t.Fatalf("render with data: %v", err) 133 - } 134 - 135 - params.IsStacked = true 136 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 137 - t.Fatalf("render stacked: %v", err) 138 - } 139 - 140 - params.PrefillError = "branch not found" 141 - params.Comparison = nil 142 - params.Diff = nil 143 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 144 - t.Fatalf("render with prefill error: %v", err) 145 - } 146 - 147 - bugDef := &models.LabelDefinition{ 148 - Did: "did:plc:test", 149 - Rkey: "bug", 150 - Name: "bug", 151 - ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 152 - Scope: []string{"sh.tangled.repo.pull"}, 153 - } 154 - priorityDef := &models.LabelDefinition{ 155 - Did: "did:plc:test", 156 - Rkey: "priority", 157 - Name: "priority", 158 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 159 - Scope: []string{"sh.tangled.repo.pull"}, 160 - } 161 - assigneeDef := &models.LabelDefinition{ 162 - Did: "did:plc:test", 163 - Rkey: "assignee", 164 - Name: "assignee", 165 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Format: models.ValueTypeFormatDid}, 166 - Scope: []string{"sh.tangled.repo.pull"}, 167 - Multiple: true, 168 - } 169 - labelDefs := map[string]*models.LabelDefinition{ 170 - bugDef.AtUri().String(): bugDef, 171 - priorityDef.AtUri().String(): priorityDef, 172 - assigneeDef.AtUri().String(): assigneeDef, 173 - } 174 - 175 - pushRepoInfo := repoinfo.RepoInfo{ 176 - OwnerDid: "did:plc:test", 177 - Name: "test-repo", 178 - Roles: repoinfo.RolesInRepo{Roles: []string{"repo:push"}}, 179 - } 180 - params = RepoNewPullParams{ 181 - RepoInfo: pushRepoInfo, 182 - Branches: sampleBranches, 183 - SourceBranches: []types.Branch{sampleBranches[0]}, 184 - Source: SourceBranch, 185 - SourceBranch: "feature", 186 - TargetBranch: "main", 187 - Comparison: comparison, 188 - Diff: &diff, 189 - LabelDefs: labelDefs, 190 - LabelState: models.NewLabelState(), 191 - } 192 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 193 - t.Fatalf("render with labels: %v", err) 194 - } 195 - 196 - params.IsStacked = true 197 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 198 - t.Fatalf("render stacked with labels: %v", err) 199 - } 200 - 201 - params.StackedDiffs = []StackedDiff{{ 202 - Diff: &diff, 203 - Opts: types.DiffOpts{Split: true, RefreshUrl: "/r", Target: "#stack-diff-x", Field: "stackSplit[x]"}, 204 - }} 205 - if err := p.PullComposeHostFragment(io.Discard, params); err != nil { 206 - t.Fatalf("render stacked with per-commit diffs: %v", err) 207 - } 208 - } 209 - 210 - func TestPullComposeLabelStateRoundTrip(t *testing.T) { 211 - cfg := &config.Config{} 212 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 213 - 214 - sampleBranches := []types.Branch{ 215 - {Reference: types.Reference{Name: "feature"}}, 216 - {Reference: types.Reference{Name: "main"}, IsDefault: true}, 217 - } 218 - 219 - bugDef := &models.LabelDefinition{ 220 - Did: "did:plc:test", Rkey: "bug", Name: "bug", 221 - ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 222 - Scope: []string{"sh.tangled.repo.pull"}, 223 - } 224 - priorityDef := &models.LabelDefinition{ 225 - Did: "did:plc:test", Rkey: "priority", Name: "priority", 226 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 227 - Scope: []string{"sh.tangled.repo.pull"}, 228 - } 229 - bugKey := bugDef.AtUri().String() 230 - priorityKey := priorityDef.AtUri().String() 231 - labelDefs := map[string]*models.LabelDefinition{ 232 - bugKey: bugDef, 233 - priorityKey: priorityDef, 234 - } 235 - 236 - state := models.NewLabelState() 237 - actx := &models.LabelApplicationCtx{Defs: labelDefs} 238 - for _, op := range []models.LabelOp{ 239 - {OperandKey: bugKey, OperandValue: "null", Operation: models.LabelOperationAdd}, 240 - {OperandKey: priorityKey, OperandValue: "high", Operation: models.LabelOperationAdd}, 241 - } { 242 - if err := actx.ApplyLabelOp(state, op); err != nil { 243 - t.Fatalf("seed state: %v", err) 244 - } 245 - } 246 - 247 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 248 - From: Test <test@best.fest> 249 - Date: Tue, 1 Jan 2020 00:00:00 +0000 250 - Subject: [PATCH] example commit 251 - 252 - --- 253 - a.txt | 1 + 254 - 1 file changed, 1 insertion(+) 255 - 256 - diff --git a/a.txt b/a.txt 257 - index 0000000..1111111 100644 258 - --- a/a.txt 259 - +++ b/a.txt 260 - @@ -0,0 +1 @@ 261 - +hello 262 - ` 263 - patches, err := patchutil.ExtractPatches(formatPatch) 264 - if err != nil { 265 - t.Fatalf("extract patches: %v", err) 266 - } 267 - comparison := &types.RepoFormatPatchResponse{ 268 - FormatPatchRaw: formatPatch, 269 - FormatPatch: patches, 270 - } 271 - 272 - params := RepoNewPullParams{ 273 - RepoInfo: repoinfo.RepoInfo{ 274 - OwnerDid: "did:plc:test", 275 - Name: "test-repo", 276 - Roles: repoinfo.RolesInRepo{Roles: []string{"repo:push"}}, 277 - }, 278 - Branches: sampleBranches, 279 - SourceBranches: []types.Branch{sampleBranches[0]}, 280 - Source: SourceBranch, 281 - SourceBranch: "feature", 282 - TargetBranch: "main", 283 - Comparison: comparison, 284 - LabelDefs: labelDefs, 285 - LabelState: state, 286 - } 287 - 288 - var buf bytes.Buffer 289 - if err := p.PullComposeHostFragment(&buf, params); err != nil { 290 - t.Fatalf("render: %v", err) 291 - } 292 - out := buf.String() 293 - for _, want := range []string{ 294 - `value="null" checked`, 295 - `value="high" checked`, 296 - } { 297 - if !strings.Contains(out, want) { 298 - t.Errorf("missing pre-selection %q", want) 299 - } 300 - } 301 - } 302 - 303 - func TestParseSource(t *testing.T) { 304 - cases := []struct { 305 - in string 306 - want Source 307 - wantOk bool 308 - }{ 309 - {"branch", SourceBranch, true}, 310 - {"BRANCH", SourceBranch, true}, 311 - {"fork", SourceFork, true}, 312 - {"patch", SourcePatch, true}, 313 - {"", "", false}, 314 - {"method", "", false}, 315 - {"strategy", "", false}, 316 - {"unknown", "", false}, 317 - } 318 - for _, c := range cases { 319 - got, ok := ParseSource(c.in) 320 - if got != c.want || ok != c.wantOk { 321 - t.Errorf("ParseSource(%q) = %q, %v; want %q, %v", c.in, got, ok, c.want, c.wantOk) 322 - } 323 - } 324 - }
+9 -7
appview/pages/funcmap.go
··· 137 137 return "" 138 138 } 139 139 // GetPull's reverse-mapping already populates pull.Repo 140 - pull, err := db.GetPull(p.db, orm.FilterEq("at_uri", pullAtStr)) 140 + pull, err := db.GetPull(context.Background(), p.db, orm.FilterEq("at_uri", pullAtStr)) 141 141 if err != nil || pull == nil || pull.Repo == nil { 142 142 return "" 143 143 } ··· 150 150 return s[:30] + "…" 151 151 }, 152 152 // short prefix of a commit hash or jj change id, safe on short input 153 - "shortId": func(s string) string { 154 - if len(s) <= 8 { 155 - return s 156 - } 157 - return s[:8] 158 - }, 153 + "shortId": shortId, 159 154 "splitOn": func(s, sep string) []string { 160 155 return strings.Split(s, sep) 161 156 }, ··· 617 612 } 618 613 } 619 614 615 + func shortId(s string) string { 616 + if len(s) <= 8 { 617 + return s 618 + } 619 + return s[:8] 620 + } 621 + 620 622 func primaryHandle(r *idresolver.Resolver, s string) string { 621 623 identity, err := r.ResolveIdent(context.Background(), s) 622 624 if err != nil || identity.Handle.IsInvalidHandle() {
+28 -28
appview/pages/templates/fragments/line-quote-button.html
··· 21 21 Array.from(document.querySelectorAll('form[hx-post="/comment"] textarea')) 22 22 .find(ta => ta.offsetParent !== null) || null; 23 23 24 - const lineOf = (el) => 25 - el?.closest?.('span[id*="-O"]') 26 - || el?.closest?.('span[id*="-N"]'); 24 + const lineOf = (el) => el?.closest?.('[id*="-O"], [id*="-N"]'); 27 25 28 - const anchorOf = (el) => { 29 - const link = el.querySelector('a[href^="#"]'); 30 - return link ? link.getAttribute('href').slice(1) : el.id || null; 31 - }; 26 + const anchorOf = (el) => 27 + el.querySelector('a[href^="#"]')?.getAttribute('href').slice(1) ?? null; 32 28 33 29 const fileOf = (el) => { 34 30 const d = el.closest('details[id^="file-"]'); 35 31 return d ? d.id.replace(/^file-/, '') : null; 36 32 }; 37 33 38 - const lineNumOf = (el) => anchorOf(el)?.match(/(\d+)(?:-[ON]?\d+)?$/)?.[1]; 39 - 40 - const columnOf = (el) => el.closest('.flex-col'); 34 + const lineNumOf = (el) => anchorOf(el)?.match(/-[ON](\d+)$/)?.[1]; 35 + 36 + // Column key: split has two flex sides per row (left = first child / -O, 37 + // right = last child / -N); unified is a single column per file. 38 + const columnOf = (el) => { 39 + const file = fileOf(el); 40 + if (file == null) return null; 41 + const side = el.classList.contains('diff-side') 42 + ? (el === el.parentElement.firstElementChild ? 'L' : 'R') 43 + : 'U'; 44 + return file + '-' + side; 45 + }; 41 46 42 - const linesInColumn = (col) => 43 - Array.from(col.querySelectorAll('span[id*="-O"], span[id*="-N"]')) 44 - .filter(s => s.querySelector('a[href^="#"]')); 47 + const linesInColumn = (el) => { 48 + const diff = el.closest('.diff'); 49 + if (!diff) return []; 50 + if (el.classList.contains('diff-side')) { 51 + const pos = el === el.parentElement.firstElementChild 52 + ? ':first-child' : ':last-child'; 53 + return Array.from(diff.querySelectorAll(`.diff-line > .diff-side[id]${pos}`)); 54 + } 55 + return Array.from(diff.querySelectorAll('.diff-line[id]')); 56 + }; 45 57 46 58 let dragLines = null; 47 59 48 60 const rangeBetween = (a, b) => { 49 61 const col = columnOf(a); 50 62 if (!col || col !== columnOf(b)) return []; 51 - const all = dragLines || linesInColumn(col); 63 + const all = dragLines || linesInColumn(a); 52 64 const ai = all.indexOf(a); 53 65 const bi = all.indexOf(b); 54 66 if (ai === -1 || bi === -1) return []; ··· 73 85 if (hash.startsWith('comment-') || hash.startsWith('round-')) return; 74 86 const parts = hash.split('~'); 75 87 const startEl = document.getElementById(parts[0]); 76 - 77 - if (!startEl) { 78 - const params = new URLSearchParams(window.location.search); 79 - const hasCombined = parts.some(p => /-O\d+-N\d+$/.test(p)); 80 - if (hasCombined && params.get('diff') !== 'unified') { 81 - params.set('diff', 'unified'); 82 - window.location.replace( 83 - `${window.location.pathname}?${params}${window.location.hash}` 84 - ); 85 - } 86 - return; 87 - } 88 + if (!startEl) return; 88 89 89 90 const endEl = parts.length === 2 ? document.getElementById(parts[1]) : startEl; 90 91 if (!endEl) return; ··· 180 181 e.preventDefault(); 181 182 dragging = true; 182 183 dragAnchor = dragCurrent = hoverTarget; 183 - const col = columnOf(hoverTarget); 184 - dragLines = col ? linesInColumn(col) : null; 184 + dragLines = linesInColumn(hoverTarget); 185 185 applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 186 186 btn.style.pointerEvents = 'none'; 187 187 document.body.style.userSelect = 'none';
+1 -1
appview/pages/templates/layouts/base.html
··· 110 110 </head> 111 111 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200 {{ block "bodyClasses" . }} {{ end }}"> 112 112 {{ block "topbarLayout" . }} 113 - <header class="w-full col-span-full md:col-span-1 md:col-start-2 shadow-sm dark:text-white bg-white dark:bg-gray-800 pt-[env(safe-area-inset-top)]" style="z-index: 20;"> 113 + <header class="w-full col-span-full md:col-span-1 md:col-start-2 shadow-sm dark:text-white bg-white dark:bg-gray-800 pt-[env(safe-area-inset-top)]" style="z-index: 40;"> 114 114 115 115 {{ if .LoggedInUser }} 116 116 <div id="upgrade-banner"
+5 -1
appview/pages/templates/repo/fragments/diff.html
··· 89 89 {{ $id := index . 0 }} 90 90 {{ $target := index . 1 }} 91 91 {{ $direction := index . 2 }} 92 + {{ $class := "hidden md:flex" }} 93 + {{ if gt (len .) 3 }} 94 + {{ $class = index . 3 }} 95 + {{ end }} 92 96 <div id="{{ $id }}" 93 97 data-resizer="vertical" 94 98 data-target="{{ $target }}" 95 99 data-direction="{{ $direction }}" 96 - class="resizer-vertical hidden md:flex w-4 sticky top-12 max-h-screen flex-col items-center justify-center group"> 100 + class="resizer-vertical w-4 sticky top-12 max-h-screen flex-col items-center justify-center group {{ $class }}"> 97 101 <div class="w-1 h-16 group-hover:h-24 group-[.resizing]:h-24 transition-all rounded-full bg-gray-400 dark:bg-gray-500 group-hover:bg-gray-500 group-hover:dark:bg-gray-400"></div> 98 102 </div> 99 103 {{ end }}
+84
appview/pages/templates/repo/pulls/fragments/composediff.html
··· 1 + {{ define "repo/pulls/fragments/composediff" }} 2 + <style> 3 + #composeFilesToggle:checked ~ * label[for="composeFilesToggle"] .show-text { display: none; } 4 + #composeFilesToggle:checked ~ * label[for="composeFilesToggle"] .hide-text { display: inline; } 5 + #composeFilesToggle:not(:checked) ~ * label[for="composeFilesToggle"] .hide-text { display: none; } 6 + #composeFilesToggle:checked ~ * div#composeFiles { width: fit-content; max-width: 15vw; } 7 + #composeFilesToggle:not(:checked) ~ * div#composeFiles { width: 0; display: none; margin-right: 0; } 8 + </style> 9 + <div id="diff-area"> 10 + <input type="checkbox" id="composeFilesToggle" class="peer/collapse hidden"/> 11 + <div class="bg-slate-100 dark:bg-gray-700/50 flex items-center gap-2 h-12 p-2 rounded-t-md border border-b-0 border-gray-200 dark:border-gray-700"> 12 + <label title="Toggle filetree panel" for="composeFilesToggle" class="hidden md:inline-flex btn-flat"> 13 + <span class="peer-checked:hidden">{{ i "panel-left-open" "size-4" }}</span> 14 + <span class="peer-checked:inline hidden">{{ i "panel-left-close" "size-4" }}</span> 15 + </label> 16 + 17 + {{ template "repo/fragments/diffStatPill" .Stats }} 18 + {{ $count := .Stats.FilesChanged }} 19 + <span class="text-xs text-gray-600 dark:text-gray-300 hidden md:inline-flex">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 20 + 21 + <div class="flex-grow"></div> 22 + 23 + <label title="Expand/Collapse diffs" class="btn font-normal normal-case"> 24 + <input type="checkbox" id="diff-collapse-toggle" class="peer/collapse hidden"/> 25 + <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 26 + {{ i "unfold-vertical" "size-4" }} 27 + <span class="hidden md:inline">Expand all</span> 28 + </span> 29 + <span class="peer-checked/collapse:inline-flex hidden items-center gap-2"> 30 + {{ i "fold-vertical" "size-4" }} 31 + <span class="hidden md:inline">Collapse all</span> 32 + </span> 33 + </label> 34 + 35 + <!-- diff settings --> 36 + {{ template "repo/pulls/fragments/diffSettings" 37 + (dict "DiffUrl" (printf "%s?baseRepo=%s&base=%s&headRepo=%s&head=%s" .DiffUrl .BaseRepo .DiffBase .HeadRepo .DiffHead) 38 + "Unified" .Unified) }} 39 + 40 + </div> 41 + <div class="flex border border-gray-200 dark:border-gray-700 rounded-b-md"> 42 + <div id="composeFiles" class="hidden md:block overflow-hidden p-1 max-h-full overflow-y-auto border-r border-gray-200 dark:border-gray-700"> 43 + {{ template "repo/fragments/fileTree" .FileTree }} 44 + </div> 45 + <div id="diff-list" class="flex-1 min-w-0 p-2 space-y-3"> 46 + {{ range .Files }} 47 + {{ template "repo/pulls/fragments/diffFile" . }} 48 + {{ end }} 49 + </div> 50 + </div> 51 + </div> 52 + <script> 53 + (() => { 54 + const checkbox = document.getElementById('diff-collapse-toggle'); 55 + const diffList = document.getElementById('diff-list'); 56 + 57 + checkbox.addEventListener('change', () => { 58 + console.debug("checked", checkbox.checked); 59 + diffList.querySelectorAll('details[id^="file-"]').forEach(detail => { 60 + detail.open = checkbox.checked; 61 + }); 62 + }); 63 + 64 + if (window.__collapseToggleHandler) { 65 + diffList.removeEventListener('toggle', window.__collapseToggleHandler, true); 66 + } 67 + 68 + const handler = (e) => { 69 + if (!e.target.matches('details[id^="file-"]')) return; 70 + const details = document.querySelectorAll('details[id^="file-"]'); 71 + const allOpen = Array.from(details).every(d => d.open); 72 + const allClosed = Array.from(details).every(d => !d.open); 73 + 74 + if (allOpen) checkbox.checked = true; 75 + else if (allClosed) checkbox.checked = false; 76 + }; 77 + 78 + console.log("diffList", diffList); 79 + 80 + window.__collapseToggleHandler = handler; 81 + diffList.addEventListener('toggle', handler, true); 82 + })(); 83 + </script> 84 + {{ end }}
+334
appview/pages/templates/repo/pulls/fragments/diff.html
··· 1 + {{ define "repo/pulls/fragments/diff" }} 2 + <div id="diff-files-content" hx-swap-oob="outerHTML"> 3 + {{ template "repo/fragments/fileTree" .FileTree }} 4 + </div> 5 + <div id="diff-stats" hx-swap-oob="outerHTML"> 6 + {{ template "repo/fragments/diffStatPill" .Stats }} 7 + </div> 8 + <div id="diff-settings" hx-swap-oob="outerHTML"> 9 + {{ template "repo/pulls/fragments/diffSettings" 10 + (dict "DiffUrl" .DiffUrl 11 + "Unified" .Unified) }} 12 + </div> 13 + <script> 14 + (() => { 15 + if (window.__diffRestrictSelect) return; 16 + window.__diffRestrictSelect = true; 17 + document.addEventListener('mousedown', (e) => { 18 + document.querySelectorAll('.diff[data-restrict-select]') 19 + .forEach(d => d.removeAttribute('data-restrict-select')); 20 + const side = e.target.closest('.diff-side'); 21 + if (!side) return; 22 + const diff = side.closest('.diff'); 23 + if (!diff) return; 24 + const isLeft = side === side.parentElement.firstElementChild; 25 + diff.setAttribute('data-restrict-select', isLeft ? 'left' : 'right'); 26 + }); 27 + })(); 28 + </script> 29 + <div id="diff-list" class="space-y-4"> 30 + {{ if .ErrorMsg }} 31 + <div class="flex items-center justify-center w-full min-h-32 p-4 rounded text-red-600 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700"> 32 + <span>{{ .ErrorMsg }}</span> 33 + </div> 34 + {{ else if .Files }} 35 + {{ range .Files }} 36 + <div class="drop-shadow-sm rounded overflow-clip"> 37 + <details id="file-{{ .Id }}" class="group w-full" open> 38 + <summary class="list-none cursor-pointer sticky top-12 z-10 bg-slate-100 dark:bg-gray-900"> 39 + <div class="flex justify-between rounded border bg-white dark:bg-gray-800 group-open:rounded-b-none border-gray-200 dark:border-gray-700"> 40 + <div class="p-2 flex gap-2 items-center"> 41 + <span class="group-open:hidden inline">{{ i "chevron-right" "size-4" }}</span> 42 + <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 43 + {{ template "repo/fragments/diffStatPill" .Stats }} 44 + <span>{{ .Path }}</span> 45 + </div> 46 + <div class="px-2 flex gap-2 items-center"> 47 + <label 48 + data-review-btn="file-{{ .Id }}" 49 + onclick="event.stopPropagation()" 50 + class="review-btn hidden p-2 items-center gap-1 text-xs text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer" 51 + title="Mark as reviewed" 52 + > 53 + <input 54 + type="checkbox" 55 + class="sr-only peer review-checkbox" 56 + data-file-id="file-{{ .Id }}" 57 + /> 58 + <span class="peer-checked:hidden">{{ i "circle" "size-4" }}</span> 59 + <span class="hidden peer-checked:inline text-green-600 dark:text-green-400">{{ i "circle-check" "size-4" }}</span> 60 + <span class="hidden md:inline">Reviewed</span> 61 + </label> 62 + </div> 63 + </div> 64 + </summary> 65 + 66 + <div class="rounded-b overflow-clip border-x border-b border-gray-200 dark:border-gray-700"> 67 + {{ if .Note }} 68 + <div class="p-4 text-center text-gray-400 dark:text-gray-500 text-sm">{{ .Note }}</div> 69 + {{ else if $.Unified }} 70 + {{ template "diffUnified" . }} 71 + {{ else }} 72 + {{ template "diffSplit" . }} 73 + {{ end }} 74 + </div> 75 + </details> 76 + </div> 77 + {{ end }} 78 + {{ else }} 79 + <div class="flex items-center justify-center w-full min-h-32 p-4 rounded text-blue-600 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700"> 80 + <span>No change between two revisions.</span> 81 + </div> 82 + {{ end }} 83 + </div> 84 + {{ template "activeFileHighlightScript" }} 85 + {{ template "fragments/line-quote-button" }} 86 + {{ template "reviewStateScript" }} 87 + {{ end }} 88 + 89 + {{ define "diffUnified" }} 90 + <div class="diff w-full"> 91 + {{ $name := .Id }} 92 + {{- range .Hunks -}} 93 + {{- if not .AtFileStart -}}<div class="diff-splitter">&middot;&middot;&middot;</div>{{- end -}} 94 + {{- range $i, $line := .Lines -}} 95 + {{- $lineId := "" -}} 96 + {{- if ge .New 0 -}} 97 + {{- $lineId = printf "%s-N%d" $name .New -}} 98 + {{- else -}} 99 + {{- $lineId = printf "%s-O%d" $name .Old -}} 100 + {{- end -}} 101 + {{- $cls := "" -}} 102 + {{- if eq .Op "+" -}} 103 + {{- $cls = "add" -}} 104 + {{- else if eq .Op "-" -}} 105 + {{- $cls = "del" -}} 106 + {{- end -}} 107 + <div id="{{ $lineId }}" class="diff-line {{ $cls }}"> 108 + <a href="#{{ $lineId }}" class="diff-num"> 109 + <span>{{ if gt .Old 0 }}{{ .Old }}{{ end }}</span> 110 + <span>{{ if gt .New 0 }}{{ .New }}{{ end }}</span> 111 + </a> 112 + <span class="diff-indicator">{{ .Op }}</span> 113 + <div class="diff-content">{{ .Content }}</div> 114 + </div> 115 + {{- end -}} 116 + {{- end -}} 117 + </div> 118 + {{ end }} 119 + 120 + {{ define "diffSplit" }} 121 + <div class="diff w-full"> 122 + {{ $name := .Id }} 123 + {{- range .Hunks -}} 124 + {{- if not .AtFileStart -}}<div class="diff-splitter">&middot;&middot;&middot;</div>{{- end -}} 125 + {{- range $i, $row := .Rows -}} 126 + <div class="diff-line"> 127 + {{- template "diffSplitSide" (list $name "O" $row.Left) -}} 128 + {{- template "diffSplitSide" (list $name "N" $row.Right) -}} 129 + </div> 130 + {{- end -}} 131 + {{- end -}} 132 + </div> 133 + {{ end }} 134 + 135 + {{ define "diffSplitSide" }} 136 + {{- $name := index . 0 -}} 137 + {{- $side := index . 1 -}} 138 + {{- $line := index . 2 -}} 139 + {{- $lineId := printf "%s-%s%d" $name $side $line.Num -}} 140 + {{- $cls := "" -}} 141 + {{- $mark := "" -}} 142 + {{- if eq $line.Kind "del" }}{{ $cls = "del" }}{{ $mark = "-" -}} 143 + {{- else if eq $line.Kind "add" }}{{ $cls = "add" }}{{ $mark = "+" -}} 144 + {{- else if eq $line.Kind "empty" }}{{ $cls = "empty" -}} 145 + {{- end -}} 146 + <div {{ if ge $line.Num 0 }}id="{{ $lineId }}"{{ end }} class="diff-side {{ $cls }}"> 147 + {{ if gt $line.Num 0 -}} 148 + <a href="#{{ $lineId }}" class="diff-num"><span>{{ $line.Num }}</span></a> 149 + {{- else -}} 150 + <span aria-hidden="true" class="diff-num"><span></span></span> 151 + {{- end }} 152 + <span class="diff-indicator">{{ $mark }}</span> 153 + <div class="diff-content">{{ $line.Content }}</div> 154 + </div> 155 + {{ end }} 156 + 157 + {{ define "activeFileHighlightScript" }} 158 + <script> 159 + (() => { 160 + if (window.__activeFileScrollHandler) { 161 + document.removeEventListener('scroll', window.__activeFileScrollHandler); 162 + } 163 + 164 + const filetreeLinks = document.querySelectorAll('.filetree-link'); 165 + if (filetreeLinks.length === 0) return; 166 + 167 + const linkMap = new Map(); 168 + filetreeLinks.forEach(link => { 169 + const path = link.getAttribute('data-path'); 170 + if (path) linkMap.set('file-' + path, link); 171 + }); 172 + 173 + let currentActive = null; 174 + const setActive = (link) => { 175 + if (link && link !== currentActive) { 176 + if (currentActive) currentActive.classList.remove('font-bold'); 177 + link.classList.add('font-bold'); 178 + currentActive = link; 179 + } 180 + }; 181 + 182 + filetreeLinks.forEach(link => { 183 + link.addEventListener('click', () => setActive(link)); 184 + }); 185 + 186 + const topbar = document.querySelector('.sticky.top-0.z-20'); 187 + const headerHeight = topbar ? topbar.offsetHeight : 0; 188 + 189 + const updateActiveFile = () => { 190 + const diffFiles = document.querySelectorAll('details[id^="file-"]'); 191 + Array.from(diffFiles).some(file => { 192 + const rect = file.getBoundingClientRect(); 193 + if (rect.top <= headerHeight && rect.bottom > headerHeight) { 194 + setActive(linkMap.get(file.id)); 195 + return true; 196 + } 197 + return false; 198 + }); 199 + }; 200 + 201 + window.__activeFileScrollHandler = updateActiveFile; 202 + document.addEventListener('scroll', updateActiveFile); 203 + updateActiveFile(); 204 + })(); 205 + </script> 206 + {{ end }} 207 + 208 + {{ define "reviewStateScript" }} 209 + <script> 210 + (() => { 211 + const targetRepoDid = document.getElementById('pull-target-repo-did')?.value; 212 + const pullId = document.getElementById('pull-id')?.value; 213 + const activeVersionId = document.getElementById('pull-active-version-id')?.value; 214 + if (!targetRepoDid || !pullId || !activeVersionId) return; 215 + 216 + const REVIEWED_PREFIX = 'reviewed:'; 217 + const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; 218 + 219 + const storageKey = REVIEWED_PREFIX + ':' + targetRepoDid + ':' + pullId + ':' + activeVersionId; 220 + 221 + const load = () => { 222 + try { 223 + const entry = JSON.parse(localStorage.getItem(storageKey) || '{}'); 224 + return new Set(Array.isArray(entry) ? entry : (entry.files || [])); 225 + } 226 + catch { return new Set(); } 227 + }; 228 + 229 + const save = (reviewed) => { 230 + const liveIds = new Set(Array.from(allFiles()).map(d => d.id)); 231 + localStorage.setItem(storageKey, JSON.stringify({ 232 + files: Array.from(reviewed).filter(id => liveIds.has(id)), 233 + ts: Date.now(), 234 + })); 235 + }; 236 + 237 + const pruneStale = () => { 238 + const now = Date.now(); 239 + Object.keys(localStorage) 240 + .filter(k => k.startsWith(REVIEWED_PREFIX) && k !== storageKey) 241 + .forEach(k => { 242 + try { 243 + const entry = JSON.parse(localStorage.getItem(k)); 244 + if (!entry.ts || now - entry.ts > MAX_AGE_MS) localStorage.removeItem(k); 245 + } catch { localStorage.removeItem(k); } 246 + }); 247 + }; 248 + if (Math.random() < 0.1) pruneStale(); 249 + 250 + const allFiles = () => 251 + document.querySelectorAll('details[id^="file-"]'); 252 + 253 + const applyOne = (fileId, isReviewed) => { 254 + const detail = document.getElementById(fileId); 255 + if (!detail) return; 256 + 257 + const btn = detail.querySelector('[data-review-btn]'); 258 + const checkbox = btn?.querySelector('input[type="checkbox"]'); 259 + const path = CSS.escape(fileId.replace('file-', '')); 260 + const treeLink = document.querySelector(`.filetree-link[data-path="${path}"]`); 261 + 262 + detail.classList.toggle('opacity-60', isReviewed); 263 + 264 + if (checkbox) checkbox.checked = isReviewed; 265 + 266 + if (treeLink) { 267 + const existing = treeLink.parentElement.querySelector('.review-indicator'); 268 + if (isReviewed && !existing) { 269 + const indicator = document.createElement('span'); 270 + indicator.className = 'review-indicator text-green-600 dark:text-green-400 flex-shrink-0'; 271 + indicator.innerHTML = '&#10003;'; 272 + treeLink.parentElement.appendChild(indicator); 273 + } else if (!isReviewed && existing) { 274 + existing.remove(); 275 + } 276 + } 277 + }; 278 + 279 + const updateProgress = (reviewed) => { 280 + const el = document.getElementById('changed-files-label'); 281 + if (!el) return; 282 + const total = parseInt(el.dataset.total, 10); 283 + const files = allFiles(); 284 + const count = Array.from(files).filter(d => reviewed.has(d.id)).length; 285 + const suffix = total === 1 ? 'file' : 'files'; 286 + const allDone = count === total; 287 + el.classList.toggle('text-green-600', allDone); 288 + el.classList.toggle('dark:text-green-400', allDone); 289 + el.classList.toggle('text-gray-600', !allDone); 290 + el.classList.toggle('dark:text-gray-400', !allDone); 291 + el.textContent = count > 0 292 + ? `${count}/${total} ${suffix} reviewed` 293 + : `${total} changed ${suffix}`; 294 + }; 295 + 296 + const reviewed = load(); 297 + 298 + const toggleReview = (fileId) => { 299 + const detail = document.getElementById(fileId); 300 + if (!detail) return; 301 + const isNowReviewed = !reviewed.has(fileId); 302 + if (isNowReviewed) { 303 + reviewed.add(fileId); 304 + detail.open = false; 305 + } else { 306 + reviewed.delete(fileId); 307 + } 308 + save(reviewed); 309 + applyOne(fileId, isNowReviewed); 310 + updateProgress(reviewed); 311 + }; 312 + 313 + document.getElementById('diff-list').addEventListener('change', (e) => { 314 + const checkbox = e.target.closest('.review-checkbox'); 315 + if (!checkbox) return; 316 + const fileId = checkbox.dataset.fileId; 317 + if (fileId) toggleReview(fileId); 318 + }); 319 + 320 + document.querySelectorAll('.review-btn').forEach(btn => { 321 + btn.classList.remove('hidden'); 322 + btn.classList.add('flex'); 323 + }); 324 + 325 + allFiles().forEach(detail => { 326 + if (reviewed.has(detail.id)) { 327 + applyOne(detail.id, true); 328 + detail.open = false; 329 + } 330 + }); 331 + updateProgress(reviewed); 332 + })(); 333 + </script> 334 + {{ end }}
+18
appview/pages/templates/repo/pulls/fragments/diffFile.html
··· 1 + {{ define "repo/pulls/fragments/diffFile" }} 2 + <details id="file-{{ .Id }}" class="group w-full drop-shadow-sm rounded overflow-clip"> 3 + <summary class="list-none cursor-pointer bg-slate-100 dark:bg-gray-900"> 4 + <div class="flex justify-between rounded border bg-white dark:bg-gray-800 group-open:rounded-b-none border-gray-200 dark:border-gray-700"> 5 + <div class="p-2 flex gap-2 items-center"> 6 + <span class="group-open:hidden inline">{{ i "chevron-right" "size-4" }}</span> 7 + <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 8 + {{ template "repo/fragments/diffStatPill" .Stats }} 9 + <span>{{ .Path }}</span> 10 + </div> 11 + <div></div> 12 + </div> 13 + </summary> 14 + <div class="rounded-b overflow-clip border-x border-b border-gray-200 dark:border-gray-700"> 15 + <pre>todo: diff content</pre> 16 + </div> 17 + </details> 18 + {{ end }}
+29
appview/pages/templates/repo/pulls/fragments/diffSettings.html
··· 1 + {{ define "repo/pulls/fragments/diffSettings" }} 2 + {{ $diffUrl := .DiffUrl }} 3 + {{ $unified := .Unified }} 4 + <div class="btn-group"> 5 + <button 6 + type="button" 7 + hx-get="{{ $diffUrl }}" 8 + hx-vals='{"view": "unified"}' 9 + hx-target="#diff-list" 10 + hx-swap="outerHTML" 11 + class="group btn-group-item {{ if $unified }}active{{ end }}" 12 + > 13 + <span class="inline group-[.htmx-request]:hidden">{{ i "square-split-vertical" "size-4" }}</span> 14 + <span class="hidden group-[.htmx-request]:inline animate-spin">{{ i "loader-circle" "size-4" }}</span> 15 + Unified 16 + </button> 17 + <button 18 + type="button" 19 + hx-get="{{ $diffUrl }}" 20 + hx-target="#diff-list" 21 + hx-swap="outerHTML" 22 + class="group btn-group-item {{ if not $unified }}active{{ end }}" 23 + > 24 + <span class="inline group-[.htmx-request]:hidden">{{ i "square-split-horizontal" "size-4" }}</span> 25 + <span class="hidden group-[.htmx-request]:inline animate-spin">{{ i "loader-circle" "size-4" }}</span> 26 + Split 27 + </button> 28 + </div> 29 + {{ end }}
+10 -25
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 1 1 {{ define "repo/pulls/fragments/pullActions" }} 2 - {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 2 + {{ $lastIdx := .Pull.LatestVersionNumber }} 3 3 {{ $roundNumber := .RoundNumber }} 4 - {{ $stack := .Stack }} 5 4 {{ $loading := .Loading }} 6 5 7 - {{ $totalPulls := sub 0 1 }} 8 - {{ $below := sub 0 1 }} 9 - {{ $stackCount := "" }} 10 - {{ if (gt (len .Stack) 1) }} 11 - {{ $totalPulls = len $stack }} 12 - {{ $below = $stack.Below .Pull }} 13 - {{ $mergeable := len $below.Mergeable }} 14 - {{ $stackCount = printf "%d/%d" $mergeable $totalPulls }} 15 - {{ end }} 16 - 17 6 {{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }} 18 7 {{ $isMerged := .Pull.State.IsMerged }} 19 8 {{ $isClosed := .Pull.State.IsClosed }} ··· 21 10 {{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }} 22 11 {{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }} 23 12 {{ $isLastRound := eq $roundNumber $lastIdx }} 24 - {{ $isSameRepoBranch := .Pull.IsBranchBased }} 25 13 {{ $isUpToDate := .ResubmitCheck.No }} 26 14 {{ $isForkBased := .Pull.IsForkBased }} 27 15 {{ $showRunCI := and (not $loading) $isPushAllowed $isOpen $isLastRound $isForkBased (not .HasPipeline) (ne .RepoInfo.Spindle "") }} 28 16 29 17 <div id="action-card-{{$roundNumber}}" class="{{ if .LoggedInUser }}relative p-2 flex flex-col gap-2{{ else }}hidden{{ end }}" 30 18 {{ if $loading }} 31 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/actions" 19 + hx-get="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ $roundNumber }}/_/actions" 32 20 hx-trigger="load" 33 21 hx-swap="outerHTML" 34 22 hx-target="this" ··· 46 34 hx-target="#pull-comments-{{ .RoundNumber }}" 47 35 hx-swap="beforeend" 48 36 hx-disabled-elt="find button[type='submit']" 49 - hx-on::after-request="if(event.target === this && event.detail.successful) htmx.ajax('GET', '/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions', {target: '#action-card-{{ .RoundNumber }}', swap: 'outerHTML'})" 37 + hx-on::after-request="if(event.target === this && event.detail.successful) htmx.ajax('GET', '/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ .RoundNumber }}/_/actions', {target: '#action-card-{{ .RoundNumber }}', swap: 'outerHTML'})" 50 38 > 51 39 <input name="subject-uri" type="hidden" value="{{ .Pull.AtUri }}"> 40 + <input name="subject-cid" type="hidden" value="{{ .Pull.Cid }}"> 52 41 <input name="pull-round-idx" type="hidden" value="{{ .RoundNumber }}"> 53 42 {{ template "fragments/markdownEditor" 54 43 (dict "Name" "body" ··· 111 100 {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 112 101 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 102 {{ end }} 114 - Merge{{if $stackCount}} {{$stackCount}}{{end}} 103 + Merge 115 104 </button> 116 105 {{ end }} 117 106 118 107 {{ if and $isPullAuthor $isOpen $isLastRound }} 119 108 <button id="resubmitBtn" 120 - {{ if not .Pull.IsPatchBased }} 121 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 122 - hx-swap="none" 123 - {{ else }} 124 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 125 - {{ end }} 109 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/resubmit" 110 + hx-swap="none" 126 111 127 112 hx-disabled-elt="#resubmitBtn" 128 113 class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" ··· 149 134 150 135 {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 151 136 <button 152 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 137 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/close" 153 138 hx-swap="none" 154 139 class="btn-flat p-2 flex items-center gap-2 group"> 155 140 {{ i "ban" "w-4 h-4 inline group-[.htmx-request]:hidden" }} ··· 160 145 161 146 {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 162 147 <button 163 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 148 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/reopen" 164 149 hx-swap="none" 165 150 class="btn-flat p-2 flex items-center gap-2 group"> 166 151 {{ i "refresh-ccw-dot" "w-4 h-4 inline group-[.htmx-request]:hidden" }} ··· 225 210 class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 w-full md:w-96 p-4 rounded drop-shadow overflow-visible" 226 211 > 227 212 <form 228 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/trigger-ci?confirm=1" 213 + hx-post="/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/trigger-ci?confirm=1" 229 214 hx-swap="none" 230 215 hx-indicator="#run-ci-spinner-{{ .Pull.PullId }}" 231 216 class="flex flex-col gap-2"
+1 -1
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
··· 3 3 <select 4 4 id="sourceBranch" 5 5 name="sourceBranch" 6 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 6 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 7 7 hx-include="closest form" 8 8 hx-target="#pr-compose-host" 9 9 hx-swap="outerHTML"
+1 -1
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 6 6 name="fork" 7 7 required 8 8 class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 9 - hx-post="/{{ $.RepoInfo.FullName }}/pulls/new/refresh" 9 + hx-post="/{{ $.RepoInfo.RepoDid }}/pulls/new/refresh" 10 10 hx-include="closest form" 11 11 hx-target="#pr-compose-host" 12 12 hx-swap="outerHTML"
+1 -1
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
··· 2 2 <div class="flex flex-wrap gap-2 items-center"> 3 3 <select 4 4 name="sourceBranch" 5 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 5 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 6 6 hx-include="closest form" 7 7 hx-target="#pr-compose-host" 8 8 hx-swap="outerHTML"
+3 -7
appview/pages/templates/repo/pulls/fragments/pullComposeHost.html
··· 7 7 </div> 8 8 {{ end }} 9 9 10 - {{ $hasCommits := and .Comparison .Comparison.FormatPatch }} 11 - {{ $hasDiff := false }} 12 - {{ if .Diff }}{{ if .Diff.Diff }}{{ $hasDiff = true }}{{ end }}{{ end }} 13 - {{ $showDetails := and (or $hasCommits $hasDiff) (not .IsStacked) }} 10 + {{ $showDetails := .StepReviewParams }} 14 11 15 12 <form 16 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 13 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new" 17 14 hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:(#patch,#title,#body)" 18 15 hx-indicator="#create-pull-spinner" 19 - hx-target="body" 20 - hx-swap="innerHTML show:top" 16 + hx-swap="none" 21 17 class="flex flex-col gap-6" 22 18 > 23 19 <section class="relative flex flex-col gap-3">
+1 -1
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
··· 11 11 </div> 12 12 <textarea 13 13 hx-trigger="paste delay:100ms, change" 14 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 14 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 15 15 hx-include="closest form" 16 16 hx-target="#pr-compose-host" 17 17 hx-swap="outerHTML"
+2 -2
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
··· 16 16 17 17 <div class="mt-4 flex flex-col"> 18 18 <form 19 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 19 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/{{ .Pull.PullId }}/resubmit" 20 20 hx-swap="none" 21 21 class="w-full flex flex-wrap gap-2" 22 22 hx-indicator="#resubmit-spinner" ··· 45 45 <button 46 46 type="button" 47 47 class="btn flex items-center gap-2" 48 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 48 + hx-get="/{{ .RepoInfo.RepoDid }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 49 49 hx-swap="outerHTML" 50 50 hx-target="#resubmit-pull-card" 51 51 hx-indicator="#cancel-resubmit-spinner"
+1
appview/pages/templates/repo/pulls/fragments/pullStepDetails.html
··· 38 38 oninput="document.getElementById('titleDirty').value='1'" 39 39 class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700" 40 40 placeholder="One-line summary of your change." 41 + required 41 42 /> 42 43 </div> 43 44
+54 -364
appview/pages/templates/repo/pulls/fragments/pullStepReview.html
··· 1 1 {{ define "repo/pulls/fragments/pullStepReview" }} 2 + {{ $root := . }} 3 + {{ $params := .StepReviewParams }} 2 4 <section class="flex flex-col gap-3"> 3 - {{ if not .Comparison }} 5 + {{ if not $params }} 4 6 <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800/30 text-sm text-gray-600 dark:text-gray-400"> 5 - {{ if eq .Source "patch" }} 7 + {{ if eq $root.Source "patch" }} 6 8 Paste a patch above to see a comparison. 7 9 {{ else }} 8 10 Pick a source and target above to see a comparison. 9 11 {{ end }} 10 12 </div> 13 + {{ else if eq .Source "patch" }} 14 + <!-- TODO: implement patch based PR --> 11 15 {{ else }} 12 - {{ $commits := .Comparison.FormatPatch }} 13 - {{ if $commits }} 16 + {{ if $params.Commits }} 17 + {{ $commits := $params.Commits }} 14 18 <div class="flex flex-col gap-2"> 15 19 <div class="flex items-center justify-between gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400"> 16 - {{ if .IsStacked }} 17 - <span class="inline-flex items-center gap-2 flex-shrink-0 text-gray-900 dark:text-gray-100"> 18 - {{ i "chevrons-down-up" "w-4 h-4" }} 19 - <span>Stack</span> 20 - <span class="text-gray-500 dark:text-gray-400">{{ len $commits }} pull request{{ if ne (len $commits) 1 }}s{{ end }}</span> 21 - </span> 22 - {{ else }} 23 - <span class="flex-shrink-0">{{ len $commits }} commit{{ if ne (len $commits) 1 }}s{{ end }}</span> 24 - {{ end }} 25 - {{ if and .SourceBranch .TargetBranch }} 20 + <span class="flex-shrink-0">{{ len $commits }} commit{{ if ne (len $commits) 1 }}s{{ end }}</span> 21 + {{ if and $root.SourceBranch $root.TargetBranch }} 26 22 <span class="inline-flex items-center gap-2 font-mono text-gray-600 dark:text-gray-300 truncate"> 27 - <span>{{ .TargetBranch }}</span> 23 + <span>{{ $root.TargetBranch }}</span> 28 24 {{ i "arrow-left-right" "w-4 h-4 flex-shrink-0" }} 29 - <span>{{ .SourceBranch }}</span> 25 + <span>{{ $root.SourceBranch }}</span> 30 26 </span> 31 27 {{ end }} 32 28 </div> 33 - {{ if .IsStacked }} 34 - {{ template "pullReviewStackedCommits" . }} 35 - {{ else }} 36 - {{ template "pullReviewFlatCommits" . }} 37 - {{ end }} 29 + {{ template "pullReviewCommits" (list $root.SourceRepo $params.Commits) }} 38 30 </div> 39 - {{ else if ne .Source "patch" }} 31 + 32 + <!-- query diff on load --> 33 + <div class="w-full relative min-w-0"> 34 + <div 35 + id="diff-area" 36 + hx-get="/{{ $root.SourceRepo }}/pulls/_/composediff?baseRepo={{ $root.RepoInfo.RepoDid }}&base={{ $root.TargetBranch }}&headRepo={{ $root.SourceRepo }}&head={{ $root.SourceBranch }}&view=unified" 37 + hx-target="this" 38 + hx-swap="outerHTML" 39 + hx-trigger="load" 40 + > 41 + Loading... 42 + </div> 43 + </div> 44 + {{ else }} 40 45 <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800/30 text-sm text-gray-600 dark:text-gray-400"> 41 46 {{ if and .SourceBranch .TargetBranch (eq .SourceBranch .TargetBranch) }} 42 47 Source and target are the same branch, nothing to merge. ··· 45 50 {{ end }} 46 51 </div> 47 52 {{ end }} 48 - 49 - {{ if and .Diff (not .IsStacked) }} 50 - {{ template "pullComposeFlatDiff" (list .Diff .DiffOpts) }} 51 - {{ end }} 52 - 53 - {{ if and .IsStacked $commits }} 54 - {{ template "pullSubmitRow" . }} 55 - {{ template "pullStackApplyAllScript" }} 56 - {{ end }} 57 53 {{ end }} 58 54 </section> 59 55 {{ end }} 60 56 61 - {{ define "pullStackApplyAllScript" }} 62 - <script> 63 - (() => { 64 - const checkbox = document.getElementById('stack-apply-all'); 65 - if (!checkbox || checkbox.dataset.applyAllWired === '1') return; 66 - checkbox.dataset.applyAllWired = '1'; 67 - 68 - const update = () => { 69 - const panels = document.querySelectorAll('[data-stack-labels]'); 70 - panels.forEach((panel, idx) => { 71 - if (idx === 0) return; 72 - panel.classList.toggle('hidden', checkbox.checked); 73 - }); 74 - }; 75 - checkbox.addEventListener('change', update); 76 - update(); 77 - })(); 78 - </script> 79 - {{ end }} 80 - 81 - {{ define "pullReviewFlatCommits" }} 82 - {{ $commits := .Comparison.FormatPatch }} 57 + {{ define "pullReviewCommits" }} 58 + {{ $sourceRepo := index . 0 }} 59 + {{ $commits := index . 1 }} 83 60 <ul class="flex flex-col gap-2"> 84 61 {{ range $commits }} 62 + {{- $messageParts := splitN .Message "\n\n" 2 -}} 63 + {{- $title := index $messageParts 0 -}} 85 64 <li class="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 px-3 py-2 flex items-center gap-3"> 86 65 <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 87 - {{ i "git-pull-request-create" "w-4 h-4" }} 66 + {{ i "git-commit-vertical" "size-4" }} 88 67 </span> 89 - <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ .Title }}</span> 90 - {{ template "pullReviewCommitTimestamp" (dict "Patch" .) }} 91 - <span class="-my-2 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 92 - {{ template "pullReviewCommitActions" (dict "Patch" . "RepoInfo" $.RepoInfo) }} 93 - </li> 94 - {{ end }} 95 - </ul> 96 - {{ end }} 97 - 98 - {{ define "pullReviewStackedCommits" }} 99 - {{ $root := . }} 100 - {{ $commits := .Comparison.FormatPatch }} 101 - {{ $hasSidePanel := and $root.LabelDefs $root.RepoInfo.Roles.IsPushAllowed }} 102 - <ul class="flex flex-col gap-2"> 103 - {{ range $idx, $p := $commits }} 104 - {{ $cid := $p.ChangeIdOrEmpty }} 105 - {{ $titleOverride := index $root.StackTitles $cid }} 106 - {{ $bodyOverride := index $root.StackBodies $cid }} 107 - {{ $displayTitle := $p.Title }} 108 - {{ if $titleOverride }}{{ $displayTitle = $titleOverride }}{{ end }} 109 - {{ $bodyValue := $p.Body }} 110 - {{ if $bodyOverride }}{{ $bodyValue = $bodyOverride }}{{ end }} 111 - {{ $perDiff := "" }} 112 - {{ $perOpts := dict }} 113 - {{ if lt $idx (len $root.StackedDiffs) }} 114 - {{ $sd := index $root.StackedDiffs $idx }} 115 - {{ $perDiff = $sd.Diff }} 116 - {{ $perOpts = $sd.Opts }} 117 - {{ end }} 118 - <li> 119 - <details class="group/stacked border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800"> 120 - <summary class="p-3 cursor-pointer flex items-center gap-3 list-none"> 121 - <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 122 - {{ i "chevron-right" "w-4 h-4 group-open/stacked:hidden inline" }} 123 - {{ i "chevron-down" "w-4 h-4 hidden group-open/stacked:inline" }} 124 - </span> 125 - <span class="-my-3 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 126 - <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 127 - {{ i "git-pull-request-create" "w-4 h-4" }} 128 - </span> 129 - <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ $displayTitle }}</span> 130 - {{ template "pullReviewCommitTimestamp" (dict "Patch" $p) }} 131 - <span class="-my-3 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 132 - {{ template "pullReviewCommitActions" (dict "Patch" $p "RepoInfo" $root.RepoInfo) }} 133 - </summary> 134 - {{ if $cid }} 135 - <div class="px-3 pb-3 pt-1 flex flex-col gap-3 border-t border-gray-100 dark:border-gray-700"> 136 - {{ $titleName := printf "stackTitle[%s]" $cid }} 137 - {{ $bodyName := printf "stackBody[%s]" $cid }} 138 - <div class="flex flex-col md:flex-row gap-6"> 139 - <div class="flex-1 min-w-0 flex flex-col gap-3"> 140 - <div class="flex flex-col gap-1"> 141 - <label class="text-xs tracking-wide text-gray-800 dark:text-gray-200">Title</label> 142 - <input 143 - type="text" 144 - name="{{ $titleName }}" 145 - value="{{ $displayTitle }}" 146 - class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700" 147 - placeholder="{{ $p.Title }}" 148 - /> 149 - </div> 150 - {{ template "fragments/markdownEditor" (dict 151 - "Name" $bodyName 152 - "Value" $bodyValue 153 - "Rows" 4 154 - "Placeholder" "Describe this pull request. Markdown is supported." 155 - ) }} 156 - </div> 157 - {{ if $hasSidePanel }} 158 - <aside data-stack-labels="{{ $cid }}" class="w-full md:w-72 md:flex-shrink-0 flex flex-col gap-4"> 159 - {{ $labelState := $root.LabelState }} 160 - {{ if $root.StackLabelStates }} 161 - {{ $perCid := index $root.StackLabelStates $cid }} 162 - {{ if $perCid }}{{ $labelState = $perCid }}{{ end }} 163 - {{ end }} 164 - {{ $labelCtx := dict "Defs" $root.LabelDefs "State" $labelState "RepoInfo" $root.RepoInfo "Subject" "" "LoggedInUser" $root.LoggedInUser "Prefix" (printf "stackLabel[%s]" $cid) }} 165 - {{ template "editBasicLabels" $labelCtx }} 166 - {{ template "editKvLabels" $labelCtx }} 167 - {{ if eq $idx 0 }} 168 - <label class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 self-start"> 169 - <input type="checkbox" id="stack-apply-all" name="applyLabelsToAll" autocomplete="off" /> 170 - Apply labels and assignees to all PRs in stack 171 - </label> 172 - {{ end }} 173 - </aside> 174 - {{ end }} 175 - </div> 176 - {{ if $perDiff }} 177 - <hr class="border-gray-200 dark:border-gray-700 my-1" /> 178 - <div id="stack-diff-{{ $cid }}"> 179 - <input type="hidden" name="stackSplit[{{ $cid }}]" value="{{ if $perOpts.Split }}split{{ else }}unified{{ end }}" /> 180 - {{ template "pullStackedDiffArea" (dict "Diff" $perDiff "DiffOpts" $perOpts "Cid" $cid) }} 181 - </div> 182 - {{ end }} 183 - </div> 184 - {{ else }} 185 - <div class="px-3 pb-3 pt-1 text-sm text-yellow-700 dark:text-yellow-300 border-t border-gray-100 dark:border-gray-700"> 186 - This commit has no <span class="font-mono">Change-Id</span> header and can't be stacked. Set one on the commit and re-push. 187 - </div> 68 + <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ $title }}</span> 69 + <span class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> 70 + {{ if not .Author.When.IsZero }} 71 + {{ template "repo/fragments/shortTimeAgo" .Author.When }} 188 72 {{ end }} 189 - </details> 190 - </li> 191 - {{ end }} 192 - </ul> 193 - {{ end }} 194 - 195 - {{ define "pullReviewCommitTimestamp" }} 196 - {{ $p := .Patch }} 197 - {{ if or (not $p.AuthorDate.IsZero) $p.SHA }} 198 - <span class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> 199 - {{ if not $p.AuthorDate.IsZero }} 200 - {{ template "repo/fragments/shortTimeAgo" $p.AuthorDate }} 201 - {{ end }} 202 - {{ if $p.SHA }} 203 - <span class="font-mono bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300 px-2 py-0.5 rounded">{{ slice $p.SHA 0 8 }}</span> 204 - {{ end }} 205 - </span> 206 - {{ end }} 207 - {{ end }} 208 - 209 - {{ define "pullReviewCommitActions" }} 210 - {{ $p := .Patch }} 211 - {{ $repoInfo := .RepoInfo }} 212 - {{ if $p.SHA }} 213 - <span class="flex items-center gap-1 text-gray-700 dark:text-gray-300 flex-shrink-0"> 214 - <button type="button" 215 - class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 216 - title="Copy SHA" 217 - onclick="event.preventDefault(); event.stopPropagation(); navigator.clipboard.writeText('{{ $p.SHA }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 218 - {{ i "copy" "w-4 h-4" }} 219 - </button> 220 - <a href="/{{ $repoInfo.FullName }}/tree/{{ $p.SHA }}" 221 - onclick="event.stopPropagation()" 222 - class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 223 - title="Browse repository at this commit"> 224 - {{ i "folder-code" "w-4 h-4" }} 225 - </a> 226 - </span> 227 - {{ end }} 228 - {{ end }} 229 - 230 - {{ define "pullStackedDiffArea" }} 231 - {{ $diff := .Diff }} 232 - {{ $opts := .DiffOpts }} 233 - {{ $cid := .Cid }} 234 - {{ $togId := printf "stack-%s-filesToggle" $cid }} 235 - {{ $colId := printf "stack-%s-collapseToggle" $cid }} 236 - {{ $filesId := printf "stack-%s-files" $cid }} 237 - {{ $diffAreaId := printf "stack-%s-diff-area" $cid }} 238 - {{ $filePrefix := printf "stack-%s-file-" $cid }} 239 - {{ $stat := $diff.Stats }} 240 - {{ $count := len $diff.ChangedFiles }} 241 - 242 - <style> 243 - #{{ $togId }}:checked ~ * label[for="{{ $togId }}"] .show-text { display: none; } 244 - #{{ $togId }}:checked ~ * label[for="{{ $togId }}"] .hide-text { display: inline; } 245 - #{{ $togId }}:not(:checked) ~ * label[for="{{ $togId }}"] .hide-text { display: none; } 246 - #{{ $togId }}:checked ~ * div#{{ $filesId }} { width: fit-content; max-width: 15vw; } 247 - #{{ $togId }}:not(:checked) ~ * div#{{ $filesId }} { width: 0; display: none; margin-right: 0; } 248 - </style> 249 - 250 - <input type="checkbox" id="{{ $togId }}" class="hidden"/> 251 - 252 - <div id="{{ $diffAreaId }}"> 253 - <div class="bg-slate-100 dark:bg-gray-700/50 flex items-center gap-2 h-12 p-2 rounded-t-md border border-b-0 border-gray-200 dark:border-gray-700"> 254 - <label title="Toggle filetree panel" for="{{ $togId }}" class="hidden md:inline-flex items-center justify-center rounded cursor-pointer text-normal font-normal normal-case"> 255 - <span class="show-text">{{ i "panel-left-open" "size-4" }}</span> 256 - <span class="hide-text">{{ i "panel-left-close" "size-4" }}</span> 257 - </label> 258 - 259 - {{ template "repo/fragments/diffStatPill" $stat }} 260 - <span class="text-xs text-gray-600 dark:text-gray-300 hidden md:inline-flex">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 261 - 262 - <div class="flex-grow"></div> 263 - 264 - <label title="Expand/Collapse diffs" for="{{ $colId }}" class="btn font-normal normal-case"> 265 - <input type="checkbox" id="{{ $colId }}" class="peer/collapse hidden"/> 266 - <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 267 - {{ i "unfold-vertical" "w-4 h-4" }} 268 - <span class="hidden md:inline">Expand all</span> 73 + <span class="font-mono bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300 px-2 py-0.5 rounded">{{ slice .Hash.String 0 8 }}</span> 269 74 </span> 270 - <span class="peer-checked/collapse:inline-flex hidden flex items-center gap-2"> 271 - {{ i "fold-vertical" "w-4 h-4" }} 272 - <span class="hidden md:inline">Collapse all</span> 75 + <span class="-my-2 self-stretch w-px bg-gray-200 dark:bg-gray-700"></span> 76 + <span class="flex items-center gap-1 text-gray-700 dark:text-gray-300 flex-shrink-0"> 77 + <button type="button" 78 + class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 79 + title="Copy SHA" 80 + onclick='event.preventDefault(); event.stopPropagation(); navigator.clipboard.writeText(`{{ .Hash.String }}`); this.innerHTML=`{{ i "copy-check" "size-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "size-4" }}`, 1500)'> 81 + {{ i "copy" "size-4" }} 82 + </button> 83 + <a href="/{{ $sourceRepo }}/tree/{{ .Hash.String }}" 84 + onclick="event.stopPropagation()" 85 + class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 86 + title="Browse repository at this commit"> 87 + {{ i "folder-code" "size-4" }} 88 + </a> 273 89 </span> 274 - </label> 275 - 276 - {{ template "repo/fragments/diffOpts" $opts }} 277 - </div> 278 - 279 - <div class="flex border border-gray-200 dark:border-gray-700 rounded-b-md"> 280 - <div id="{{ $filesId }}" class="hidden md:block overflow-hidden max-h-[60vh] overflow-y-auto border-r border-gray-200 dark:border-gray-700"> 281 - <section class="overflow-x-auto text-sm px-3 py-2 w-full mx-auto"> 282 - {{ template "repo/fragments/fileTreePrefixed" (dict "Tree" $diff.FileTree "Prefix" $filePrefix) }} 283 - </section> 284 - </div> 285 - 286 - <div class="flex-1 min-w-0 p-2"> 287 - <div class="flex flex-col gap-2"> 288 - {{ if eq $count 0 }} 289 - <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 290 - <p>No differences found.</p> 291 - </div> 292 - {{ else if le $count 5 }} 293 - {{ range $idx, $file := $diff.ChangedFiles }} 294 - {{ template "stackedDiffFile" (dict "Idx" $idx "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 295 - {{ end }} 296 - {{ else }} 297 - {{ range $idx, $file := slice $diff.ChangedFiles 0 5 }} 298 - {{ template "stackedDiffFile" (dict "Idx" $idx "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 299 - {{ end }} 300 - {{ $remaining := sub $count 5 }} 301 - <details class="group/showmore"> 302 - <summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-300 py-2 px-1 flex items-center gap-2 select-none hover:text-gray-900 dark:hover:text-gray-100 list-none"> 303 - <span class="group-open/showmore:hidden inline-flex items-center gap-2"> 304 - {{ i "chevron-right" "w-4 h-4" }} 305 - Show {{ $remaining }} more file{{ if ne $remaining 1 }}s{{ end }} 306 - </span> 307 - <span class="hidden group-open/showmore:inline-flex items-center gap-2"> 308 - {{ i "chevron-down" "w-4 h-4" }} 309 - Hide {{ $remaining }} file{{ if ne $remaining 1 }}s{{ end }} 310 - </span> 311 - </summary> 312 - <div class="flex flex-col gap-2 mt-2"> 313 - {{ range $idx, $file := slice $diff.ChangedFiles 5 }} 314 - {{ template "stackedDiffFile" (dict "Idx" (add $idx 5) "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 315 - {{ end }} 316 - </div> 317 - </details> 318 - {{ end }} 319 - </div> 320 - </div> 321 - </div> 322 - </div> 323 - 324 - <script> 325 - (() => { 326 - const cb = document.getElementById('{{ $colId }}'); 327 - const area = document.getElementById('{{ $diffAreaId }}'); 328 - if (!cb || !area) return; 329 - const all = () => area.querySelectorAll('details[id^="{{ $filePrefix }}"]'); 330 - cb.addEventListener('change', () => { 331 - all().forEach(d => { d.open = cb.checked; }); 332 - }); 333 - area.addEventListener('toggle', (e) => { 334 - if (!e.target.matches('details[id^="{{ $filePrefix }}"]')) return; 335 - const dets = Array.from(all()); 336 - const allOpen = dets.every(d => d.open); 337 - const allClosed = dets.every(d => !d.open); 338 - if (allOpen) cb.checked = true; 339 - else if (allClosed) cb.checked = false; 340 - }, true); 341 - })(); 342 - </script> 343 - {{ end }} 344 - 345 - {{ define "stackedDiffFile" }} 346 - {{ $idx := .Idx }} 347 - {{ $file := .File }} 348 - {{ $isSplit := .IsSplit }} 349 - {{ $prefix := .Prefix }} 350 - {{ $isGenerated := false }} 351 - {{ $isDeleted := false }} 352 - {{ with $file }} 353 - {{ $n := .Names }} 354 - {{ $isDeleted = and (eq $n.New "") (ne $n.Old "") }} 355 - {{ if $n.New }} 356 - {{ $isGenerated = isGenerated $n.New }} 357 - {{ else if $n.Old }} 358 - {{ $isGenerated = isGenerated $n.Old }} 90 + </li> 359 91 {{ end }} 360 - <details id="{{ $prefix }}{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 361 - <summary class="list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700"> 362 - <div class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 363 - <div class="p-2 flex gap-2 items-center overflow-x-auto"> 364 - <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 365 - <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 366 - {{ template "repo/fragments/diffStatPill" .Stats }} 367 - <div class="flex gap-2 items-center overflow-x-auto"> 368 - {{ if and $n.New $n.Old (ne $n.New $n.Old)}} 369 - {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }} 370 - {{ else if $n.New }} 371 - {{ $n.New }} 372 - {{ else }} 373 - {{ $n.Old }} 374 - {{ end }} 375 - {{ if $isDeleted }} 376 - <span class="text-gray-400 dark:text-gray-500" title="Deleted files are collapsed by default"> 377 - {{ i "circle-question-mark" "size-4" }} 378 - </span> 379 - {{ else if $isGenerated }} 380 - <span class="text-gray-400 dark:text-gray-500" title="Generated files are collapsed by default"> 381 - {{ i "circle-question-mark" "size-4" }} 382 - </span> 383 - {{ end }} 384 - </div> 385 - </div> 386 - </div> 387 - </summary> 388 - 389 - <div class="transition-all duration-700 ease-in-out"> 390 - {{ $reason := .CanRender }} 391 - {{ if $reason }} 392 - <p class="text-center text-gray-400 dark:text-gray-500 p-4">{{ $reason }}</p> 393 - {{ else }} 394 - {{ if $isSplit }} 395 - {{- template "repo/fragments/splitDiff" .Split -}} 396 - {{ else }} 397 - {{- template "repo/fragments/unifiedDiff" . -}} 398 - {{ end }} 399 - {{- end -}} 400 - </div> 401 - </details> 402 - {{ end }} 92 + </ul> 403 93 {{ end }} 404 94 405 95 {{ define "pullComposeFlatDiff" }}
+8 -34
appview/pages/templates/repo/pulls/fragments/pullStepSource.html
··· 28 28 {{ end }} 29 29 30 30 <div id="patch-error" class="error dark:text-red-300 empty:hidden"></div> 31 - 32 - {{ if ne .Source "patch" }} 33 - <div class="flex items-center gap-2"> 34 - <input type="checkbox" id="mode-stack" name="mode" value="stack" autocomplete="off" {{ if .IsStacked }}checked{{ end }} 35 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 36 - hx-include="closest form" 37 - hx-target="#pr-compose-host" 38 - hx-swap="outerHTML" 39 - hx-trigger="change" 40 - hx-indicator="this" 41 - class="peer"> 42 - <label for="mode-stack" class="my-0 py-0 normal-case font-normal dark:text-white"> 43 - Submit as stacked PRs 44 - </label> 45 - <a 46 - href="https://blog.tangled.org/stacking" 47 - target="_blank" 48 - rel="noopener noreferrer" 49 - aria-label="What are stacked PRs?" 50 - class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white peer-[.htmx-request]:hidden" 51 - > 52 - {{ i "circle-question-mark" "size-4" }} 53 - </a> 54 - {{ i "loader-circle" "size-4 animate-spin hidden peer-[.htmx-request]:inline text-gray-500 dark:text-gray-400" }} 55 - </div> 56 - {{ end }} 57 31 </section> 58 32 {{ end }} 59 33 ··· 63 37 id="targetBranch" 64 38 name="targetBranch" 65 39 required 66 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 40 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 67 41 hx-include="closest form" 68 42 hx-target="#pr-compose-host" 69 43 hx-swap="outerHTML" ··· 99 73 {{ $shared := "group flex-1 p-3 text-left hover:no-underline flex flex-col gap-1 rounded" }} 100 74 {{ $titleCls := "font-medium text-sm dark:text-white flex items-center gap-2" }} 101 75 {{ $descCls := "text-xs text-gray-500 dark:text-gray-400" }} 102 - {{ $fullName := .RepoInfo.FullName }} 103 76 <div class="flex gap-1 p-1.5 rounded-md bg-slate-100 dark:bg-gray-900 border dark:border-gray-700 items-center justify-stretch" 104 77 hx-on::before-request="const t=event.target.closest('button'); if(!t||t.classList.contains('shadow-sm'))return event.preventDefault();"> 105 78 {{ if .RepoInfo.Roles.IsPushAllowed }} 106 79 <button 107 80 type="button" 108 - class="{{ $shared }} {{ if eq .Source "branch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 109 - hx-post="/{{ $fullName }}/pulls/new/refresh" 81 + class='{{ $shared }} {{ if eq .Source "branch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}' 82 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 110 83 hx-vals='{"source": "branch"}' 111 84 hx-include="closest form" 112 85 hx-target="#pr-compose-host" ··· 122 95 {{ end }} 123 96 <button 124 97 type="button" 125 - class="{{ $shared }} {{ if eq .Source "fork" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 126 - hx-post="/{{ $fullName }}/pulls/new/refresh" 98 + class='{{ $shared }} {{ if eq .Source "fork" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}' 99 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 127 100 hx-vals='{"source": "fork"}' 128 101 hx-include="closest form" 129 102 hx-target="#pr-compose-host" ··· 138 111 </button> 139 112 <button 140 113 type="button" 141 - class="{{ $shared }} {{ if eq .Source "patch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 142 - hx-post="/{{ $fullName }}/pulls/new/refresh" 114 + disabled 115 + class='{{ $shared }} cursor-not-allowed {{ if eq .Source "patch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}' 116 + hx-post="/{{ .RepoInfo.RepoDid }}/pulls/new/refresh" 143 117 hx-vals='{"source": "patch"}' 144 118 hx-include="closest form" 145 119 hx-target="#pr-compose-host"
-629
appview/pages/templates/repo/pulls/pull.html
··· 1 - {{ define "title" }} 2 - {{ .Pull.Title }} &middot; Pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} &middot; Tangled 3 - {{ end }} 4 - 5 - {{ define "extrameta" }} 6 - {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 - {{ end }} 8 - 9 - {{ define "mainLayout" }} 10 - <div class="px-1 flex-grow flex flex-col gap-4"> 11 - <div class="max-w-full md:max-w-screen-lg mx-auto"> 12 - {{ block "contentLayout" . }} 13 - {{ block "content" . }}{{ end }} 14 - {{ end }} 15 - </div> 16 - {{ block "contentAfterLayout" . }} 17 - <main> 18 - {{ block "contentAfter" . }}{{ end }} 19 - </main> 20 - {{ end }} 21 - </div> 22 - <script> 23 - (function() { 24 - const details = document.getElementById('bottomSheet'); 25 - const backdrop = document.getElementById('bottomSheetBackdrop'); 26 - const isDesktop = () => window.matchMedia('(min-width: 1024px)').matches; 27 - 28 - // function to update backdrop 29 - const updateBackdrop = () => { 30 - if (backdrop) { 31 - if (details.open && !isDesktop()) { 32 - backdrop.classList.remove('opacity-0', 'pointer-events-none'); 33 - backdrop.classList.add('opacity-100', 'pointer-events-auto'); 34 - document.body.style.overflow = 'hidden'; 35 - } else { 36 - backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 37 - backdrop.classList.add('opacity-0', 'pointer-events-none'); 38 - document.body.style.overflow = ''; 39 - } 40 - } 41 - }; 42 - 43 - // close on mobile initially 44 - if (!isDesktop()) { 45 - details.open = false; 46 - } 47 - updateBackdrop(); // initialize backdrop 48 - 49 - // prevent closing on desktop 50 - details.addEventListener('toggle', function(e) { 51 - if (isDesktop() && !this.open) { 52 - this.open = true; 53 - } 54 - updateBackdrop(); 55 - }); 56 - 57 - const mediaQuery = window.matchMedia('(min-width: 1024px)'); 58 - mediaQuery.addEventListener('change', function(e) { 59 - if (e.matches) { 60 - // switched to desktop - keep open 61 - details.open = true; 62 - } else { 63 - // switched to mobile - close 64 - details.open = false; 65 - } 66 - updateBackdrop(); 67 - }); 68 - 69 - // close when clicking backdrop 70 - if (backdrop) { 71 - backdrop.addEventListener('click', () => { 72 - if (!isDesktop()) { 73 - details.open = false; 74 - } 75 - }); 76 - } 77 - })(); 78 - </script> 79 - <script> 80 - (function() { 81 - const isPermalink = (id) => id.startsWith('comment-') || id.startsWith('round-'); 82 - 83 - const reveal = () => { 84 - const raw = window.location.hash.slice(1); 85 - if (!raw) return; 86 - let id; 87 - try { id = decodeURIComponent(raw); } catch (e) { return; } 88 - if (!isPermalink(id)) return; 89 - const target = document.getElementById(id); 90 - if (!target) return; 91 - 92 - for (let el = target.parentElement; el; el = el.parentElement) { 93 - if (el.tagName === 'DETAILS') el.open = true; 94 - } 95 - 96 - requestAnimationFrame(() => requestAnimationFrame(() => { 97 - target.scrollIntoView({ block: 'center' }); 98 - const card = target.closest('.group\\/comment') || target; 99 - card.classList.add('comment-hl'); 100 - })); 101 - }; 102 - 103 - if (document.readyState === 'loading') { 104 - document.addEventListener('DOMContentLoaded', reveal); 105 - } else { 106 - reveal(); 107 - } 108 - window.addEventListener('hashchange', reveal); 109 - })(); 110 - </script> 111 - {{ end }} 112 - 113 - {{ define "repoContentLayout" }} 114 - <div class="grid grid-cols-1 lg:grid-cols-10 gap-4 w-full"> 115 - <div class="col-span-1 lg:col-span-8 flex flex-col gap-4"> 116 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 117 - {{ block "repoContent" . }}{{ end }} 118 - </section> 119 - {{ template "repo/pulls/fragments/pullVouchNudge" . }} 120 - </div> 121 - <div class="flex flex-col gap-6 col-span-1 lg:col-span-2"> 122 - {{ template "repo/fragments/labelPanel" 123 - (dict "RepoInfo" $.RepoInfo 124 - "Defs" $.LabelDefs 125 - "Subject" $.Pull.AtUri 126 - "State" $.Pull.Labels) }} 127 - {{ template "repo/fragments/participants" $.Pull.Participants }} 128 - {{ if $.LoggedInUser }} 129 - {{ template "repo/pulls/fragments/subscribeButton" 130 - (dict "RepoInfo" $.RepoInfo 131 - "PullId" $.Pull.PullId 132 - "IsSubscribed" $.IsSubscribed) }} 133 - {{ end }} 134 - {{ template "repo/fragments/backlinks" 135 - (dict "RepoInfo" $.RepoInfo 136 - "Backlinks" $.Backlinks) }} 137 - {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 138 - </div> 139 - </div> 140 - {{ end }} 141 - 142 - {{ define "contentAfter" }} 143 - <input type="hidden" id="round-link-base" value="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .ActiveRound }}" /> 144 - {{ if .IsInterdiff }} 145 - <input type="hidden" id="is-interdiff" value="1" /> 146 - {{ end }} 147 - {{ template "repo/fragments/diff" (list .Diff .DiffOpts $) }} 148 - {{ end }} 149 - 150 - {{ define "repoContent" }} 151 - {{ template "repo/pulls/fragments/pullHeader" . }} 152 - {{ if (gt (len .Stack) 1) }} 153 - <div class="mt-8"> 154 - {{ template "repo/pulls/fragments/pullStack" . }} 155 - </div> 156 - {{ end }} 157 - {{ end }} 158 - 159 - {{ define "resize-grip" }} 160 - {{ $id := index . 0 }} 161 - {{ $target := index . 1 }} 162 - {{ $direction := index . 2 }} 163 - <div id="{{ $id }}" 164 - data-resizer="vertical" 165 - data-target="{{ $target }}" 166 - data-direction="{{ $direction }}" 167 - class="resizer-vertical hidden lg:flex w-4 sticky top-12 max-h-screen flex-col items-center justify-center group"> 168 - <div class="w-1 h-16 group-hover:h-24 group-[.resizing]:h-24 transition-all rounded-full bg-gray-400 dark:bg-gray-500 group-hover:bg-gray-500 group-hover:dark:bg-gray-400"></div> 169 - </div> 170 - {{ end }} 171 - 172 - {{ define "diffLayout" }} 173 - {{ $diff := index . 0 }} 174 - {{ $opts := index . 1 }} 175 - {{ $root := index . 2 }} 176 - 177 - <div class="flex col-span-full"> 178 - <!-- left panel --> 179 - <div id="files" class="w-0 hidden lg:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 180 - <section class="overflow-x-auto text-sm px-6 py-2 border-b border-x border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded-b rounded-t-none bg-white dark:bg-gray-800 shadow-sm"> 181 - {{ template "repo/fragments/fileTree" $diff.FileTree }} 182 - </section> 183 - </div> 184 - 185 - {{ template "resize-grip" (list "resize-files" "files" "before") }} 186 - 187 - <!-- main content --> 188 - <div id="diff-files" class="flex-1 min-w-0 sticky top-12 pb-12"> 189 - {{ template "diffFiles" (list $diff $opts) }} 190 - </div> 191 - 192 - {{ template "resize-grip" (list "resize-subs" "subs" "after") }} 193 - 194 - <!-- right panel --> 195 - {{ template "subsPanel" $ }} 196 - </div> 197 - {{ end }} 198 - 199 - {{ define "subsPanel" }} 200 - {{ $root := index . 2 }} 201 - {{ $pull := $root.Pull }} 202 - {{ $bgColor := "bg-gray-600 dark:bg-gray-700" }} 203 - 204 - {{ if $pull.State.IsOpen }} 205 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 206 - {{ else if $pull.State.IsMerged }} 207 - {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 208 - {{ else if $pull.State.IsAbandoned }} 209 - {{ $bgColor = "bg-red-600 dark:bg-red-700" }} 210 - {{ end }} 211 - 212 - <!-- backdrop overlay - only visible on mobile when open --> 213 - <div id="bottomSheetBackdrop" class="fixed inset-0 bg-black/50 lg:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-20"></div> 214 - <!-- right panel - bottom sheet on mobile, side panel on desktop --> 215 - <div id="subs" class="fixed bottom-0 left-0 right-0 z-30 w-full lg:static lg:z-auto lg:max-h-screen lg:sticky lg:top-12 overflow-hidden"> 216 - <details open id="bottomSheet" class="rounded-t-2xl lg:rounded-t shadow-lg lg:shadow-none group/panel"> 217 - <summary class=" 218 - flex gap-4 items-center justify-between 219 - rounded-t-2xl lg:rounded-t cursor-pointer list-none p-4 lg:h-12 220 - text-white lg:text-black lg:dark:text-white 221 - {{ $bgColor }} 222 - lg:bg-white lg:dark:bg-gray-800 223 - shadow-sm border-t lg:border-x border-gray-200 dark:border-gray-700 224 - lg:pointer-events-none 225 - "> 226 - <h2 class="">History</h2> 227 - <span class="pointer-events-auto cursor-text"> 228 - {{ template "subsPanelSummary" $ }} 229 - </span> 230 - </summary> 231 - <div class="max-h-[85vh] lg:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col-reverse gap-4 overflow-y-auto bg-slate-100 dark:bg-gray-900 lg:bg-transparent"> 232 - {{ template "submissions" $root }} 233 - </div> 234 - </details> 235 - </div> 236 - {{ end }} 237 - 238 - {{ define "subsPanelSummary" }} 239 - {{ $root := index . 2 }} 240 - {{ $pull := $root.Pull }} 241 - {{ $rounds := len $pull.Submissions }} 242 - {{ $comments := $pull.TotalComments }} 243 - <div class="flex items-center gap-2 text-sm"> 244 - <span> 245 - {{ $rounds }} round{{ if ne $rounds 1 }}s{{ end }} 246 - </span> 247 - <span class="select-none before:content-['\00B7']"></span> 248 - <span> 249 - {{ $comments }} comment{{ if ne $comments 1 }}s{{ end }} 250 - </span> 251 - 252 - <span class="lg:hidden inline"> 253 - <span class="inline group-open:hidden">{{ i "chevron-up" "size-4" }}</span> 254 - <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 255 - </span> 256 - </div> 257 - {{ end }} 258 - 259 - {{ define "subsCheckbox" }} 260 - <input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/> 261 - {{ end }} 262 - 263 - {{ define "subsToggle" }} 264 - <style> 265 - #subsToggle:checked ~ div div#subs { 266 - width: 100%; 267 - margin-left: 0; 268 - } 269 - #subsToggle:checked ~ div label[for="subsToggle"] .show-toggle { display: none; } 270 - #subsToggle:checked ~ div label[for="subsToggle"] .hide-toggle { display: flex; } 271 - #subsToggle:not(:checked) ~ div label[for="subsToggle"] .hide-toggle { display: none; } 272 - 273 - @media (min-width: 1024px) { 274 - #subsToggle:checked ~ div div#subs { 275 - width: 25vw; 276 - max-width: 50vw; 277 - } 278 - #subsToggle:not(:checked) ~ div div#subs { 279 - width: 0; 280 - display: none; 281 - margin-left: 0; 282 - } 283 - #subsToggle:not(:checked) ~ div div#resize-subs { 284 - display: none; 285 - } 286 - } 287 - </style> 288 - <label title="Toggle review panel" for="subsToggle" class="hidden lg:flex items-center justify-end pointer-events-none"> 289 - <span class="show-toggle hit-area hit-area-4 hit-area-x-2 pointer-events-auto cursor-pointer">{{ i "message-square-more" "size-4" }}</span> 290 - <span class="hide-toggle w-[25vw] justify-end"><span class="hit-area hit-area-4 hit-area-x-2 pointer-events-auto cursor-pointer">{{ i "message-square" "size-4" }}</span></span> 291 - </label> 292 - {{ end }} 293 - 294 - 295 - {{ define "submissions" }} 296 - {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 297 - {{ if not .LoggedInUser }} 298 - {{ template "loginPrompt" $ }} 299 - {{ end }} 300 - {{ range $ridx, $item := reverse .Pull.Submissions }} 301 - {{ $idx := sub $lastIdx $ridx }} 302 - {{ template "submission" (list $item $idx $lastIdx $) }} 303 - {{ end }} 304 - {{ end }} 305 - 306 - {{ define "submission" }} 307 - {{ $item := index . 0 }} 308 - {{ $idx := index . 1 }} 309 - {{ $lastIdx := index . 2 }} 310 - {{ $root := index . 3 }} 311 - {{ $round := $item.RoundNumber }} 312 - <div id="round-{{ $round }}" class=" 313 - w-full shadow-sm border overflow-clip 314 - 315 - {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 316 - {{ if eq $round $root.ActiveRound }} 317 - bg-blue-50/25 dark:bg-blue-900/10 border-blue-200 dark:border-blue-900 318 - {{ else }} 319 - bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700 320 - {{ end }} 321 - "> 322 - {{ template "submissionHeader" $ }} 323 - {{ template "submissionComments" $ }} 324 - </div> 325 - {{ end }} 326 - 327 - {{ define "submissionHeader" }} 328 - {{ $item := index . 0 }} 329 - {{ $lastIdx := index . 2 }} 330 - {{ $root := index . 3 }} 331 - {{ $round := $item.RoundNumber }} 332 - <div class=" 333 - {{ if ne $round 0 }}rounded-t{{ end }} 334 - px-6 py-4 pr-2 pt-2 335 - {{ if eq $round $root.ActiveRound }} 336 - bg-blue-50 dark:bg-blue-950 337 - {{ else }} 338 - bg-white dark:bg-gray-800 339 - {{ end }} 340 - 341 - flex gap-2 sticky top-0 z-20"> 342 - <!-- left column: just profile picture --> 343 - <div class="flex-shrink-0 pt-2"> 344 - {{ template "user/fragments/picLink" (list $root.Pull.OwnerDid "size-8" (index $root.VouchRelationships (did $root.Pull.OwnerDid))) }} 345 - </div> 346 - <!-- right column --> 347 - <div class="flex-1 min-w-0 flex flex-col gap-1"> 348 - {{ template "submissionInfo" $ }} 349 - {{ template "submissionCommits" $ }} 350 - {{ template "submissionPipeline" $ }} 351 - {{ if eq $lastIdx $round }} 352 - <div id="mergecheck-banner"> 353 - {{ if $root.Pull.State.IsOpen }} 354 - <div class="flex items-center gap-2 text-gray-500 dark:text-gray-400"> 355 - {{ i "loader-circle" "w-4 h-4 animate-spin" }} 356 - <span>Checking mergeability…</span> 357 - </div> 358 - {{ end }} 359 - </div> 360 - {{ end }} 361 - </div> 362 - </div> 363 - {{ end }} 364 - 365 - {{ define "submissionInfo" }} 366 - {{ $item := index . 0 }} 367 - {{ $idx := index . 1 }} 368 - {{ $root := index . 3 }} 369 - {{ $round := $item.RoundNumber }} 370 - <div class="flex gap-2 items-center justify-between mb-1"> 371 - <span class="inline-flex items-center gap-2 text-sm 372 - {{ if eq $round $root.ActiveRound }} 373 - text-gray-600 dark:text-gray-300 374 - {{ else }} 375 - text-gray-500 dark:text-gray-400 376 - {{ end }} 377 - pt-2"> 378 - {{ $handle := resolve $root.Pull.OwnerDid }} 379 - <a class=" 380 - {{ if eq $round $root.ActiveRound }} 381 - text-gray-800 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-200 382 - {{ else }} 383 - text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 384 - {{ end }} 385 - " href="/{{ $handle }}">{{ $handle }}</a> 386 - submitted 387 - <span class="px-2 py-0.5 rounded font-mono text-xs border 388 - {{ if eq $round $root.ActiveRound }} 389 - text-blue-800 dark:text-white bg-blue-100 dark:bg-blue-600 border-blue-200 dark:border-blue-500 390 - {{ else }} 391 - text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 392 - {{ end }} 393 - "> 394 - #{{ $round }} 395 - </span> 396 - <span class="select-none before:content-['\00B7']"></span> 397 - <a class=" 398 - {{ if eq $round $root.ActiveRound }} 399 - text-gray-600 dark:text-gray-300 hover:text-gray-600 dark:hover:text-gray-200 400 - {{ else }} 401 - text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 402 - {{ end }} 403 - " href="#round-{{ $round }}"> 404 - {{ template "repo/fragments/shortTime" $item.Created }} 405 - </a> 406 - </span> 407 - <div class="flex gap-2 items-center"> 408 - {{ if or $root.IsInterdiff (ne $root.ActiveRound $round) }} 409 - <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 410 - href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}#round-{{ $round }}"> 411 - {{ i "diff" "w-4 h-4" }} 412 - Diff 413 - </a> 414 - {{ end }} 415 - {{ if and (ne $idx 0) (or (not $root.IsInterdiff) (ne $root.ActiveRound $round)) }} 416 - <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 417 - href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}/interdiff?{{ safeUrl $root.DiffOpts.Encode }}"> 418 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 419 - Interdiff 420 - </a> 421 - {{ end }} 422 - </div> 423 - </div> 424 - {{ end }} 425 - 426 - {{ define "submissionCommits" }} 427 - {{ $item := index . 0 }} 428 - {{ $root := index . 3 }} 429 - {{ $round := $item.RoundNumber }} 430 - {{ $patches := $item.AsFormatPatch }} 431 - {{ if $patches }} 432 - <details class="group/commit"> 433 - <summary class="list-none cursor-pointer flex items-center gap-2"> 434 - <span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span> 435 - {{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }} 436 - <div class="text-sm text-gray-500 dark:text-gray-400"> 437 - <span class="group-open/commit:hidden inline">Expand</span> 438 - <span class="hidden group-open/commit:inline">Collapse</span> 439 - </div> 440 - </summary> 441 - {{ range $patches }} 442 - {{ template "submissionCommit" (list . $item $root) }} 443 - {{ end }} 444 - </details> 445 - {{ end }} 446 - {{ end }} 447 - 448 - {{ define "submissionCommit" }} 449 - {{ $patch := index . 0 }} 450 - {{ $item := index . 1 }} 451 - {{ $root := index . 2 }} 452 - {{ $round := $item.RoundNumber }} 453 - {{ with $patch }} 454 - <div id="commit-{{.SHA}}" class="py-1 relative w-full md:max-w-3/5 md:w-fit flex flex-col text-gray-600 dark:text-gray-300"> 455 - <div class="flex items-baseline gap-2"> 456 - <div class="text-xs"> 457 - <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 458 - {{ $fullRepo := "" }} 459 - {{ if and $root.Pull.IsForkBased $root.Pull.PullSource.Repo }} 460 - {{ $fullRepo = printf "%s/%s" (resolve $root.Pull.PullSource.Repo.Did) $root.Pull.PullSource.Repo.Slug }} 461 - {{ else if $root.Pull.IsBranchBased }} 462 - {{ $fullRepo = $root.RepoInfo.FullName }} 463 - {{ end }} 464 - 465 - <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 466 - {{ if $fullRepo }} 467 - <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a> 468 - {{ else }} 469 - <span class="font-mono">{{ slice .SHA 0 8 }}</span> 470 - {{ end }} 471 - </div> 472 - 473 - <div> 474 - <span>{{ .Title | description }}</span> 475 - {{ if gt (len .Body) 0 }} 476 - <button 477 - class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 478 - hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 479 - > 480 - {{ i "ellipsis" "w-3 h-3" }} 481 - </button> 482 - {{ end }} 483 - {{ if gt (len .Body) 0 }} 484 - <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 pb-2">{{ nl2br .Body }}</p> 485 - {{ end }} 486 - </div> 487 - </div> 488 - </div> 489 - {{ end }} 490 - {{ end }} 491 - 492 - {{ define "mergeStatus" }} 493 - {{ if .Pull.State.IsClosed }} 494 - <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded shadow-sm px-6 py-2 relative"> 495 - <div class="flex items-center gap-2 text-black dark:text-white"> 496 - {{ i "ban" "w-4 h-4" }} 497 - <span class="font-medium">Closed without merging</span 498 - > 499 - </div> 500 - </div> 501 - {{ else if .Pull.State.IsMerged }} 502 - <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded shadow-sm px-6 py-2 relative"> 503 - <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 504 - {{ i "git-merge" "w-4 h-4" }} 505 - <span class="font-medium">Pull request successfully merged</span 506 - > 507 - </div> 508 - </div> 509 - {{ else if .Pull.State.IsAbandoned }} 510 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded shadow-sm px-6 py-2 relative"> 511 - <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 512 - {{ i "git-pull-request-closed" "w-4 h-4" }} 513 - <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 514 - </div> 515 - </div> 516 - {{ end }} 517 - {{ end }} 518 - 519 - {{ define "submissionPipeline" }} 520 - {{ $item := index . 0 }} 521 - {{ $root := index . 3 }} 522 - {{ $pipeline := index $root.Pipelines $item.SourceRev }} 523 - {{ if and $pipeline $pipeline.Statuses }} 524 - {{ $id := $pipeline.Id }} 525 - <details class="group/pipeline"> 526 - <summary class="cursor-pointer list-none flex items-center gap-2"> 527 - {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }} 528 - <div class="text-sm text-gray-500 dark:text-gray-400"> 529 - <span class="group-open/pipeline:hidden inline">Expand</span> 530 - <span class="hidden group-open/pipeline:inline">Collapse</span> 531 - </div> 532 - </summary> 533 - <div class="my-2 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 534 - {{ range $name, $all := $pipeline.Statuses }} 535 - <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 536 - <div 537 - class="flex gap-2 items-center justify-between p-2"> 538 - {{ $lastStatus := $all.Latest }} 539 - {{ $kind := $lastStatus.Status.String }} 540 - 541 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 542 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 543 - {{ $name }} 544 - </div> 545 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 546 - <span class="font-bold">{{ $kind }}</span> 547 - {{ if .TimeTaken }} 548 - {{ template "repo/fragments/duration" .TimeTaken }} 549 - {{ else }} 550 - {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 551 - {{ end }} 552 - </div> 553 - </div> 554 - </a> 555 - {{ end }} 556 - </div> 557 - </details> 558 - {{ end }} 559 - {{ end }} 560 - 561 - {{ define "submissionComments" }} 562 - {{ $item := index . 0 }} 563 - {{ $idx := index . 1 }} 564 - {{ $lastIdx := index . 2 }} 565 - {{ $root := index . 3 }} 566 - {{ $round := $item.RoundNumber }} 567 - {{ $c := len $item.Comments }} 568 - <details class="relative ml-10 group/comments group/collapse" {{ if or (eq $c 0) (eq $root.ActiveRound $round) }}open{{ end }}> 569 - <summary class="cursor-pointer list-none"> 570 - <div class="collapse-trigger hidden group-open/comments:block absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center group/border z-4"> 571 - <div class="absolute left-1/2 -translate-x-1/2 top-0 bottom-0 w-0.5 group-open/comments:bg-gray-200 dark:group-open/comments:bg-gray-700 group-has-[.collapse-trigger:hover]/collapse:bg-gray-400 dark:group-has-[.collapse-trigger:hover]/collapse:bg-gray-500 transition-colors"> </div> 572 - </div> 573 - <div class="group-open/comments:hidden block relative group/summary py-4"> 574 - <div class="absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center z-4"> 575 - <div class="absolute left-1/2 -translate-x-1/2 h-1/3 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-hover/summary:bg-gray-400 dark:group-hover/summary:bg-gray-500 transition-colors"></div> 576 - </div> 577 - <span class="text-gray-500 dark:text-gray-400 text-sm group-hover/summary:text-gray-600 dark:group-hover/summary:text-gray-300 transition-colors flex items-center gap-2 -ml-2 relative"> 578 - {{ i "circle-plus" "size-4 z-5" }} 579 - Expand {{ $c }} comment{{ if ne $c 1 }}s{{ end }} 580 - </span> 581 - </div> 582 - </summary> 583 - <div id="pull-comments-{{ $round }}"> 584 - {{ range $item.Comments }} 585 - {{ template "fragments/comment/pullComment" 586 - (dict "LoggedInUser" $root.LoggedInUser 587 - "Reactions" (index (asReactionMapMap $root.Reactions) .FeedCommentAtUri) 588 - "UserReacted" (index (asReactionStatusMapMap $root.UserReacted) .FeedCommentAtUri) 589 - "Comment" .) }} 590 - {{ end }} 591 - </div> 592 - {{ if gt $c 0}} 593 - <button class="collapse-trigger flex items-center gap-2 -ml-2 relative cursor-pointer text-sm text-gray-500 dark:text-gray-400 group-has-[.collapse-trigger:hover]/collapse:text-gray-600 dark:group-has-[.collapse-trigger:hover]/collapse:text-gray-300 mt-4 pb-4 transition-colors" hx-on:click="this.closest('details').open = false"> 594 - <span class="bg-gray-50 dark:bg-slate-900 dark:rounded-full">{{ i "circle-chevron-up" "size-4 z-5" }}</span> Collapse comment{{ if ne $c 1 }}s{{ end }} 595 - </button> 596 - {{ end }} 597 - 598 - <div class="relative -ml-10"> 599 - {{ if eq $lastIdx $item.RoundNumber }} 600 - {{ block "mergeStatus" $root }} {{ end }} 601 - <div id="pull-action-error" class="error empty:hidden"></div> 602 - {{ end }} 603 - </div> 604 - <div class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 605 - {{ template "repo/pulls/fragments/pullActions" 606 - (dict 607 - "LoggedInUser" $root.LoggedInUser 608 - "Pull" $root.Pull 609 - "RepoInfo" $root.RepoInfo 610 - "RoundNumber" $item.RoundNumber 611 - "MergeCheck" $root.MergeCheck 612 - "ResubmitCheck" $root.ResubmitCheck 613 - "BranchDeleteStatus" $root.BranchDeleteStatus 614 - "Stack" $root.Stack 615 - "Loading" (eq $lastIdx $item.RoundNumber)) }} 616 - </div> 617 - </details> 618 - {{ end }} 619 - 620 - {{ define "loginPrompt" }} 621 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded shadow-sm p-2 relative flex gap-2 items-center"> 622 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 623 - Sign up 624 - </a> 625 - <span class="text-gray-500 dark:text-gray-400">or</span> 626 - <a href="/login" class="underline">Login</a> 627 - to add to the discussion 628 - </div> 629 - {{ end }}
+4 -47
appview/pages/templates/repo/pulls/pulls.html
··· 69 69 70 70 {{ define "repoAfter" }} 71 71 <div class="flex flex-col gap-2 mt-2"> 72 - {{ range $stack := .Stacks }} 73 - {{ $topPR := index $stack 0 }} 72 + {{ range $topPR := .Pulls }} 74 73 <div class="rounded bg-white dark:bg-gray-800"> 75 74 <div class="px-6 py-4 z-5"> 76 75 <div class="pb-2"> ··· 82 81 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 83 82 {{ template "repo/pulls/fragments/pullState" $topPR.State }} 84 83 <span class="ml-1 flex items-center gap-1"> 85 - {{ template "user/fragments/picLink" (list $topPR.OwnerDid "size-6" (index $.VouchRelationships (did $topPR.OwnerDid))) }} 86 - <a href="/{{ resolve $topPR.OwnerDid }}">{{ resolve $topPR.OwnerDid }}</a> 84 + {{ template "user/fragments/picLink" (list $topPR.OwnerDid.String "size-6" (index $.VouchRelationships $topPR.OwnerDid)) }} 85 + <a href="/{{ resolve $topPR.OwnerDid.String }}">{{ resolve $topPR.OwnerDid.String }}</a> 87 86 </span> 88 87 89 88 <span class="before:content-['·']"> ··· 98 97 <span class="before:content-['·']"> 99 98 Round 100 99 <span class="font-mono"> 101 - #{{ $topPR.LastRoundNumber }} 100 + #{{ $topPR.LatestVersionNumber }} 102 101 </span> 103 102 </span> 104 103 ··· 112 111 {{ end }} 113 112 </div> 114 113 </div> 115 - {{ if gt (len $stack) 1 }} 116 - <details class="group"> 117 - <summary class="px-6 pb-4 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 118 - <span class="flex items-center gap-2"> 119 - <span class="group-open:hidden"> 120 - {{ i "chevrons-up-down" "size-3" }} 121 - </span> 122 - <span class="hidden group-open:flex"> 123 - {{ i "chevrons-down-up" "size-3" }} 124 - </span> 125 - {{ $rest := sub (len $stack) 1 }} 126 - Expand {{ $rest }} pull{{if ne $rest 1 }}s{{end}} in this stack 127 - </span> 128 - </summary> 129 - {{ template "stackedPullList" (list (slice $stack 1) $) }} 130 - </details> 131 - {{ end }} 132 114 </div> 133 115 {{ end }} 134 116 </div> ··· 140 122 "QueryParams" (queryParams "q" .FilterQuery) 141 123 ) }} 142 124 {{ end }} 143 - {{ if and .Stacks .RepoInfo.Spindle }} 144 - <div 145 - class="hidden" 146 - hx-get="/{{ .RepoInfo.FullName }}/pulls/pipeline-statuses?{{ range .Stacks }}{{ range . }}sha={{ .LatestSha }}&{{ end }}{{ end }}" 147 - hx-trigger="load" 148 - hx-swap="none" 149 - ></div> 150 - {{ end }} 151 - {{ end }} 152 - 153 - {{ define "stackedPullList" }} 154 - {{ $list := index . 0 }} 155 - {{ $root := index . 1 }} 156 - <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 157 - {{ range $pull := $list }} 158 - {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 159 - <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 160 - <div class="flex gap-2 items-center px-6"> 161 - <div class="flex-grow min-w-0 w-full py-2"> 162 - {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline $root.RepoInfo) }} 163 - </div> 164 - </div> 165 - </a> 166 - {{ end }} 167 - </div> 168 125 {{ end }}
+652
appview/pages/templates/repo/pulls/single.html
··· 1 + {{ define "title" }} 2 + {{ .Pull.Title }} &middot; Pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} &middot; Tangled 3 + {{ end }} 4 + 5 + {{ define "mainLayout" }} 6 + {{ template "fragments/resizable" }} 7 + <input type="hidden" id="pull-id" value="{{ .Pull.PullId }}" /> 8 + <input type="hidden" id="pull-target-repo-did" value="{{ .Pull.RepoDid }}" /> 9 + <input type="hidden" id="pull-active-version-id" value="{{ .ActiveVersionId }}" /> 10 + <div class="flex px-1 group/pr-layout"> 11 + <div class="flex-1 min-w-0"> 12 + <div class="max-w-screen-lg mx-auto"> 13 + <!-- repo header --> 14 + <section id="repo-header" class="mb-2 py-2 px-4 dark:text-white"> 15 + <div class="flex flex-col sm:flex-row items-start gap-4 justify-between mb-2"> 16 + <div class="flex flex-col gap-2"> 17 + {{ template "repoOwnerAndName" . }} 18 + {{ template "repoForkInfo" . }} 19 + </div> 20 + <div class="hidden sm:block sm:flex-shrink-0"> 21 + {{ template "repoActions" . }} 22 + </div> 23 + </div> 24 + {{ template "repoMetadata" . }} 25 + 26 + <div class="block sm:hidden mt-4"> 27 + {{ template "repoActions" . }} 28 + </div> 29 + </section> 30 + <!-- repo nav --> 31 + <nav class="w-full pl-4 overflow-auto"> 32 + <div class="flex z-60"> 33 + {{ range $item := .RepoInfo.GetTabs }} 34 + {{ $key := index $item 0 }} 35 + {{ $value := index $item 1 }} 36 + {{ $icon := index $item 2 }} 37 + {{ $meta := index $.RepoInfo.TabMetadata $key }} 38 + {{/* no hx-boost here because PR page can be super large */}} 39 + <a 40 + href="/{{ $.RepoInfo.FullName }}{{ $value }}" 41 + class="relative -mr-px group" 42 + > 43 + <div 44 + class='px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 45 + {{ if eq "pulls" $key }} 46 + -mb-px bg-white dark:bg-gray-800 47 + {{ else }} 48 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 49 + {{ end }} 50 + ' 51 + > 52 + <span class="flex items-center justify-center"> 53 + {{ i $icon "size-4 mr-2" }} 54 + {{ $key | capitalize }} 55 + {{ if $meta }} 56 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ scaleFmt $meta }}</span> 57 + {{ end }} 58 + </span> 59 + </div> 60 + </a> 61 + {{ end }} 62 + </div> 63 + </nav> 64 + <div class="grid grid-cols-1 lg:grid-cols-[4fr_1fr] gap-4 mb-4"> 65 + <div class="min-w-0"> 66 + <section class="bg-white dark:bg-gray-800 p-6 rounded"> 67 + <!-- PR header --> 68 + <header class="pb-2"> 69 + <h1 class="text-2xl"> 70 + {{ .Pull.Title | description }} 71 + <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 72 + </h1> 73 + </header> 74 + <div class="flex items-center gap-2"> 75 + {{ template "repo/pulls/fragments/pullState" .Pull.State }} 76 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 77 + opened by 78 + {{ template "user/fragments/picLink" (list .Pull.OwnerDid.String "size-6" (index .VouchRelationships .Pull.OwnerDid)) }} 79 + <a href="/{{ resolve .Pull.OwnerDid.String }}">{{ resolve .Pull.OwnerDid.String }}</a> 80 + <span class="select-none before:content-['\00B7']"></span> 81 + {{ template "repo/fragments/time" .Pull.Created }} 82 + <span class="select-none before:content-['\00B7']"></span> 83 + targeting 84 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 85 + <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}">{{ .Pull.TargetBranch }}</a> 86 + </span> 87 + 88 + {{ if .Pull.SourceBranch }} 89 + from 90 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 91 + {{ if not .Pull.IsForkBased }} 92 + {{ $repoPath := .RepoInfo.FullName }} 93 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.SourceBranch }}">{{ .Pull.SourceBranch }}</a> 94 + {{ else }} 95 + <a href="/{{ .Pull.SourceRepo }}">fork</a>: 96 + <a href="/{{ .Pull.SourceRepo }}/tree/{{ pathEscape .Pull.SourceBranch }}">{{ .Pull.SourceBranch }}</a> 97 + {{ end }} 98 + </span> 99 + {{ end }} 100 + </span> 101 + </div> 102 + <article class="mt-4 prose dark:prose-invert"> 103 + {{ if .Pull.Body }} 104 + {{ .Pull.Body | markdown }} 105 + {{ else }} 106 + <span class="italic">No description provided</span> 107 + {{ end }} 108 + </article> 109 + <div class="mt-4"> 110 + {{ $aturi := .Pull.AtUri }} 111 + {{ template "repo/fragments/reactions" 112 + (dict "Reactions" (index .Reactions $aturi) 113 + "UserReacted" (index .UserReacted $aturi) 114 + "ThreadAt" $aturi) }} 115 + </div> 116 + </section> 117 + </div> 118 + <div class="lg:row-start-1 lg:row-end-3 lg:col-start-2 min-w-0"> 119 + {{ template "repo/fragments/labelPanel" 120 + (dict "RepoInfo" $.RepoInfo 121 + "Defs" $.LabelDefs 122 + "Subject" $.Pull.AtUri 123 + "State" $.Pull.Labels) }} 124 + {{ template "repo/fragments/participants" $.Pull.Participants }} 125 + {{ if $.LoggedInUser }} 126 + {{ template "repo/pulls/fragments/subscribeButton" 127 + (dict "RepoInfo" $.RepoInfo 128 + "PullId" $.Pull.PullId 129 + "IsSubscribed" $.IsSubscribed) }} 130 + {{ end }} 131 + {{ template "repo/fragments/backlinks" 132 + (dict "RepoInfo" $.RepoInfo 133 + "Backlinks" $.Backlinks) }} 134 + {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 135 + </div> 136 + <div class="lg:row-start-2 lg:col-start-1"> 137 + <h2 id="commits">Commits</h2> 138 + {{ if .ErrorMsg }} 139 + <div class="flex items-center justify-center w-full min-h-32 p-4 rounded text-red-600 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700"> 140 + <span>{{ .ErrorMsg }}</span> 141 + </div> 142 + {{ else }} 143 + <div class="grid grid-cols-1 bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 144 + {{ $seeAll := false }} 145 + {{ if .IsInterdiff }} 146 + {{ $seeAll = not .ChangeId }} 147 + {{ else }} 148 + {{ $version := index .Pull.Versions .VersionId }} 149 + {{ $seeAll = and .IsDiffBase .IsDiffHead }} 150 + {{ end }} 151 + <div class="text-sm px-2 {{ if $seeAll }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 152 + <div class="relative"> 153 + {{ if $seeAll }} 154 + <div class="flex-shrink-0 absolute top-3 left-0"> 155 + {{ i "arrow-right" "size-4" }} 156 + </div> 157 + {{ end }} 158 + <div class="py-2 ml-6"> 159 + {{ if .IsInterdiff }} 160 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/{{ .Version1 }}..{{ .Version2 }}/all">See all changes</a> 161 + {{ else }} 162 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/{{ .VersionId }}">See all changes</a> 163 + {{ end }} 164 + </div> 165 + </div> 166 + </div> 167 + {{ range .Commits }} 168 + {{ $messageParts := splitN .Message "\n\n" 2 }} 169 + {{ $active := and (not $seeAll) (eq (shortId $.ActiveCommitId) (shortId .Hash.String)) }} 170 + {{ $toggleId := printf "commit-message-toggle-%s" .Hash }} 171 + {{ $pipeline := index $.Pipelines .Hash.String }} 172 + <div class="text-sm px-2 {{ if $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 173 + <input id="{{ $toggleId }}" type="checkbox" class="peer hidden" /> 174 + <div class="relative flex items-center justify-between"> 175 + {{ if $active }} 176 + <div class="flex-shrink-0 absolute left-0"> 177 + {{ i "arrow-right" "size-4" }} 178 + </div> 179 + {{ end }} 180 + <div class="py-2 ml-6"> 181 + <code class="py-0.5 px-1">{{ shortId .Hash.String }}</code> 182 + {{ if .ChangeId }} 183 + <code class="py-0.5 px-1">{{ shortId .ChangeId }}</code> 184 + {{ end }} 185 + {{ if $.IsInterdiff }} 186 + <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/{{ $.Version1 }}..{{ $.Version2 }}/{{ .ChangeId }}">{{ index $messageParts 0 }}</a> 187 + {{ else }} 188 + <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/{{ $.VersionId }}/{{ shortId .FirstParentHash.String }}..{{ shortId .Hash.String }}">{{ index $messageParts 0 }}</a> 189 + {{ end }} 190 + <label 191 + for="{{ $toggleId }}" 192 + class="cursor-pointer inline-flex py-0.5 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 193 + > 194 + {{ i "ellipsis" "size-3" }} 195 + </label> 196 + </div> 197 + {{ if and $pipeline $pipeline.Id }} 198 + <div> 199 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 200 + </div> 201 + {{ end }} 202 + </div> 203 + {{ if gt (len $messageParts) 1 }} 204 + <pre class="ml-6 mt-1 mb-2 hidden peer-checked:block">{{ index $messageParts 1 }}</pre> 205 + {{ end }} 206 + </div> 207 + {{ end }} 208 + </div> 209 + {{ end }} 210 + </div> 211 + </div> 212 + </div> 213 + <!-- diff --> 214 + <div hx-include="this" class="min-h-[50vh] group/diff-layout"> 215 + {{ $diffUrl := "" }} 216 + {{ if not .IsInterdiff }} 217 + {{ $diffUrl = (printf "/%s/pulls/%d/_/diff?view=unified" .Pull.RepoDid .Pull.PullId) }} 218 + {{ $diff := .DiffParams }} 219 + <input type="hidden" name="baseRepo" value="{{ .Pull.RepoDid }}"> 220 + <input type="hidden" name="base" value="{{ $diff.Base }}"> 221 + <input type="hidden" name="headRepo" value="{{ .Pull.SourceRepo }}"> 222 + <input type="hidden" name="head" value="{{ $diff.Head }}"> 223 + {{ else if .DiffParams.Diff }} 224 + {{ $diffUrl = (printf "/%s/pulls/%d/_/diff?view=unified" .Pull.RepoDid .Pull.PullId) }} 225 + {{ $diff := .DiffParams.Diff }} 226 + <input type="hidden" name="baseRepo" value="{{ .Pull.SourceRepo }}"> 227 + <input type="hidden" name="base" value="{{ $diff.Base }}"> 228 + <input type="hidden" name="headRepo" value="{{ .Pull.SourceRepo }}"> 229 + <input type="hidden" name="head" value="{{ $diff.Head }}"> 230 + {{ else }} 231 + {{ $diffUrl = (printf "/%s/pulls/%d/_/interdiff?view=unified" .Pull.RepoDid .Pull.PullId) }} 232 + {{ $diff := .DiffParams.Interdiff }} 233 + <input type="hidden" name="repo" value="{{ .Pull.SourceRepo }}"> 234 + <input type="hidden" name="base1" value="{{ $diff.From.Base }}"> 235 + <input type="hidden" name="head1" value="{{ $diff.From.Head }}"> 236 + <input type="hidden" name="base2" value="{{ $diff.To.Base }}"> 237 + <input type="hidden" name="head2" value="{{ $diff.To.Head }}"> 238 + {{ end }} 239 + <!-- diff header --> 240 + <header class="bg-slate-100 dark:bg-gray-900 sticky px-1 top-0 z-20 flex flex-col md:flex-row md:justify-between"> 241 + <!-- left --> 242 + <div class="h-12 flex items-center gap-2"> 243 + <div class="md:block hidden"> 244 + <label for="diff-tree-toggle" title="Toggle filetree panel" class="btn-flat"> 245 + <span class="group-has-[#diff-tree-toggle:checked]/diff-layout:hidden inline">{{ i "panel-left-open" "size-4" }}</span> 246 + <span class="hidden group-has-[#diff-tree-toggle:checked]/diff-layout:inline">{{ i "panel-left-close" "size-4" }}</span> 247 + </label> 248 + </div> 249 + <div id="diff-stats"> 250 + <div class="flex items-center font-mono text-sm"> 251 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ i "loader-circle" "size-3 inline-flex animate-spin" }}</span> 252 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ i "loader-circle" "size-3 inline-flex animate-spin" }}</span> 253 + </div> 254 + </div> 255 + {{ if .IsInterdiff }} 256 + <span class="text-sm"> 257 + Interdiff 258 + <span class="ml-1 text-xs after:content-['|'] after:ml-1 after:text-gray-300 dark:after:text-gray-600"> 259 + <code class="inline-block box-content min-h-[1lh] py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .Version1 }}</code> 260 + {{ i "arrow-right" "size-3 inline-flex" }} 261 + <code class="inline-block box-content min-h-[1lh] py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .Version2 }}</code> 262 + </span> 263 + <a href="#commits" class="btn-flat text-xs hover:bg-gray-50 dark:hover:bg-gray-800"> 264 + {{ with .ChangeId }} 265 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ shortId . }}</code> 266 + {{ else }} 267 + <span class="px-1">All changes</span> 268 + {{ end }} 269 + </a> 270 + </span> 271 + {{ else }} 272 + <span class="text-sm"> 273 + Diff 274 + <span class="ml-1 text-xs after:content-['|'] after:ml-1 after:text-gray-300 dark:after:text-gray-600"> 275 + <code class="py-0.5 px-1 border border-gray-300 dark:border-gray-600">v{{ .VersionId }}</code> 276 + </span> 277 + <a href="#commits" class="btn-flat text-xs hover:bg-gray-50 dark:hover:bg-gray-800"> 278 + <code class="inline-block box-content min-h-[1lh] py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ .DisplayDiffBase }}</code> 279 + {{ i "arrow-right" "size-3 inline-flex" }} 280 + <code class="inline-block box-content min-h-[1lh] py-0.5 px-1 border border-gray-300 dark:border-gray-600">{{ .DisplayDiffHead }}</code> 281 + </a> 282 + </span> 283 + {{ end }} 284 + </div> 285 + <!-- right --> 286 + <div class="h-12 flex items-center gap-2"> 287 + <label title="Expand/Collapse diffs" class="btn font-normal normal-case"> 288 + <input type="checkbox" id="diff-collapse-toggle" class="peer/collapse hidden" checked/> 289 + <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 290 + {{ i "unfold-vertical" "size-4" }} 291 + <span class="hidden md:inline">Expand all</span> 292 + </span> 293 + <span class="peer-checked/collapse:inline-flex hidden items-center gap-2"> 294 + {{ i "fold-vertical" "size-4" }} 295 + <span class="hidden md:inline">Collapse all</span> 296 + </span> 297 + </label> 298 + 299 + <div class="md:hidden flex-grow"></div> 300 + 301 + <!-- diff settings --> 302 + <div id="diff-settings"> 303 + {{ template "repo/pulls/fragments/diffSettings" (dict "DiffUrl" $diffUrl "Unified" true) }} 304 + </div> 305 + 306 + {{ $isInterdiff := .IsInterdiff }} 307 + {{ $canInterdiff := gt .ActiveVersionId 0 }} 308 + <div class="btn-group"> 309 + <button 310 + type="button" 311 + role="link" 312 + onclick="window.location.href='/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ .ActiveVersionId }}';" 313 + class="btn-group-item {{ if not $isInterdiff }}active{{ end }}" 314 + > 315 + {{ i "diff" "size-4" }} 316 + Diff 317 + </button> 318 + <button 319 + type="button" 320 + role="link" 321 + onclick="window.location.href='/{{ .Pull.RepoDid }}/pulls/{{ .Pull.PullId }}/{{ sub .ActiveVersionId 1 }}..{{ .ActiveVersionId }}';" 322 + class="btn-group-item {{ if $isInterdiff }}active{{ end }}" 323 + {{ if not $canInterdiff }}disabled{{ end }} 324 + > 325 + {{ i "chevrons-left-right-ellipsis" "size-4 rotate-90" }} 326 + Interdiff 327 + </button> 328 + </div> 329 + <label 330 + for="discussion-toggle" 331 + title="Toggle discussion panel" 332 + class="btn-flat hidden lg:inline-flex group-has-[#discussion-toggle:checked]/pr-layout:hidden" 333 + > 334 + {{ i "message-square-more" "size-4" }} 335 + </label> 336 + </div> 337 + </header> 338 + <div class="flex"> 339 + <input type="checkbox" id="diff-tree-toggle" class="peer hidden" checked> 340 + <aside id="diff-files" class="hidden peer-checked:md:block w-[20%] max-w-[40%]"> 341 + <div class="sticky top-12 bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700"> 342 + <div class=" 343 + max-h-[calc(100vh-3rem-0.5rem)] pb-2 344 + overflow-y-auto 345 + "> 346 + <section class="text-sm px-6 py-2 w-full"> 347 + <div class="mb-8"> 348 + <div id="diff-files-content"> 349 + {{ i "loader-circle" "size-4 inline-flex animate-spin" }} loading... 350 + </div> 351 + </div> 352 + </section> 353 + </div> 354 + </div> 355 + </aside> 356 + 357 + {{ template "resize-grip" (list "resize-diff-files" "diff-files" "before" "hidden peer-checked:md:flex") }} 358 + 359 + <div id="diff-list-container" class="flex-1 min-w-0"> 360 + <div 361 + id="diff-list" 362 + hx-get="{{ $diffUrl }}" 363 + hx-swap="outerHTML" 364 + hx-trigger="load" 365 + > 366 + {{ i "loader-circle" "size-4 inline-flex animate-spin" }} loading... 367 + </div> 368 + </div> 369 + <script> 370 + (() => { 371 + const checkbox = document.getElementById('diff-collapse-toggle'); 372 + const diffList = document.getElementById('diff-list-container'); 373 + 374 + checkbox.addEventListener('change', () => { 375 + console.debug("checked", checkbox.checked); 376 + diffList.querySelectorAll('details[id^="file-"]').forEach(detail => { 377 + detail.open = checkbox.checked; 378 + }); 379 + }); 380 + 381 + if (window.__collapseToggleHandler) { 382 + diffList.removeEventListener('toggle', window.__collapseToggleHandler, true); 383 + } 384 + 385 + const handler = (e) => { 386 + if (!e.target.matches('details[id^="file-"]')) return; 387 + const details = document.querySelectorAll('details[id^="file-"]'); 388 + const allOpen = Array.from(details).every(d => d.open); 389 + const allClosed = Array.from(details).every(d => !d.open); 390 + 391 + if (allOpen) checkbox.checked = true; 392 + else if (allClosed) checkbox.checked = false; 393 + }; 394 + 395 + window.__collapseToggleHandler = handler; 396 + diffList.addEventListener('toggle', handler, true); 397 + })(); 398 + </script> 399 + </div> 400 + </div> 401 + </div> 402 + 403 + <div id="bottom-sheet-backdrop" class="fixed inset-0 bg-black/50 lg:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-20"></div> 404 + 405 + <input type="checkbox" id="discussion-toggle" class="peer hidden" checked> 406 + {{ template "resize-grip" (list "resize-discussion" "discussion" "after" "hidden peer-checked:lg:flex") }} 407 + 408 + <div id="discussion" class=" 409 + fixed z-30 bottom-0 right-0 w-full max-h-[calc(100vh-10rem)] rounded-t-2xl 410 + bg-slate-100 dark:bg-gray-900 411 + lg:sticky lg:top-0 lg:hidden peer-checked:lg:block lg:w-[25vw] lg:max-w-[50vw] lg:max-h-screen 412 + lg:bg-transparent 413 + "> 414 + <details open id="bottom-sheet" class=" 415 + flex flex-col group/history 416 + {{ if .Pull.State.IsOpen -}} *:border-green-600 *:dark:border-green-900 417 + {{ else if .Pull.State.IsMerged -}} *:border-purple-600 *:dark:border-purple-900 418 + {{ else if .Pull.State.IsAbandoned -}} *:border-red-600 *:dark:border-red-900 419 + {{ else -}} *:border-gray-600 *:dark:border-gray-900 420 + {{ end }} 421 + "> 422 + {{ $versions := len .Pull.Versions }} 423 + {{ $comments := .Pull.TotalComments }} 424 + <summary class=" 425 + list-none z-20 flex items-center justify-between 426 + pl-3 pr-4 py-2 rounded-t-2xl 427 + relative 428 + border-t-4 border-x-4 429 + group-open/history:bg-transparent 430 + lg:border-0 431 + lg:h-12 lg:px-1 lg:pointer-events-none 432 + lg:bg-transparent 433 + "> 434 + <div class="flex items-center gap-2"> 435 + <span class="lg:hidden"> 436 + {{ template "repo/pulls/fragments/pullState" .Pull.State }} 437 + </span> 438 + <h2>History</h2> 439 + </div> 440 + <div class="flex items-center gap-2 text-sm"> 441 + <span> 442 + {{ $versions }} version{{ if ne $versions 1 }}s{{ end }} 443 + </span> 444 + <span class="select-none before:content-['\00B7']"></span> 445 + <span> 446 + {{ $comments }} comment{{ if ne $comments 1 }}s{{ end }} 447 + </span> 448 + <label for="discussion-toggle" title="Toggle discussion panel" class="hidden lg:inline-flex btn-flat pointer-events-auto"> 449 + {{ i "message-square" "size-4" }} 450 + </label> 451 + <span class="lg:hidden"> 452 + <span class="group-open/history:hidden inline">{{ i "chevron-up" "size-4" }}</span> 453 + <span class="group-open/history:inline hidden">{{ i "chevron-down" "size-4" }}</span> 454 + </span> 455 + </div> 456 + </summary> 457 + <div class=" 458 + max-h-[calc(100vh-3rem-0.5rem)] pb-32 overflow-y-auto space-y-4 459 + px-1 border-x-4 460 + lg:px-0 lg:border-0 461 + "> 462 + {{ range .Pull.Versions }} 463 + {{ $active := eq .ID $.ActiveVersionId }} 464 + <div 465 + id="version-{{ .ID }}" 466 + class=" 467 + {{ if $active }}active{{ end }} 468 + w-full shadow-sm rounded overflow-clip bg-gray-50 dark:bg-gray-900 469 + " 470 + > 471 + <!-- header --> 472 + <header class="sticky top-0 z-20 bg-slate-100 dark:bg-gray-900"> 473 + <div class="pt-2 pr-2 pb-4 pl-4 lg:pl-6 flex gap-2 474 + rounded-t border-t border-x border-gray-200 dark:border-gray-700 475 + {{ if $active }} 476 + bg-blue-50 dark:bg-blue-950 477 + {{ else }} 478 + bg-white dark:bg-gray-800 479 + {{ end }} 480 + "> 481 + <!-- left column: just profile picture --> 482 + <div class="flex-shrink-0 pt-2"> 483 + {{ template "user/fragments/picLink" (list $.Pull.OwnerDid.String "size-8" (index $.VouchRelationships $.Pull.OwnerDid)) }} 484 + </div> 485 + <!-- right column --> 486 + <div class="flex-1 min-w-0 flex flex-col gap-1"> 487 + <!-- submission info --> 488 + {{ $handle := resolve $.Pull.OwnerDid.String }} 489 + <div class="flex gap-2 items-center justify-between mb-1"> 490 + <span class="inline-flex items-center gap-2 text-sm pt-2 491 + {{ if $active }} 492 + text-gray-600 dark:text-gray-300 493 + {{ else }} 494 + text-gray-500 dark:text-gray-400 495 + {{ end }}" 496 + > 497 + <a href="/{{ $handle }}">{{ $handle }}</a> 498 + submitted 499 + <span class=" 500 + px-2 py-0.5 rounded font-mono text-xs border 501 + {{ if $active }} 502 + text-blue-800 dark:text-white bg-blue-100 dark:bg-blue-600 border-blue-200 dark:border-blue-500 503 + {{ else }} 504 + text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 505 + {{ end }} 506 + ">#{{ .ID }}</span> 507 + <span class="select-none before:content-['\00B7']"></span> 508 + <a href="/{{ $.Pull.RepoDid }}/pulls/{{ $.Pull.PullId }}/{{ .ID }}">{{ template "repo/fragments/shortTime" .Created }}</a> 509 + </span> 510 + <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 511 + href="/{{ $.Pull.RepoDid }}/pulls/{{ $.Pull.PullId }}/{{ .ID }}"> 512 + {{ i "diff" "size-4" }} 513 + Diff 514 + </a> 515 + </div> 516 + {{ if eq .ID $.Pull.LatestVersionNumber }} 517 + <div id="mergecheck-banner"> 518 + {{ if $.Pull.State.IsOpen }} 519 + <div class="flex items-center gap-2 text-gray-500 dark:text-gray-400"> 520 + {{ i "loader-circle" "size-4 animate-spin" }} 521 + <span>Checking mergeability…</span> 522 + </div> 523 + {{ end }} 524 + </div> 525 + {{ end }} 526 + </div> 527 + </div> 528 + </header> 529 + <!-- comments --> 530 + {{ $count := len .Comments }} 531 + <details 532 + class="relative pl-8 lg:pl-10 rounded-b border-x border-b border-gray-200 dark:border-gray-700 group/comments group/collapse" 533 + {{ if or $active (eq $count 0) }}open{{ end }} 534 + > 535 + <summary class="cursor-pointer list-none"> 536 + <div class="collapse-trigger hidden group-open/comments:block absolute left-2 top-0 bottom-0 w-12 lg:w-16 transition-colors flex items-center justify-center group/border z-4"> 537 + <div class="absolute left-1/2 -translate-x-1/2 top-0 bottom-0 w-0.5 group-open/comments:bg-gray-200 dark:group-open/comments:bg-gray-700 group-has-[.collapse-trigger:hover]/collapse:bg-gray-400 dark:group-has-[.collapse-trigger:hover]/collapse:bg-gray-500 transition-colors"> </div> 538 + </div> 539 + <div class="group-open/comments:hidden block relative group/summary py-4"> 540 + <div class="absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center z-4"> 541 + <div class="absolute left-1/2 -translate-x-1/2 h-1/3 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-hover/summary:bg-gray-400 dark:group-hover/summary:bg-gray-500 transition-colors"></div> 542 + </div> 543 + <span class="text-gray-500 dark:text-gray-400 text-sm group-hover/summary:text-gray-600 dark:group-hover/summary:text-gray-300 transition-colors flex items-center gap-2 -ml-2 relative"> 544 + {{ i "circle-plus" "size-4 z-5" }} 545 + Expand {{ $count }} comment{{ if ne $count 1 }}s{{ end }} 546 + </span> 547 + </div> 548 + </summary> 549 + <div id="pull-comments-{{ .ID }}"> 550 + {{ range .Comments }} 551 + {{ template "fragments/comment/pullComment" 552 + (dict "LoggedInUser" $.LoggedInUser 553 + "Reactions" (index (asReactionMapMap $.Reactions) .FeedCommentAtUri) 554 + "UserReacted" (index (asReactionStatusMapMap $.UserReacted) .FeedCommentAtUri) 555 + "Comment" .) }} 556 + {{ end }} 557 + </div> 558 + {{ if gt $count 0}} 559 + <button 560 + class="collapse-trigger flex items-center gap-2 -ml-2 relative cursor-pointer text-sm text-gray-500 dark:text-gray-400 group-has-[.collapse-trigger:hover]/collapse:text-gray-600 dark:group-has-[.collapse-trigger:hover]/collapse:text-gray-300 mt-4 pb-4 transition-colors" 561 + hx-on:click="this.closest('details').open = false" 562 + > 563 + <span class="bg-gray-50 dark:bg-slate-900 dark:rounded-full">{{ i "circle-chevron-up" "size-4 z-5" }}</span> Collapse comment{{ if ne $count 1 }}s{{ end }} 564 + </button> 565 + {{ end }} 566 + 567 + <div hx-include="this" class="relative -ml-8 lg:-ml-10 rounded-b bg-gray-50 dark:bg-gray-900"> 568 + {{ if eq .ID $.Pull.LatestVersionNumber }} 569 + {{ block "mergeStatus" $ }} {{ end }} 570 + <div id="pull-action-error" class="error"></div> 571 + {{ end }} 572 + <input name="subject-uri" type="hidden" value="{{ $.Pull.AtUri }}"> 573 + <input name="pull-round-idx" type="hidden" value="{{ .ID }}"> 574 + {{ template "repo/pulls/fragments/pullActions" 575 + (dict 576 + "LoggedInUser" $.LoggedInUser 577 + "Pull" $.Pull 578 + "RepoInfo" $.RepoInfo 579 + "RoundNumber" .ID 580 + "MergeCheck" $.MergeCheck 581 + "ResubmitCheck" $.ResubmitCheck 582 + "BranchDeleteStatus" $.BranchDeleteStatus 583 + "Loading" (eq .ID $.Pull.LatestVersionNumber)) }} 584 + </div> 585 + </details> 586 + </div> 587 + {{ end }} 588 + </div> 589 + <div class="hidden sticky bottom-0 h-12"> 590 + <!-- TODO: bottom PR actions --> 591 + </div> 592 + </details> 593 + </div> 594 + <script> 595 + (function() { 596 + const details = document.getElementById('bottom-sheet'); 597 + const backdrop = document.getElementById('bottom-sheet-backdrop'); 598 + const isDesktop = () => window.matchMedia('(min-width: 1024px)').matches; 599 + 600 + // function to update backdrop 601 + const updateBackdrop = () => { 602 + if (backdrop) { 603 + if (details.open && !isDesktop()) { 604 + backdrop.classList.remove('opacity-0', 'pointer-events-none'); 605 + backdrop.classList.add('opacity-100', 'pointer-events-auto'); 606 + document.body.style.overflow = 'hidden'; 607 + } else { 608 + backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 609 + backdrop.classList.add('opacity-0', 'pointer-events-none'); 610 + document.body.style.overflow = ''; 611 + } 612 + } 613 + }; 614 + 615 + // close on mobile initially 616 + if (!isDesktop()) { 617 + details.open = false; 618 + } 619 + updateBackdrop(); // initialize backdrop 620 + 621 + // prevent closing on desktop 622 + details.addEventListener('toggle', function(e) { 623 + if (isDesktop() && !this.open) { 624 + this.open = true; 625 + } 626 + updateBackdrop(); 627 + }); 628 + 629 + const mediaQuery = window.matchMedia('(min-width: 1024px)'); 630 + mediaQuery.addEventListener('change', function(e) { 631 + if (e.matches) { 632 + // switched to desktop - keep open 633 + details.open = true; 634 + } else { 635 + // switched to mobile - close 636 + details.open = false; 637 + } 638 + updateBackdrop(); 639 + }); 640 + 641 + // close when clicking backdrop 642 + if (backdrop) { 643 + backdrop.addEventListener('click', () => { 644 + if (!isDesktop()) { 645 + details.open = false; 646 + } 647 + }); 648 + } 649 + })(); 650 + </script> 651 + </div> 652 + {{ end }}
+2 -2
appview/pages/url.go
··· 69 69 } 70 70 71 71 func (p *Pages) MakePullUrl(ctx context.Context, uri syntax.ATURI, roundIdx int) (string, error) { 72 - pull, err := db.GetPull(p.db, orm.FilterEq("at_uri", uri)) 72 + pull, err := db.GetPull(ctx, p.db, orm.FilterEq("at_uri", uri)) 73 73 if err != nil { 74 74 return "", fmt.Errorf("failed to get pull: %w", err) 75 75 } ··· 77 77 if err != nil { 78 78 return "", fmt.Errorf("failed to make repo url: %w", err) 79 79 } 80 - return path.Join(repoUrl, "pulls", strconv.Itoa(pull.PullId), "rounds", strconv.Itoa(roundIdx)), nil 80 + return path.Join(repoUrl, "pulls", strconv.FormatInt(pull.PullId, 10), "rounds", strconv.Itoa(roundIdx)), nil 81 81 } 82 82 83 83 func (p *Pages) makeRepoUrlInner(ctx context.Context, repo *models.Repo) (string, error) {
+160
appview/pulls/actions.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "strconv" 8 + 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pages" 13 + "tangled.org/core/xrpc/xrpcclient" 14 + 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "github.com/go-chi/chi/v5" 17 + ) 18 + 19 + // htmx fragment 20 + func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 21 + l := s.logger.With("handler", "PullActions") 22 + 23 + switch r.Method { 24 + case http.MethodGet: 25 + user := s.oauth.GetMultiAccountUser(r) 26 + if user != nil { 27 + l = l.With("user", user.Did) 28 + } 29 + 30 + f, err := s.repoResolver.Resolve(r) 31 + if err != nil { 32 + l.Error("failed to get repo and knot", "err", err) 33 + return 34 + } 35 + 36 + pull, ok := r.Context().Value("pull").(*models.Pull) 37 + if !ok { 38 + l.Error("failed to get pull") 39 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 40 + return 41 + } 42 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 43 + 44 + versionNumber, err := strconv.Atoi(chi.URLParam(r, "version")) 45 + if err != nil { 46 + versionNumber = pull.LatestVersionNumber() 47 + } 48 + if versionNumber >= len(pull.Versions) { 49 + http.Error(w, "bad round id", http.StatusBadRequest) 50 + l.Error("failed to parse round id", "err", err, "round_number", versionNumber) 51 + return 52 + } 53 + 54 + isLastVersion := versionNumber == pull.LatestVersionNumber() 55 + 56 + var workflowsChanged bool 57 + var changedWorkflows []string 58 + hasPipeline := false 59 + if isLastVersion && f.Spindle != "" { 60 + pipelines := fetchPipelines(r.Context(), l, f, []string{pull.LatestSha()}) 61 + _, hasPipeline = pipelines[pull.LatestSha()] 62 + 63 + if pull.IsForkBased() && !hasPipeline { 64 + latest := pull.LatestVersion() 65 + changedWorkflows, err = func(ctx context.Context) ([]string, error) { 66 + base, err := s.resolveRev(ctx, pull.RepoDid, pull.TargetBranch) 67 + if err != nil { 68 + return nil, fmt.Errorf("failed to resolve target branch: %w", err) 69 + } 70 + return s.changedWorkflowFiles(ctx, pull.RepoDid, base, pull.SourceRepo, latest.Head) 71 + }(r.Context()) 72 + if err != nil { 73 + l.Error("failed to inspect latest round's patch for workflow changes", "err", err) 74 + } 75 + workflowsChanged = len(changedWorkflows) > 0 76 + } 77 + } 78 + 79 + // only the last round's buttons and banners use merge/resubmit checks 80 + var mergeCheckParams pages.MergeCheckParams 81 + var resubmitResult = pages.Unknown 82 + if isLastVersion { 83 + mergeCheckParams = s.composeMergeCheck(r.Context(), f, pull.TargetBranch, pull.SourceRepo, pull.LatestVersion().Head) 84 + if user != nil && syntax.DID(user.Did) == pull.OwnerDid { 85 + resubmitResult = s.resubmitCheck(r.Context(), pull) 86 + } 87 + } 88 + 89 + s.pages.PullActionsFragment(w, pages.PullActionsParams{ 90 + BaseParams: pages.BaseParamsFromContext(r.Context()), 91 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 92 + Pull: pull, 93 + RoundNumber: versionNumber, 94 + MergeCheck: mergeCheckParams, 95 + ResubmitCheck: resubmitResult, 96 + BranchDeleteStatus: s.branchDeleteStatus(r, f, pull), 97 + 98 + WorkflowsChanged: workflowsChanged, 99 + ChangedWorkflowFiles: changedWorkflows, 100 + HasPipeline: hasPipeline, 101 + }) 102 + return 103 + } 104 + } 105 + 106 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *pages.BranchDeleteStatus { 107 + if pull.State != models.PullMerged || pull.SourceBranch == nil { 108 + return nil 109 + } 110 + 111 + user := s.oauth.GetMultiAccountUser(r) 112 + if user == nil { 113 + return nil 114 + } 115 + 116 + branch := *pull.SourceBranch 117 + 118 + if syntax.DID(repo.RepoDid) != pull.SourceRepo { 119 + var err error 120 + repo, err = db.GetRepoByDid(s.db, pull.SourceRepo.String()) 121 + if err != nil { 122 + return nil 123 + } 124 + } 125 + 126 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 127 + if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") { 128 + return nil 129 + } 130 + 131 + xrpcc := s.knotMirrorXRPC 132 + resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid) 133 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 134 + s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 135 + return nil 136 + } 137 + 138 + return &pages.BranchDeleteStatus{ 139 + Repo: repo, 140 + Branch: resp.Name, 141 + } 142 + } 143 + 144 + func (s *Pulls) resubmitCheck(ctx context.Context, pull *models.Pull) pages.ResubmitResult { 145 + if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.SourceBranch == nil { 146 + return pages.Unknown 147 + } 148 + 149 + sourceSha, err := s.resolveRev(ctx, pull.SourceRepo, *pull.SourceBranch) 150 + if err != nil { 151 + s.logger.Error("failed to resolve source branch", "err", err, "pull_id", pull.PullId, "branch", *pull.SourceBranch) 152 + return pages.Unknown 153 + } 154 + 155 + if pull.LatestVersion().Head != sourceSha { 156 + return pages.ShouldResubmit 157 + } 158 + 159 + return pages.ShouldNotResubmit 160 + }
+208 -308
appview/pulls/compose.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "cmp" 4 5 "context" 5 6 "database/sql" 6 7 "encoding/json" 7 8 "errors" 8 9 "fmt" 10 + "log/slog" 9 11 "net/http" 10 12 "net/url" 11 13 "slices" ··· 14 16 15 17 "tangled.org/core/api/tangled" 16 18 "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/knotcompat" 17 20 "tangled.org/core/appview/models" 18 - "tangled.org/core/appview/oauth" 19 21 "tangled.org/core/appview/pages" 20 22 "tangled.org/core/appview/pages/markup/sanitizer" 21 - "tangled.org/core/patchutil" 23 + "tangled.org/core/consts" 24 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 22 25 "tangled.org/core/types" 23 - "tangled.org/core/xrpc/xrpcclient" 24 26 25 27 "github.com/bluesky-social/indigo/atproto/syntax" 26 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 27 28 ) 28 29 29 30 func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { ··· 49 50 s.pages.Error503(w) 50 51 return 51 52 } 52 - s.pages.RepoNewPull(w, params) 53 + if err := s.pages.RepoNewPull(w, params); err != nil { 54 + l.Error("failed to render", "err", err) 55 + } 53 56 54 57 case http.MethodPost: 55 - title := r.FormValue("title") 56 - body := r.FormValue("body") 57 - targetBranch := r.FormValue("targetBranch") 58 - fromFork := r.FormValue("fork") 58 + userDid := syntax.DID(user.Did) 59 + var ( 60 + title = r.FormValue("title") 61 + body = r.FormValue("body") 62 + targetBranch = r.FormValue("targetBranch") 63 + sourceRepoRaw = cmp.Or(r.FormValue("fork"), f.RepoDid) 64 + ) 65 + sourceRepoDid, err := syntax.ParseDID(sourceRepoRaw) 66 + if err != nil { 67 + s.pages.Notice(w, "pull", fmt.Sprintf("Source repo is invalid: %q", sourceRepoRaw)) 68 + return 69 + } 59 70 sourceBranch := r.FormValue("sourceBranch") 60 71 patch := r.FormValue("patch") 61 - userDid := syntax.DID(user.Did) 62 72 63 - if targetBranch == "" { 64 - s.pages.Notice(w, "pull", "Target branch is required.") 73 + if title == "" { 74 + s.pages.Notice(w, "pull", "Title is required") 75 + return 76 + } 77 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 78 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 65 79 return 66 80 } 67 81 68 - // Determine PR type based on input parameters 69 - roles := s.acl.RolesInRepo(r.Context(), f, userDid.String()) 70 - isPushAllowed := roles.IsPushAllowed() 71 - isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 72 - isForkBased := fromFork != "" && sourceBranch != "" 73 - isPatchBased := patch != "" && !isBranchBased && !isForkBased 74 - isStacked := r.FormValue("mode") == "stack" && !isPatchBased 75 - 76 - if isPatchBased && !patchutil.IsFormatPatch(patch) { 77 - if title == "" { 78 - s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 79 - return 80 - } 81 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 82 - s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 83 - return 84 - } 82 + if targetBranch == "" { 83 + s.pages.Notice(w, "pull", "Target branch is required.") 84 + return 85 85 } 86 86 87 87 // Validate we have at least one valid PR creation method 88 - if !isBranchBased && !isPatchBased && !isForkBased { 88 + if sourceBranch == "" && patch == "" { 89 89 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 90 90 return 91 91 } 92 - 93 92 // Can't mix branch-based and patch-based approaches 94 - if isBranchBased && patch != "" { 93 + if sourceBranch != "" && patch != "" { 95 94 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 96 95 return 97 96 } 98 97 99 - if isBranchBased && sourceBranch == targetBranch { 100 - s.pages.Notice(w, "pull", "Source and target branch must be different.") 101 - return 98 + var sourceRepo *models.Repo 99 + if sourceRepoDid == syntax.DID(f.RepoDid) { 100 + sourceRepo = f 101 + } else { 102 + var err error 103 + sourceRepo, err = db.GetRepoByDid(s.db, sourceRepoDid.String()) 104 + if err != nil { 105 + s.pages.Notice(w, "pull", fmt.Sprintf("Unknown source repository: %q", sourceRepoDid)) 106 + return 107 + } 102 108 } 103 109 104 - // TODO: make capabilities an xrpc call 105 - caps := struct { 106 - PullRequests struct { 107 - FormatPatch bool 108 - BranchSubmissions bool 109 - ForkSubmissions bool 110 - PatchSubmissions bool 110 + if sourceBranch != "" { 111 + roles := s.acl.RolesInRepo(r.Context(), sourceRepo, userDid.String()) 112 + if !roles.IsPushAllowed() { 113 + s.pages.Notice(w, "pull", "Cannot select forbidden branch.") 114 + return 111 115 } 112 - }{ 113 - PullRequests: struct { 114 - FormatPatch bool 115 - BranchSubmissions bool 116 - ForkSubmissions bool 117 - PatchSubmissions bool 118 - }{ 119 - FormatPatch: true, 120 - BranchSubmissions: true, 121 - ForkSubmissions: true, 122 - PatchSubmissions: true, 123 - }, 124 116 } 125 117 126 - if !caps.PullRequests.FormatPatch { 127 - s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 118 + if sourceRepoDid == syntax.DID(f.RepoDid) && sourceBranch == targetBranch { 119 + s.pages.Notice(w, "pull", "Source and target branch must be different.") 128 120 return 129 121 } 130 122 131 - stackTitles := parseBracketedForm(r.Form, "stackTitle") 132 - stackBodies := parseBracketedForm(r.Form, "stackBody") 123 + if ok := knotcompat.KnotHasCapability(r.Context(), f.Knot, s.config.Core.Dev, consts.CapKeepCommit); !ok { 124 + s.pages.Notice(w, "pull", "Source repo's knot doesn't support ref-based pull requests. Try another way?") 125 + return 126 + } 133 127 134 - // Handle the PR creation based on the type 135 - if isBranchBased { 136 - if !caps.PullRequests.BranchSubmissions { 137 - s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 138 - return 139 - } 140 - s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 141 - } else if isForkBased { 142 - if !caps.PullRequests.ForkSubmissions { 143 - s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 144 - return 145 - } 146 - s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 147 - } else if isPatchBased { 148 - if !caps.PullRequests.PatchSubmissions { 149 - s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 150 - return 151 - } 152 - s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 128 + if sourceBranch != "" { 129 + s.handlePull(w, r, userDid, f, targetBranch, sourceRepo, sourceBranch, title, body) 130 + return 131 + } else if patch != "" { 132 + s.pages.Notice(w, "pull", "Patch based PR is currently unsupported.") 133 + return 153 134 } 135 + } 136 + } 137 + 138 + func (s *Pulls) PullComposeDiffFragment(w http.ResponseWriter, r *http.Request) { 139 + l := s.logger.With("handler", "PullComposeDiffFragment") 140 + ctx := r.Context() 141 + 142 + var ( 143 + baseRepoRaw = r.URL.Query().Get("baseRepo") 144 + baseBranch = r.URL.Query().Get("base") // base branch name 145 + headRepoRaw = r.URL.Query().Get("headRepo") 146 + headBranch = r.URL.Query().Get("head") // head branch name 147 + unified = r.URL.Query().Get("view") == "unified" 148 + ) 149 + baseRepo, err := syntax.ParseDID(baseRepoRaw) 150 + if err != nil { 151 + http.Error(w, "invalid base repo DID", http.StatusBadRequest) 152 + return 153 + } 154 + headRepo, err := syntax.ParseDID(headRepoRaw) 155 + if err != nil { 156 + http.Error(w, "invalid head repo DID", http.StatusBadRequest) 154 157 return 155 158 } 159 + l.Debug( 160 + "compose diff fragment", 161 + "base.repo", baseRepo, 162 + "base.commit", baseBranch, 163 + "head.repo", headRepo, 164 + "head.commit", headBranch, 165 + ) 166 + 167 + // resolve branch to commit IDs 168 + base, err := s.resolveRev(ctx, baseRepo, baseBranch) 169 + if err != nil { 170 + l.Error("failed to resolve base branch", "branch", baseBranch, "repo", baseRepo, "err", err) 171 + s.renderComposeDiffErr(w, l, "Failed to resolve base branch.") 172 + return 173 + } 174 + head, err := s.resolveRev(ctx, headRepo, headBranch) 175 + if err != nil { 176 + l.Error("failed to resolve head branch", "branch", headBranch, "repo", headRepo, "err", err) 177 + s.renderComposeDiffErr(w, l, "Failed to resolve head branch.") 178 + return 179 + } 180 + 181 + var params pages.PullDiffFragmentParams 182 + params.BaseRepo = baseRepo 183 + params.HeadRepo = headRepo 184 + params.DiffBase = baseBranch 185 + params.DiffHead = headBranch 186 + params.DiffUrl = r.URL.Path 187 + params.Unified = unified 188 + params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, baseRepo, base, headRepo, head, unified) 189 + if err := s.pages.PullComposeDiffFragment(w, params); err != nil { 190 + l.Error("failed to render", "err", err) 191 + } 192 + } 193 + 194 + func (s *Pulls) resolveRev(ctx context.Context, repo syntax.DID, rev string) (string, error) { 195 + out, err := s.gitmirror.ResolveRev(ctx, &gitmirrorv1.ResolveRevRequest{ 196 + Repo: repo.String(), 197 + Rev: []byte(rev), 198 + }) 199 + if err != nil { 200 + return "", err 201 + } 202 + return out.GetCommit(), nil 203 + } 204 + 205 + func (s *Pulls) renderComposeDiffErr(w http.ResponseWriter, l *slog.Logger, msg string) { 206 + if err := s.pages.PullComposeDiffFragment(w, pages.PullDiffFragmentParams{ErrorMsg: msg}); err != nil { 207 + l.Error("failed to render", "err", err) 208 + } 156 209 } 157 210 158 211 func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { ··· 178 231 func composeCanonicalURL(params pages.RepoNewPullParams) string { 179 232 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 180 233 q := url.Values{} 181 - if params.IsStacked { 182 - q.Set("mode", "stack") 183 - } 184 234 if params.Source != "" && params.Source != pages.SourceBranch { 185 235 q.Set("source", string(params.Source)) 186 236 } ··· 205 255 206 256 branches, err := s.listBranches(r.Context(), repo) 207 257 if err != nil { 208 - return pages.RepoNewPullParams{}, err 258 + return pages.RepoNewPullParams{}, fmt.Errorf("failed to list branches: %w", err) 209 259 } 210 260 211 261 var forks []models.Repo ··· 219 269 return f.RepoDid == "" 220 270 }) 221 271 272 + f, err := s.repoResolver.Resolve(r) 273 + if err != nil { 274 + return pages.RepoNewPullParams{}, fmt.Errorf("failed to resolve repo: %w", err) 275 + } 276 + 222 277 repoInfo := s.repoResolver.GetRepoInfo(r, user) 223 278 source, ok := pages.ParseSource(r.FormValue("source")) 224 279 if !ok { ··· 237 292 fork = forks[0].RepoDid 238 293 } 239 294 295 + var prefillErr error 296 + 240 297 var forkBranches []types.Branch 241 - var forkBranchesErr error 242 298 if source == pages.SourceFork && fork != "" { 243 - forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 244 - if forkBranchesErr != nil { 245 - l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 299 + forkBranches, err = s.listForkBranches(r.Context(), fork) 300 + if err != nil { 301 + l.Warn("failed to list fork branches", "err", prefillErr, "fork", fork) 302 + prefillErr = errors.Join(prefillErr, err) 246 303 } 247 304 } 248 305 ··· 250 307 targetBranch = defaultTargetBranch(branches, targetBranch) 251 308 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 252 309 253 - comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 254 - var prefillErr string 255 - if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 256 - prefillErr = joined.Error() 310 + var sourceRepo syntax.DID 311 + if fork != "" { 312 + sourceRepo = syntax.DID(fork) 313 + } else { 314 + sourceRepo = syntax.DID(repoInfo.RepoDid) 315 + } 316 + 317 + if sourceRepo == "" || sourceBranch == "" || targetBranch == "" { 318 + l.Error("params missing", "source", sourceRepo, "source.branch", sourceBranch, "target.branch", targetBranch) 319 + return pages.RepoNewPullParams{ 320 + BaseParams: pages.BaseParamsFromContext(r.Context()), 321 + RepoInfo: repoInfo, 322 + Branches: branches, 323 + SourceBranches: sourceBranchList, 324 + ForkBranches: forkBranches, 325 + Forks: forks, 326 + Source: source, 327 + SourceBranch: sourceBranch, 328 + TargetBranch: targetBranch, 329 + Fork: fork, 330 + Patch: patch, 331 + }, nil 332 + } 333 + 334 + var stepReviewParams pages.RepoNewPull_StepReviewParams 335 + 336 + commits, err := s.listCommits(r.Context(), sourceRepo, targetBranch, sourceBranch) 337 + if err != nil { 338 + prefillErr = errors.Join(prefillErr, err) 257 339 } 340 + stepReviewParams.Commits = commits 258 341 259 - mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 260 - 261 - refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 262 - var diffOpts types.DiffOpts 263 - if r.FormValue("diff") == "split" { 264 - diffOpts.Split = true 342 + var prefillErrorMsg string 343 + if prefillErr != nil { 344 + prefillErrorMsg = prefillErr.Error() 265 345 } 266 - diffOpts.RefreshUrl = refreshUrl 267 - diffOpts.Target = "#diff-area" 268 346 269 347 labelDefs, err := s.pullLabelDefs(repo) 270 348 if err != nil { 271 - l.Warn("failed to load label definitions", "err", err) 349 + l.Error("failed to load label definitions", "err", err) 272 350 } 273 351 labelState := labelStateFromForm(r.Form, labelDefs) 274 - perCidLabelForms := parseStackLabelForms(r.Form) 275 - stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 276 - for cid, perForm := range perCidLabelForms { 277 - stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 278 - } 279 - 280 - stackTitles := parseBracketedForm(r.Form, "stackTitle") 281 - stackBodies := parseBracketedForm(r.Form, "stackBody") 282 - stackSplits := parseBracketedForm(r.Form, "stackSplit") 283 352 284 353 title := r.FormValue("title") 285 354 body := r.FormValue("body") 286 355 titleDirty := r.FormValue("titleDirty") == "1" 287 356 bodyDirty := r.FormValue("bodyDirty") == "1" 288 - if comparison != nil && len(comparison.FormatPatch) > 0 { 289 - first := comparison.FormatPatch[0] 290 - if !titleDirty && first.PatchHeader != nil { 291 - title = first.Title 357 + if len(commits) == 1 { 358 + message := strings.SplitN(strings.TrimSpace(commits[0].Message), "\n\n", 2) 359 + if !titleDirty { 360 + title = message[0] 292 361 } 293 - if !bodyDirty && first.PatchHeader != nil { 294 - body = first.Body 362 + if !bodyDirty && len(message) > 1 && message[1] != "" { 363 + // TODO: strip trailers? 364 + body = message[1] 295 365 } 296 366 } 297 367 298 - isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 299 - var stackedDiffs []pages.StackedDiff 300 - if isStacked { 301 - stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 368 + l.Debug("label defs", "defs", labelDefs) 369 + 370 + var mergeCheckParams pages.MergeCheckParams 371 + if len(commits) > 0 { 372 + mergeCheckParams = s.composeMergeCheck(r.Context(), f, targetBranch, sourceRepo, commits[0].Hash.String()) 302 373 } 303 374 304 375 return pages.RepoNewPullParams{ ··· 317 388 Body: body, 318 389 TitleDirty: titleDirty, 319 390 BodyDirty: bodyDirty, 320 - IsStacked: isStacked, 321 - Comparison: comparison, 322 - Diff: diff, 323 - DiffOpts: diffOpts, 324 - StackedDiffs: stackedDiffs, 325 - MergeCheck: mergeCheck, 326 - StackTitles: stackTitles, 327 - StackBodies: stackBodies, 328 - PrefillError: prefillErr, 391 + StepReviewParams: &stepReviewParams, 392 + MergeCheck: mergeCheckParams, 393 + PrefillError: prefillErrorMsg, 329 394 LabelDefs: labelDefs, 330 395 LabelState: labelState, 331 - StackLabelStates: stackLabelStates, 332 396 }, nil 333 397 } 334 398 335 399 func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 336 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 400 + xrpcc := s.knotMirrorXRPC 337 401 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid) 338 402 if err != nil { 339 403 return nil, err ··· 410 474 return out 411 475 } 412 476 413 - func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 414 - var ( 415 - comparison *types.RepoFormatPatchResponse 416 - err error 417 - ) 418 - switch source { 419 - case pages.SourcePatch: 420 - if strings.TrimSpace(patch) == "" { 421 - return nil, nil, nil 422 - } 423 - if verr := validatePatch(&patch); verr != nil { 424 - return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 425 - } 426 - comparison = parsePastedPatch(patch) 427 - case pages.SourceBranch: 428 - if targetBranch == "" || sourceBranch == "" { 429 - return nil, nil, nil 430 - } 431 - comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 432 - case pages.SourceFork: 433 - if fork == "" || targetBranch == "" || sourceBranch == "" { 434 - return nil, nil, nil 435 - } 436 - comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 437 - default: 438 - return nil, nil, nil 439 - } 440 - if err != nil { 441 - s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 442 - return nil, nil, err 443 - } 444 - 445 - return comparison, deriveDiff(comparison, targetBranch), nil 446 - } 477 + func (s *Pulls) composeMergeCheck(ctx context.Context, targetRepo *models.Repo, targetBranch string, sourceRepoDid syntax.DID, sourceCommit string) pages.MergeCheckParams { 478 + l := s.logger.With("handler", "composeMergeCheck", "repo", targetRepo.RepoDid, "branch", targetBranch, "source", sourceCommit) 447 479 448 - func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 449 - if comparison == nil || targetBranch == "" { 450 - return nil 451 - } 452 - patch := comparison.CombinedPatchRaw 453 - if patch == "" { 454 - patch = comparison.FormatPatchRaw 455 - } 456 - if patch == "" { 457 - return nil 480 + targetSha, err := s.resolveRev(ctx, syntax.DID(targetRepo.RepoDid), targetBranch) 481 + if err != nil { 482 + l.Warn("failed to resolve target branch", "err", err) 483 + return pages.MergeCheckParams{Error: "merge check failed"} 458 484 } 459 485 460 - xrpcc := s.knotClient(repo.Knot) 461 - 462 - resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 463 - Did: repo.Did, 464 - Name: repo.Name, 465 - Repo: repo.RepoDidPtr(), 466 - Branch: targetBranch, 467 - Patch: patch, 486 + out, err := s.gitmirror.MergeCheck(ctx, &gitmirrorv1.MergeCheckRequest{ 487 + Target: &gitmirrorv1.RepoCommit{Repo: targetRepo.RepoDid, Commit: []byte(targetSha)}, 488 + Source: &gitmirrorv1.RepoCommit{Repo: sourceRepoDid.String(), Commit: []byte(sourceCommit)}, 468 489 }) 469 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 470 - s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 471 - return &types.MergeCheckResponse{Error: xrpcerr.Error()} 490 + if err != nil { 491 + l.Warn("failed to do merge-check", "err", err) 492 + return pages.MergeCheckParams{Error: "merge check failed"} 493 + } 494 + return pages.MergeCheckParams{ 495 + IsConflicted: out.IsConflicted, 496 + Conflicts: out.Conflicts, 472 497 } 473 - 474 - out := mergeCheckResponseFrom(resp) 475 - return &out 476 498 } 477 499 478 500 func bracketComponents(key, prefix string) ([]string, bool) { ··· 525 547 } 526 548 return out 527 549 } 528 - 529 - func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 530 - if patch == "" { 531 - return nil 532 - } 533 - response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 534 - if patchutil.IsFormatPatch(patch) { 535 - if patches, err := patchutil.ExtractPatches(patch); err == nil { 536 - response.FormatPatch = patches 537 - } 538 - } 539 - return response 540 - } 541 - 542 - func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 543 - xrpcc := s.knotClient(repo.Knot) 544 - 545 - xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 546 - if err != nil { 547 - return nil, err 548 - } 549 - 550 - var comparison types.RepoFormatPatchResponse 551 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 552 - return nil, err 553 - } 554 - return &comparison, nil 555 - } 556 - 557 - func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 558 - if forkRepoDid == "" { 559 - return nil, fmt.Errorf("fork not found") 560 - } 561 - fork, err := db.GetForkByRepoDid(s.db, forkRepoDid) 562 - if errors.Is(err, sql.ErrNoRows) { 563 - return nil, fmt.Errorf("fork not found") 564 - } 565 - if err != nil { 566 - return nil, err 567 - } 568 - 569 - client, err := s.oauth.ServiceClient( 570 - r, 571 - oauth.WithService(fork.Knot), 572 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 573 - oauth.WithDev(s.config.Core.Dev), 574 - ) 575 - if err != nil { 576 - return nil, err 577 - } 578 - 579 - resp, err := tangled.RepoHiddenRef( 580 - r.Context(), 581 - client, 582 - &tangled.RepoHiddenRef_Input{ 583 - ForkRef: sourceBranch, 584 - RemoteRef: targetBranch, 585 - Repo: fork.RepoAt().String(), 586 - }, 587 - ) 588 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 589 - return nil, xrpcerr 590 - } 591 - if !resp.Success { 592 - if resp.Error != nil { 593 - return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 594 - } 595 - return nil, fmt.Errorf("hidden ref failed") 596 - } 597 - 598 - hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 599 - forkXrpcc := s.knotClient(fork.Knot) 600 - 601 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 602 - if err != nil { 603 - return nil, err 604 - } 605 - 606 - var comparison types.RepoFormatPatchResponse 607 - if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 608 - return nil, err 609 - } 610 - return &comparison, nil 611 - } 612 - 613 - func stackPerCommitDiffs( 614 - comparison *types.RepoFormatPatchResponse, 615 - targetBranch, refreshUrl string, 616 - stackSplits map[string]string, 617 - ) []pages.StackedDiff { 618 - if comparison == nil { 619 - return nil 620 - } 621 - out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 622 - for i, p := range comparison.FormatPatch { 623 - nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 624 - out[i].Diff = &nd 625 - cid := p.ChangeIdOrEmpty() 626 - if cid == "" { 627 - continue 628 - } 629 - out[i].Opts = types.DiffOpts{ 630 - Split: stackSplits[cid] == "split", 631 - RefreshUrl: refreshUrl, 632 - Target: fmt.Sprintf("#stack-diff-%s", cid), 633 - Field: fmt.Sprintf("stackSplit[%s]", cid), 634 - } 635 - } 636 - return out 637 - } 638 - 639 - func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 640 - if comparison == nil { 641 - return nil 642 - } 643 - raw := comparison.CombinedPatchRaw 644 - if raw == "" { 645 - raw = comparison.FormatPatchRaw 646 - } 647 - d := patchutil.AsNiceDiff(raw, targetBranch) 648 - return &d 649 - }
-463
appview/pulls/compose_helpers_test.go
··· 1 - package pulls 2 - 3 - import ( 4 - "io" 5 - "log/slog" 6 - "net/url" 7 - "reflect" 8 - "testing" 9 - "time" 10 - 11 - "github.com/go-git/go-git/v5/plumbing/object" 12 - "tangled.org/core/appview/models" 13 - "tangled.org/core/appview/pages" 14 - "tangled.org/core/appview/pages/repoinfo" 15 - "tangled.org/core/patchutil" 16 - "tangled.org/core/types" 17 - ) 18 - 19 - func TestBracketComponents(t *testing.T) { 20 - cases := []struct { 21 - key, prefix string 22 - want []string 23 - ok bool 24 - }{ 25 - {"foo[a]", "foo", []string{"a"}, true}, 26 - {"foo[a][b]", "foo", []string{"a", "b"}, true}, 27 - {"foo[a][b][c]", "foo", []string{"a", "b", "c"}, true}, 28 - {"foo[]", "foo", []string{""}, true}, 29 - {"foo[a][]", "foo", []string{"a", ""}, true}, 30 - {"foo", "foo", nil, false}, 31 - {"bar[a]", "foo", nil, false}, 32 - {"foo[a", "foo", nil, false}, 33 - {"fooa]", "foo", nil, false}, 34 - {"foo[a]extra", "foo", nil, false}, 35 - {"", "foo", nil, false}, 36 - } 37 - for _, c := range cases { 38 - got, ok := bracketComponents(c.key, c.prefix) 39 - if ok != c.ok || !reflect.DeepEqual(got, c.want) { 40 - t.Errorf("bracketComponents(%q, %q) = %v, %v; want %v, %v", c.key, c.prefix, got, ok, c.want, c.ok) 41 - } 42 - } 43 - } 44 - 45 - func TestParseBracketedForm(t *testing.T) { 46 - form := url.Values{ 47 - "stackTitle[abc]": {"hello"}, 48 - "stackTitle[xyz]": {"world", "ignored"}, 49 - "stackTitle[]": {"empty-id"}, 50 - "stackTitle[a][b]": {"too-deep"}, 51 - "stackTitle": {"no-bracket"}, 52 - "unrelated[abc]": {"skip"}, 53 - "stackTitle[noval]": {}, 54 - } 55 - got := parseBracketedForm(form, "stackTitle") 56 - want := map[string]string{ 57 - "abc": "hello", 58 - "xyz": "world", 59 - } 60 - if !reflect.DeepEqual(got, want) { 61 - t.Errorf("parseBracketedForm = %v; want %v", got, want) 62 - } 63 - } 64 - 65 - func TestParseStackLabelForms(t *testing.T) { 66 - form := url.Values{ 67 - "stackLabel[c1][at://uri/a]": {"v1"}, 68 - "stackLabel[c1][at://uri/b]": {"v2"}, 69 - "stackLabel[c2][at://uri/a]": {"v3", "v4"}, 70 - "stackLabel[c1][]": {"empty-uri"}, 71 - "stackLabel[][at://uri/a]": {"empty-cid"}, 72 - "stackLabel[c1]": {"missing-second-bracket"}, 73 - "stackLabel[c1][a][b]": {"too-deep"}, 74 - "stackTitle[c1]": {"wrong-prefix"}, 75 - } 76 - got := parseStackLabelForms(form) 77 - want := map[string]url.Values{ 78 - "c1": { 79 - "at://uri/a": {"v1"}, 80 - "at://uri/b": {"v2"}, 81 - }, 82 - "c2": { 83 - "at://uri/a": {"v3", "v4"}, 84 - }, 85 - } 86 - if !reflect.DeepEqual(got, want) { 87 - t.Errorf("parseStackLabelForms = %v; want %v", got, want) 88 - } 89 - } 90 - 91 - func TestDefaultTargetBranch(t *testing.T) { 92 - branches := []types.Branch{ 93 - {Reference: types.Reference{Name: "feature"}}, 94 - {Reference: types.Reference{Name: "main"}, IsDefault: true}, 95 - } 96 - cases := []struct { 97 - name string 98 - branches []types.Branch 99 - current string 100 - want string 101 - }{ 102 - {"current is valid", branches, "feature", "feature"}, 103 - {"current is default", branches, "main", "main"}, 104 - {"current invalid, falls to default", branches, "ghost", "main"}, 105 - {"current empty, falls to default", branches, "", "main"}, 106 - {"no default, no match returns empty", []types.Branch{{Reference: types.Reference{Name: "only"}}}, "ghost", ""}, 107 - {"empty branches returns empty", nil, "anything", ""}, 108 - } 109 - for _, c := range cases { 110 - t.Run(c.name, func(t *testing.T) { 111 - if got := defaultTargetBranch(c.branches, c.current); got != c.want { 112 - t.Errorf("defaultTargetBranch = %q; want %q", got, c.want) 113 - } 114 - }) 115 - } 116 - } 117 - 118 - func TestDefaultSourceBranch(t *testing.T) { 119 - choices := []types.Branch{ 120 - {Reference: types.Reference{Name: "feature"}}, 121 - {Reference: types.Reference{Name: "wip"}}, 122 - } 123 - forks := []types.Branch{ 124 - {Reference: types.Reference{Name: "fork-feature"}}, 125 - } 126 - cases := []struct { 127 - name string 128 - source pages.Source 129 - current string 130 - want string 131 - }{ 132 - {"branch source, valid current", pages.SourceBranch, "feature", "feature"}, 133 - {"branch source, invalid falls to first", pages.SourceBranch, "ghost", "feature"}, 134 - {"branch source, empty falls to first", pages.SourceBranch, "", "feature"}, 135 - {"fork source, valid current", pages.SourceFork, "fork-feature", "fork-feature"}, 136 - {"fork source, invalid falls to first fork", pages.SourceFork, "ghost", "fork-feature"}, 137 - {"patch source preserves current", pages.SourcePatch, "anything", "anything"}, 138 - } 139 - for _, c := range cases { 140 - t.Run(c.name, func(t *testing.T) { 141 - if got := defaultSourceBranch(c.source, c.current, choices, forks); got != c.want { 142 - t.Errorf("defaultSourceBranch = %q; want %q", got, c.want) 143 - } 144 - }) 145 - } 146 - if got := defaultSourceBranch(pages.SourceBranch, "", nil, nil); got != "" { 147 - t.Errorf("empty choices should return empty, got %q", got) 148 - } 149 - } 150 - 151 - func TestSortBranchesByRecency(t *testing.T) { 152 - mk := func(name string, when *time.Time) types.Branch { 153 - b := types.Branch{Reference: types.Reference{Name: name}} 154 - if when != nil { 155 - b.Commit = &object.Commit{Committer: object.Signature{When: *when}} 156 - } 157 - return b 158 - } 159 - t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) 160 - t2 := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) 161 - t3 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) 162 - 163 - in := []types.Branch{ 164 - mk("oldest", &t1), 165 - mk("newest", &t3), 166 - mk("nil-commit", nil), 167 - mk("middle", &t2), 168 - } 169 - got := sortBranchesByRecency(in) 170 - wantNames := []string{"newest", "middle", "oldest", "nil-commit"} 171 - for i, want := range wantNames { 172 - if got[i].Reference.Name != want { 173 - t.Errorf("position %d: got %q, want %q", i, got[i].Reference.Name, want) 174 - } 175 - } 176 - 177 - if &got[0] == &in[0] { 178 - t.Error("expected new slice, got aliased input") 179 - } 180 - } 181 - 182 - func TestComposeCanonicalURL(t *testing.T) { 183 - repo := repoinfo.RepoInfo{OwnerDid: "did:plc:abc", Name: "demo", Rkey: "demo"} 184 - cases := []struct { 185 - name string 186 - p pages.RepoNewPullParams 187 - want string 188 - }{ 189 - { 190 - "defaults", 191 - pages.RepoNewPullParams{RepoInfo: repo, Source: pages.SourceBranch}, 192 - "/did:plc:abc/demo/pulls/new", 193 - }, 194 - { 195 - "stacked", 196 - pages.RepoNewPullParams{RepoInfo: repo, Source: pages.SourceBranch, IsStacked: true}, 197 - "/did:plc:abc/demo/pulls/new?mode=stack", 198 - }, 199 - { 200 - "fork with selection", 201 - pages.RepoNewPullParams{ 202 - RepoInfo: repo, 203 - Source: pages.SourceFork, 204 - Fork: "did:plc:limpet", 205 - SourceBranch: "feature", 206 - TargetBranch: "main", 207 - }, 208 - "/did:plc:abc/demo/pulls/new?fork=did%3Aplc%3Alimpet&source=fork&sourceBranch=feature&targetBranch=main", 209 - }, 210 - { 211 - "branch with selection drops source param", 212 - pages.RepoNewPullParams{ 213 - RepoInfo: repo, 214 - Source: pages.SourceBranch, 215 - SourceBranch: "feature", 216 - TargetBranch: "main", 217 - }, 218 - "/did:plc:abc/demo/pulls/new?sourceBranch=feature&targetBranch=main", 219 - }, 220 - { 221 - "fork field skipped when source != fork", 222 - pages.RepoNewPullParams{ 223 - RepoInfo: repo, 224 - Source: pages.SourceBranch, 225 - Fork: "stale", 226 - }, 227 - "/did:plc:abc/demo/pulls/new", 228 - }, 229 - } 230 - for _, c := range cases { 231 - t.Run(c.name, func(t *testing.T) { 232 - if got := composeCanonicalURL(c.p); got != c.want { 233 - t.Errorf("composeCanonicalURL = %q; want %q", got, c.want) 234 - } 235 - }) 236 - } 237 - } 238 - 239 - func TestLabelStateFromForm(t *testing.T) { 240 - bug := &models.LabelDefinition{ 241 - Did: "did:plc:test", Rkey: "bug", Name: "bug", 242 - ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 243 - Scope: []string{"sh.tangled.repo.pull"}, 244 - } 245 - priority := &models.LabelDefinition{ 246 - Did: "did:plc:test", Rkey: "priority", Name: "priority", 247 - ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 248 - Scope: []string{"sh.tangled.repo.pull"}, 249 - } 250 - defs := map[string]*models.LabelDefinition{ 251 - bug.AtUri().String(): bug, 252 - priority.AtUri().String(): priority, 253 - } 254 - 255 - form := url.Values{ 256 - bug.AtUri().String(): {"null"}, 257 - priority.AtUri().String(): {"high", ""}, 258 - "unrelated": {"ignored"}, 259 - } 260 - state := labelStateFromForm(form, defs) 261 - if !state.ContainsLabel(bug.AtUri().String()) { 262 - t.Error("expected bug label in state") 263 - } 264 - if !state.ContainsLabel(priority.AtUri().String()) { 265 - t.Error("expected priority label in state") 266 - } 267 - 268 - emptyState := labelStateFromForm(url.Values{}, defs) 269 - if emptyState.ContainsLabel(bug.AtUri().String()) { 270 - t.Error("empty form should produce empty state") 271 - } 272 - } 273 - 274 - func TestStackPerCommitDiffs(t *testing.T) { 275 - if got := stackPerCommitDiffs(nil, "main", "", nil); got != nil { 276 - t.Errorf("nil comparison should return nil, got %v", got) 277 - } 278 - 279 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 280 - From: Test <t@e.st> 281 - Date: Tue, 1 Jan 2020 00:00:00 +0000 282 - Subject: [PATCH] one 283 - Change-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 284 - 285 - --- 286 - a.txt | 1 + 287 - 1 file changed, 1 insertion(+) 288 - 289 - diff --git a/a.txt b/a.txt 290 - index 0000000..1111111 100644 291 - --- a/a.txt 292 - +++ b/a.txt 293 - @@ -0,0 +1 @@ 294 - +hello 295 - ` 296 - patches, err := patchutil.ExtractPatches(formatPatch) 297 - if err != nil { 298 - t.Fatalf("extract: %v", err) 299 - } 300 - if len(patches) != 1 { 301 - t.Fatalf("expected 1 patch, got %d", len(patches)) 302 - } 303 - if cid, err := patches[0].ChangeId(); err != nil || cid == "" { 304 - t.Fatalf("change-id missing from fixture: %v %q", err, cid) 305 - } 306 - comp := &types.RepoFormatPatchResponse{ 307 - FormatPatchRaw: formatPatch, 308 - FormatPatch: patches, 309 - } 310 - 311 - got := stackPerCommitDiffs(comp, "main", "/repo/pulls/new/refresh", map[string]string{ 312 - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": "split", 313 - }) 314 - if len(got) != 1 { 315 - t.Fatalf("expected 1 entry, got %d", len(got)) 316 - } 317 - if got[0].Diff == nil { 318 - t.Error("Diff should be set") 319 - } 320 - if !got[0].Opts.Split { 321 - t.Error("Split should propagate from stackSplits") 322 - } 323 - if got[0].Opts.RefreshUrl != "/repo/pulls/new/refresh" { 324 - t.Errorf("RefreshUrl: got %q", got[0].Opts.RefreshUrl) 325 - } 326 - if got[0].Opts.Target != "#stack-diff-Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { 327 - t.Errorf("Target: got %q", got[0].Opts.Target) 328 - } 329 - if got[0].Opts.Field != "stackSplit[Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]" { 330 - t.Errorf("Field: got %q", got[0].Opts.Field) 331 - } 332 - } 333 - 334 - func TestStackPerCommitDiffsNoChangeId(t *testing.T) { 335 - formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 336 - From: Test <t@e.st> 337 - Date: Tue, 1 Jan 2020 00:00:00 +0000 338 - Subject: [PATCH] no-cid 339 - 340 - --- 341 - a.txt | 1 + 342 - 1 file changed, 1 insertion(+) 343 - 344 - diff --git a/a.txt b/a.txt 345 - index 0000000..1111111 100644 346 - --- a/a.txt 347 - +++ b/a.txt 348 - @@ -0,0 +1 @@ 349 - +hello 350 - ` 351 - patches, err := patchutil.ExtractPatches(formatPatch) 352 - if err != nil { 353 - t.Fatalf("extract: %v", err) 354 - } 355 - comp := &types.RepoFormatPatchResponse{ 356 - FormatPatchRaw: formatPatch, 357 - FormatPatch: patches, 358 - } 359 - got := stackPerCommitDiffs(comp, "main", "/r", nil) 360 - if len(got) != 1 { 361 - t.Fatalf("len: %d", len(got)) 362 - } 363 - if got[0].Diff == nil { 364 - t.Error("Diff still set even without change-id") 365 - } 366 - if got[0].Opts != (types.DiffOpts{}) { 367 - t.Errorf("Opts should be zero without change-id, got %+v", got[0].Opts) 368 - } 369 - } 370 - 371 - func TestPrefetchComparisonPatch(t *testing.T) { 372 - s := &Pulls{ 373 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 374 - } 375 - 376 - cases := []struct { 377 - name string 378 - patch string 379 - wantNil bool 380 - wantErr bool 381 - }{ 382 - {"empty patch returns nil", "", true, false}, 383 - {"whitespace patch returns nil", " \n ", true, false}, 384 - {"garbage patch errors", "not a patch", false, true}, 385 - } 386 - for _, c := range cases { 387 - t.Run(c.name, func(t *testing.T) { 388 - comp, diff, err := s.prefetchComparison(nil, nil, pages.SourcePatch, "", "", "", c.patch) 389 - if c.wantErr { 390 - if err == nil { 391 - t.Fatal("expected error") 392 - } 393 - return 394 - } 395 - if err != nil { 396 - t.Fatalf("unexpected error: %v", err) 397 - } 398 - if c.wantNil { 399 - if comp != nil || diff != nil { 400 - t.Errorf("expected nil, got comp=%v diff=%v", comp, diff) 401 - } 402 - } 403 - }) 404 - } 405 - } 406 - 407 - func TestPrefetchComparisonValidPatch(t *testing.T) { 408 - s := &Pulls{ 409 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 410 - } 411 - patch := `diff --git a/a.txt b/a.txt 412 - index 0000000..1111111 100644 413 - --- a/a.txt 414 - +++ b/a.txt 415 - @@ -0,0 +1 @@ 416 - +hello 417 - ` 418 - comp, diff, err := s.prefetchComparison(nil, nil, pages.SourcePatch, "", "main", "", patch) 419 - if err != nil { 420 - t.Fatalf("err: %v", err) 421 - } 422 - if comp == nil { 423 - t.Fatal("comp nil") 424 - } 425 - if comp.FormatPatchRaw == "" { 426 - t.Error("FormatPatchRaw empty") 427 - } 428 - if diff == nil { 429 - t.Error("diff nil") 430 - } 431 - } 432 - 433 - func TestPrefetchComparisonMissingInputs(t *testing.T) { 434 - s := &Pulls{ 435 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 436 - } 437 - 438 - cases := []struct { 439 - name string 440 - source pages.Source 441 - fork string 442 - targetBranch string 443 - sourceBranch string 444 - }{ 445 - {"branch missing target", pages.SourceBranch, "", "", "feature"}, 446 - {"branch missing source", pages.SourceBranch, "", "main", ""}, 447 - {"fork missing fork", pages.SourceFork, "", "main", "feature"}, 448 - {"fork missing target", pages.SourceFork, "did:plc:limpet", "", "feature"}, 449 - {"fork missing source", pages.SourceFork, "did:plc:limpet", "main", ""}, 450 - {"unknown source", pages.Source("bogus"), "", "", ""}, 451 - } 452 - for _, c := range cases { 453 - t.Run(c.name, func(t *testing.T) { 454 - comp, diff, err := s.prefetchComparison(nil, nil, c.source, c.fork, c.targetBranch, c.sourceBranch, "") 455 - if err != nil { 456 - t.Errorf("expected nil err, got %v", err) 457 - } 458 - if comp != nil || diff != nil { 459 - t.Errorf("expected nil result, got comp=%v diff=%v", comp, diff) 460 - } 461 - }) 462 - } 463 - }
+96 -449
appview/pulls/create.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "context" 5 - "database/sql" 6 - "encoding/json" 7 - "errors" 8 4 "fmt" 9 5 "net/http" 10 - "strings" 11 6 "time" 12 7 8 + "golang.org/x/sync/errgroup" 13 9 "tangled.org/core/api/tangled" 14 10 "tangled.org/core/appview/db" 15 11 "tangled.org/core/appview/knotcompat" 16 12 "tangled.org/core/appview/models" 17 13 "tangled.org/core/appview/oauth" 18 14 "tangled.org/core/appview/reporesolver" 19 - "tangled.org/core/patchutil" 20 15 "tangled.org/core/tid" 21 - "tangled.org/core/types" 22 - "tangled.org/core/xrpc" 23 - "tangled.org/core/xrpc/xrpcclient" 24 16 25 17 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 18 "github.com/bluesky-social/indigo/atproto/syntax" 27 - lexutil "github.com/bluesky-social/indigo/lex/util" 28 19 ) 29 20 30 - func (s *Pulls) handleBranchBasedPull( 21 + func (s *Pulls) handlePull( 31 22 w http.ResponseWriter, 32 23 r *http.Request, 33 - repo *models.Repo, 34 24 userDid syntax.DID, 35 - title, 36 - body, 37 - targetBranch, 25 + targetRepo *models.Repo, 26 + targetBranch string, 27 + sourceRepo *models.Repo, 38 28 sourceBranch string, 39 - isStacked bool, 40 - stackTitles, stackBodies map[string]string, 41 - ) { 42 - l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 43 - 44 - xrpcc := s.knotClient(repo.Knot) 45 - 46 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 47 - if err != nil { 48 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 49 - l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err) 50 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 51 - return 52 - } 53 - l.Error("failed to compare", "err", err) 54 - s.pages.Notice(w, "pull", err.Error()) 55 - return 56 - } 57 - 58 - var comparison types.RepoFormatPatchResponse 59 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 60 - l.Error("failed to decode XRPC compare response", "err", err) 61 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 62 - return 63 - } 64 - 65 - if len(comparison.FormatPatch) == 0 { 66 - s.pages.Notice(w, "pull", "No commits between target and source.") 67 - return 68 - } 69 - 70 - sourceRev := comparison.Rev2 71 - patch := comparison.FormatPatchRaw 72 - combined := comparison.CombinedPatchRaw 73 - 74 - if err := validatePatch(&patch); err != nil { 75 - s.logger.Error("failed to validate patch", "err", err) 76 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 77 - return 78 - } 79 - 80 - pullSource := &models.PullSource{ 81 - Branch: sourceBranch, 82 - } 83 - 84 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 85 - } 86 - 87 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { 88 - if err := validatePatch(&patch); err != nil { 89 - s.logger.Error("patch validation failed", "err", err) 90 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 91 - return 92 - } 93 - 94 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 95 - } 96 - 97 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepoDid string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { 98 - l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo_did", forkRepoDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 99 - 100 - if forkRepoDid == "" { 101 - s.pages.Notice(w, "pull", "No such fork.") 102 - return 103 - } 104 - fork, err := db.GetForkByRepoDid(s.db, forkRepoDid) 105 - if errors.Is(err, sql.ErrNoRows) { 106 - s.pages.Notice(w, "pull", "No such fork.") 107 - return 108 - } else if err != nil { 109 - l.Error("failed to fetch fork", "err", err, "fork_repo_did", forkRepoDid) 110 - s.pages.Notice(w, "pull", "Failed to fetch fork.") 111 - return 112 - } 113 - 114 - client, err := s.oauth.ServiceClient( 115 - r, 116 - oauth.WithService(fork.Knot), 117 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 118 - oauth.WithDev(s.config.Core.Dev), 119 - ) 120 - 121 - resp, err := tangled.RepoHiddenRef( 122 - r.Context(), 123 - client, 124 - &tangled.RepoHiddenRef_Input{ 125 - ForkRef: sourceBranch, 126 - RemoteRef: targetBranch, 127 - Repo: fork.RepoAt().String(), 128 - }, 129 - ) 130 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 131 - s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 132 - s.pages.Notice(w, "pull", xrpcerr.Error()) 133 - return 134 - } 135 - 136 - if !resp.Success { 137 - errorMsg := "Failed to create pull request" 138 - if resp.Error != nil { 139 - errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 140 - } 141 - s.pages.Notice(w, "pull", errorMsg) 142 - return 143 - } 144 - 145 - hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 146 - // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 147 - // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 148 - // hiddenRef: hidden/feature-1/main (on repo-fork) 149 - // targetBranch: main (on repo-1) 150 - // sourceBranch: feature-1 (on repo-fork) 151 - forkXrpcc := s.knotClient(fork.Knot) 152 - 153 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 154 - if err != nil { 155 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 156 - l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef) 157 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 158 - return 159 - } 160 - l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef) 161 - s.pages.Notice(w, "pull", err.Error()) 162 - return 163 - } 164 - 165 - var comparison types.RepoFormatPatchResponse 166 - if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 167 - l.Error("failed to decode XRPC compare response for fork", "err", err) 168 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 169 - return 170 - } 171 - 172 - if len(comparison.FormatPatch) == 0 { 173 - s.pages.Notice(w, "pull", "No commits between target and source.") 174 - return 175 - } 176 - 177 - sourceRev := comparison.Rev2 178 - patch := comparison.FormatPatchRaw 179 - combined := comparison.CombinedPatchRaw 180 - 181 - if err := validatePatch(&patch); err != nil { 182 - s.logger.Error("failed to validate patch", "err", err) 183 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 184 - return 185 - } 186 - 187 - forkDid := syntax.DID(fork.RepoDid) 188 - pullSource := &models.PullSource{ 189 - Branch: sourceBranch, 190 - RepoDid: &forkDid, 191 - } 192 - 193 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 194 - } 195 - 196 - func (s *Pulls) createPullRequest( 197 - w http.ResponseWriter, 198 - r *http.Request, 199 - repo *models.Repo, 200 - userDid syntax.DID, 201 - title, body, targetBranch string, 202 - patch string, 203 - combined string, 204 - sourceRev string, 205 - pullSource *models.PullSource, 206 - isStacked bool, 207 - stackTitles, stackBodies map[string]string, 29 + title, 30 + body string, 208 31 ) { 209 - l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 210 - 211 - if isStacked { 212 - // creates a series of PRs, each linking to the previous, identified by jj's change-id 213 - s.createStackedPullRequest( 214 - w, 215 - r, 216 - repo, 217 - userDid, 218 - targetBranch, 219 - patch, 220 - sourceRev, 221 - pullSource, 222 - stackTitles, 223 - stackBodies, 224 - ) 225 - return 226 - } 32 + l := s.logger.With("handler", "handlePull", "user", userDid) 33 + ctx := r.Context() 227 34 228 35 client, err := s.oauth.AuthorizedClient(r) 229 36 if err != nil { ··· 232 39 return 233 40 } 234 41 235 - tx, err := s.db.BeginTx(r.Context(), nil) 236 - if err != nil { 237 - l.Error("failed to start tx", "err", err) 238 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 239 - return 240 - } 241 - defer tx.Rollback() 242 - 243 - // We've already checked earlier if it's diff-based and title is empty, 244 - // so if it's still empty now, it's intentionally skipped owing to format-patch. 245 - if title == "" || body == "" { 246 - formatPatches, err := patchutil.ExtractPatches(patch) 247 - if err != nil { 248 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 249 - return 250 - } 251 - if len(formatPatches) == 0 { 252 - s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 42 + // 1. fetch heads of source & target branches 43 + var base, head string 44 + { 45 + xrpcc := s.knotMirrorXRPC 46 + g, gctx := errgroup.WithContext(ctx) 47 + g.Go(func() error { 48 + // find merge-base between targetBranch & sourceBranch 49 + out, err := tangled.GitTempGetMergeBase(gctx, xrpcc, targetBranch, sourceBranch, sourceRepo.RepoDid) 50 + if err != nil { 51 + return err 52 + } 53 + base = out.Commit 54 + return nil 55 + }) 56 + g.Go(func() error { 57 + out, err := tangled.GitTempGetBranch(gctx, xrpcc, sourceBranch, sourceRepo.RepoDid) 58 + if err != nil { 59 + return err 60 + } 61 + head = out.Hash 62 + return nil 63 + }) 64 + if err := g.Wait(); err != nil { 65 + l.Error("failed to fetch branch heads", "err", err) 66 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 253 67 return 254 68 } 255 - 256 - if title == "" { 257 - title = formatPatches[0].Title 258 - } 259 - if body == "" { 260 - body = formatPatches[0].Body 261 - } 262 - } 263 - 264 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 265 - 266 - rkey := tid.TID() 267 - 268 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 269 - if err != nil { 270 - l.Error("failed to upload patch", "err", err) 271 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 272 - return 273 69 } 274 70 275 - now := time.Now() 276 - 71 + created := time.Now() 277 72 pull := &models.Pull{ 73 + ID: -1, // uninitialized 74 + OwnerDid: userDid, 75 + Rkey: syntax.RecordKey(tid.TID()), 76 + Cid: "", // uninitialized 77 + RepoDid: syntax.DID(targetRepo.RepoDid), 78 + PullId: 0, // uninitialized 79 + 278 80 Title: title, 279 81 Body: body, 280 82 TargetBranch: targetBranch, 281 - OwnerDid: userDid.String(), 282 - RepoDid: syntax.DID(repo.RepoDid), 283 - Rkey: rkey, 284 - Mentions: mentions, 285 - References: references, 286 - Submissions: []*models.PullSubmission{ 83 + SourceRepo: syntax.DID(sourceRepo.RepoDid), 84 + SourceBranch: &sourceBranch, 85 + Versions: []models.PullVersion{ 287 86 { 288 - Patch: patch, 289 - Combined: combined, 290 - SourceRev: sourceRev, 291 - Blob: *blob.Blob, 292 - Created: now, 87 + ID: 0, 88 + Base: base, 89 + Head: head, 90 + Created: created, 293 91 }, 294 92 }, 295 - PullSource: pullSource, 296 - State: models.PullOpen, 297 - Created: now, 298 - Repo: repo, 299 - } 300 - 301 - record := pull.AsRecord() 302 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 303 - Collection: tangled.RepoPullNSID, 304 - Repo: userDid.String(), 305 - Rkey: rkey, 306 - Record: knotcompat.Pull(&record), 307 - }) 308 - if err != nil { 309 - l.Error("failed to create pull request", "err", err) 310 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 311 - return 312 - } 313 - 314 - err = db.PutPull(tx, pull) 315 - if err != nil { 316 - l.Error("failed to create pull request in database", "err", err) 317 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 318 - return 319 - } 320 - pullId, err := db.NextPullId(tx, repo.RepoDid) 321 - if err != nil { 322 - s.logger.Error("failed to get pull id", "err", err) 323 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 324 - return 325 - } 326 - 327 - if err = tx.Commit(); err != nil { 328 - l.Error("failed to commit transaction for pull request", "err", err) 329 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 330 - return 331 - } 332 - 333 - s.notifier.NewPull(r.Context(), pull) 334 - 335 - s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 336 - 337 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 338 - s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 339 - } 340 - 341 - func (s *Pulls) createStackedPullRequest( 342 - w http.ResponseWriter, 343 - r *http.Request, 344 - repo *models.Repo, 345 - userDid syntax.DID, 346 - targetBranch string, 347 - patch string, 348 - sourceRev string, 349 - pullSource *models.PullSource, 350 - stackTitles, stackBodies map[string]string, 351 - ) { 352 - l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 353 - 354 - // run some necessary checks for stacked-prs first 93 + Created: created, 94 + State: models.PullOpen, 355 95 356 - formatPatches, err := patchutil.ExtractPatches(patch) 357 - if err != nil { 358 - l.Error("failed to extract patches", "err", err) 359 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 360 - return 361 - } 362 - 363 - // must have atleast 1 patch to begin with 364 - if len(formatPatches) == 0 { 365 - l.Error("empty patches") 366 - s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 367 - return 368 - } 369 - 370 - client, err := s.oauth.AuthorizedClient(r) 371 - if err != nil { 372 - l.Error("failed to get authorized client", "err", err) 373 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 374 - return 96 + Repo: targetRepo, 375 97 } 376 98 377 - // first upload all blobs 378 - blobs := make([]*lexutil.LexBlob, len(formatPatches)) 379 - for i, p := range formatPatches { 380 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 99 + // 2. call git.keepCommit 100 + { 101 + client, err := s.oauth.ServiceClient( 102 + r, 103 + oauth.WithService(sourceRepo.Knot), 104 + oauth.WithLxm(tangled.GitKeepCommitNSID), 105 + oauth.WithDev(s.config.Core.Dev), 106 + ) 381 107 if err != nil { 382 - l.Error("failed to upload patch blob", "err", err, "patch_index", i) 108 + l.Error("failed to comment to knot", "err", err) 383 109 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 384 110 return 385 111 } 386 - l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 387 - blobs[i] = blob.Blob 388 - } 389 112 390 - // build a stack out of this patch 391 - stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies) 392 - if err != nil { 393 - l.Error("failed to create stack", "err", err) 394 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 395 - return 396 - } 397 - 398 - // apply all record creations at once 399 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 400 - for _, p := range stack { 401 - record := p.AsRecord() 402 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 403 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 404 - Collection: tangled.RepoPullNSID, 405 - Rkey: &p.Rkey, 406 - Value: knotcompat.Pull(&record), 113 + _, err = tangled.GitKeepCommit(ctx, client, &tangled.GitKeepCommit_Input{ 114 + Repo: sourceRepo.RepoDid, 115 + Record: pull.AtUri().String(), 116 + Source: &tangled.GitKeepCommit_Input_Source{ 117 + GitKeepCommit_Commit: &tangled.GitKeepCommit_Commit{ 118 + Repo: sourceRepo.RepoDid, 119 + Oid: head, 120 + }, 407 121 }, 408 122 }) 123 + if err != nil { 124 + l.Error("failed to keep commit", "err", err) 125 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 126 + return 127 + } 409 128 } 410 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 411 - Repo: userDid.String(), 412 - Writes: writes, 129 + 130 + // 3. create PR record 131 + record := pull.AsRecord() 132 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 133 + Collection: tangled.RepoPullNSID, 134 + Repo: userDid.String(), 135 + Rkey: pull.Rkey.String(), 136 + Record: knotcompat.Pull(&record), 413 137 }) 414 - if err != nil { 415 - l.Error("failed to create stacked pull request", "err", err) 416 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 417 - return 418 - } 138 + pull.Cid = syntax.CID(out.Cid) 419 139 420 - // create all pulls at once 421 140 tx, err := s.db.BeginTx(r.Context(), nil) 422 141 if err != nil { 423 142 l.Error("failed to start tx", "err", err) ··· 426 145 } 427 146 defer tx.Rollback() 428 147 429 - for _, p := range stack { 430 - err = db.PutPull(tx, p) 431 - if err != nil { 432 - l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey) 433 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 434 - return 435 - } 436 - 148 + var references []syntax.ATURI 149 + if pull.Body != "" { 150 + _, references = s.mentionsResolver.Resolve(ctx, pull.Body) 437 151 } 438 152 439 - if err = tx.Commit(); err != nil { 440 - l.Error("failed to commit transaction for pull requests", "err", err) 153 + if err := db.PutPull(r.Context(), tx, pull, references); err != nil { 154 + l.Error("failed to create pull request in database", "err", err) 441 155 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 442 156 return 443 157 } 444 158 445 - // notify about each pull 446 - // 447 - // this is performed after tx.Commit, because it could result in a locked DB otherwise 448 - for _, p := range stack { 449 - s.notifier.NewPull(r.Context(), p) 159 + if err = tx.Commit(); err != nil { 160 + l.Error("failed to commit transaction for pull request", "err", err) 161 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 162 + return 450 163 } 451 164 452 - s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 453 - 454 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 455 - s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 456 - } 457 - 458 - func (s *Pulls) newStack( 459 - ctx context.Context, 460 - repo *models.Repo, 461 - userDid syntax.DID, 462 - targetBranch string, 463 - pullSource *models.PullSource, 464 - formatPatches []types.FormatPatch, 465 - blobs []*lexutil.LexBlob, 466 - stackTitles, stackBodies map[string]string, 467 - ) (models.Stack, error) { 468 - var stack models.Stack 469 - var parentAtUri *syntax.ATURI 470 - for i, fp := range formatPatches { 471 - // all patches must have a jj change-id 472 - cid, err := fp.ChangeId() 473 - if err != nil { 474 - return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 475 - } 476 - 477 - title := fp.Title 478 - body := fp.Body 479 - if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 480 - title = override 481 - } 482 - if override, ok := stackBodies[cid]; ok { 483 - body = override 484 - } 485 - rkey := tid.TID() 486 - 487 - mentions, references := s.mentionsResolver.Resolve(ctx, body) 488 - 489 - now := time.Now() 490 - 491 - pull := models.Pull{ 492 - Title: title, 493 - Body: body, 494 - TargetBranch: targetBranch, 495 - OwnerDid: userDid.String(), 496 - RepoDid: syntax.DID(repo.RepoDid), 497 - Rkey: rkey, 498 - Mentions: mentions, 499 - References: references, 500 - Submissions: []*models.PullSubmission{ 501 - { 502 - Patch: fp.Raw, 503 - SourceRev: fp.SHA, 504 - Combined: fp.Raw, 505 - Blob: *blobs[i], 506 - Created: now, 507 - }, 508 - }, 509 - PullSource: pullSource, 510 - Created: now, 511 - State: models.PullOpen, 512 - 513 - DependentOn: parentAtUri, 514 - Repo: repo, 515 - } 516 - 517 - stack = append(stack, &pull) 165 + s.notifier.NewPull(r.Context(), pull) 518 166 519 - parent := pull.AtUri() 520 - parentAtUri = &parent 521 - } 167 + s.applyCreationLabels(r.Context(), client, userDid, pull, r.Form, targetRepo) 522 168 523 - return stack, nil 169 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, targetRepo) 170 + s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 524 171 }
+168
appview/pulls/diff.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "html/template" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "strings" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "golang.org/x/sync/errgroup" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 18 + ) 19 + 20 + // htmx fragment. render diff between commits 21 + func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) { 22 + l := s.logger.With("handler", "PullDiffFragment") 23 + ctx := r.Context() 24 + 25 + var ( 26 + baseRepoRaw = r.URL.Query().Get("baseRepo") 27 + headRepoRaw = r.URL.Query().Get("headRepo") 28 + base = r.URL.Query().Get("base") // base commit SHA 29 + head = r.URL.Query().Get("head") // head commit SHA 30 + unified = r.URL.Query().Get("view") == "unified" 31 + ) 32 + baseRepo, err := syntax.ParseDID(baseRepoRaw) 33 + if err != nil { 34 + if err := s.pages.PullDiffFragment(w, pages.PullDiffFragmentParams{ 35 + ErrorMsg: "invalid base repository DID", 36 + }); err != nil { 37 + l.Error("failed to render", "err", err) 38 + } 39 + return 40 + } 41 + headRepo, err := syntax.ParseDID(headRepoRaw) 42 + if err != nil { 43 + if err := s.pages.PullDiffFragment(w, pages.PullDiffFragmentParams{ 44 + ErrorMsg: "invalid head repository DID", 45 + }); err != nil { 46 + l.Error("failed to render", "err", err) 47 + } 48 + return 49 + } 50 + l.Debug("diff fragment", "baseRepo", baseRepo, "base", base, "headRepo", headRepo, "head", head) 51 + 52 + var params pages.PullDiffFragmentParams 53 + params.BaseRepo = baseRepo 54 + params.HeadRepo = headRepo 55 + params.DiffBase = base 56 + params.DiffHead = head 57 + params.DiffUrl = r.URL.Path 58 + params.Unified = unified 59 + params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, baseRepo, base, headRepo, head, unified) 60 + if err := s.pages.PullDiffFragment(w, params); err != nil { 61 + l.Error("failed to render", "err", err) 62 + } 63 + } 64 + 65 + func (s *Pulls) diffFragmentParams(ctx context.Context, l *slog.Logger, baseRepo syntax.DID, base string, headRepo syntax.DID, head string, unified bool) ([]pages.DiffFile, string) { 66 + var err error 67 + if base != "" && !models.IsHash(base) { 68 + l := l.With("rev", base, "repo", baseRepo) 69 + if base, err = s.resolveRev(ctx, baseRepo, base); err != nil { 70 + l.Error("failed to resolve base revision", "err", err) 71 + return nil, "Failed to resolve base revision." 72 + } 73 + } 74 + if head != "" && !models.IsHash(head) { 75 + l := l.With("rev", head, "repo", headRepo) 76 + if head, err = s.resolveRev(ctx, headRepo, head); err != nil { 77 + l.Error("failed to resolve head revision", "err", err) 78 + return nil, "Failed to resolve head revision." 79 + } 80 + } 81 + 82 + // a. drain the diff stream into one fileDiff per changed file. 83 + req := &gitmirrorv1.DiffRequest{ 84 + Head: &gitmirrorv1.RepoCommit{Repo: headRepo.String(), Commit: []byte(head)}, 85 + } 86 + if base != "" { 87 + req.Base = &gitmirrorv1.RepoCommit{Repo: baseRepo.String(), Commit: []byte(base)} 88 + } 89 + stream, err := s.gitmirror.Diff(ctx, req) 90 + if err != nil { 91 + l.Error("failed to diff", "err", err) 92 + return nil, "Failed to diff. Try again later." 93 + } 94 + var files []*fileDiff 95 + for { 96 + fd, err := stream.Recv() 97 + if errors.Is(err, io.EOF) { 98 + break 99 + } 100 + if err != nil { 101 + l.Error("failed to drain diff response", "err", err) 102 + return nil, "Failed to diff. Try again later." 103 + } 104 + files = append(files, &fileDiff{diff: fd}) 105 + } 106 + 107 + // b. fetch each file's base/head blob in parallel and split into lines. 108 + g, gctx := errgroup.WithContext(ctx) 109 + for _, f := range files { 110 + g.Go(func() error { 111 + lhs, rhs := f.diff.GetLhsSrc(), f.diff.GetRhsSrc() 112 + // Binary/submodule files have no line content to fetch. 113 + if isBinaryOrSubmodule(lhs) || isBinaryOrSubmodule(rhs) { 114 + return nil 115 + } 116 + baseBlob, err := s.getBlob(gctx, baseRepo, lhs.GetOid()) 117 + if err != nil { 118 + return err 119 + } 120 + headBlob, err := s.getBlob(gctx, headRepo, rhs.GetOid()) 121 + if err != nil { 122 + return err 123 + } 124 + 125 + // TODO: highlight each blobs 126 + // pass lhs_positions & rhs_positions so highlighter can apply diff highlights 127 + for line := range strings.SplitSeq(strings.TrimSuffix(string(baseBlob), "\n"), "\n") { 128 + f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 129 + } 130 + for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") { 131 + f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 132 + } 133 + 134 + // f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n") 135 + // f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n") 136 + return nil 137 + }) 138 + } 139 + if err := g.Wait(); err != nil { 140 + l.Error("failed to prepare html", "err", err) 141 + return nil, "Failed to render diff. Try again later." 142 + } 143 + 144 + // c. build the render model 145 + var outFiles []pages.DiffFile 146 + for _, f := range files { 147 + df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()} 148 + 149 + if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) { 150 + df.Note = "binary or submodule" 151 + outFiles = append(outFiles, df) 152 + continue 153 + } 154 + 155 + for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { 156 + var dh pages.DiffHunk 157 + if unified { 158 + dh.Lines = buildUnifiedLines(h, f.baseLines, f.headLines) 159 + } else { 160 + dh.Rows = buildSplitRows(h, f.baseLines, f.headLines) 161 + } 162 + df.Hunks = append(df.Hunks, dh) 163 + } 164 + outFiles = append(outFiles, df) 165 + } 166 + 167 + return outFiles, "" 168 + }
+380
appview/pulls/diff_helpers.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "html/template" 7 + "io" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/appview/pages" 11 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 12 + ) 13 + 14 + type fileDiff struct { 15 + diff *gitmirrorv1.FileDiff 16 + baseLines []template.HTML 17 + headLines []template.HTML 18 + } 19 + 20 + const ( 21 + numContextLines = 3 22 + maxDistance = 4 23 + ) 24 + 25 + type linePair struct { 26 + lhs int // 0-based line number, -1 when empty 27 + rhs int // 0-based line number, -1 when empty 28 + } 29 + 30 + type displayHunk struct { 31 + rows []diffRow 32 + } 33 + 34 + type diffRow struct { 35 + lhs int // 0-based line number, -1 when empty 36 + rhs int // 0-based line number, -1 when empty 37 + changed bool 38 + } 39 + 40 + func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk { 41 + var flat []linePair 42 + for _, h := range hunks { 43 + for _, lp := range h.Lines { 44 + flat = append(flat, toPair(lp)) 45 + } 46 + } 47 + 48 + pairs, changed := alignFile(baseLines, headLines, hunks) 49 + merged := mergeAdjacent(linesToHunks(flat), pairs) 50 + 51 + var out []displayHunk 52 + prevEnd := 0 53 + for _, h := range merged { 54 + lo, hi := indexesForHunk(pairs, h, numContextLines) 55 + if lo < prevEnd { 56 + lo = prevEnd // don't re-emit rows shared with the previous hunk's slice 57 + } 58 + var dh displayHunk 59 + for i := lo; i < hi; i++ { 60 + dh.rows = append(dh.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: changed[i]}) 61 + } 62 + out = append(out, dh) 63 + prevEnd = hi 64 + } 65 + return out 66 + } 67 + 68 + // alignFile builds the whole-file aligned list: every displayed line as a pair, 69 + // plus a parallel `changed` flag for pairs that came from a gitmirror hunk. 70 + // Unchanged lines are a 1:1 bijection, so the two cursors advance together 71 + // across gaps. 72 + func alignFile(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) { 73 + li, ri := 0, 0 74 + emitContext := func(n int) { 75 + for k := range n { 76 + pairs = append(pairs, linePair{lhs: li + k, rhs: ri + k}) 77 + changed = append(changed, false) 78 + } 79 + li += n 80 + ri += n 81 + } 82 + for _, h := range hunks { 83 + lhsStart, _, ok := hunkStart(h, li, ri) 84 + if !ok { 85 + continue 86 + } 87 + emitContext(lhsStart - li) // unchanged gap before this change (== rhsStart-ri) 88 + for _, lp := range h.Lines { 89 + p := toPair(lp) 90 + if p.lhs >= 0 { 91 + li = p.lhs + 1 92 + } 93 + if p.rhs >= 0 { 94 + ri = p.rhs + 1 95 + } 96 + pairs = append(pairs, p) 97 + changed = append(changed, true) 98 + } 99 + } 100 + for li < len(baseLines) && ri < len(headLines) { 101 + pairs = append(pairs, linePair{lhs: li, rhs: ri}) 102 + changed = append(changed, false) 103 + li++ 104 + ri++ 105 + } 106 + return pairs, changed 107 + } 108 + 109 + // indexesForHunk returns the [start,end) slice of the aligned pairs to display 110 + // for a hunk: the span from its smallest to largest novel line, expanded by n 111 + // context lines each side and clamped. 112 + func indexesForHunk(pairs, hunkLines []linePair, n int) (start, end int) { 113 + minLhs, minRhs, maxLhs, maxRhs := -1, -1, -1, -1 114 + for _, lp := range hunkLines { 115 + if lp.lhs >= 0 { 116 + if minLhs < 0 { 117 + minLhs = lp.lhs 118 + } 119 + maxLhs = lp.lhs 120 + } 121 + if lp.rhs >= 0 { 122 + if minRhs < 0 { 123 + minRhs = lp.rhs 124 + } 125 + maxRhs = lp.rhs 126 + } 127 + } 128 + smallest, largest := linePair{minLhs, minRhs}, linePair{maxLhs, maxRhs} 129 + 130 + start = 0 131 + for i, p := range pairs { 132 + if eitherSideEqual(p, smallest) { 133 + start = i 134 + break 135 + } 136 + } 137 + end = len(pairs) 138 + for i := len(pairs) - 1; i >= 0; i-- { 139 + if eitherSideEqual(pairs[i], largest) { 140 + end = i + 1 141 + break 142 + } 143 + } 144 + 145 + start = max(0, start-n) 146 + end = min(len(pairs), end+n) 147 + return start, end 148 + } 149 + 150 + // eitherSideEqual reports whether a and b share a present line number on the same side. 151 + func eitherSideEqual(a, b linePair) bool { 152 + if a.lhs >= 0 && a.lhs == b.lhs { 153 + return true 154 + } 155 + if a.rhs >= 0 && a.rhs == b.rhs { 156 + return true 157 + } 158 + return false 159 + } 160 + 161 + func toPair(lp *gitmirrorv1.LinePair) linePair { 162 + p := linePair{lhs: -1, rhs: -1} 163 + if lp.Lhs != nil { 164 + p.lhs = int(*lp.Lhs) 165 + } 166 + if lp.Rhs != nil { 167 + p.rhs = int(*lp.Rhs) 168 + } 169 + return p 170 + } 171 + 172 + // hunkStart returns the first changed line number on each side, deriving the 173 + // empty side from the cursors (unchanged lines advance both sides equally). ok 174 + // is false for an empty hunk. 175 + func hunkStart(h *gitmirrorv1.Hunk, li, ri int) (lhsStart, rhsStart int, ok bool) { 176 + lhsStart, rhsStart = -1, -1 177 + for _, lp := range h.Lines { 178 + if lp.Lhs != nil && lhsStart < 0 { 179 + lhsStart = int(*lp.Lhs) 180 + } 181 + if lp.Rhs != nil && rhsStart < 0 { 182 + rhsStart = int(*lp.Rhs) 183 + } 184 + } 185 + switch { 186 + case lhsStart < 0 && rhsStart < 0: 187 + return 0, 0, false 188 + case lhsStart < 0: // pure insertion 189 + lhsStart = li + (rhsStart - ri) 190 + case rhsStart < 0: // pure deletion 191 + rhsStart = ri + (lhsStart - li) 192 + } 193 + return lhsStart, rhsStart, true 194 + } 195 + 196 + // enforceIncreasing drops any line number that would go backwards, keeping each 197 + // side monotonically increasing. 198 + func enforceIncreasing(lines []linePair) []linePair { 199 + var out []linePair 200 + maxLhs, maxRhs := -1, -1 201 + for _, lp := range lines { 202 + l, r := lp.lhs, lp.rhs 203 + if maxLhs < 0 { 204 + maxLhs = l 205 + } else if l >= 0 && l > maxLhs { 206 + maxLhs = l 207 + } else { 208 + l = -1 209 + } 210 + if maxRhs < 0 { 211 + maxRhs = r 212 + } else if r >= 0 && r > maxRhs { 213 + maxRhs = r 214 + } else { 215 + r = -1 216 + } 217 + if l >= 0 || r >= 0 { 218 + out = append(out, linePair{lhs: l, rhs: r}) 219 + } 220 + } 221 + return out 222 + } 223 + 224 + // linesAreClose reports whether a line is within maxDistance of the last seen 225 + // line on either side. 226 + func linesAreClose(maxLhs, maxRhs int, lp linePair) bool { 227 + if maxLhs >= 0 && lp.lhs >= 0 && lp.lhs <= maxLhs+maxDistance { 228 + return true 229 + } 230 + if maxRhs >= 0 && lp.rhs >= 0 && lp.rhs <= maxRhs+maxDistance { 231 + return true 232 + } 233 + return false 234 + } 235 + 236 + // linesToHunks splits changed line pairs into hunks by per-side proximity. 237 + func linesToHunks(flat []linePair) [][]linePair { 238 + var hunks [][]linePair 239 + var cur []linePair 240 + maxLhs, maxRhs := -1, -1 241 + for _, lp := range enforceIncreasing(flat) { 242 + if len(cur) == 0 || linesAreClose(maxLhs, maxRhs, lp) { 243 + cur = append(cur, lp) 244 + } else { 245 + hunks = append(hunks, cur) 246 + cur = []linePair{lp} 247 + } 248 + if lp.lhs >= 0 { 249 + maxLhs = lp.lhs 250 + } 251 + if lp.rhs >= 0 { 252 + maxRhs = lp.rhs 253 + } 254 + } 255 + if len(cur) > 0 { 256 + hunks = append(hunks, cur) 257 + } 258 + return hunks 259 + } 260 + 261 + // mergeAdjacent folds consecutive hunks whose context windows overlap in the 262 + // aligned pair list into one group. It pads by numContextLines+1 (one more than 263 + // the displayed context) so hunks separated only by shared context merge. 264 + func mergeAdjacent(hunks [][]linePair, pairs []linePair) [][]linePair { 265 + var merged [][]linePair 266 + prevHi := -1 267 + for _, h := range hunks { 268 + lo, hi := indexesForHunk(pairs, h, numContextLines+1) 269 + if len(merged) > 0 && lo < prevHi { 270 + last := len(merged) - 1 271 + merged[last] = append(merged[last], h...) 272 + if hi > prevHi { 273 + prevHi = hi 274 + } 275 + continue 276 + } 277 + merged = append(merged, h) 278 + prevHi = hi 279 + } 280 + return merged 281 + } 282 + 283 + func buildSplitRows(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffRow { 284 + rows := make([]pages.DiffRow, 0, len(h.rows)) 285 + for _, r := range h.rows { 286 + var row pages.DiffRow 287 + if !r.changed { 288 + row.Left = pages.DiffCell{Kind: "ctx", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)} 289 + row.Right = pages.DiffCell{Kind: "ctx", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)} 290 + } else { 291 + if r.lhs >= 0 { 292 + row.Left = pages.DiffCell{Kind: "del", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)} 293 + } else { 294 + row.Left = pages.DiffCell{Kind: "empty", Num: 0} 295 + } 296 + if r.rhs >= 0 { 297 + row.Right = pages.DiffCell{Kind: "add", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)} 298 + } else { 299 + row.Right = pages.DiffCell{Kind: "empty", Num: 0} 300 + } 301 + } 302 + rows = append(rows, row) 303 + } 304 + return rows 305 + } 306 + 307 + func buildUnifiedLines(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffLine { 308 + var out []pages.DiffLine 309 + for i := 0; i < len(h.rows); { 310 + r := h.rows[i] 311 + if !r.changed { 312 + if r.lhs >= 0 { 313 + out = append(out, pages.DiffLine{Op: " ", Old: r.lhs + 1, New: r.rhs + 1, Content: lineAt(baseLines, r.lhs)}) 314 + } 315 + i++ 316 + continue 317 + } 318 + j := i 319 + for j < len(h.rows) && h.rows[j].changed { 320 + j++ 321 + } 322 + for _, cr := range h.rows[i:j] { 323 + if cr.lhs >= 0 { 324 + out = append(out, pages.DiffLine{Op: "-", Old: cr.lhs + 1, New: 0, Content: lineAt(baseLines, cr.lhs)}) 325 + } 326 + } 327 + for _, cr := range h.rows[i:j] { 328 + if cr.rhs >= 0 { 329 + out = append(out, pages.DiffLine{Op: "+", Old: 0, New: cr.rhs + 1, Content: lineAt(headLines, cr.rhs)}) 330 + } 331 + } 332 + i = j 333 + } 334 + return out 335 + } 336 + 337 + func lineAt(lines []template.HTML, n int) template.HTML { 338 + if n < 0 || n >= len(lines) { 339 + return "" 340 + } 341 + return lines[n] 342 + } 343 + 344 + func (s *Pulls) getBlob(ctx context.Context, repo syntax.DID, oid string) ([]byte, error) { 345 + if isNullOid(oid) { 346 + return nil, nil 347 + } 348 + stream, err := s.gitmirror.GetBlob(ctx, &gitmirrorv1.GetBlobRequest{Repo: repo.String(), Oid: oid}) 349 + if err != nil { 350 + return nil, err 351 + } 352 + var buf []byte 353 + for { 354 + chunk, err := stream.Recv() 355 + if errors.Is(err, io.EOF) { 356 + break 357 + } 358 + if err != nil { 359 + return nil, err 360 + } 361 + buf = append(buf, chunk.GetData()...) 362 + } 363 + return buf, nil 364 + } 365 + 366 + func isBinaryOrSubmodule(fc *gitmirrorv1.FileContent) bool { 367 + return fc != nil && (fc.GetIsBinary() || fc.GetIsSubmodule()) 368 + } 369 + 370 + func isNullOid(oid string) bool { 371 + if oid == "" { 372 + return true 373 + } 374 + for _, c := range oid { 375 + if c != '0' { 376 + return false 377 + } 378 + } 379 + return true 380 + }
+10 -6
appview/pulls/edit.go
··· 4 4 "net/http" 5 5 6 6 comatproto "github.com/bluesky-social/indigo/api/atproto" 7 + "github.com/bluesky-social/indigo/atproto/syntax" 7 8 lexutil "github.com/bluesky-social/indigo/lex/util" 8 9 9 10 "tangled.org/core/api/tangled" ··· 36 37 newPull := *pull 37 38 newPull.Title = r.FormValue("title") 38 39 newPull.Body = r.FormValue("body") 39 - newPull.Mentions, newPull.References = s.mentionsResolver.Resolve(ctx, newPull.Body) 40 + var references []syntax.ATURI 41 + if pull.Body != "" { 42 + _, references = s.mentionsResolver.Resolve(ctx, newPull.Body) 43 + } 40 44 41 45 // edit an atproto record 42 46 client, err := s.oauth.AuthorizedClient(r) ··· 46 50 return 47 51 } 48 52 49 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, newPull.Rkey) 53 + ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, user.Did, newPull.Rkey.String()) 50 54 if err != nil { 51 55 l.Error("failed to get record", "err", err) 52 56 s.pages.Notice(w, noticeId, "Failed to edit pull, no record found on PDS.") ··· 54 58 } 55 59 56 60 newRecord := newPull.AsRecord() 57 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 61 + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 58 62 Collection: tangled.RepoPullNSID, 59 63 Repo: user.Did, 60 - Rkey: newPull.Rkey, 64 + Rkey: newPull.Rkey.String(), 61 65 SwapRecord: ex.Cid, 62 66 Record: &lexutil.LexiconTypeDecoder{ 63 67 Val: &newRecord, ··· 69 73 return 70 74 } 71 75 72 - tx, err := s.db.BeginTx(r.Context(), nil) 76 + tx, err := s.db.BeginTx(ctx, nil) 73 77 if err != nil { 74 78 l.Error("failed to start tx", "err", err) 75 79 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 77 81 } 78 82 defer tx.Rollback() 79 83 80 - err = db.PutPull(tx, &newPull) 84 + err = db.PutPull(ctx, tx, &newPull, references) 81 85 if err != nil { 82 86 l.Error("failed to create pull request in database", "err", err) 83 87 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+120
appview/pulls/interdiff.go
··· 1 + package pulls 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "html/template" 7 + "io" 8 + "net/http" 9 + "strings" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "golang.org/x/sync/errgroup" 13 + "tangled.org/core/appview/pages" 14 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 15 + ) 16 + 17 + // htmx fragment. render interdiff between changes 18 + func (s *Pulls) PullInterdiffFragment(w http.ResponseWriter, r *http.Request) { 19 + l := s.logger.With("handler", "PullInterdiffFragment") 20 + ctx := r.Context() 21 + var ( 22 + repoRaw = r.URL.Query().Get("repo") // source repo DID 23 + base1 = r.URL.Query().Get("base1") // base1 commit ID 24 + head1 = r.URL.Query().Get("head1") // head1 commit ID 25 + base2 = r.URL.Query().Get("base2") // base2 commit ID 26 + head2 = r.URL.Query().Get("head2") // head2 commit ID 27 + unified = r.URL.Query().Get("view") == "unified" 28 + ) 29 + repo, err := syntax.ParseDID(repoRaw) 30 + if err != nil { 31 + http.Error(w, "invalid repo DID", http.StatusBadRequest) 32 + return 33 + } 34 + l.Debug("interdiff", "base1", base1, "head1", head1, "base2", base2, "head2", head2) 35 + 36 + var params pages.PullDiffFragmentParams 37 + params.DiffUrl = r.URL.Path 38 + params.Unified = unified 39 + defer func() { 40 + s.pages.PullDiffFragment(w, params) 41 + }() 42 + 43 + stream, err := s.gitmirror.Interdiff(ctx, &gitmirrorv1.InterdiffRequest{ 44 + Repo: repo.String(), 45 + FromBase: []byte(base1), 46 + FromHead: []byte(head1), 47 + ToBase: []byte(base2), 48 + ToHead: []byte(head2), 49 + }) 50 + if err != nil { 51 + l.Error("failed to interdiff", "err", err) 52 + params.ErrorMsg = "Failed to interdiff. Try again later." 53 + return 54 + } 55 + var files []*fileDiff 56 + for { 57 + fd, err := stream.Recv() 58 + if errors.Is(err, io.EOF) { 59 + break 60 + } 61 + if err != nil { 62 + l.Error("failed to drain interdiff response", "err", err) 63 + params.ErrorMsg = "Failed to interdiff. Try again later." 64 + return 65 + } 66 + files = append(files, &fileDiff{diff: fd}) 67 + } 68 + 69 + g, gctx := errgroup.WithContext(ctx) 70 + for _, f := range files { 71 + g.Go(func() error { 72 + lhs, rhs := f.diff.GetLhsSrc(), f.diff.GetRhsSrc() 73 + // Binary/submodule files have no line content to fetch. 74 + if isBinaryOrSubmodule(lhs) || isBinaryOrSubmodule(rhs) { 75 + return nil 76 + } 77 + 78 + headBlob, err := s.getBlob(gctx, syntax.DID(repo), rhs.GetOid()) 79 + if err != nil { 80 + return err 81 + } 82 + 83 + if f.diff.LhsSrc.Content != nil { 84 + for line := range strings.SplitSeq(strings.TrimSuffix(string(f.diff.LhsSrc.Content), "\n"), "\n") { 85 + f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 86 + } 87 + } 88 + 89 + for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") { 90 + f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 91 + } 92 + return nil 93 + }) 94 + } 95 + if err := g.Wait(); err != nil { 96 + l.Error("failed to prepare interdiff UI", "err", err) 97 + params.ErrorMsg = "Failed to render interdiff. Try again later." 98 + return 99 + } 100 + for _, f := range files { 101 + df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()} 102 + 103 + if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) { 104 + df.Note = "binary or submodule" 105 + params.Files = append(params.Files, df) 106 + continue 107 + } 108 + 109 + for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { 110 + var dh pages.DiffHunk 111 + if unified { 112 + dh.Lines = buildUnifiedLines(h, f.baseLines, f.headLines) 113 + } else { 114 + dh.Rows = buildSplitRows(h, f.baseLines, f.headLines) 115 + } 116 + df.Hunks = append(df.Hunks, dh) 117 + } 118 + params.Files = append(params.Files, df) 119 + } 120 + }
+51 -76
appview/pulls/labels.go
··· 95 95 ctx context.Context, 96 96 client *atclient.APIClient, 97 97 userDid syntax.DID, 98 - pulls []*models.Pull, 98 + pull *models.Pull, 99 99 form url.Values, 100 100 repo *models.Repo, 101 101 ) { ··· 110 110 return 111 111 } 112 112 113 - perCidForms := parseStackLabelForms(form) 113 + rkey := tid.TID() 114 + raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, form, defs, time.Now()) 114 115 115 - applyAll := form.Get("applyLabelsToAll") == "on" 116 - var firstStackForm url.Values 117 - if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 { 118 - if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" { 119 - if f, ok := perCidForms[firstCid]; ok { 120 - firstStackForm = f 121 - } 122 - } 123 - } 116 + valid := make([]models.LabelOp, 0, len(raw)) 117 + for _, op := range raw { 118 + def := defs[op.OperandKey] 124 119 125 - performedAt := time.Now() 126 - for _, pull := range pulls { 127 - labelForm := form 128 - if firstStackForm != nil { 129 - labelForm = firstStackForm 130 - } else if len(perCidForms) > 0 && len(pull.Submissions) > 0 { 131 - if cid := pull.Submissions[0].ChangeId(); cid != "" { 132 - if perForm, ok := perCidForms[cid]; ok { 133 - labelForm = perForm 134 - } 135 - } 120 + // validate permissions: only collaborators can apply labels currently 121 + // 122 + // TODO: introduce a repo:triage permission 123 + ok, err := s.acl.HasRepoPermissionErr(ctx, repo, op.Did, "repo:push") 124 + if err != nil { 125 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 126 + continue 127 + } 128 + if !ok { 129 + l.Warn("forbidden label op", "subject", op.Subject, "key", op.OperandKey) 130 + continue 136 131 } 137 - rkey := tid.TID() 138 - raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt) 139 - 140 - valid := make([]models.LabelOp, 0, len(raw)) 141 - for _, op := range raw { 142 - def := defs[op.OperandKey] 143 - 144 - // validate permissions: only collaborators can apply labels currently 145 - // 146 - // TODO: introduce a repo:triage permission 147 - ok, err := s.acl.HasRepoPermissionErr(ctx, repo, op.Did, "repo:push") 148 - if err != nil { 149 - l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 150 - continue 151 - } 152 - if !ok { 153 - l.Warn("forbidden label op", "subject", op.Subject, "key", op.OperandKey) 154 - continue 155 - } 156 132 157 - // resolve Handle to DID 158 - if def.ValueType.IsString() && def.ValueType.IsDidFormat() { 159 - val := syntax.AtIdentifier(op.OperandValue) 160 - if val.IsHandle() { 161 - ident, err := s.idResolver.Directory().Lookup(ctx, val) 162 - if err != nil { 163 - l.Warn("failed to resolve handle", "err", err, "subject", op.Subject, "key", op.OperandKey) 164 - } 165 - op.OperandValue = ident.DID.String() 133 + // resolve Handle to DID 134 + if def.ValueType.IsString() && def.ValueType.IsDidFormat() { 135 + val := syntax.AtIdentifier(op.OperandValue) 136 + if val.IsHandle() { 137 + ident, err := s.idResolver.Directory().Lookup(ctx, val) 138 + if err != nil { 139 + l.Warn("failed to resolve handle", "err", err, "subject", op.Subject, "key", op.OperandKey) 166 140 } 141 + op.OperandValue = ident.DID.String() 167 142 } 168 - 169 - if err := def.ValidateOperandValue(&op); err != nil { 170 - l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 171 - continue 172 - } 173 - valid = append(valid, op) 174 143 } 175 - if len(valid) == 0 { 144 + 145 + if err := def.ValidateOperandValue(&op); err != nil { 146 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 176 147 continue 177 148 } 149 + valid = append(valid, op) 150 + } 151 + if len(valid) == 0 { 152 + return 153 + } 178 154 179 - record := models.LabelOpsAsRecord(valid) 180 - if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 155 + record := models.LabelOpsAsRecord(valid) 156 + if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 157 + Collection: tangled.LabelOpNSID, 158 + Repo: userDid.String(), 159 + Rkey: rkey, 160 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 161 + }); err != nil { 162 + l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 163 + return 164 + } 165 + 166 + if err := s.indexLabelOps(ctx, valid); err != nil { 167 + l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 168 + if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 181 169 Collection: tangled.LabelOpNSID, 182 170 Repo: userDid.String(), 183 171 Rkey: rkey, 184 - Record: &lexutil.LexiconTypeDecoder{Val: &record}, 185 172 }); err != nil { 186 - l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 187 - continue 173 + l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 188 174 } 189 - 190 - if err := s.indexLabelOps(ctx, valid); err != nil { 191 - l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 192 - if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 193 - Collection: tangled.LabelOpNSID, 194 - Repo: userDid.String(), 195 - Rkey: rkey, 196 - }); err != nil { 197 - l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 198 - } 199 - continue 200 - } 201 - 202 - s.notifier.NewPullLabelOp(ctx, userDid, pull, valid) 175 + return 203 176 } 177 + 178 + s.notifier.NewPullLabelOp(ctx, userDid, pull, valid) 204 179 } 205 180 206 181 func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error {
+17 -40
appview/pulls/lifecycle.go
··· 14 14 15 15 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 16 16 l := s.logger.With("handler", "ClosePull") 17 + l.Debug("request") 17 18 18 19 user := s.oauth.GetMultiAccountUser(r) 19 20 if user == nil { ··· 35 36 s.pages.Notice(w, "pull-action-error", "Failed to close pull. Try again later.") 36 37 return 37 38 } 38 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 39 + l = l.With("pull", pull.AtUri(), "pull_id", pull.PullId, "state", pull.State) 39 40 40 41 // auth filter: only owner or collaborators can close 41 42 roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 42 43 isOwner := roles.IsOwner() 43 44 isCollaborator := roles.IsCollaborator() 44 - isPullAuthor := user.Did == pull.OwnerDid 45 + isPullAuthor := syntax.DID(user.Did) == pull.OwnerDid 45 46 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 46 47 if !isCloseAllowed { 47 48 l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) ··· 49 50 return 50 51 } 51 52 52 - // if this PR is stacked, then we want to close all PRs above this one on the stack 53 - stack := r.Context().Value("stack").(models.Stack) 54 - pullsToClose := stack.Above(pull) 55 - var atUris []syntax.ATURI 56 - for _, p := range pullsToClose { 57 - atUris = append(atUris, p.AtUri()) 58 - p.State = models.PullClosed 59 - } 60 - 61 - if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateClosed); err != nil { 62 - l.Error("failed to write pull status records", "err", err) 63 - s.pages.Notice(w, "pull-action-error", "Failed to close pull. Try again later.") 53 + if err := s.writePullStatusRecord(r, user.Did, pull.AtUri(), models.StateClosed); err != nil { 54 + l.Error("failed to write issue state record", "err", err) 55 + s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 64 56 return 65 57 } 66 58 ··· 74 66 75 67 err = db.ClosePulls( 76 68 tx, 77 - orm.FilterEq("repo_did", string(f.RepoDid)), 78 - orm.FilterIn("at_uri", atUris), 69 + orm.FilterEq("at_uri", pull.AtUri()), 79 70 ) 80 71 if err != nil { 81 - l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose)) 72 + l.Error("failed to close pulls in database", "err", err) 82 73 s.pages.Notice(w, "pull-action-error", "Failed to close pull.") 83 74 return 84 75 } ··· 90 81 return 91 82 } 92 83 93 - for _, p := range pullsToClose { 94 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 95 - } 84 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), pull) 96 85 97 86 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 98 87 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) ··· 122 111 s.pages.Notice(w, "pull-action-error", "Failed to reopen pull. Try again later.") 123 112 return 124 113 } 125 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) 114 + l = l.With("pull", pull.AtUri(), "pull_id", pull.PullId, "state", pull.State) 126 115 127 116 // auth filter: only owner or collaborators can close 128 117 roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 129 118 isOwner := roles.IsOwner() 130 119 isCollaborator := roles.IsCollaborator() 131 - isPullAuthor := user.Did == pull.OwnerDid 120 + isPullAuthor := syntax.DID(user.Did) == pull.OwnerDid 132 121 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 133 122 if !isCloseAllowed { 134 123 l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) ··· 136 125 return 137 126 } 138 127 139 - // if this PR is stacked, then we want to reopen all PRs above this one on the stack 140 - stack := r.Context().Value("stack").(models.Stack) 141 - pullsToReopen := stack.Below(pull) 142 - var atUris []syntax.ATURI 143 - for _, p := range pullsToReopen { 144 - atUris = append(atUris, p.AtUri()) 145 - p.State = models.PullOpen 146 - } 147 - 148 - if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateOpen); err != nil { 149 - l.Error("failed to write pull status records", "err", err) 150 - s.pages.Notice(w, "pull-action-error", "Failed to reopen pull. Try again later.") 128 + if err := s.writePullStatusRecord(r, user.Did, pull.AtUri(), models.StateOpen); err != nil { 129 + l.Error("failed to write issue state record", "err", err) 130 + s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 151 131 return 152 132 } 153 133 ··· 161 141 162 142 err = db.ReopenPulls( 163 143 tx, 164 - orm.FilterEq("repo_did", string(f.RepoDid)), 165 - orm.FilterIn("at_uri", atUris), 144 + orm.FilterEq("at_uri", pull.AtUri()), 166 145 ) 167 146 if err != nil { 168 - l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen)) 147 + l.Error("failed to reopen pulls in database", "err", err) 169 148 s.pages.Notice(w, "pull-action-error", "Failed to reopen pull.") 170 149 return 171 150 } ··· 177 156 return 178 157 } 179 158 180 - for _, p := range pullsToReopen { 181 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 182 - } 159 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), pull) 183 160 184 161 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 185 162 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
+8 -72
appview/pulls/list.go
··· 3 3 import ( 4 4 "context" 5 5 "net/http" 6 - "slices" 7 6 8 7 "tangled.org/core/api/tangled" 9 8 "tangled.org/core/appview/db" ··· 132 131 133 132 repoInfo := s.repoResolver.GetRepoInfo(r, user) 134 133 134 + ctx := r.Context() 135 + 135 136 var pulls []*models.Pull 136 137 137 138 if searchOpts.HasSearchFilters() { 138 - res, err := s.indexer.Search(r.Context(), searchOpts) 139 + res, err := s.indexer.Search(ctx, searchOpts) 139 140 if err != nil { 140 141 l.Error("failed to search for pulls", "err", err) 141 142 return ··· 148 149 countOpts.Page = pagination.Page{Limit: 1} 149 150 for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 150 151 countOpts.State = &ps 151 - countRes, err := s.indexer.Search(r.Context(), countOpts) 152 + countRes, err := s.indexer.Search(ctx, countOpts) 152 153 if err != nil { 153 154 continue 154 155 } ··· 163 164 } 164 165 165 166 if len(res.Hits) > 0 { 166 - pulls, err = db.GetPulls( 167 + pulls, err = db.GetPullsPaginated( 168 + ctx, 167 169 s.db, 170 + pagination.Page{Limit: searchOpts.Page.Limit}, 168 171 orm.FilterIn("id", res.Hits), 169 172 ) 170 173 if err != nil { ··· 181 184 filters = append(filters, orm.FilterEq("state", *state)) 182 185 } 183 186 pulls, err = db.GetPullsPaginated( 187 + ctx, 184 188 s.db, 185 189 page, 186 190 filters..., ··· 192 196 } 193 197 } 194 198 195 - for _, p := range pulls { 196 - var pullSourceRepo *models.Repo 197 - if p.PullSource != nil { 198 - if p.PullSource.RepoDid != nil { 199 - pullSourceRepo, err = db.GetRepoByDid(s.db, string(*p.PullSource.RepoDid)) 200 - if err != nil { 201 - l.Error("failed to get repo by did", "err", err, "repo_did", p.PullSource.RepoDid.String()) 202 - continue 203 - } else { 204 - p.PullSource.Repo = pullSourceRepo 205 - } 206 - } 207 - } 208 - } 209 - 210 - var stacks []models.Stack 211 - 212 - pullMap := make(map[string]*models.Pull) 213 - for _, p := range pulls { 214 - pullMap[p.AtUri().String()] = p 215 - } 216 - 217 - // track which PRs have been added to stacks 218 - visited := make(map[string]bool) 219 - 220 - // group stacked PRs together using dependent_on relationships 221 - for _, p := range pulls { 222 - if visited[p.AtUri().String()] { 223 - continue 224 - } 225 - 226 - root := p 227 - for root.DependentOn != nil { 228 - if parent, ok := pullMap[root.DependentOn.String()]; ok { 229 - root = parent 230 - } else { 231 - break // parent not in current page 232 - } 233 - } 234 - 235 - var stack models.Stack 236 - current := root 237 - for { 238 - if visited[current.AtUri().String()] { 239 - break 240 - } 241 - stack = append(stack, current) 242 - visited[current.AtUri().String()] = true 243 - 244 - found := false 245 - for _, candidate := range pulls { 246 - if candidate.DependentOn != nil && 247 - candidate.DependentOn.String() == current.AtUri().String() { 248 - current = candidate 249 - found = true 250 - break 251 - } 252 - } 253 - if !found { 254 - break 255 - } 256 - } 257 - 258 - slices.Reverse(stack) 259 - stacks = append(stacks, stack) 260 - } 261 - 262 199 labelDefs, err := db.GetLabelDefinitions( 263 200 s.db, 264 201 orm.FilterIn("at_uri", f.Labels), ··· 299 236 LabelDefs: defs, 300 237 FilterState: filterState, 301 238 FilterQuery: query.String(), 302 - Stacks: stacks, 303 239 Page: page, 304 240 PullCount: totalPulls, 305 241 VouchRelationships: vouchRelationships,
+47 -82
appview/pulls/merge.go
··· 5 5 "net/http" 6 6 "time" 7 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "tangled.org/core/api/tangled" 9 10 "tangled.org/core/appview/db" 10 11 "tangled.org/core/appview/models" 11 12 "tangled.org/core/appview/oauth" 12 13 "tangled.org/core/appview/reporesolver" 13 14 "tangled.org/core/orm" 14 - "tangled.org/core/xrpc/xrpcclient" 15 - 16 - "github.com/bluesky-social/indigo/atproto/syntax" 17 15 ) 18 16 19 17 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 33 31 s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 34 32 return 35 33 } 36 - l = l.With("repo_at", f.RepoAt().String()) 34 + l = l.With("repo", f.RepoDid) 37 35 38 36 pull, ok := r.Context().Value("pull").(*models.Pull) 39 37 if !ok { ··· 43 41 } 44 42 l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch) 45 43 46 - stack, ok := r.Context().Value("stack").(models.Stack) 47 - if !ok { 48 - l.Error("failed to get stack") 49 - s.pages.Notice(w, "pull-action-error", "Failed to merge patch. Try again later.") 50 - return 51 - } 52 - 53 - // combine patches of substack 54 - subStack := stack.Below(pull) 55 - // collect the portion of the stack that is mergeable 56 - pullsToMerge := subStack.Mergeable() 57 - l = l.With("pulls_to_merge", len(pullsToMerge)) 58 - 59 - patch := pullsToMerge.CombinedPatch() 60 - 61 - ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 62 - if err != nil { 63 - l.Error("failed to resolve identity", "err", err, "owner_did", pull.OwnerDid) 64 - w.WriteHeader(http.StatusNotFound) 44 + // merge target branch 45 + { 46 + client, err := s.oauth.ServiceClient( 47 + r, 48 + oauth.WithService(f.Knot), 49 + oauth.WithLxm(tangled.GitMergeCommitNSID), 50 + oauth.WithDev(s.config.Core.Dev), 51 + oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 52 + ) 53 + if err != nil { 54 + l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 55 + s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 56 + return 57 + } 58 + 59 + var mergeCommit *tangled.GitMergeCommit_Input_MergeCommit 60 + // TODO: pass custom merge commit body 61 + 62 + err = tangled.GitMergeCommit(r.Context(), client, &tangled.GitMergeCommit_Input{ 63 + Target: &tangled.GitMergeCommit_Input_Target{ 64 + Repo: pull.RepoDid.String(), 65 + Branch: pull.TargetBranch, 66 + }, 67 + Source: &tangled.GitMergeCommit_Input_Source{ 68 + Repo: pull.SourceRepo.String(), 69 + Commit: pull.LatestVersion().Head, 70 + }, 71 + MergeCommit: mergeCommit, 72 + Style: "rebase", 73 + }) 74 + if err != nil { 75 + s.logger.Error("failed to merge", "err", err) 76 + s.pages.Notice(w, "pull-action-error", err.Error()) 77 + return 78 + } 79 + } 80 + 81 + if err := s.writePullStatusRecord(r, user.Did, pull.AtUri(), models.StateMerged); err != nil { 82 + l.Error("failed to write issue state record", "err", err) 83 + s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 65 84 return 66 85 } 67 86 68 - email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 69 - if err != nil { 70 - l.Warn("failed to get primary email", "err", err, "owner_did", pull.OwnerDid) 71 - } 72 - 73 - authorName := ident.Handle.String() 74 - mergeInput := &tangled.RepoMerge_Input{ 75 - Did: f.Did, 76 - Name: f.Name, 77 - Repo: f.RepoDidPtr(), 78 - Branch: pull.TargetBranch, 79 - Patch: patch, 80 - CommitMessage: &pull.Title, 81 - AuthorName: &authorName, 82 - } 83 - 84 - if pull.Body != "" { 85 - mergeInput.CommitBody = &pull.Body 86 - } 87 - 88 - if email.Address != "" { 89 - mergeInput.AuthorEmail = &email.Address 90 - } 91 - 92 - client, err := s.oauth.ServiceClient( 93 - r, 94 - oauth.WithService(f.Knot), 95 - oauth.WithLxm(tangled.RepoMergeNSID), 96 - oauth.WithDev(s.config.Core.Dev), 97 - oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 98 - ) 99 - if err != nil { 100 - l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 101 - s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") 102 - return 103 - } 104 - 105 - err = tangled.RepoMerge(r.Context(), client, mergeInput) 106 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 107 - s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 108 - s.pages.Notice(w, "pull-action-error", xrpcerr.Error()) 109 - return 110 - } 111 - 112 - var atUris []syntax.ATURI 113 - for _, p := range pullsToMerge { 114 - atUris = append(atUris, p.AtUri()) 115 - p.State = models.PullMerged 116 - } 117 - 118 - if err := s.writePullStatusRecords(r, user.Did, atUris, models.StateMerged); err != nil { 119 - l.Error("failed to write pull status records after merge", "err", err) 120 - } 121 - 122 87 tx, err := s.db.Begin() 123 88 if err != nil { 124 89 l.Error("failed to start transaction", "err", err) ··· 127 92 } 128 93 defer tx.Rollback() 129 94 130 - err = db.MergePulls(tx, orm.FilterEq("repo_did", string(f.RepoDid)), orm.FilterIn("at_uri", atUris)) 95 + err = db.MergePulls( 96 + tx, 97 + orm.FilterEq("at_uri", pull.AtUri()), 98 + ) 131 99 if err != nil { 132 100 l.Error("failed to update pull request status in database", "err", err) 133 101 s.pages.Notice(w, "pull-action-error", "Failed to merge pull request. Try again later.") ··· 142 110 return 143 111 } 144 112 145 - // notify about the pull merge 146 - for _, p := range pullsToMerge { 147 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 148 - } 113 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), pull) 149 114 150 115 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 151 116 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
+45
appview/pulls/middleware.go
··· 1 + package pulls 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/orm" 11 + ) 12 + 13 + // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 14 + func (s *Pulls) ResolvePullMiddleware(next http.Handler) http.Handler { 15 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + l := s.logger.With("middleware", "ResolvePullMiddleware") 17 + f, err := s.repoResolver.Resolve(r) 18 + if err != nil { 19 + l.Error("failed to fully resolve repo", "err", err) 20 + w.WriteHeader(http.StatusNotFound) 21 + s.pages.ErrorKnot404(w) 22 + return 23 + } 24 + 25 + prId, err := strconv.Atoi(chi.URLParam(r, "pull")) 26 + if err != nil { 27 + l.Debug("failed to parse pr id", "err", err) 28 + w.WriteHeader(http.StatusNotFound) 29 + s.pages.Error404(w) 30 + return 31 + } 32 + 33 + pr, err := db.GetPull(r.Context(), s.db, orm.FilterEq("repo_did", f.RepoDid), orm.FilterEq("pull_id", prId)) 34 + if err != nil { 35 + l.Debug("failed to query PR", "err", err) 36 + w.WriteHeader(http.StatusNotFound) 37 + s.pages.Error404(w) 38 + return 39 + } 40 + 41 + ctx := context.WithValue(r.Context(), "pull", pr) 42 + 43 + next.ServeHTTP(w, r.WithContext(ctx)) 44 + }) 45 + }
+13 -15
appview/pulls/opengraph.go
··· 8 8 "tangled.org/core/appview/db" 9 9 "tangled.org/core/appview/models" 10 10 "tangled.org/core/ogre" 11 - "tangled.org/core/patchutil" 12 11 ) 13 12 14 13 func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { ··· 26 25 } 27 26 28 27 ownerHandle := s.pages.DisplayHandle(r.Context(), f.Did) 29 - authorHandle := s.pages.DisplayHandle(r.Context(), pull.OwnerDid) 28 + authorHandle := s.pages.DisplayHandle(r.Context(), pull.OwnerDid.String()) 30 29 31 30 avatarUrl := s.pages.AvatarUrl(f.Did, "256") 32 - authorAvatarUrl := s.pages.AvatarUrl(pull.OwnerDid, "256") 31 + authorAvatarUrl := s.pages.AvatarUrl(pull.OwnerDid.String(), "256") 33 32 34 33 var status string 35 34 if pull.State.IsOpen() { ··· 44 43 var additions int64 45 44 var deletions int64 46 45 47 - if len(pull.Submissions) > 0 { 48 - latestSubmission := pull.LatestSubmission() 49 - niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) 50 - filesChanged = niceDiff.Stat.FilesChanged 51 - additions = int64(niceDiff.Stat.Insertions) 52 - deletions = int64(niceDiff.Stat.Deletions) 46 + if len(pull.Versions) > 0 { 47 + latestVersion := pull.LatestVersion() 48 + _ = latestVersion 49 + // niceDiff := patchutil.AsNiceDiff(latestVersion.Patch, pull.TargetBranch) 50 + // filesChanged = niceDiff.Stat.FilesChanged 51 + // additions = niceDiff.Stat.Insertions 52 + // deletions = niceDiff.Stat.Deletions 53 53 } 54 54 55 - commentCount := pull.TotalComments() 56 - 57 55 reactionCount, _ := db.GetReactionCount(s.db, pull.AtUri()) 58 56 59 - rounds := max(1, len(pull.Submissions)) 57 + versions := max(1, len(pull.Versions)) 60 58 61 59 payload := ogre.PullRequestCardPayload{ 62 60 Type: "pullRequest", ··· 66 64 AvatarUrl: avatarUrl, 67 65 AuthorAvatarUrl: authorAvatarUrl, 68 66 Title: pull.Title, 69 - PullRequestNumber: pull.PullId, 67 + PullRequestNumber: int(pull.PullId), 70 68 Status: status, 71 69 FilesChanged: filesChanged, 72 70 Additions: int(additions), 73 71 Deletions: int(deletions), 74 - Rounds: rounds, 75 - CommentCount: commentCount, 72 + Rounds: versions, 73 + CommentCount: pull.TotalComments(), 76 74 ReactionCount: reactionCount, 77 75 CreatedAt: pull.Created.Format(time.RFC3339), 78 76 }
+93 -548
appview/pulls/resubmit.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "encoding/json" 4 + "context" 5 5 "fmt" 6 6 "net/http" 7 7 "time" 8 8 9 9 "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/db" 11 - "tangled.org/core/appview/knotcompat" 12 11 "tangled.org/core/appview/models" 13 12 "tangled.org/core/appview/oauth" 14 - "tangled.org/core/appview/pages" 15 13 "tangled.org/core/appview/reporesolver" 16 - "tangled.org/core/orm" 17 - "tangled.org/core/patchutil" 18 - "tangled.org/core/types" 19 - "tangled.org/core/xrpc" 20 - "tangled.org/core/xrpc/xrpcclient" 21 14 22 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 23 16 "github.com/bluesky-social/indigo/atproto/syntax" ··· 26 19 27 20 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 28 21 l := s.logger.With("handler", "ResubmitPull") 22 + noticeId := "pull-action-error" 29 23 30 24 user := s.oauth.GetMultiAccountUser(r) 31 25 if user != nil { 32 26 l = l.With("user", user.Did) 33 27 } 34 28 35 - pull, ok := r.Context().Value("pull").(*models.Pull) 36 - if !ok { 37 - l.Error("failed to get pull") 38 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 39 - return 40 - } 41 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 42 - 43 - switch r.Method { 44 - case http.MethodGet: 45 - s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 46 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 47 - Pull: pull, 48 - }) 49 - return 50 - case http.MethodPost: 51 - if pull.IsPatchBased() { 52 - s.resubmitPatch(w, r) 53 - return 54 - } else if pull.IsBranchBased() { 55 - s.resubmitBranch(w, r) 56 - return 57 - } else if pull.IsForkBased() { 58 - s.resubmitFork(w, r) 59 - return 60 - } 61 - } 62 - } 63 - 64 - func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 65 - l := s.logger.With("handler", "resubmitPatch") 66 - 67 - user := s.oauth.GetMultiAccountUser(r) 68 - if user != nil { 69 - l = l.With("user", user.Did) 70 - } 71 - 72 - pull, ok := r.Context().Value("pull").(*models.Pull) 73 - if !ok { 74 - l.Error("failed to get pull") 75 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 76 - return 77 - } 78 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 79 - 80 - if user == nil || user.Did != pull.OwnerDid { 81 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 82 - w.WriteHeader(http.StatusUnauthorized) 83 - return 84 - } 85 - 86 - f, err := s.repoResolver.Resolve(r) 29 + repo, err := s.repoResolver.Resolve(r) 87 30 if err != nil { 88 31 l.Error("failed to get repo and knot", "err", err) 89 32 return 90 33 } 91 34 92 - patch := r.FormValue("patch") 93 - 94 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "") 95 - } 96 - 97 - func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 98 - l := s.logger.With("handler", "resubmitBranch") 99 - 100 - user := s.oauth.GetMultiAccountUser(r) 101 - if user != nil { 102 - l = l.With("user", user.Did) 103 - } 104 - 105 35 pull, ok := r.Context().Value("pull").(*models.Pull) 106 36 if !ok { 107 37 l.Error("failed to get pull") 108 - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 109 - return 110 - } 111 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 112 - 113 - if user == nil || user.Did != pull.OwnerDid { 114 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 115 - w.WriteHeader(http.StatusUnauthorized) 38 + s.pages.Notice(w, noticeId, "Failed to get PR. Try again later.") 116 39 return 117 40 } 41 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 118 42 119 - f, err := s.repoResolver.Resolve(r) 120 - if err != nil { 121 - l.Error("failed to get repo and knot", "err", err) 43 + if pull.OwnerDid != syntax.DID(user.Did) { 44 + s.pages.Notice(w, noticeId, "Unauthorized user. Try again later.") 122 45 return 123 46 } 124 47 125 - roles := s.acl.RolesInRepo(r.Context(), f, user.Did) 126 - if !roles.IsPushAllowed() { 127 - l.Warn("unauthorized user - no push permission") 128 - w.WriteHeader(http.StatusUnauthorized) 48 + if pull.SourceBranch == nil { 49 + // can't resubmit if source is unknown. fail earlier 50 + s.pages.Notice(w, noticeId, "PR source branch is unknown.") 129 51 return 130 52 } 53 + sourceBranch := *pull.SourceBranch 131 54 132 - xrpcc := s.knotClient(f.Knot) 133 - 134 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 135 - if err != nil { 136 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 137 - l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch) 138 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 55 + var sourceRepo *models.Repo 56 + if pull.SourceRepo == syntax.DID(repo.RepoDid) { 57 + sourceRepo = repo 58 + } else { 59 + var err error 60 + sourceRepo, err = db.GetRepoByDid(s.db, pull.SourceRepo.String()) 61 + if err != nil { 62 + s.pages.Notice(w, noticeId, fmt.Sprintf("Unknown source repository: %q", pull.SourceRepo)) 139 63 return 140 64 } 141 - l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch) 142 - s.pages.Notice(w, "resubmit-error", err.Error()) 143 - return 144 - } 145 - 146 - var comparison types.RepoFormatPatchResponse 147 - if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 148 - l.Error("failed to decode XRPC compare response", "err", err) 149 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 150 - return 151 65 } 152 66 153 - sourceRev := comparison.Rev2 154 - patch := comparison.FormatPatchRaw 155 - combined := comparison.CombinedPatchRaw 67 + ctx := r.Context() 156 68 157 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 158 - } 159 - 160 - func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 161 - l := s.logger.With("handler", "resubmitFork") 162 - 163 - user := s.oauth.GetMultiAccountUser(r) 164 - if user != nil { 165 - l = l.With("user", user.Did) 166 - } 167 - 168 - pull, ok := r.Context().Value("pull").(*models.Pull) 169 - if !ok { 170 - l.Error("failed to get pull") 171 - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 172 - return 173 - } 174 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 175 - 176 - if user == nil || user.Did != pull.OwnerDid { 177 - l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 178 - w.WriteHeader(http.StatusUnauthorized) 179 - return 180 - } 181 - 182 - f, err := s.repoResolver.Resolve(r) 183 - if err != nil { 184 - l.Error("failed to get repo and knot", "err", err) 185 - return 186 - } 187 - 188 - forkRepo, err := db.GetRepoByDid(s.db, string(*pull.PullSource.RepoDid)) 189 - if err != nil { 190 - l.Error("failed to get source repo", "err", err, "repo_did", pull.PullSource.RepoDid.String()) 191 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 192 - return 193 - } 194 - 195 - // update the hidden tracking branch to latest 196 - client, err := s.oauth.ServiceClient( 197 - r, 198 - oauth.WithService(forkRepo.Knot), 199 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 200 - oauth.WithDev(s.config.Core.Dev), 201 - ) 202 - if err != nil { 203 - l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot) 204 - return 205 - } 206 - 207 - resp, err := tangled.RepoHiddenRef( 208 - r.Context(), 209 - client, 210 - &tangled.RepoHiddenRef_Input{ 211 - ForkRef: pull.PullSource.Branch, 212 - RemoteRef: pull.TargetBranch, 213 - Repo: forkRepo.RepoAt().String(), 214 - }, 215 - ) 216 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 217 - s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 218 - s.pages.Notice(w, "resubmit-error", xrpcerr.Error()) 219 - return 220 - } 221 - if !resp.Success { 222 - l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch) 223 - s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 224 - return 225 - } 226 - 227 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 228 - // extract patch by performing compare 229 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 230 - if err != nil { 231 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 232 - l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 233 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 69 + var base, head string 70 + { 71 + xrpcc := s.knotMirrorXRPC 72 + branch, err := tangled.GitTempGetBranch(ctx, xrpcc, sourceBranch, pull.RepoDid.String()) 73 + if err != nil { 74 + s.pages.Notice(w, noticeId, "Failed to get source branch") 234 75 return 235 76 } 236 - l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 237 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 238 - return 239 - } 240 - 241 - var forkComparison types.RepoFormatPatchResponse 242 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 243 - l.Error("failed to decode XRPC compare response for fork", "err", err) 244 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 245 - return 246 - } 247 - 248 - // Use the fork comparison we already made 249 - comparison := forkComparison 250 - 251 - sourceRev := comparison.Rev2 252 - patch := comparison.FormatPatchRaw 253 - combined := comparison.CombinedPatchRaw 254 - 255 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 256 - } 257 - 258 - func (s *Pulls) resubmitPullHelper( 259 - w http.ResponseWriter, 260 - r *http.Request, 261 - repo *models.Repo, 262 - userDid syntax.DID, 263 - pull *models.Pull, 264 - patch string, 265 - combined string, 266 - sourceRev string, 267 - ) { 268 - l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 269 - 270 - stack := r.Context().Value("stack").(models.Stack) 271 - if stack != nil && len(stack) != 1 { 272 - l.Info("resubmitting stacked PR", "stack_size", len(stack)) 273 - s.resubmitStackedPullHelper(w, r, repo, userDid, pull, patch) 274 - return 275 - } 276 - 277 - if err := validatePatch(&patch); err != nil { 278 - s.pages.Notice(w, "resubmit-error", err.Error()) 279 - return 280 - } 281 - 282 - if patch == pull.LatestPatch() { 283 - s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 284 - return 285 - } 286 - 287 - // validate sourceRev if branch/fork based 288 - if pull.IsBranchBased() || pull.IsForkBased() { 289 - if sourceRev == pull.LatestSha() { 290 - s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 77 + out, err := tangled.GitTempGetMergeBase(ctx, xrpcc, pull.TargetBranch, branch.Hash, pull.RepoDid.String()) 78 + if err != nil { 79 + s.pages.Notice(w, noticeId, "Failed to compute merge-base.") 291 80 return 292 81 } 293 - } 294 - 295 - pullAt := pull.AtUri() 296 - newRoundNumber := len(pull.Submissions) 297 - newPatch := patch 298 - newSourceRev := sourceRev 299 - combinedPatch := combined 300 - 301 - client, err := s.oauth.AuthorizedClient(r) 302 - if err != nil { 303 - l.Error("failed to authorize client", "err", err) 304 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 305 - return 306 - } 307 - 308 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, userDid.String(), pull.Rkey) 309 - if err != nil { 310 - // failed to get record 311 - l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey) 312 - s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 313 - return 314 - } 315 - 316 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 317 - if err != nil { 318 - l.Error("failed to upload patch blob", "err", err) 319 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 320 - return 321 - } 322 - record := pull.AsRecord() 323 - record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 324 - CreatedAt: time.Now().Format(time.RFC3339), 325 - PatchBlob: blob.Blob, 326 - }) 327 82 328 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 329 - Collection: tangled.RepoPullNSID, 330 - Repo: userDid.String(), 331 - Rkey: pull.Rkey, 332 - SwapRecord: ex.Cid, 333 - Record: knotcompat.Pull(&record), 334 - }) 335 - if err != nil { 336 - l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) 337 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 338 - return 83 + head = branch.Hash 84 + base = out.Commit 339 85 } 340 86 341 - err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 342 - if err != nil { 343 - l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber) 344 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 345 - return 87 + newVersion := models.PullVersion{ 88 + ID: pull.LatestVersionNumber() + 1, 89 + Base: base, 90 + Head: head, 91 + Created: time.Now(), 346 92 } 93 + pull.Versions = append(pull.Versions, newVersion) 347 94 348 - pull.Submissions = append(pull.Submissions, &models.PullSubmission{ 349 - PullAt: pullAt, 350 - RoundNumber: newRoundNumber, 351 - Patch: newPatch, 352 - Combined: combinedPatch, 353 - SourceRev: newSourceRev, 354 - Created: time.Now(), 355 - }) 356 - s.notifier.ResubmitPull(r.Context(), pull) 357 - 358 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 359 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 360 - } 361 - 362 - func (s *Pulls) resubmitStackedPullHelper( 363 - w http.ResponseWriter, 364 - r *http.Request, 365 - repo *models.Repo, 366 - userDid syntax.DID, 367 - pull *models.Pull, 368 - patch string, 369 - ) { 370 - l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 371 - 372 - targetBranch := pull.TargetBranch 373 - 374 - origStack, _ := r.Context().Value("stack").(models.Stack) 375 - 376 - formatPatches, err := patchutil.ExtractPatches(patch) 377 - if err != nil { 378 - l.Error("failed to extract patches", "err", err) 379 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 380 - return 381 - } 382 - 383 - // must have atleast 1 patch to begin with 384 - if len(formatPatches) == 0 { 385 - l.Error("no patches found in the generated format-patch") 386 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 387 - return 388 - } 389 - 390 - client, err := s.oauth.AuthorizedClient(r) 391 - if err != nil { 392 - l.Error("failed to get authorized client", "err", err) 393 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 394 - return 395 - } 396 - 397 - // first upload all blobs 398 - blobs := make([]*lexutil.LexBlob, len(formatPatches)) 399 - for i, p := range formatPatches { 400 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 95 + // keep new version head in Knot 96 + { 97 + client, err := s.oauth.ServiceClient( 98 + r, 99 + oauth.WithService(sourceRepo.Knot), 100 + oauth.WithLxm(tangled.GitKeepCommitNSID), 101 + oauth.WithDev(s.config.Core.Dev), 102 + ) 401 103 if err != nil { 402 - l.Error("failed to upload patch blob", "err", err, "patch_index", i) 403 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 104 + l.Error("failed to create service auth", "err", err) 105 + s.pages.Notice(w, noticeId, "Failed to create service auth. Try again later.") 404 106 return 405 107 } 406 - l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 407 - blobs[i] = blob.Blob 408 - } 409 108 410 - newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 411 - if err != nil { 412 - l.Error("failed to create resubmitted stack", "err", err) 413 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 414 - return 415 - } 416 - 417 - // find the diff between the stacks, first, map them by changeId 418 - origById := make(map[string]*models.Pull) 419 - newById := make(map[string]*models.Pull) 420 - for _, p := range origStack { 421 - origById[p.LatestSubmission().ChangeId()] = p 422 - } 423 - for _, p := range newStack { 424 - newById[p.LatestSubmission().ChangeId()] = p 425 - } 426 - 427 - // commits that got deleted: corresponding pull is closed 428 - // commits that got added: new pull is created 429 - // commits that got updated: corresponding pull is resubmitted & new round begins 430 - additions := make(map[string]*models.Pull) 431 - deletions := make(map[string]*models.Pull) 432 - updated := make(map[string]struct{}) 433 - 434 - // pulls in original stack but not in new one 435 - for _, op := range origStack { 436 - if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 437 - deletions[op.LatestSubmission().ChangeId()] = op 438 - } 439 - } 440 - 441 - // pulls in new stack but not in original one 442 - for _, np := range newStack { 443 - if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 444 - additions[np.LatestSubmission().ChangeId()] = np 445 - } 446 - } 447 - 448 - // NOTE: this loop can be written in any of above blocks, 449 - // but is written separately in the interest of simpler code 450 - for _, np := range newStack { 451 - if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 452 - // pull exists in both stacks 453 - updated[op.LatestSubmission().ChangeId()] = struct{}{} 454 - } 455 - } 456 - 457 - // NOTE: we can go through the newStack and update dependent relations and 458 - // rkeys now that we know which ones have been updated 459 - // update dependentOn relations for the entire stack 460 - var parentAt *syntax.ATURI 461 - for _, np := range newStack { 462 - if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 463 - // pull exists in both stacks 464 - np.Rkey = op.Rkey 465 - } 466 - np.DependentOn = parentAt 467 - x := np.AtUri() 468 - parentAt = &x 469 - } 470 - 471 - l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated)) 472 - 473 - tx, err := s.db.Begin() 474 - if err != nil { 475 - l.Error("failed to start transaction", "err", err) 476 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 477 - return 478 - } 479 - defer tx.Rollback() 480 - 481 - // pds updates to make 482 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 483 - 484 - // pulls to notify for after the transaction commits 485 - var resubmitted []*models.Pull 486 - 487 - // deleted pulls are marked as deleted in the DB 488 - for _, p := range deletions { 489 - // do not do delete already merged PRs 490 - if p.State == models.PullMerged { 491 - continue 492 - } 493 - 494 - err := db.AbandonPulls(tx, orm.FilterEq("repo_did", string(p.RepoDid)), orm.FilterEq("at_uri", p.AtUri())) 109 + _, err = tangled.GitKeepCommit(ctx, client, &tangled.GitKeepCommit_Input{ 110 + Repo: sourceRepo.RepoDid, 111 + Record: pull.AtUri().String(), 112 + Source: &tangled.GitKeepCommit_Input_Source{ 113 + GitKeepCommit_Commit: &tangled.GitKeepCommit_Commit{ 114 + Repo: sourceRepo.RepoDid, 115 + Oid: head, 116 + }, 117 + }, 118 + }) 495 119 if err != nil { 496 - l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 497 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 120 + l.Error("failed to keep commit", "err", err) 121 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 498 122 return 499 123 } 500 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 501 - RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 502 - Collection: tangled.RepoPullNSID, 503 - Rkey: p.Rkey, 504 - }, 505 - }) 506 124 } 507 125 508 - // new pulls are created 509 - for _, p := range additions { 510 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 126 + // update PDS record 127 + { 128 + client, err := s.oauth.AuthorizedClient(r) 511 129 if err != nil { 512 - l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId()) 513 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 130 + s.pages.Notice(w, noticeId, "Unauthorized user. Try again later.") 514 131 return 515 132 } 516 - p.Submissions[0].Blob = *blob.Blob 517 133 518 - if err = db.PutPull(tx, p); err != nil { 519 - l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId()) 520 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 134 + // NOTE: some old PR records are missing CID 135 + if err := s.ensurePullCid(ctx, client, pull); err != nil { 136 + s.pages.Notice(w, noticeId, "Failed to get existing PR record. Is PR deleted from PDS?") 521 137 return 522 138 } 523 139 524 - record := p.AsRecord() 525 - record.Rounds = []*tangled.RepoPull_Round{ 526 - { 527 - CreatedAt: time.Now().Format(time.RFC3339), 528 - PatchBlob: blob.Blob, 529 - }, 530 - } 531 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 532 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 533 - Collection: tangled.RepoPullNSID, 534 - Rkey: &p.Rkey, 535 - Value: knotcompat.Pull(&record), 536 - }, 140 + record := pull.AsRecord() 141 + exCid := pull.Cid.String() 142 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 143 + Collection: tangled.RepoPullNSID, 144 + Repo: pull.OwnerDid.String(), 145 + Rkey: pull.Rkey.String(), 146 + SwapRecord: &exCid, 147 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 537 148 }) 538 - } 539 - 540 - // updated pulls are, well, updated; to start a new round 541 - for id := range updated { 542 - op, _ := origById[id] 543 - np, _ := newById[id] 544 - 545 - // do not update already merged PRs 546 - if op.State == models.PullMerged { 547 - continue 548 - } 549 - 550 - // resubmit the new pull 551 - np.Rkey = op.Rkey 552 - pullAt := op.AtUri() 553 - newRoundNumber := len(op.Submissions) 554 - newPatch := np.LatestPatch() 555 - combinedPatch := np.LatestSubmission().Combined 556 - newSourceRev := np.LatestSha() 557 - 558 - blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 559 149 if err != nil { 560 - l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId) 561 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 150 + l.Error("failed to create PDS record", "err", err) 151 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 562 152 return 563 153 } 564 - 565 - // create new round 566 - err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 567 - if err != nil { 568 - l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 569 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 570 - return 571 - } 572 - 573 - // update dependent-on relation 574 - if np.DependentOn != nil { 575 - err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri())) 576 - if err != nil { 577 - l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 578 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 579 - return 580 - } 581 - } 582 - 583 - record := np.AsRecord() 584 - record.Rounds = op.AsRecord().Rounds 585 - record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 586 - CreatedAt: time.Now().Format(time.RFC3339), 587 - PatchBlob: blob.Blob, 588 - }) 589 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 590 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 591 - Collection: tangled.RepoPullNSID, 592 - Rkey: op.Rkey, 593 - Value: knotcompat.Pull(&record), 594 - }, 595 - }) 596 - 597 - op.Submissions = append(op.Submissions, &models.PullSubmission{ 598 - PullAt: pullAt, 599 - RoundNumber: newRoundNumber, 600 - Patch: newPatch, 601 - Combined: combinedPatch, 602 - SourceRev: newSourceRev, 603 - Created: time.Now(), 604 - }) 605 - resubmitted = append(resubmitted, op) 154 + pull.Cid = syntax.CID(out.Cid) 606 155 } 607 156 608 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 609 - Repo: userDid.String(), 610 - Writes: writes, 611 - }) 612 - if err != nil { 613 - l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes)) 614 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 157 + if err := db.SubmitPullVersion(ctx, s.db, pull.AtUri(), newVersion); err != nil { 158 + l.Error("failed to update PR in DB", "err", err) 159 + s.pages.Notice(w, noticeId, "Failed to resubmit pull request. Try again later.") 615 160 return 616 161 } 617 162 618 - err = tx.Commit() 619 - if err != nil { 620 - l.Error("failed to commit resubmit transaction", "err", err) 621 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 622 - return 623 - } 624 - 625 - for _, p := range additions { 626 - s.notifier.NewPull(r.Context(), p) 627 - } 628 - for _, p := range resubmitted { 629 - s.notifier.ResubmitPull(r.Context(), p) 630 - } 163 + s.notifier.ResubmitPull(r.Context(), pull) 631 164 632 165 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 633 166 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 634 167 } 168 + 169 + func (s *Pulls) ensurePullCid(ctx context.Context, client lexutil.LexClient, pull *models.Pull) error { 170 + out, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, pull.OwnerDid.String(), pull.Rkey.String()) 171 + if err != nil { 172 + return err 173 + } 174 + if out.Cid == nil { 175 + return fmt.Errorf("record CID is empty") 176 + } 177 + pull.Cid = syntax.CID(*out.Cid) 178 + return nil 179 + }
+22 -51
appview/pulls/resubmit_check_test.go
··· 59 59 } 60 60 } 61 61 62 - func newForkPull(state models.PullState) (*models.Pull, *models.Repo, models.Stack) { 62 + func newForkPull(state models.PullState) (*models.Pull, *models.Repo) { 63 63 sourceRepoDid := syntax.DID(resubmitTestRepoDID) 64 + sourceBranch := resubmitTestBranch 64 65 pull := &models.Pull{ 65 66 State: state, 66 67 OwnerDid: resubmitTestOwnerDID, 67 68 TargetBranch: "main", 68 - Submissions: []*models.PullSubmission{ 69 - {SourceRev: resubmitTestSourceRev}, 70 - }, 71 - PullSource: &models.PullSource{ 72 - Branch: resubmitTestBranch, 73 - RepoDid: &sourceRepoDid, 69 + Versions: []models.PullVersion{ 70 + {Head: resubmitTestSourceRev}, 74 71 }, 72 + SourceBranch: &sourceBranch, 73 + SourceRepo: sourceRepoDid, 75 74 } 76 75 repo := &models.Repo{RepoDid: resubmitTestRepoDID} 77 - stack := models.Stack{pull} 78 - return pull, repo, stack 76 + return pull, repo 79 77 } 80 78 81 79 func TestResubmitCheck_BranchAdvanced(t *testing.T) { ··· 83 81 defer srv.Close() 84 82 85 83 s := newPullsFromKnotURL(srv.URL) 86 - req := httptest.NewRequest(http.MethodGet, "/", nil) 87 - pull, repo, stack := newForkPull(models.PullOpen) 84 + ctx := t.Context() 85 + pull, _ := newForkPull(models.PullOpen) 88 86 89 - got := s.resubmitCheck(req, repo, pull, stack) 87 + got := s.resubmitCheck(ctx, pull) 90 88 if got != pages.ShouldResubmit { 91 89 t.Errorf("resubmitCheck() = %v, want ShouldResubmit", got) 92 90 } ··· 97 95 defer srv.Close() 98 96 99 97 s := newPullsFromKnotURL(srv.URL) 100 - req := httptest.NewRequest(http.MethodGet, "/", nil) 101 - pull, repo, stack := newForkPull(models.PullOpen) 98 + ctx := t.Context() 99 + pull, _ := newForkPull(models.PullOpen) 102 100 103 - got := s.resubmitCheck(req, repo, pull, stack) 101 + got := s.resubmitCheck(ctx, pull) 104 102 if got != pages.ShouldNotResubmit { 105 103 t.Errorf("resubmitCheck() = %v, want ShouldNotResubmit", got) 106 104 } ··· 108 106 109 107 func TestResubmitCheck_MergedReturnsUnknown(t *testing.T) { 110 108 s := newPullsFromKnotURL("http://unused") 111 - req := httptest.NewRequest(http.MethodGet, "/", nil) 112 - pull, repo, stack := newForkPull(models.PullMerged) 109 + ctx := t.Context() 110 + pull, _ := newForkPull(models.PullMerged) 113 111 114 - if got := s.resubmitCheck(req, repo, pull, stack); got != pages.Unknown { 112 + if got := s.resubmitCheck(ctx, pull); got != pages.Unknown { 115 113 t.Errorf("resubmitCheck() = %v, want Unknown for merged pull", got) 116 114 } 117 115 } 118 116 119 117 func TestResubmitCheck_PatchBasedReturnsUnknown(t *testing.T) { 120 118 s := newPullsFromKnotURL("http://unused") 121 - req := httptest.NewRequest(http.MethodGet, "/", nil) 122 - pull, repo, stack := newForkPull(models.PullOpen) 123 - pull.PullSource = nil 119 + ctx := t.Context() 120 + pull, _ := newForkPull(models.PullOpen) 121 + pull.SourceBranch = nil 124 122 125 - if got := s.resubmitCheck(req, repo, pull, stack); got != pages.Unknown { 123 + if got := s.resubmitCheck(ctx, pull); got != pages.Unknown { 126 124 t.Errorf("resubmitCheck() = %v, want Unknown for patch-based pull", got) 127 125 } 128 126 } ··· 131 129 s := newPullsFromKnotURL("http://127.0.0.1:1") 132 130 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) 133 131 defer cancel() 134 - req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) 135 - pull, repo, stack := newForkPull(models.PullOpen) 132 + pull, _ := newForkPull(models.PullOpen) 136 133 137 - if got := s.resubmitCheck(req, repo, pull, stack); got != pages.Unknown { 134 + if got := s.resubmitCheck(ctx, pull); got != pages.Unknown { 138 135 t.Errorf("resubmitCheck() = %v, want Unknown when knot unreachable", got) 139 136 } 140 137 } 141 - 142 - func TestResubmitCheck_NonForkUsesRepoDid(t *testing.T) { 143 - const targetRepoDID = "did:plc:scallop" 144 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 145 - if got := r.URL.Query().Get("repo"); got != targetRepoDID { 146 - t.Errorf("repo param = %q, want %q for non-fork pull", got, targetRepoDID) 147 - } 148 - w.Header().Set("Content-Type", "application/json") 149 - _ = json.NewEncoder(w).Encode(tangled.GitTempGetBranch_Output{ 150 - Name: resubmitTestBranch, 151 - Hash: resubmitTestSourceRev, 152 - When: time.Now().UTC().Format(time.RFC3339), 153 - }) 154 - })) 155 - defer srv.Close() 156 - 157 - s := newPullsFromKnotURL(srv.URL) 158 - req := httptest.NewRequest(http.MethodGet, "/", nil) 159 - pull, _, stack := newForkPull(models.PullOpen) 160 - pull.PullSource.RepoDid = nil 161 - repo := &models.Repo{RepoDid: targetRepoDID} 162 - 163 - if got := s.resubmitCheck(req, repo, pull, stack); got != pages.ShouldNotResubmit { 164 - t.Errorf("resubmitCheck() = %v, want ShouldNotResubmit", got) 165 - } 166 - }
+15 -18
appview/pulls/router.go
··· 14 14 r.Get("/pipeline-statuses", pipelines.StatusesHandler(s.oauth, s.repoResolver, s.pages, s.logger)) 15 15 r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 16 16 r.Get("/", s.NewPull) 17 - r.Get("/refresh", s.RefreshCompose) 18 - r.Post("/refresh", s.RefreshCompose) 19 17 r.Post("/", s.NewPull) 18 + r.Post("/refresh", s.RefreshCompose) // TODO: remove this. we just have to refresh source & review steps 20 19 }) 20 + r.Get("/_/composediff", s.PullComposeDiffFragment) 21 21 22 22 r.Route("/{pull}", func(r chi.Router) { 23 - r.Use(mw.ResolvePull()) 24 - r.Get("/", s.RepoSinglePull) 23 + r.Use(s.ResolvePullMiddleware) 25 24 r.Get("/opengraph", s.PullOpenGraphSummary) 26 25 27 - r.Route("/round/{round}", func(r chi.Router) { 28 - r.Get("/", s.RepoPullPatch) 29 - r.Get("/interdiff", s.RepoPullInterdiff) 30 - r.Get("/actions", s.PullActions) 31 - }) 26 + // PR routes 27 + r.Get("/", s.RedirectLatestVersion) 28 + r.Get("/{version}", s.PullSingle) 29 + r.Get("/{version}/{revspec}", s.PullSingle) 30 + r.Get("/{version}.patch", s.PullPatchRaw) 32 31 33 - r.Route("/round/{round}.patch", func(r chi.Router) { 34 - r.Get("/", s.RepoPullPatchRaw) 35 - }) 32 + // htmx fragments 33 + r.Get("/{version}/_/actions", s.PullActions) 34 + 35 + r.Get("/_/diff", s.PullDiffFragment) 36 + r.Get("/_/interdiff", s.PullInterdiffFragment) 36 37 37 38 r.Group(func(r chi.Router) { 38 39 r.Use(middleware.AuthMiddleware(s.oauth)) 39 40 r.Get("/edit", s.EditPull) 40 41 r.Post("/edit", s.EditPull) 41 - r.Route("/resubmit", func(r chi.Router) { 42 - r.Get("/", s.ResubmitPull) 43 - r.Post("/", s.ResubmitPull) 44 - }) 45 - // permissions here require us to know pull author 46 - // it is handled within the route 42 + r.Post("/resubmit", s.ResubmitPull) 47 43 r.Post("/close", s.ClosePull) 48 44 r.Post("/reopen", s.ReopenPull) 49 45 r.Post("/subscribe", s.SubscribePull) ··· 60 56 }) 61 57 }) 62 58 }) 59 + 63 60 return r 64 61 65 62 }
+372 -357
appview/pulls/single.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "cmp" 4 5 "context" 6 + "errors" 5 7 "fmt" 8 + "io" 9 + "log/slog" 6 10 "net/http" 7 11 "strconv" 12 + "strings" 8 13 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/go-chi/chi/v5" 17 + "github.com/go-git/go-git/v5/plumbing" 18 + "github.com/go-git/go-git/v5/plumbing/object" 19 + "golang.org/x/sync/errgroup" 9 20 "tangled.org/core/api/tangled" 10 21 "tangled.org/core/appview/db" 11 22 "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/oauth" 12 24 "tangled.org/core/appview/pages" 25 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 26 + "tangled.org/core/hostutil" 13 27 "tangled.org/core/orm" 14 - "tangled.org/core/patchutil" 15 28 "tangled.org/core/types" 16 - "tangled.org/core/xrpc/xrpcclient" 17 - 18 - "github.com/bluesky-social/indigo/atproto/syntax" 19 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 20 - "github.com/go-chi/chi/v5" 21 - "tangled.org/core/hostutil" 22 29 ) 23 30 24 - // htmx fragment 25 - func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 26 - l := s.logger.With("handler", "PullActions") 31 + func (s *Pulls) RedirectLatestVersion(w http.ResponseWriter, r *http.Request) { 32 + pull, ok := r.Context().Value("pull").(*models.Pull) 33 + if !ok { 34 + s.logger.Error("failed to get pull") 35 + s.pages.Error500(w) 36 + return 37 + } 38 + u := r.URL.JoinPath(strconv.Itoa(pull.LatestVersionNumber())) 39 + http.Redirect(w, r, u.String(), http.StatusFound) 40 + } 27 41 28 - switch r.Method { 29 - case http.MethodGet: 30 - user := s.oauth.GetMultiAccountUser(r) 31 - if user != nil { 32 - l = l.With("user", user.Did) 33 - } 42 + func (s *Pulls) PullSingle(w http.ResponseWriter, r *http.Request) { 43 + if strings.Contains(chi.URLParam(r, "version"), "..") { 44 + s.PullInterDiff(w, r) 45 + } else { 46 + s.PullDiff(w, r) 47 + } 48 + } 34 49 35 - f, err := s.repoResolver.Resolve(r) 36 - if err != nil { 37 - l.Error("failed to get repo and knot", "err", err) 38 - return 39 - } 50 + // PullDiff is router for /pulls/{pull}/{version}/{commit}..{commit} 51 + // 52 + // Examples: 53 + // - /pulls/123/latest 54 + // - /pulls/123/2/head 55 + // - /pulls/123/2/base..head 56 + // - /pulls/123/2/a53ab251e..d8add468c 57 + // - /pulls/123/2/d8add468c 58 + func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) { 59 + l := s.logger.With("handler", "PullDiff") 60 + ctx := r.Context() 40 61 41 - pull, ok := r.Context().Value("pull").(*models.Pull) 42 - if !ok { 43 - l.Error("failed to get pull") 44 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 45 - return 46 - } 47 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 62 + user := s.oauth.GetMultiAccountUser(r) 63 + if user != nil { 64 + l = l.With("user", user.Did) 65 + } 48 66 49 - // can be nil if this pull is not stacked 50 - stack, _ := r.Context().Value("stack").(models.Stack) 67 + pull, ok := r.Context().Value("pull").(*models.Pull) 68 + if !ok { 69 + l.Error("failed to get pull") 70 + http.Error(w, "failed to get PR", http.StatusInternalServerError) 71 + return 72 + } 51 73 52 - roundNumberStr := chi.URLParam(r, "round") 53 - roundNumber, err := strconv.Atoi(roundNumberStr) 74 + var version models.PullVersion 75 + var versionIdRaw = chi.URLParam(r, "version") 76 + if versionIdRaw == "latest" { 77 + version = pull.LatestVersion() 78 + } else { 79 + versionId, err := strconv.Atoi(versionIdRaw) 54 80 if err != nil { 55 - roundNumber = pull.LastRoundNumber() 81 + // invalid version number. redirect 82 + http.Redirect(w, r, 83 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 84 + http.StatusSeeOther, 85 + ) 86 + return 56 87 } 57 - if roundNumber >= len(pull.Submissions) { 58 - http.Error(w, "bad round id", http.StatusBadRequest) 59 - l.Error("failed to parse round id", "err", err, "round_number", roundNumber) 88 + var ok bool 89 + version, ok = pull.GetVersion(versionId) 90 + if !ok { 91 + // invalid version number. redirect 92 + http.Redirect(w, r, 93 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 94 + http.StatusSeeOther, 95 + ) 60 96 return 61 97 } 98 + } 62 99 63 - // only the last round's buttons and banners use merge/resubmit checks 64 - isLastRound := roundNumber == pull.LastRoundNumber() 65 - branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 66 - 67 - var workflowsChanged bool 68 - var changedWorkflows []string 69 - hasPipeline := false 70 - if isLastRound && f.Spindle != "" { 71 - pipelines, err := s.fetchPipelines(r.Context(), f.Spindle, f.RepoDid, []string{pull.LatestSha()}) 72 - if err != nil { 73 - l.Error("failed to fetch latest pipeline", "err", err) 74 - } else if pipelines != nil { 75 - _, hasPipeline = pipelines[pull.LatestSha()] 76 - } 100 + diffBase, diffHead, err := parseRange(chi.URLParam(r, "revspec")) 101 + if err != nil { 102 + http.Redirect(w, r, 103 + fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.PullId, versionIdRaw), 104 + http.StatusSeeOther, 105 + ) 106 + return 107 + } 77 108 78 - if pull.IsForkBased() && !hasPipeline { 79 - changedWorkflows, err = changedWorkflowFiles(pull.LatestSubmission().CombinedPatch()) 80 - if err != nil { 81 - l.Error("failed to inspect latest round's patch for workflow changes", "err", err) 82 - } 83 - workflowsChanged = len(changedWorkflows) > 0 84 - } 109 + // defer render 110 + var params pages.PullDiffParams 111 + params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull) 112 + params.VersionId = version.ID 113 + defer func() { 114 + if err := s.pages.PullDiff(w, params); err != nil { 115 + l.Error("Failed to render", "err", err) 85 116 } 117 + }() 86 118 87 - mergeCheckResponse := types.MergeCheckResponse{} 88 - resubmitResult := pages.Unknown 89 - if isLastRound { 90 - mergeCheckResponse = s.mergeCheck(r, f, pull, stack) 91 - if user != nil && user.Did == pull.OwnerDid { 92 - resubmitResult = s.resubmitCheck(r, f, pull, stack) 93 - } 94 - } 119 + // special cases 120 + // default to {current.base}..{current.head} 121 + if diffBase == "" || diffBase == "base" { 122 + params.IsDiffBase = true 123 + // NOTE: We fallback to target-branch for legacy reason. 124 + // Old PRs before ref-based-PR refactor doesn't have `version.base`. 125 + diffBase = cmp.Or(version.Base, pull.TargetBranch) 126 + } 127 + if diffHead == "" || diffHead == "head" { 128 + params.IsDiffHead = true 129 + diffHead = version.Head 130 + } 131 + params.DiffParams.Base = diffBase 132 + params.DiffParams.Head = diffHead 95 133 96 - s.pages.PullActionsFragment(w, pages.PullActionsParams{ 97 - BaseParams: pages.BaseParamsFromContext(r.Context()), 98 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 99 - Pull: pull, 100 - RoundNumber: roundNumber, 101 - MergeCheck: mergeCheckResponse, 102 - ResubmitCheck: resubmitResult, 103 - BranchDeleteStatus: branchDeleteStatus, 104 - Stack: stack, 105 - WorkflowsChanged: workflowsChanged, 106 - ChangedWorkflowFiles: changedWorkflows, 107 - HasPipeline: hasPipeline, 108 - }) 134 + commits, err := s.listCommits(ctx, pull.SourceRepo, version.Base, version.Head) 135 + if err != nil { 136 + l.Error("failed to list commits", "err", err) 137 + params.ErrorMsg = "Failed to list commits. Try again later." 109 138 return 110 139 } 140 + params.Commits = commits 141 + 142 + // commitId -> latest pipeline 143 + shas := make([]string, len(params.Commits)) 144 + for i, commit := range params.Commits { 145 + shas[i] = commit.Hash.String() 146 + } 147 + params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas) 111 148 } 112 149 113 - func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 114 - l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff) 150 + // PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change} 151 + // 152 + // Examples: 153 + // - /pulls/123/0..2/all 154 + // - /pulls/123/0..2/nrpytyzw 155 + func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) { 156 + l := s.logger.With("handler", "PullInterDiff") 157 + ctx := r.Context() 115 158 116 159 user := s.oauth.GetMultiAccountUser(r) 117 160 if user != nil { 118 161 l = l.With("user", user.Did) 119 162 } 120 163 121 - f, err := s.repoResolver.Resolve(r) 122 - if err != nil { 123 - l.Error("failed to get repo and knot", "err", err) 124 - return 125 - } 126 - 127 164 pull, ok := r.Context().Value("pull").(*models.Pull) 128 165 if !ok { 129 - l.Error("failed to get pull") 130 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 166 + s.logger.Error("failed to get pull") 167 + s.pages.Error500(w) 131 168 return 132 169 } 133 - l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 134 170 135 - if user != nil { 136 - userDid := user.Did 137 - repoDid := f.RepoDid 138 - pullId := pull.PullId 139 - atUri := pull.AtUri().String() 140 - focusing := pages.BaseParamsFromContext(r.Context()).FocusParams.Focusing 141 - go func() { 142 - if !focusing { 143 - if err := db.MarkNotificationsReadForPull(s.db, userDid, repoDid, pullId); err != nil { 144 - l.Error("failed to mark pull notifications as read", "err", err) 145 - } 146 - } 147 - if err := db.UpsertRecentLink(s.db, userDid, models.RecentLinkTypePull, atUri); err != nil { 148 - l.Error("failed to upsert recent link", "err", err) 149 - } 150 - }() 171 + version1Raw, version2Raw, err := parseRange(chi.URLParam(r, "version")) 172 + if err != nil { 173 + http.Redirect(w, r, 174 + fmt.Sprintf("/%s/pulls/%d/0", pull.RepoDid, pull.PullId), 175 + http.StatusSeeOther, 176 + ) 177 + return 151 178 } 152 - 153 - backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 179 + version1, err := strconv.Atoi(version1Raw) 180 + version2, err := strconv.Atoi(version2Raw) 154 181 if err != nil { 155 - l.Error("failed to get pull backlinks", "err", err) 156 - s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 182 + http.Redirect(w, r, 183 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), 184 + http.StatusSeeOther, 185 + ) 157 186 return 158 187 } 159 188 160 - roundId := chi.URLParam(r, "round") 161 - roundIdInt := pull.LastRoundNumber() 162 - if r, err := strconv.Atoi(roundId); err == nil { 163 - roundIdInt = r 189 + changeId := chi.URLParam(r, "revspec") 190 + if changeId == "all" { 191 + changeId = "" 192 + } 193 + 194 + // defer render 195 + var params pages.PullInterdiffParams 196 + params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull) 197 + params.Version1 = version1 198 + params.Version2 = version2 199 + params.ChangeId = changeId 200 + defer func() { 201 + if err := s.pages.PullInterdiff(w, params); err != nil { 202 + l.Error("Failed to render", "err", err) 203 + } 204 + }() 205 + 206 + var commits1, commits2 []types.Commit 207 + g, gctx := errgroup.WithContext(ctx) 208 + if changeId != "" { 209 + g.Go(func() error { 210 + commits1, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version1].Base, pull.Versions[version1].Head) 211 + return err 212 + }) 164 213 } 165 - if roundIdInt < 0 || roundIdInt >= len(pull.Submissions) { 166 - http.Error(w, "bad round id", http.StatusBadRequest) 167 - l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 214 + g.Go(func() error { 215 + commits2, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version2].Base, pull.Versions[version2].Head) 216 + return err 217 + }) 218 + if err := g.Wait(); err != nil { 219 + l.Error("failed to list commits", "err", err) 220 + params.ErrorMsg = "Failed to list commits. Try again later." 168 221 return 169 222 } 223 + params.Commits = commits2 170 224 171 - var diffOpts types.DiffOpts 172 - if d := r.URL.Query().Get("diff"); d == "split" { 173 - diffOpts.Split = true 225 + // commitId -> latest pipeline 226 + shas := make([]string, len(params.Commits)) 227 + for i, commit := range params.Commits { 228 + shas[i] = commit.Hash.String() 174 229 } 230 + params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas) 175 231 176 - // can be nil if this pull is not stacked 177 - stack, _ := r.Context().Value("stack").(models.Stack) 232 + if changeId != "" { 233 + // interdiff by change-id 234 + var from, to *types.Commit 235 + for _, commit := range commits1 { 236 + if commit.ChangeId == changeId { 237 + from = &commit 238 + break 239 + } 240 + } 241 + for _, commit := range commits2 { 242 + if commit.ChangeId == changeId { 243 + to = &commit 244 + break 245 + } 246 + } 247 + l.Debug("commits", "old", from, "new", to) 248 + 249 + switch { 250 + case to == nil: 251 + // can't find change-id from v2 branch. 252 + // NOTE: This can't happen because user selected from v2's commits 253 + params.ErrorMsg = "Can't find commit with given change-id." 254 + case from == nil: 255 + // new commit -> diff <parent1>..<new> 256 + params.ActiveCommitId = to.Hash.String() 257 + params.DiffParams.Diff = &pages.DiffParams_Diff{ 258 + Base: to.FirstParentHash().String(), 259 + Head: to.Hash.String(), 260 + } 261 + default: 262 + // interdiff 263 + params.ActiveCommitId = to.Hash.String() 264 + // TODO: use merged tree of all parents 265 + params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{ 266 + From: pages.DiffParams_Diff{ 267 + Base: from.FirstParentHash().String(), 268 + Head: from.Hash.String(), 269 + }, 270 + To: pages.DiffParams_Diff{ 271 + Base: to.FirstParentHash().String(), 272 + Head: to.Hash.String(), 273 + }, 274 + } 275 + } 276 + } else { 277 + // interdiff of two versions 278 + params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{ 279 + From: pages.DiffParams_Diff{ 280 + Base: pull.Versions[version1].Base, 281 + Head: pull.Versions[version1].Head, 282 + }, 283 + To: pages.DiffParams_Diff{ 284 + Base: pull.Versions[version2].Base, 285 + Head: pull.Versions[version2].Head, 286 + }, 287 + } 178 288 179 - var shas []string 180 - for _, s := range pull.Submissions { 181 - shas = append(shas, s.SourceRev) 289 + // TODO: if any of them is "", show error message 182 290 } 183 - for _, p := range stack { 184 - shas = append(shas, p.LatestSha()) 291 + } 292 + 293 + func (s *Pulls) PullPatchRaw(w http.ResponseWriter, r *http.Request) { 294 + l := s.logger.With("handler", "RepoPullPatchRaw") 295 + 296 + pull, ok := r.Context().Value("pull").(*models.Pull) 297 + if !ok { 298 + l.Error("failed to get pull") 299 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 300 + return 185 301 } 302 + l = l.With("pull_id", pull.PullId) 186 303 187 - pipelines, err := s.fetchPipelines(r.Context(), f.Spindle, f.RepoDid, shas) 188 - if err != nil { 189 - l.Error("failed to fetch pipelines", "err", err) 304 + var version models.PullVersion 305 + var versionIdRaw = chi.URLParam(r, "version") 306 + if versionIdRaw == "latest" { 307 + version = pull.LatestVersion() 308 + } else { 309 + versionId, err := strconv.Atoi(versionIdRaw) 310 + if err != nil { 311 + http.Error(w, "bad version id", http.StatusBadRequest) 312 + return 313 + } 314 + var ok bool 315 + version, ok = pull.GetVersion(versionId) 316 + if !ok { 317 + http.Error(w, "unknown version", http.StatusNotFound) 318 + return 319 + } 190 320 } 191 - if pipelines == nil { 192 - pipelines = make(map[string]types.Pipeline) 321 + 322 + xrpcc := s.knotMirrorXRPC 323 + rawOut, err := tangled.GitTempFormatPatch(r.Context(), xrpcc, version.Base, pull.RepoDid.String(), version.Head) 324 + if err != nil { 325 + http.Error(w, "Failed to compute patch", http.StatusInternalServerError) 326 + return 193 327 } 194 328 329 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 330 + w.Write(rawOut) 331 + } 332 + 333 + func (s *Pulls) makePullPageBaseParams(r *http.Request, user *oauth.MultiAccountUser, pull *models.Pull) pages.PullPageBaseParams { 334 + l := s.logger 335 + ctx := r.Context() 336 + 195 337 entities := []syntax.ATURI{pull.AtUri()} 196 - for _, s := range pull.Submissions { 197 - for _, c := range s.Comments { 338 + for _, v := range pull.Versions { 339 + for _, c := range v.Comments { 198 340 entities = append(entities, c.FeedCommentAtUri()) 199 341 } 200 342 } 201 343 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20) 202 344 if err != nil { 203 - l.Error("failed to get pull reactions", "err", err) 345 + l.Error("failed to get reactions", "err", err) 204 346 } 205 347 206 348 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool ··· 213 355 214 356 labelDefs, err := db.GetLabelDefinitions( 215 357 s.db, 216 - orm.FilterIn("at_uri", f.Labels), 358 + orm.FilterIn("at_uri", pull.Repo.Labels), 217 359 orm.FilterContains("scope", tangled.RepoPullNSID), 218 360 ) 219 361 if err != nil { 220 362 l.Error("failed to fetch labels", "err", err) 221 - s.pages.Error503(w) 222 - return 223 363 } 224 - 225 364 defs := make(map[string]*models.LabelDefinition) 226 365 for _, l := range labelDefs { 227 366 defs[l.AtUri().String()] = &l ··· 236 375 l.Error("failed to fetch vouch relationships", "err", err) 237 376 } 238 377 ownerDid := syntax.DID(pull.OwnerDid) 239 - skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid) 378 + skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid.String()) 240 379 if err != nil { 241 380 l.Error("failed to check vouch skip", "err", err) 242 381 } 243 382 vouchSkips[ownerDid] = skipped 244 383 } 245 384 246 - var diff types.DiffRenderer 247 - if interdiff { 248 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 249 - if err != nil { 250 - l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 251 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 252 - return 253 - } 254 - 255 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 256 - if err != nil { 257 - l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 258 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 259 - return 260 - } 261 - 262 - diff = patchutil.Interdiff(previousPatch, currentPatch) 263 - } else { 264 - diff = s.combinedDiff(pull, roundIdInt) 265 - } 266 - 267 385 var isSubscribed *bool 268 386 if user != nil { 269 387 pullDbId := int64(pull.ID) ··· 273 391 isSubscribed = &sub 274 392 } else { 275 393 // Implicitly subscribed if author or participant. 276 - isAuthorOrParticipant := pull.OwnerDid == user.Did 394 + isAuthorOrParticipant := pull.OwnerDid == syntax.DID(user.Did) 277 395 if !isAuthorOrParticipant { 278 396 for _, p := range pull.Participants() { 279 397 if p.String() == user.Did { ··· 290 408 } 291 409 } 292 410 293 - err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 294 - BaseParams: pages.BaseParamsFromContext(r.Context()), 295 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 296 - Pull: pull, 297 - Stack: stack, 298 - Backlinks: backlinks, 299 - BranchDeleteStatus: nil, 300 - MergeCheck: types.MergeCheckResponse{}, 301 - ResubmitCheck: pages.Unknown, 302 - Pipelines: pipelines, 303 - Diff: diff, 304 - DiffOpts: diffOpts, 305 - ActiveRound: roundIdInt, 306 - IsInterdiff: interdiff, 307 - 308 - Reactions: reactions, 309 - UserReacted: userReactions, 310 - 311 - LabelDefs: defs, 312 - VouchRelationships: vouchRelationships, 313 - VouchSkips: vouchSkips, 314 - IsSubscribed: isSubscribed, 411 + params := pages.PullPageBaseParams{} 412 + params.BaseParams = pages.BaseParamsFromContext(ctx) 413 + params.RepoInfo = s.repoResolver.GetRepoInfo(r, user) 414 + params.Pull = pull 415 + params.Backlinks = nil 416 + params.LabelDefs = defs 417 + params.Reactions = reactions 418 + params.UserReacted = userReactions 419 + params.VouchRelationships = vouchRelationships 420 + params.VouchSkips = vouchSkips 421 + params.IsSubscribed = isSubscribed 422 + return params 423 + } 424 + 425 + func (s *Pulls) listCommits(ctx context.Context, repo syntax.DID, base, head string) ([]types.Commit, error) { 426 + s.logger.Debug("logging commits", "repo", repo, "base", base, "head", head) 427 + stream, err := s.gitmirror.CommitLog(ctx, &gitmirrorv1.CommitLogRequest{ 428 + Repo: repo.String(), 429 + Ranges: [][]byte{fmt.Appendf(nil, "%s..%s", base, head)}, 430 + AllRefs: false, 315 431 }) 316 432 if err != nil { 317 - l.Error("failed to render page", "err", err) 433 + return nil, err 434 + } 435 + var commits []types.Commit 436 + for { 437 + res, err := stream.Recv() 438 + if errors.Is(err, io.EOF) { 439 + break 440 + } 441 + if err != nil { 442 + return nil, err 443 + } 444 + for _, commit := range res.Commits { 445 + commits = append(commits, types.Commit{ 446 + Hash: plumbing.NewHash(commit.Oid), 447 + Author: object.Signature{ 448 + Name: string(commit.Author.GetName()), 449 + Email: string(commit.Author.GetEmail()), 450 + When: commit.Author.Date.AsTime(), 451 + }, 452 + Committer: object.Signature{ 453 + Name: string(commit.Committer.GetName()), 454 + Email: string(commit.Committer.GetEmail()), 455 + When: commit.Committer.Date.AsTime(), 456 + }, 457 + Message: string(commit.Message), 458 + ParentHashes: func() []plumbing.Hash { 459 + var parents []plumbing.Hash 460 + for _, hash := range commit.Parents { 461 + parents = append(parents, plumbing.NewHash(hash)) 462 + } 463 + return parents 464 + }(), 465 + ChangeId: commit.ExtraHeaders["change-id"], 466 + }) 467 + } 318 468 } 469 + return commits, err 319 470 } 320 471 321 472 // SubscribePull handles subscribe/unsubscribe for a specific pull request. ··· 351 502 }) 352 503 } 353 504 354 - func (s *Pulls) combinedDiff(pull *models.Pull, round int) types.DiffRenderer { 355 - submission := pull.Submissions[round] 356 - key := fmt.Sprintf("%s|%d|%s", pull.AtUri(), round, submission.SourceRev) 357 - if cached, ok := s.diffCache.Get(key); ok { 358 - return cached 359 - } 360 - 361 - diff := patchutil.AsNiceDiff(submission.CombinedPatch(), pull.TargetBranch) 362 - s.diffCache.Add(key, diff) 363 - return diff 364 - } 365 - 366 505 func (s *Pulls) fetchPipelines(ctx context.Context, spindle string, repoDid string, shas []string) (map[string]types.Pipeline, error) { 367 - if spindle == "" { 506 + if spindle == "" || len(shas) == 0 { 368 507 return nil, nil 369 508 } 370 509 spindleUrl, err := hostutil.EnsureHttpScheme(spindle) ··· 379 518 return types.PipelinesByCommit(out.Pipelines), nil 380 519 } 381 520 382 - func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 383 - l := s.logger.With("handler", "RepoSinglePull") 384 - 385 - pull, ok := r.Context().Value("pull").(*models.Pull) 386 - if !ok { 387 - l.Error("failed to get pull") 388 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 389 - return 390 - } 391 - 392 - http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 393 - } 394 - 395 - func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 396 - if pull.State == models.PullMerged { 397 - return types.MergeCheckResponse{} 398 - } 399 - 400 - xrpcc := s.knotClient(f.Knot) 401 - 402 - // combine patches of substack 403 - subStack := stack.Below(pull) 404 - // collect the portion of the stack that is mergeable 405 - mergeable := subStack.Mergeable() 406 - // combine each patch 407 - patch := mergeable.CombinedPatch() 408 - 409 - resp, err := tangled.RepoMergeCheck( 410 - r.Context(), 411 - xrpcc, 412 - &tangled.RepoMergeCheck_Input{ 413 - Did: f.Did, 414 - Name: f.Name, 415 - Repo: f.RepoDidPtr(), 416 - Branch: pull.TargetBranch, 417 - Patch: patch, 418 - }, 419 - ) 420 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 421 - s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 422 - return types.MergeCheckResponse{ 423 - Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 424 - } 425 - } 426 - 427 - return mergeCheckResponseFrom(resp) 428 - } 429 - 430 - func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 431 - conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 432 - for i, c := range resp.Conflicts { 433 - conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 434 - } 435 - out := types.MergeCheckResponse{ 436 - IsConflicted: resp.Is_conflicted, 437 - Conflicts: conflicts, 438 - } 439 - if resp.Message != nil { 440 - out.Message = *resp.Message 441 - } 442 - if resp.Error != nil { 443 - out.Error = *resp.Error 444 - } 445 - return out 446 - } 447 - 448 - func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 449 - if pull.State != models.PullMerged { 450 - return nil 451 - } 452 - 453 - user := s.oauth.GetMultiAccountUser(r) 454 - if user == nil { 455 - return nil 456 - } 457 - 458 - var branch string 459 - // check if the branch exists 460 - // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 461 - if pull.IsBranchBased() { 462 - branch = pull.PullSource.Branch 463 - } else if pull.IsForkBased() { 464 - branch = pull.PullSource.Branch 465 - repo = pull.PullSource.Repo 466 - } else { 467 - return nil 468 - } 469 - 470 - // deleted fork 471 - if repo == nil { 472 - return nil 521 + func fetchPipelines(ctx context.Context, l *slog.Logger, f *models.Repo, shas []string) map[string]types.Pipeline { 522 + m := make(map[string]types.Pipeline) 523 + if f.Spindle == "" || len(shas) == 0 { 524 + return m 473 525 } 474 - 475 - // user can only delete branch if they are a collaborator in the repo that the branch belongs to 476 - if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") { 477 - return nil 526 + spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle) 527 + if err != nil { 528 + l.Error("invalid spindle host", "host", f.Spindle, "err", err) 529 + return m 478 530 } 479 - 480 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 481 - resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid) 482 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 483 - s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 484 - return nil 531 + xrpcc := &indigoxrpc.Client{Host: spindleUrl} 532 + out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, f.RepoDid) 533 + if err != nil { 534 + l.Error("failed to fetch pipelines", "err", err) 535 + return m 485 536 } 486 537 487 - return &models.BranchDeleteStatus{ 488 - Repo: repo, 489 - Branch: resp.Name, 538 + for _, pipeline := range out.Pipelines { 539 + if pipeline == nil { 540 + continue 541 + } 542 + m[pipeline.Commit] = types.Pipeline{CiPipeline: pipeline} 490 543 } 544 + return m 491 545 } 492 546 493 - func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 494 - if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 495 - return pages.Unknown 496 - } 497 - 498 - var sourceRepoDid string 499 - if pull.PullSource.RepoDid != nil { 500 - sourceRepoDid = string(*pull.PullSource.RepoDid) 501 - } else { 502 - sourceRepoDid = repo.RepoDid 503 - } 504 - 505 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 506 - branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid) 507 - if err != nil { 508 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 509 - s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 510 - return pages.Unknown 511 - } 512 - s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 513 - return pages.Unknown 547 + // parseRange parses <base>..<base> string 548 + func parseRange(input string) (base string, head string, err error) { 549 + input = strings.TrimSpace(input) 550 + if input == "" { 551 + return "", "", nil 514 552 } 515 553 516 - targetBranch := branchResp 517 - 518 - top := stack[0] 519 - latestSourceRev := top.LatestSha() 520 - 521 - if latestSourceRev != targetBranch.Hash { 522 - return pages.ShouldResubmit 554 + if strings.Count(input, "..") > 1 || strings.Contains(input, "...") { 555 + return "", "", fmt.Errorf("invalid revspec format: %q", input) 523 556 } 524 557 525 - return pages.ShouldNotResubmit 526 - } 527 - 528 - func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 529 - s.repoPullHelper(w, r, false) 530 - } 531 - 532 - func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 533 - s.repoPullHelper(w, r, true) 534 - } 535 - 536 - func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 537 - l := s.logger.With("handler", "RepoPullPatchRaw") 538 - 539 - pull, ok := r.Context().Value("pull").(*models.Pull) 540 - if !ok { 541 - l.Error("failed to get pull") 542 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 543 - return 558 + // /{head} 559 + if !strings.Contains(input, "..") { 560 + return "", input, nil 544 561 } 545 - l = l.With("pull_id", pull.PullId) 546 562 547 - roundId := chi.URLParam(r, "round") 548 - roundIdInt, err := strconv.Atoi(roundId) 549 - if err != nil || roundIdInt >= len(pull.Submissions) { 550 - http.Error(w, "bad round id", http.StatusBadRequest) 551 - l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 552 - return 563 + // /{base}..{head} 564 + parts := strings.SplitN(input, "..", 2) 565 + base = strings.TrimSpace(parts[0]) 566 + head = strings.TrimSpace(parts[1]) 567 + if base == "" && head == "" { 568 + return "", "", fmt.Errorf("invalid empty range: \"..\"") 553 569 } 554 570 555 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 556 - w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 571 + return base, head, nil 557 572 }
+9 -23
appview/pulls/state.go
··· 7 7 comatproto "github.com/bluesky-social/indigo/api/atproto" 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "github.com/samber/lo" 11 10 12 11 "tangled.org/core/api/tangled" 13 12 "tangled.org/core/appview/models" 14 13 "tangled.org/core/tid" 15 14 ) 16 15 17 - func (s *Pulls) writePullStatusRecords(r *http.Request, actorDid string, subjects []syntax.ATURI, value models.StateValue) error { 18 - if len(subjects) == 0 { 19 - return nil 20 - } 21 - 16 + func (s *Pulls) writePullStatusRecord(r *http.Request, actorDid string, subject syntax.ATURI, value models.StateValue) error { 22 17 client, err := s.oauth.AuthorizedClient(r) 23 18 if err != nil { 24 19 return err 25 20 } 26 21 27 - records, err := models.AsPullStatusRecords(subjects, value, time.Now()) 22 + record, err := models.AsPullStatusRecord(subject, value, time.Now()) 28 23 if err != nil { 29 24 return err 30 25 } 31 26 32 - writes := lo.Map(records, func(record tangled.RepoPullStatus, _ int) *comatproto.RepoApplyWrites_Input_Writes_Elem { 33 - rkey := tid.TID() 34 - return &comatproto.RepoApplyWrites_Input_Writes_Elem{ 35 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 36 - Collection: tangled.RepoPullStatusNSID, 37 - Rkey: &rkey, 38 - Value: &lexutil.LexiconTypeDecoder{ 39 - Val: &record, 40 - }, 41 - }, 42 - } 43 - }) 44 - 45 - _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 46 - Repo: actorDid, 47 - Writes: writes, 27 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 28 + Collection: tangled.RepoPullStatusNSID, 29 + Repo: actorDid, 30 + Rkey: tid.TID(), 31 + Record: &lexutil.LexiconTypeDecoder{ 32 + Val: &record, 33 + }, 48 34 }) 49 35 return err 50 36 }
+36 -17
appview/pulls/trigger_ci.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "context" 5 + "errors" 4 6 "fmt" 7 + "io" 5 8 "net/http" 6 9 "strings" 7 10 11 + "github.com/bluesky-social/indigo/atproto/syntax" 8 12 "tangled.org/core/api/tangled" 9 13 "tangled.org/core/appview/db" 10 14 "tangled.org/core/appview/models" 11 - "tangled.org/core/patchutil" 15 + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 12 16 "tangled.org/core/workflow" 13 17 ) 14 18 15 - func changedWorkflowFiles(patch string) ([]string, error) { 16 - files, err := patchutil.AsDiff(patch) 19 + func (s *Pulls) changedWorkflowFiles(ctx context.Context, baseRepo syntax.DID, base string, headRepo syntax.DID, head string) ([]string, error) { 20 + req := &gitmirrorv1.DiffRequest{ 21 + Head: &gitmirrorv1.RepoCommit{Repo: headRepo.String(), Commit: []byte(head)}, 22 + } 23 + if base != "" { 24 + req.Base = &gitmirrorv1.RepoCommit{Repo: baseRepo.String(), Commit: []byte(base)} 25 + } 26 + stream, err := s.gitmirror.Diff(ctx, req) 17 27 if err != nil { 18 - return nil, err 28 + return nil, fmt.Errorf("failed to diff: %w", err) 19 29 } 20 - 21 30 var changed []string 22 - for _, f := range files { 23 - if f == nil { 24 - continue 31 + for { 32 + fd, err := stream.Recv() 33 + if errors.Is(err, io.EOF) { 34 + break 25 35 } 26 - for _, name := range []string{f.NewName, f.OldName} { 36 + if err != nil { 37 + return nil, fmt.Errorf("failed to drain diff response: %w", err) 38 + } 39 + for _, name := range []string{fd.LhsSrc.Path, fd.RhsSrc.Path} { 27 40 if name != "" && strings.HasPrefix(name, workflow.WorkflowDir+"/") { 28 41 changed = append(changed, name) 29 42 break ··· 72 85 return 73 86 } 74 87 75 - latest := pull.LatestSubmission() 76 - if latest.SourceRev == "" { 88 + latest := pull.LatestVersion() 89 + if latest.Base == "" || latest.Head == "" { 77 90 fail("cannot trigger ci: this round has no commit to run", nil) 78 91 return 79 92 } 80 93 81 - changedFiles, err := changedWorkflowFiles(latest.CombinedPatch()) 94 + changedFiles, err := func(ctx context.Context) ([]string, error) { 95 + base, err := s.resolveRev(ctx, pull.RepoDid, pull.TargetBranch) 96 + if err != nil { 97 + return nil, fmt.Errorf("failed to resolve target branch: %w", err) 98 + } 99 + return s.changedWorkflowFiles(ctx, pull.RepoDid, base, pull.SourceRepo, latest.Head) 100 + }(r.Context()) 82 101 if err != nil { 83 102 fail("failed to inspect the latest round's patch", err) 84 103 return ··· 88 107 return 89 108 } 90 109 91 - forkRepo, err := db.GetRepoByDid(s.db, pull.PullSource.RepoDid.String()) 110 + forkRepo, err := db.GetRepoByDid(s.db, pull.SourceRepo.String()) 92 111 if err != nil { 93 112 fail("failed to resolve the fork this pull request comes from", err) 94 113 return ··· 101 120 } 102 121 103 122 pullAt := pull.AtUri().String() 104 - sourceBranch := pull.PullSource.Branch 123 + sourceBranch := pull.SourceBranch 105 124 targetBranch := pull.TargetBranch 106 125 out, err := tangled.CiTriggerPipeline( 107 126 r.Context(), ··· 111 130 Trigger: &tangled.CiTriggerPipeline_Input_Trigger{ 112 131 CiTrigger_PullRequest: &tangled.CiTrigger_PullRequest{ 113 132 Pull: &pullAt, 114 - SourceBranch: &sourceBranch, 133 + SourceBranch: sourceBranch, 115 134 SourceRepo: &forkRepo.RepoDid, 116 - SourceSha: latest.SourceRev, 135 + SourceSha: latest.Head, 117 136 TargetBranch: targetBranch, 118 137 }, 119 138 }, ··· 127 146 128 147 user := s.oauth.GetMultiAccountUser(r) 129 148 repoInfo := s.repoResolver.GetRepoInfo(r, user) 130 - dest := fmt.Sprintf("/%s/pulls/%d/round/%d", repoInfo.FullName(), pull.PullId, pull.LastRoundNumber()) 149 + dest := fmt.Sprintf("/%s/pulls/%d/round/%d", repoInfo.FullName(), pull.PullId, pull.LatestVersionNumber()) 131 150 if r.Header.Get("HX-Request") == "true" { 132 151 w.Header().Set("HX-Redirect", dest) 133 152 w.WriteHeader(http.StatusOK)
+11 -11
appview/repo/feed.go
··· 73 73 74 74 // fetch and add pull requests if requested 75 75 if opts.IncludePulls { 76 - pulls, err := db.GetPullsPaginated(rp.db, feedPagePerType, orm.FilterEq("repo_did", repo.RepoDid)) 76 + pulls, err := db.GetPullsPaginated(ctx, rp.db, feedPagePerType, orm.FilterEq("repo_did", repo.RepoDid)) 77 77 if err != nil { 78 78 return nil, err 79 79 } ··· 148 148 } 149 149 150 150 func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, ownerSlashRepo string) ([]*feeds.Item, error) { 151 - owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 151 + owner, err := rp.idResolver.Directory().LookupDID(ctx, pull.OwnerDid) 152 152 if err != nil { 153 153 return nil, err 154 154 } 155 155 156 156 var items []*feeds.Item 157 157 158 - state := rp.getPullState(pull) 159 - description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo) 158 + description := rp.buildPullDescription(owner.Handle, pull, ownerSlashRepo) 160 159 161 160 mainItem := &feeds.Item{ 162 161 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), ··· 167 166 } 168 167 items = append(items, mainItem) 169 168 170 - for _, round := range pull.Submissions { 171 - if round == nil || round.RoundNumber == 0 { 169 + for _, round := range pull.Versions { 170 + if round.ID == 0 { 172 171 continue 173 172 } 174 173 175 174 roundItem := &feeds.Item{ 176 - Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 177 - Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 178 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.RoundNumber)}, 175 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.ID), 176 + Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.ID, pull.PullId, ownerSlashRepo), 177 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.ID)}, 179 178 Created: round.Created, 180 179 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 181 180 } ··· 293 292 return pull.State.String() 294 293 } 295 294 296 - func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string { 295 + func (rp *Repo) buildPullDescription(handle syntax.Handle, pull *models.Pull, repoName string) string { 296 + state := rp.getPullState(pull) 297 297 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 298 298 299 299 if pull.State == models.PullMerged { 300 - return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 300 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LatestVersionNumber(), repoName) 301 301 } 302 302 303 303 return fmt.Sprintf("%s in %s", base, repoName)
+2 -2
appview/state/profile.go
··· 502 502 503 503 evidencePulls := make(map[syntax.ATURI]*models.Pull) 504 504 if len(pullAts) > 0 { 505 - pulls, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAts)) 505 + pulls, err := db.GetPullsPaginated(r.Context(), s.db, pagination.Page{}, orm.FilterIn("at_uri", pullAts)) 506 506 if err != nil { 507 507 l.Error("failed to get evidence pulls", "err", err) 508 508 } else { ··· 731 731 732 732 func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 733 733 for _, pull := range pulls { 734 - owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 734 + owner, err := s.idResolver.Directory().LookupDID(ctx, pull.RepoDid) 735 735 if err != nil { 736 736 return err 737 737 }
+4 -3
appview/timeline/timeline.go
··· 1 1 package timeline 2 2 3 3 import ( 4 + "context" 4 5 "net/http" 5 6 "sort" 6 7 ··· 99 100 100 101 var recents []pages.RecentItem 101 102 if user != nil { 102 - recents, err = t.buildRecents(user.Did) 103 + recents, err = t.buildRecents(r.Context(), user.Did) 103 104 if err != nil { 104 105 t.logger.Error("failed to build recents for timeline", "err", err) 105 106 } ··· 138 139 } 139 140 } 140 141 141 - func (t *Timeline) buildRecents(userDid string) ([]pages.RecentItem, error) { 142 + func (t *Timeline) buildRecents(ctx context.Context, userDid string) ([]pages.RecentItem, error) { 142 143 links, err := db.GetRecentLinks(t.db, orm.FilterEq("user_did", userDid)) 143 144 if err != nil { 144 145 return nil, err ··· 187 188 // fetch pulls by aturi 188 189 pullByAtUri := make(map[string]*models.Pull) 189 190 if len(pullAtUris) > 0 { 190 - fetched, err := db.GetPulls(t.db, orm.FilterIn("at_uri", pullAtUris)) 191 + fetched, err := db.GetPullsPaginated(ctx, t.db, pagination.Page{}, orm.FilterIn("at_uri", pullAtUris)) 191 192 if err != nil { 192 193 return nil, err 193 194 }
-38
cmd/interdiff/main.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - "os" 6 - 7 - "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.org/core/patchutil" 9 - ) 10 - 11 - func main() { 12 - if len(os.Args) != 3 { 13 - fmt.Println("Usage: interdiff <patch1> <patch2>") 14 - os.Exit(1) 15 - } 16 - 17 - patch1, err := os.Open(os.Args[1]) 18 - if err != nil { 19 - fmt.Println(err) 20 - } 21 - patch2, err := os.Open(os.Args[2]) 22 - if err != nil { 23 - fmt.Println(err) 24 - } 25 - 26 - files1, _, err := gitdiff.Parse(patch1) 27 - if err != nil { 28 - fmt.Println(err) 29 - } 30 - 31 - files2, _, err := gitdiff.Parse(patch2) 32 - if err != nil { 33 - fmt.Println(err) 34 - } 35 - 36 - interDiffResult := patchutil.Interdiff(files1, files2) 37 - fmt.Println(interDiffResult) 38 - }
+53
input.css
··· 335 335 } 336 336 } 337 337 338 + .diff { 339 + @apply font-mono bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400; 340 + font-size: 12px; 341 + content-visibility: auto; 342 + content-intrinsic-size: 32rem; 343 + } 344 + .diff-line { 345 + @apply flex; 346 + } 347 + .diff-side { 348 + @apply flex flex-1 min-w-0; 349 + } 350 + .diff-side + .diff-side { 351 + @apply border-l border-gray-200 dark:border-gray-700; 352 + } 353 + .diff-num { 354 + @apply contents text-gray-400 dark:text-gray-500; 355 + } 356 + .diff-num > span { 357 + @apply flex-none pr-2 text-right select-none; 358 + @apply bg-white dark:bg-gray-800; 359 + min-width: calc(6ch + 0.5rem); 360 + } 361 + .diff-indicator { 362 + @apply flex-none w-[2ch] px-1 text-center select-none; 363 + @apply border-l border-gray-200 dark:border-gray-700; 364 + } 365 + .diff-content { 366 + @apply flex-1 min-w-0 whitespace-pre-wrap pl-2 pr-3 relative; 367 + overflow-wrap: anywhere; 368 + } 369 + .diff-content div { 370 + @apply inline; 371 + } 372 + .diff-side.add, 373 + .diff-line.add { 374 + @apply bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400; 375 + } 376 + .diff-side.del, 377 + .diff-line.del { 378 + @apply bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400; 379 + } 380 + .diff-side.empty { 381 + @apply bg-gray-200/30 dark:bg-gray-700/30; 382 + } 383 + .diff-splitter { 384 + @apply bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center; 385 + } 386 + .diff[data-restrict-select="left"] .diff-side:last-child, 387 + .diff[data-restrict-select="right"] .diff-side:first-child { 388 + @apply select-none; 389 + } 390 + 338 391 .prose { 339 392 overflow-wrap: anywhere; 340 393 }
-325
patchutil/interdiff.go
··· 1 - package patchutil 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.org/core/appview/filetree" 9 - "tangled.org/core/types" 10 - ) 11 - 12 - type InterdiffResult struct { 13 - Files []*InterdiffFile 14 - } 15 - 16 - func (i *InterdiffResult) Stats() types.DiffStat { 17 - var ins, del int64 18 - for _, s := range i.ChangedFiles() { 19 - stat := s.Stats() 20 - ins += stat.Insertions 21 - del += stat.Deletions 22 - } 23 - return types.DiffStat{ 24 - Insertions: ins, 25 - Deletions: del, 26 - FilesChanged: len(i.Files), 27 - } 28 - } 29 - 30 - func (i *InterdiffResult) ChangedFiles() []types.DiffFileRenderer { 31 - drs := make([]types.DiffFileRenderer, len(i.Files)) 32 - for i, s := range i.Files { 33 - drs[i] = s 34 - } 35 - return drs 36 - } 37 - 38 - func (i *InterdiffResult) FileTree() *filetree.FileTreeNode { 39 - fs := make([]string, len(i.Files)) 40 - for i, s := range i.Files { 41 - fs[i] = s.Name 42 - } 43 - return filetree.FileTree(fs) 44 - } 45 - 46 - func (i *InterdiffResult) String() string { 47 - var b strings.Builder 48 - for _, f := range i.Files { 49 - b.WriteString(f.String()) 50 - b.WriteString("\n") 51 - } 52 - 53 - return b.String() 54 - } 55 - 56 - type InterdiffFile struct { 57 - *gitdiff.File 58 - Name string 59 - Status InterdiffFileStatus 60 - } 61 - 62 - func (s *InterdiffFile) Id() string { 63 - return s.Name 64 - } 65 - 66 - func (s *InterdiffFile) Split() types.SplitDiff { 67 - fragments := make([]types.SplitFragment, len(s.TextFragments)) 68 - 69 - for i, fragment := range s.TextFragments { 70 - leftLines, rightLines := types.SeparateLines(fragment) 71 - 72 - fragments[i] = types.SplitFragment{ 73 - Header: fragment.Header(), 74 - LeftLines: leftLines, 75 - RightLines: rightLines, 76 - } 77 - } 78 - 79 - return types.SplitDiff{ 80 - Name: s.Id(), 81 - TextFragments: fragments, 82 - } 83 - } 84 - 85 - func (s *InterdiffFile) CanRender() string { 86 - if s.Status.IsUnchanged() { 87 - return "This file has not been changed." 88 - } else if s.Status.IsRebased() { 89 - return "This patch was likely rebased, as context lines do not match." 90 - } else if s.Status.IsError() { 91 - return "Failed to calculate interdiff for this file." 92 - } else { 93 - return "" 94 - } 95 - } 96 - 97 - func (s *InterdiffFile) Names() types.DiffFileName { 98 - var n types.DiffFileName 99 - n.New = s.Name 100 - return n 101 - } 102 - 103 - func (s *InterdiffFile) Stats() types.DiffFileStat { 104 - var ins, del int64 105 - 106 - if s.File != nil { 107 - for _, f := range s.TextFragments { 108 - ins += f.LinesAdded 109 - del += f.LinesDeleted 110 - } 111 - } 112 - 113 - return types.DiffFileStat{ 114 - Insertions: ins, 115 - Deletions: del, 116 - } 117 - } 118 - 119 - func (s *InterdiffFile) String() string { 120 - var b strings.Builder 121 - b.WriteString(s.Status.String()) 122 - b.WriteString(" ") 123 - 124 - if s.File != nil { 125 - b.WriteString(bestName(s.File)) 126 - b.WriteString("\n") 127 - b.WriteString(s.File.String()) 128 - } 129 - 130 - return b.String() 131 - } 132 - 133 - type InterdiffFileStatus struct { 134 - StatusKind StatusKind 135 - Error error 136 - } 137 - 138 - func (s *InterdiffFileStatus) String() string { 139 - kind := s.StatusKind.String() 140 - if s.Error != nil { 141 - return fmt.Sprintf("%s [%s]", kind, s.Error.Error()) 142 - } else { 143 - return kind 144 - } 145 - } 146 - 147 - func (s *InterdiffFileStatus) IsOk() bool { 148 - return s.StatusKind == StatusOk 149 - } 150 - 151 - func (s *InterdiffFileStatus) IsUnchanged() bool { 152 - return s.StatusKind == StatusUnchanged 153 - } 154 - 155 - func (s *InterdiffFileStatus) IsOnlyInOne() bool { 156 - return s.StatusKind == StatusOnlyInOne 157 - } 158 - 159 - func (s *InterdiffFileStatus) IsOnlyInTwo() bool { 160 - return s.StatusKind == StatusOnlyInTwo 161 - } 162 - 163 - func (s *InterdiffFileStatus) IsRebased() bool { 164 - return s.StatusKind == StatusRebased 165 - } 166 - 167 - func (s *InterdiffFileStatus) IsError() bool { 168 - return s.StatusKind == StatusError 169 - } 170 - 171 - type StatusKind int 172 - 173 - func (k StatusKind) String() string { 174 - switch k { 175 - case StatusOnlyInOne: 176 - return "only in one" 177 - case StatusOnlyInTwo: 178 - return "only in two" 179 - case StatusUnchanged: 180 - return "unchanged" 181 - case StatusRebased: 182 - return "rebased" 183 - case StatusError: 184 - return "error" 185 - default: 186 - return "changed" 187 - } 188 - } 189 - 190 - const ( 191 - StatusOk StatusKind = iota 192 - StatusOnlyInOne 193 - StatusOnlyInTwo 194 - StatusUnchanged 195 - StatusRebased 196 - StatusError 197 - ) 198 - 199 - func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile { 200 - re1 := CreatePreImage(f1) 201 - re2 := CreatePreImage(f2) 202 - 203 - interdiffFile := InterdiffFile{ 204 - Name: bestName(f1), 205 - } 206 - 207 - merged, err := re1.Merge(&re2) 208 - if err != nil { 209 - interdiffFile.Status = InterdiffFileStatus{ 210 - StatusKind: StatusRebased, 211 - Error: err, 212 - } 213 - return &interdiffFile 214 - } 215 - 216 - rev1, err := merged.Apply(f1) 217 - if err != nil { 218 - interdiffFile.Status = InterdiffFileStatus{ 219 - StatusKind: StatusError, 220 - Error: err, 221 - } 222 - return &interdiffFile 223 - } 224 - 225 - rev2, err := merged.Apply(f2) 226 - if err != nil { 227 - interdiffFile.Status = InterdiffFileStatus{ 228 - StatusKind: StatusError, 229 - Error: err, 230 - } 231 - return &interdiffFile 232 - } 233 - 234 - diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2)) 235 - if err != nil { 236 - interdiffFile.Status = InterdiffFileStatus{ 237 - StatusKind: StatusError, 238 - Error: err, 239 - } 240 - return &interdiffFile 241 - } 242 - 243 - parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 244 - if err != nil { 245 - interdiffFile.Status = InterdiffFileStatus{ 246 - StatusKind: StatusError, 247 - Error: err, 248 - } 249 - return &interdiffFile 250 - } 251 - 252 - if len(parsed) != 1 { 253 - // files are identical? 254 - interdiffFile.Status = InterdiffFileStatus{ 255 - StatusKind: StatusUnchanged, 256 - } 257 - return &interdiffFile 258 - } 259 - 260 - if interdiffFile.Status.StatusKind == StatusOk { 261 - interdiffFile.File = parsed[0] 262 - } 263 - 264 - return &interdiffFile 265 - } 266 - 267 - func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult { 268 - fileToIdx1 := make(map[string]int) 269 - fileToIdx2 := make(map[string]int) 270 - visited := make(map[string]struct{}) 271 - var result InterdiffResult 272 - 273 - for idx, f := range patch1 { 274 - fileToIdx1[bestName(f)] = idx 275 - } 276 - 277 - for idx, f := range patch2 { 278 - fileToIdx2[bestName(f)] = idx 279 - } 280 - 281 - for _, f1 := range patch1 { 282 - var interdiffFile *InterdiffFile 283 - 284 - fileName := bestName(f1) 285 - if idx, ok := fileToIdx2[fileName]; ok { 286 - f2 := patch2[idx] 287 - 288 - // we have f1 and f2, calculate interdiff 289 - interdiffFile = interdiffFiles(f1, f2) 290 - } else { 291 - // only in patch 1, this change would have to be "inverted" to disappear 292 - // from patch 2, so we reverseDiff(f1) 293 - reverseDiff(f1) 294 - 295 - interdiffFile = &InterdiffFile{ 296 - File: f1, 297 - Name: fileName, 298 - Status: InterdiffFileStatus{ 299 - StatusKind: StatusOnlyInOne, 300 - }, 301 - } 302 - } 303 - 304 - result.Files = append(result.Files, interdiffFile) 305 - visited[fileName] = struct{}{} 306 - } 307 - 308 - // for all files in patch2 that remain unvisited; we can just add them into the output 309 - for _, f2 := range patch2 { 310 - fileName := bestName(f2) 311 - if _, ok := visited[fileName]; ok { 312 - continue 313 - } 314 - 315 - result.Files = append(result.Files, &InterdiffFile{ 316 - File: f2, 317 - Name: fileName, 318 - Status: InterdiffFileStatus{ 319 - StatusKind: StatusOnlyInTwo, 320 - }, 321 - }) 322 - } 323 - 324 - return &result 325 - }
-9
patchutil/patchutil_test.go
··· 4 4 "errors" 5 5 "reflect" 6 6 "testing" 7 - 8 - "tangled.org/core/types" 9 7 ) 10 8 11 9 func TestIsPatchValid(t *testing.T) { ··· 406 404 }) 407 405 } 408 406 } 409 - 410 - func TestImplsInterfaces(t *testing.T) { 411 - id := &InterdiffResult{} 412 - _ = isDiffsRenderer(id) 413 - } 414 - 415 - func isDiffsRenderer[S types.DiffRenderer](S) bool { return true }
+5 -52
spindle/tapclient.go
··· 7 7 "errors" 8 8 "fmt" 9 9 "log/slog" 10 - "net/http" 11 - "net/url" 12 10 "sync" 13 11 "time" 14 12 15 13 "github.com/bluesky-social/indigo/atproto/syntax" 16 14 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 15 "tangled.org/core/api/tangled" 18 - avmodels "tangled.org/core/appview/models" 19 16 "tangled.org/core/eventconsumer" 20 17 "tangled.org/core/log" 21 18 "tangled.org/core/rbac" ··· 357 354 return nil 358 355 } 359 356 360 - latestSubmission, err := t.fetchLatestSubmission(ctx, evt.Did.String(), evt.Rkey.String(), &record) 361 - if err != nil { 362 - return err 357 + if len(record.Versions) == 0 { 358 + l.Warn("skipping PR without versions") 359 + return nil 363 360 } 364 - sourceSha := latestSubmission.SourceRev 361 + 362 + sourceSha := record.Versions[len(record.Versions)-1].Head 365 363 366 364 scheme := "https" 367 365 if t.spindle.cfg.Server.Dev { ··· 515 513 t.logger.Warn("expired buffered collaborator events without matching repo arrival", "count", expired, "ttl", pendingCollabTTL) 516 514 } 517 515 } 518 - 519 - func (t *Tap) fetchLatestSubmission(ctx context.Context, did, rkey string, record *tangled.RepoPull) (*avmodels.PullSubmission, error) { 520 - // resolve the PR owner's identity to fetch the blob from their PDS 521 - prOwnerIdent, err := t.spindle.res.ResolveIdent(ctx, did) 522 - if err != nil || prOwnerIdent.Handle.IsInvalidHandle() { 523 - return nil, fmt.Errorf("failed to resolve PR owner handle: %w", err) 524 - } 525 - 526 - if len(record.Rounds) == 0 { 527 - return nil, fmt.Errorf("failed to fetch latest submission, no rounds in record") 528 - } 529 - 530 - roundNumber := len(record.Rounds) - 1 531 - round := record.Rounds[roundNumber] 532 - 533 - // fetch the blob from the PR owner's PDS 534 - prOwnerPds := prOwnerIdent.PDSEndpoint() 535 - blobUrl, err := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", prOwnerPds)) 536 - if err != nil { 537 - return nil, fmt.Errorf("failed to construct blob URL: %w", err) 538 - } 539 - q := blobUrl.Query() 540 - q.Set("cid", round.PatchBlob.Ref.String()) 541 - q.Set("did", did) 542 - blobUrl.RawQuery = q.Encode() 543 - 544 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, blobUrl.String(), nil) 545 - if err != nil { 546 - return nil, fmt.Errorf("failed to create blob request: %w", err) 547 - } 548 - req.Header.Set("Content-Type", "application/json") 549 - 550 - blobResp, err := http.DefaultClient.Do(req) 551 - if err != nil { 552 - return nil, fmt.Errorf("failed to fetch blob: %w", err) 553 - } 554 - defer blobResp.Body.Close() 555 - 556 - latestSubmission, err := avmodels.PullSubmissionFromRecord(did, rkey, roundNumber, round, blobResp.Body) 557 - if err != nil { 558 - return nil, fmt.Errorf("failed to parse submission: %w", err) 559 - } 560 - 561 - return latestSubmission, nil 562 - }
+7
types/commit.go
··· 197 197 198 198 return coAuthors 199 199 } 200 + 201 + func (commit Commit) FirstParentHash() plumbing.Hash { 202 + if len(commit.ParentHashes) > 0 { 203 + return commit.ParentHashes[0] 204 + } 205 + return plumbing.ZeroHash 206 + }

History

2 rounds 0 comments
Sign up or Login to add to the discussion
14 commits
Expand
lexicons: git.keepCommit
knotserver/xrpc: git.keepCommit
lexicons: git.temp.getMergeBase
knotmirror/xrpc: git.getMergeBase
lexicons: new git.merge* method schema
knotserver/xrpc: git.mergeCheck and git.mergeCommit
lexicons/git: git.formatPatch
knotmirror/xrpc: git.formatPatch
lexicons/pulls: rename PR round to version
cargo: fix rust build linker on aarch64-darwin
gitmirror: introduce gitmirror
docker-compose: use directory mount
appview: use gitmirror
appview: ref based PR
Checking mergeability…
Expand 0 comments
13 commits
Expand
lexicons: git.keepCommit
knotserver/xrpc: git.keepCommit
lexicons: git.temp.getMergeBase
knotmirror/xrpc: git.getMergeBase
lexicons: new git.merge* method schema
knotserver/xrpc: git.mergeCheck and git.mergeCommit
lexicons/git: git.formatPatch
knotmirror/xrpc: git.formatPatch
lexicons/pulls: rename PR round to version
cargo: fix rust build linker on aarch64-darwin
gitmirror: introduce gitmirror
docker-compose: use directory mount
appview: use gitmirror
Expand 0 comments