Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 8 additions & 4 deletions internal/control/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
78 changes: 78 additions & 0 deletions internal/gitstore/snapshot_merge_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 3 additions & 1 deletion internal/gitstore/snapshots.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions internal/httpserver/repository_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
15 changes: 15 additions & 0 deletions internal/httpserver/repository_access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion internal/httpserver/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion web/assets/management.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
35 changes: 32 additions & 3 deletions web/assets/repos-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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='<option value="">'+esc(t('全部管理员','All managers'))+'</option><option value="me">'+esc(t('我管理的','Managed by me'))+'</option><option value="none">'+esc(t('仅站点管理员','Site administrators only'))+'</option>'+
list.map(p=>'<option value="'+esc(managerKey(p))+'" title="'+esc(p.email||'')+'">'+esc(managerName(p))+'</option>').join('');
if(managerChoice&&!['me','none'].includes(managerChoice)&&!people.has(managerChoice))managerChoice='';
sel.value=managerChoice;
}
$('#sort').innerHTML='<option value="">'+esc(t('默认排序','Default order'))+'</option><option value="disk">'+esc(t('按占用空间排序','Sort by disk usage'))+'</option>';
$('#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){
Expand Down Expand Up @@ -67,7 +94,9 @@
$('#add').innerHTML=(A.user()?'':A.ICON.lock)+'<span>'+t(A.user()?'登记仓库':'登录后登记仓库',A.user()?'Register a repository':'Sign in to register')+'</span>';
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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions web/repos.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ <h1 class="type-page" data-en="Searchable repositories">可以搜索的仓库</h
<section class="border-t rule pt-8">
<div class="flex flex-wrap items-center gap-3">
<input id="filter" class="w-64 rounded-control bg-ink/[0.04] px-3 py-1.5 font-mono text-[13px] outline-none placeholder:text-muted/70 focus:bg-ink/[0.06]" placeholder="按名称筛选" data-en-ph="Filter by name">
<span id="manager-filter-wrap" class="hidden"><select id="manager-filter" class="w-48 rounded-control bg-ink/[0.04] px-3 py-1.5 text-[13px] outline-none" aria-label="按管理员筛选"></select></span>
<select id="sort" class="w-48 rounded-control bg-ink/[0.04] px-3 py-1.5 text-[13px] outline-none" aria-label="排序"></select>
<label id="show-deleted-wrap" class="hidden items-center gap-2 text-[13px] text-muted"><input id="show-deleted" type="checkbox" class="accent-current"><span data-en="Show deleted">显示已删除</span></label>
<span id="updated" class="type-fig ml-auto"></span>
</div>
Expand Down
Loading