Commit ec8b566b11

ec8b566b114c735cb30865164b59c804684098f3

parent: 390e0c9531

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 19:41 UTC

control, e2e, wiki: a deploy key closes its own repository's issue by full path

A cross-repository close requires the pushing key's scope to reach the
target, and no deploy scope reaches any repository, so a deploy-key
push saying Closes owner/self#N, naming the repository the key is
bound to, closed nothing while a bare Closes #N in the same commit
did. closeTarget now treats a path that resolves to the source
repository as the bare form; the rule that a deploy key acts nowhere
else is unchanged.

Closes #213
.gitbay/wiki/Users.org +4 −1
@@ -349,7 +349,10 @@ branch (direct push, MR merge, =repo commit-file= or the web editor):
349349=closes/fixes/resolves #4= closes the
350350issue with a linking comment, and a bare =#4= leaves a reference
351351comment. Each issue/commit pair acts once, ever. =Closes owner/name#N= closes an issue in another repository when
352you hold write there; otherwise it stays a plain link. The comment left
352you hold write there with a key whose scope reaches it; otherwise it
353stays a plain link. A deploy key closes nothing outside the repository
354it is bound to, but naming that repository by full path works as the
355bare form does. The comment left
353356on the closed issue links the closing commit by its repository path, so
354357closing a public repository's issue from a private one names the private
355358repository there. A bare =owner/name#N= links and does nothing.
e2e/deploykey_test.go +13 −1
@@ -77,8 +77,20 @@ func TestDeployKeys(t *testing.T) {
7777 rwWork := t.TempDir()
7878 mustGit(t, rwWork, rwEnv, "clone", inst.sshURL("alice/app"), "w")
7979 rwDir := filepath.Join(rwWork, "w")
80 mustGit(t, rwDir, rwEnv, "commit", "-q", "--allow-empty", "-m", "ci push")
80 // Its push closes the bound repository's issue whether the message
81 // names it bare or by full path (#213).
82 for _, title := range []string{"'bare'", "'full path'"} {
83 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", title); code != 0 {
84 t.Fatalf("issue create: %s", errOut)
85 }
86 }
87 mustGit(t, rwDir, rwEnv, "commit", "-q", "--allow-empty", "-m", "ci push\n\nCloses #1, closes alice/app#2")
8188 mustGit(t, rwDir, rwEnv, "push", "-q", "origin", "main")
89 for _, n := range []string{"1", "2"} {
90 if out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", n, "--json"); !strings.Contains(out, `"state":"closed"`) {
91 t.Fatalf("deploy key push did not close issue %s: %s", n, out)
92 }
93 }
8294
8395 // The binding survives an owner rename (keys bind to the repo ID).
8496 if _, errOut, code = inst.ssh(t, aliceKey, "", "org", "create", "moved"); code != 0 {
internal/control/commitrefs.go +6
@@ -163,6 +163,12 @@ func closeTarget(st *store.Store, source store.Repo, actorID int64, scope, path
163163 if err != nil {
164164 return store.Repo{}, false
165165 }
166 // The source repository named by its full path is the bare form
167 // spelled out, so a deploy key bound to it closes there as a bare #N
168 // would (#213).
169 if target.ID == source.ID {
170 return source, true
171 }
166172 actor, err := st.UserByID(actorID)
167173 if err != nil {
168174 return store.Repo{}, false
internal/control/commitrefs_test.go +9
@@ -67,6 +67,15 @@ func TestMRDescriptionClosesAcrossRepos(t *testing.T) {
6767 if iss, _ := f.st.IssueByNumber(f.priv.ID, 1); iss.State != "open" {
6868 t.Fatal("a deploy key closed an issue outside its binding")
6969 }
70 // The same key naming its own repository by full path is the bare
71 // form spelled out, not a cross-repo close (#213).
72 if _, err := f.st.CreateIssue(f.app.ID, f.alice, "in app too", "", "md"); err != nil {
73 t.Fatal(err)
74 }
75 ProcessMRDescription(f.st, f.app, mr(6, "Closes alice/app#2"), f.alice, deploy)
76 if iss, _ := f.st.IssueByNumber(f.app.ID, 2); iss.State != "closed" {
77 t.Fatal("a deploy key did not close its own repository's issue by full path")
78 }
7079 // An archived target is read-only, cross-repo closes included.
7180 if _, err := f.st.UpdateRepoSettings(f.priv.ID, func(rs *store.RepoSettings) { rs.Archived = true }); err != nil {
7281 t.Fatal(err)