diff --git a/docs/interfaces.md b/docs/interfaces.md index d5f71dd..196b2a9 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -37,7 +37,7 @@ Repository routes take `?repo=owner/repository`. | --- | --- | | `GET /sourcegraph/healthz`, `GET /sourcegraph/readyz` | Liveness and readiness | | `GET /sourcegraph/v1/capabilities` | Supported operations | -| `GET /sourcegraph/v1/repos` | Repository catalog | +| `GET /sourcegraph/v1/repos` | Repository catalog; signed-in callers also get each repository's `managers` | | `GET /sourcegraph/v1/code-sources` | Configured code sources in display order: `id/name/host/username/anonymous/editable/locked` | | `GET /sourcegraph/v1/repo/availability` | Indexed commits and readiness; `/v1/repo/commits` is an alias | | `GET /sourcegraph/v1/repo/plugins` | Read plugins available for a revision | diff --git a/internal/control/model.go b/internal/control/model.go index 86e78c7..429a1c3 100644 --- a/internal/control/model.go +++ b/internal/control/model.go @@ -54,9 +54,13 @@ type Version struct { } const ( - SourceManual = "manual" - SourceAutomatic = "automatic" - DefaultSyncIntervalMinutes = 5 + SourceManual = "manual" + SourceAutomatic = "automatic" + // DefaultSyncIntervalMinutes applies to new registrations. + DefaultSyncIntervalMinutes = 180 + // legacySyncIntervalMinutes keeps records saved before the interval was + // stored (zero) on the interval they always had. + legacySyncIntervalMinutes = 5 ) func ValidateSyncInterval(minutes int) error { @@ -205,7 +209,7 @@ func (r *Repository) Version(sha string) (Version, bool) { } func (r *Repository) SyncInterval() int { if r.SyncIntervalMinutes == 0 { - return DefaultSyncIntervalMinutes + return legacySyncIntervalMinutes } return r.SyncIntervalMinutes } diff --git a/internal/gitstore/snapshot_merge_test.go b/internal/gitstore/snapshot_merge_test.go new file mode 100644 index 0000000..b121948 --- /dev/null +++ b/internal/gitstore/snapshot_merge_test.go @@ -0,0 +1,78 @@ +package gitstore + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// A retained merge commit in a shallow, blob-filtered group mirror must report +// its committer time without loading its parents, which are not local. +func TestSnapshotOfMergeCommitInPartialMirror(t *testing.T) { + work := t.TempDir() + run := func(dir string, args ...string) string { + t.Helper() + c := exec.Command("git", args...) + c.Dir = dir + c.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=t@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=t@example.test", "GIT_COMMITTER_DATE=1700000000 +0000") + b, e := c.CombinedOutput() + if e != nil { + t.Fatalf("%v: %v %s", args, e, b) + } + return strings.TrimSpace(string(b)) + } + write := func(name, body string) { + t.Helper() + if e := os.WriteFile(filepath.Join(work, name), []byte(body), 0600); e != nil { + t.Fatal(e) + } + } + run(work, "init", "-q", "-b", "main") + run(work, "config", "uploadpack.allowFilter", "true") + write("a.txt", "base\n") + write("b.txt", "base\n") + run(work, "add", ".") + run(work, "commit", "-qm", "base") + run(work, "checkout", "-q", "-b", "side") + write("a.txt", "side\n") + run(work, "commit", "-qam", "side") + run(work, "checkout", "-q", "main") + write("b.txt", "main\n") + run(work, "commit", "-qam", "main") + run(work, "merge", "-q", "--no-ff", "-m", "merge", "side") + merge := run(work, "rev-parse", "HEAD") + + root := t.TempDir() + s, err := New(root) + if err != nil { + t.Fatal(err) + } + if s, err = s.ForGroup("org/repo", "src"); err != nil { + t.Fatal(err) + } + dir, err := GroupRepositoryDir(root, "org/repo", "src") + if err != nil { + t.Fatal(err) + } + if err = os.MkdirAll(filepath.Dir(dir), 0700); err != nil { + t.Fatal(err) + } + // Production state: the merge is retained but no longer a shallow boundary, + // and its parents' objects were never fetched into this snapshot mirror. + run(root, "-c", "protocol.file.allow=always", "clone", "-q", "--bare", "--depth=1", "--filter=blob:none", "--no-local", "file://"+work, dir) + if err = os.WriteFile(filepath.Join(dir, "shallow"), nil, 0600); err != nil { + t.Fatal(err) + } + + commit, err := s.Snapshot(context.Background(), "org/repo", merge) + if err != nil { + t.Fatalf("snapshot of a merge commit without local parent blobs: %v", err) + } + if commit.SHA != merge || strconv.FormatInt(commit.CommitterTime.Unix(), 10) != "1700000000" { + t.Fatalf("commit = %+v", commit) + } +} diff --git a/internal/gitstore/snapshots.go b/internal/gitstore/snapshots.go index 1d9e770..0edc1a9 100644 --- a/internal/gitstore/snapshots.go +++ b/internal/gitstore/snapshots.go @@ -22,7 +22,9 @@ func (s *Store) Snapshot(ctx context.Context, repo, revision string) (Commit, er if err != nil { return Commit{}, err } - data, err := s.run(ctx, p, 128, "show", "-s", "--format=%ct", sha, "--") + // log --no-walk reads only the commit object. show -s still loads parents for + // merges, which a retained snapshot that is no longer a shallow boundary may lack. + data, err := s.run(ctx, p, 128, "log", "-1", "--no-walk", "--format=%ct", sha, "--") if err != nil { return Commit{}, err } diff --git a/internal/httpserver/repository_access.go b/internal/httpserver/repository_access.go index 963963b..e74b220 100644 --- a/internal/httpserver/repository_access.go +++ b/internal/httpserver/repository_access.go @@ -82,3 +82,31 @@ func viewRepository(ctx context.Context, row control.Repository, auth *sso.Handl view.Managers = auth.People(ctx, row.EffectiveManagers()) return view } + +// catalogRow adds a repository's managers to the catalog for signed-in callers, +// so the list can be filtered by who manages a repository. +type catalogRow struct { + service.Summary + Managers []sso.Person `json:"managers,omitempty"` +} + +func viewCatalog(ctx context.Context, rows []service.Summary, auth *sso.Handler) any { + if auth == nil || (identity.UserID(ctx) == "" && !identity.IsAdmin(ctx)) { + return rows + } + people := map[string]sso.Person{} + out := make([]catalogRow, 0, len(rows)) + for _, row := range rows { + view := catalogRow{Summary: row} + for _, id := range row.ManagerIDs { + p, ok := people[id] + if !ok { + p = auth.People(ctx, []string{id})[0] + people[id] = p + } + view.Managers = append(view.Managers, p) + } + out = append(out, view) + } + return out +} diff --git a/internal/httpserver/repository_access_test.go b/internal/httpserver/repository_access_test.go index 88a785a..1ea8eb4 100644 --- a/internal/httpserver/repository_access_test.go +++ b/internal/httpserver/repository_access_test.go @@ -238,6 +238,21 @@ func TestOrdinaryUsersRegisterAndManageTheirRepositories(t *testing.T) { if _, view = s.call(bob, "GET", repo+q, ""); view["can_manage"] != false || view["creator"] == nil { t.Fatalf("other user view: %v", view) } + // The catalog lists managers for signed-in callers, so the page can filter by them. + catalog := func(user string) []map[string]any { + r := httptest.NewRequest("GET", "/sourcegraph/api/admin/v1/repos", nil) + if user != "" { + r.AddCookie(&http.Cookie{Name: "sourcegraph_session", Value: "cookie-" + user}) + } + w := httptest.NewRecorder() + s.handler.ServeHTTP(w, r) + var rows []map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &rows) + return rows + } + if rows := catalog(bob); len(rows) != 1 || len(rows[0]["managers"].([]any)) != 1 || rows[0]["managers"].([]any)[0].(map[string]any)["name"] != "alice" { + t.Fatalf("signed-in catalog: %v", rows) + } // With public read on, anonymous callers see the repository but not who manages it. rev0 := s.settings.Snapshot().Access.Revision if _, err := s.settings.Update(context.Background(), sitesettings.AccessName, rev0, []byte(`{"public_read":true,"allow_registration":true,"allow_user_repositories":true}`), "sso:admin"); err != nil { diff --git a/internal/httpserver/runtime.go b/internal/httpserver/runtime.go index 72b08a4..0f276a6 100644 --- a/internal/httpserver/runtime.go +++ b/internal/httpserver/runtime.go @@ -318,7 +318,10 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string } case Prefix + "/v1/repos", Prefix + "/api/admin/v1/repos": if r.Method == "GET" { - result, err = s.Catalog(r.Context(), r.URL.Query().Get("after")) + var rows []service.Summary + if rows, err = s.Catalog(r.Context(), r.URL.Query().Get("after")); err == nil { + result = viewCatalog(r.Context(), rows, browserAuth) + } } else if r.URL.Path == Prefix+"/api/admin/v1/repos" && method("POST") { var q registrationRequest if decode(&q) { diff --git a/internal/service/service.go b/internal/service/service.go index a4452ec..4949f23 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -271,6 +271,8 @@ type Summary struct { VersionCount int `json:"version_count"` IndexedCount int `json:"published_index_count"` ObservedAt time.Time `json:"observed_at"` + // ManagerIDs feeds the signed-in catalog view; people are resolved there. + ManagerIDs []string `json:"-"` } func (s *Service) Catalog(ctx context.Context, after string) ([]Summary, error) { @@ -287,7 +289,7 @@ func (s *Service) Catalog(ctx context.Context, after string) ([]Summary, error) n++ } } - out = append(out, Summary{CodeProvider: r.CodeProvider, NextSyncAt: s.nextSyncTime(r), DiskUsage: r.DiskUsage, PublicRead: r.AllowsAnonymous(), DefaultBranch: r.DefaultBranch, IndexMode: r.EffectiveIndexMode(), PathGroups: r.PathGroups, LastIndexedAt: r.LastIndexedAt, LastIndexDurationSeconds: r.LastIndexDurationSeconds, SyncIntervalMinutes: r.SyncInterval(), StorageMissing: r.StorageMissing, ID: r.ID, Name: r.Name, Enabled: r.Enabled, Deleted: r.Deleted, Policy: r.Policy, Heads: r.Heads, VersionCount: len(r.Versions), IndexedCount: n, ObservedAt: r.ObservedAt}) + out = append(out, Summary{CodeProvider: r.CodeProvider, NextSyncAt: s.nextSyncTime(r), DiskUsage: r.DiskUsage, PublicRead: r.AllowsAnonymous(), DefaultBranch: r.DefaultBranch, IndexMode: r.EffectiveIndexMode(), PathGroups: r.PathGroups, LastIndexedAt: r.LastIndexedAt, LastIndexDurationSeconds: r.LastIndexDurationSeconds, SyncIntervalMinutes: r.SyncInterval(), StorageMissing: r.StorageMissing, ID: r.ID, Name: r.Name, Enabled: r.Enabled, Deleted: r.Deleted, Policy: r.Policy, Heads: r.Heads, VersionCount: len(r.Versions), IndexedCount: n, ObservedAt: r.ObservedAt, ManagerIDs: r.EffectiveManagers()}) } return out, nil } diff --git a/web/assets/management.js b/web/assets/management.js index 7054c75..69e4699 100644 --- a/web/assets/management.js +++ b/web/assets/management.js @@ -24,7 +24,7 @@ return {branches: bs, ...(defaultBranch ? {default_branch:true} : {}), days: kind === 'days' ? value : 0, count: kind === 'count' ? value : 0}; } const SYNC_INTERVAL_MINUTES = Object.freeze([1,3,5,10,30,60,180,360,720,1440]); - const DEFAULT_SYNC_INTERVAL_MINUTES = 5; + const DEFAULT_SYNC_INTERVAL_MINUTES = 180; function syncInterval(value) { const minutes = Number(value); if (!['number','string'].includes(typeof value) || !SYNC_INTERVAL_MINUTES.includes(minutes)) throw new Error('同步间隔无效 / Invalid sync interval'); diff --git a/web/assets/repos-page.js b/web/assets/repos-page.js index 037826d..d57f902 100644 --- a/web/assets/repos-page.js +++ b/web/assets/repos-page.js @@ -11,6 +11,33 @@ $('#f-git-timeout').innerHTML=M.gitTimeoutOptions(0); const initial=new URLSearchParams(location.search); $('#filter').value=initial.get('q')||''; $('#show-deleted').checked=initial.get('deleted')==='1'; + let managerChoice=initial.get('manager')||'', sortChoice=initial.get('sort')==='disk'?'disk':''; + // Managers come with the catalog for signed-in callers only; the filter is hidden otherwise. + function managerKey(p){return p.id||p.email||p.name||'';} + function managerName(p){return p.name||p.email||p.id;} + function renderControls(){ + const people=new Map(); + for(const r of rows)for(const p of r.managers||[])if(managerKey(p))people.set(managerKey(p),p); + const me=A.user(), sel=$('#manager-filter'); + const show=!!me&&rows.some(r=>Array.isArray(r.managers)); + $('#manager-filter-wrap').classList.toggle('hidden',!show); + if(show){ + const list=[...people.values()].sort((a,b)=>managerName(a).localeCompare(managerName(b))); + sel.innerHTML=''+ + list.map(p=>'').join(''); + if(managerChoice&&!['me','none'].includes(managerChoice)&&!people.has(managerChoice))managerChoice=''; + sel.value=managerChoice; + } + $('#sort').innerHTML=''; + $('#sort').value=sortChoice; + } + function matchesManager(r){ + if(!managerChoice||!Array.isArray(r.managers))return true; + if(managerChoice==='none')return !r.managers.length; + const me=A.user(), key=managerChoice==='me'?(me&&me.id):managerChoice; + return r.managers.some(p=>managerKey(p)===key); + } + function diskBytes(r){const u=(details.get(r.name)||r).disk_usage;return u&&Number.isFinite(u.bytes)?u.bytes:-1;} const KIND={sync:['同步','Sync'],index:['建索引','Index']}; function jobLine(job){ @@ -67,7 +94,9 @@ $('#add').innerHTML=(A.user()?'':A.ICON.lock)+''+t(A.user()?'登记仓库':'登录后登记仓库',A.user()?'Register a repository':'Sign in to register')+''; const deleted=rows.some(r=>r.deleted); $('#show-deleted-wrap').classList.toggle('hidden',!deleted||!A.canManage());$('#show-deleted-wrap').classList.toggle('flex',deleted&&A.canManage()); - const f=$('#filter').value.toLowerCase(),list=rows.filter(r=>($('#show-deleted').checked||!r.deleted)&&r.name.toLowerCase().includes(f)); + renderControls(); + const f=$('#filter').value.toLowerCase(),list=rows.filter(r=>($('#show-deleted').checked||!r.deleted)&&r.name.toLowerCase().includes(f)&&matchesManager(r)); + if(sortChoice==='disk')list.sort((a,b)=>diskBytes(b)-diskBytes(a)||a.name.localeCompare(b.name)); const live=rows.filter(r=>!r.deleted); $('#summary').textContent=loaded?t(live.length+' 个仓库',live.length+' repositories'):''; $('#cards').innerHTML=list.map(card).join('');updateSyncTimes();$('#empty').classList.toggle('hidden',!loaded||!!list.length); @@ -105,8 +134,8 @@ finally{submitting=false;$('#submit').disabled=false;$('#submit').textContent=t('登记并准备','Register and prepare');} } $('#submit').onclick=submit;$('#form').onsubmit=submit; - function filter(){const p=new URLSearchParams();if($('#filter').value)p.set('q',$('#filter').value);if($('#show-deleted').checked)p.set('deleted','1');history.replaceState(null,'',location.pathname+(p.size?'?'+p:'')+location.hash);render();} - $('#filter').oninput=filter;$('#show-deleted').onchange=filter; + function filter(){managerChoice=$('#manager-filter-wrap').classList.contains('hidden')?managerChoice:$('#manager-filter').value;sortChoice=$('#sort').value;const p=new URLSearchParams();if($('#filter').value)p.set('q',$('#filter').value);if(managerChoice)p.set('manager',managerChoice);if(sortChoice)p.set('sort',sortChoice);if($('#show-deleted').checked)p.set('deleted','1');history.replaceState(null,'',location.pathname+(p.size?'?'+p:'')+location.hash);render();} + $('#filter').oninput=filter;$('#show-deleted').onchange=filter;$('#manager-filter').onchange=filter;$('#sort').onchange=filter; document.addEventListener('langchange',()=>{render();renderSyncInterval();branchInput.refresh();pathInput.refresh();}); document.addEventListener('app:auth',render); const poller=M.poll(load,10000,60); diff --git a/web/repos.html b/web/repos.html index cda0c04..4d429c5 100644 --- a/web/repos.html +++ b/web/repos.html @@ -27,6 +27,8 @@