Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
80867f6
entities/common: add Type enum; promote Status to 4-state
gilescope Apr 17, 2026
15b063e
entities/bug: extend Bug with PR shape and three PR-only ops
gilescope Apr 17, 2026
c4ac2a6
cache: excerpt and matcher learn about Kind and PR fields
gilescope Apr 17, 2026
ef00943
query: parse kind: / type: filters and the new status values
gilescope Apr 17, 2026
849c150
common,bug,cache,query,api: rename common.Type to common.Kind
gilescope Apr 17, 2026
6c16f88
api/graphql: expose PR fields, ops, reviews and the new status values
gilescope Apr 17, 2026
05b9a90
commands/bug: --pr on new; status merge and status ready subcommands
gilescope Apr 17, 2026
8536551
bridge/github: import pull-requests as Kind=PR bugs
gilescope Apr 17, 2026
8a6129f
doc: mark PR support as partial in the feature matrix
gilescope Apr 17, 2026
973d7c0
bridge/github,cache,core: import PR reviews and review comments
gilescope Apr 17, 2026
692b720
doc: reflect review / review comment import in feature matrix
gilescope Apr 17, 2026
a3f2aad
bridge/github: paginate review comments past the inline batch
gilescope Apr 17, 2026
dfb8f1a
doc: review comments are now fully imported
gilescope Apr 17, 2026
ecfe5a8
webui: PR-specific chrome in bug list and detail pages
gilescope Apr 17, 2026
f79aa89
bridge/github: export updates to already-imported pull-requests
gilescope Apr 17, 2026
b44eaf1
bridge/github: export-create for pull-requests
gilescope Apr 17, 2026
8641b30
commands,cache,bridge: author PR reviews locally and export them
gilescope Apr 17, 2026
0126ede
doc: review authoring via CLI and export now supported
gilescope Apr 17, 2026
5bdbdfa
doc: regenerate man pages for new PR commands; add local markdownlint…
gilescope Apr 17, 2026
3b574e7
api,webui: expose originUrl on Bug; show upstream #NNN linked to GitHub
gilescope Apr 17, 2026
61beed3
commands,api,webui: multi-repo webui via --repo and --root + bleve mi…
gilescope Apr 17, 2026
b135498
webui,query: cross-repo search via fan-out + repo:/org: query filters
gilescope Apr 18, 2026
06d216b
webui,api/http: add Sync button that re-pulls every registered repo
gilescope Apr 18, 2026
c929552
feat: PR support ( + dir of repo's checkouts support)
gilescope Apr 22, 2026
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
Prev Previous commit
Next Next commit
commands,api,webui: multi-repo webui via --repo and --root + bleve mi…
…gration

git-bug webui gains --repo PATH (repeatable) and --root DIR (scan for
<org>/<repo> siblings). Each extra repo is registered in the existing
MultiRepoCache with name "<org>/<repo>". The cwd repo still serves as
the default, and is de-duplicated from the scan so lockfile contention
is avoided.

Request routing: a new X-Repo-Name HTTP header is parsed by a mux
middleware into the request context; the Repository resolver falls
back to that when no ref is supplied, letting every existing GraphQL
query (which writes `repository { ... }`) scope automatically. This
avoids touching ~12 .graphql files and their generated siblings.

WebUI:
  * Apollo setContext sets X-Repo-Name from a mutable module-level var
    that RepoContextBinder updates from the URL (/r/:repoName/*).
  * New /r/:repoName/* route mounts the usual list/detail/new pages
    with the header bound. Slashes in repo names are URL-encoded.
  * Single-repo routes stay intact. Root "/" adapts: multi-repo mode
    shows a ReposLanding page listing registered repos with counts;
    single-repo mode shows the bug list as before.
  * Header grows a "Repositories" link and a RepoPicker dropdown
    (hidden in single-repo mode).
  * Multi-repo forces --read-only in v1 — auth middleware uses a
    single identity and authoring across N repos needs per-repo
    identity selection, deferred.

Bleve resilience: openBleveIndex now detects stale-but-incompatible
indexes (bleve.Open fails and index_meta.json is present) and wipes the
path before bleve.New. This covers the migration case where the
existing on-disk store is from an older bleve version (e.g. moltdb or
upsidedown) and the new version treats the "store" path as a directory
only. The rebuild is automatic on first access; git refs (source of
truth) are untouched.

Verified against 149 midnightntwrk+shieldedtech repos: all register
cleanly after the new tolerant open path; X-Repo-Name routing returns
the expected bugs for a probed repo (example-bboard → 417 PRs).

Signed-off-by: Giles Cope <gilescope@gmail.com>
  • Loading branch information
gilescope committed Apr 18, 2026
commit 61beed39a8b8f03ab8123e729bf97e17c13ca247
18 changes: 14 additions & 4 deletions api/graphql/resolvers/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/git-bug/git-bug/api/graphql/connections"
"github.com/git-bug/git-bug/api/graphql/graph"
"github.com/git-bug/git-bug/api/graphql/models"
"github.com/git-bug/git-bug/api/repoctx"
"github.com/git-bug/git-bug/cache"
)

Expand All @@ -15,14 +16,23 @@ type rootQueryResolver struct {
cache *cache.MultiRepoCache
}

func (r rootQueryResolver) Repository(_ context.Context, ref *string) (*models.Repository, error) {
// Repository resolves a repository by explicit ref, or (in multi-repo mode)
// falls back to the name carried in the request context (set from the
// X-Repo-Name HTTP header by the webui middleware). Unchanged for single-
// repo mode: ref==nil and only one repo registered still returns it.
func (r rootQueryResolver) Repository(ctx context.Context, ref *string) (*models.Repository, error) {
var repo *cache.RepoCache
var err error

if ref == nil {
repo, err = r.cache.DefaultRepo()
} else {
switch {
case ref != nil:
repo, err = r.cache.ResolveRepo(*ref)
default:
if name := repoctx.Name(ctx); name != "" {
repo, err = r.cache.ResolveRepo(name)
} else {
repo, err = r.cache.DefaultRepo()
}
}

if err != nil {
Expand Down
22 changes: 22 additions & 0 deletions api/repoctx/repoctx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Package repoctx carries the per-request repository name through the
// GraphQL handler so the default-repo resolver can route to the correct
// repo in a multi-repo webui.
package repoctx

import "context"

type ctxKey struct{}

// WithName returns a copy of ctx that carries the given repository name.
func WithName(ctx context.Context, name string) context.Context {
if name == "" {
return ctx
}
return context.WithValue(ctx, ctxKey{}, name)
}

// Name returns the repo name stashed in ctx, or "" if none was set.
func Name(ctx context.Context) string {
v, _ := ctx.Value(ctxKey{}).(string)
return v
}
161 changes: 155 additions & 6 deletions commands/webui.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import (
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/99designs/gqlgen/graphql/playground"
Expand All @@ -20,6 +23,7 @@ import (
"github.com/git-bug/git-bug/api/auth"
"github.com/git-bug/git-bug/api/graphql"
httpapi "github.com/git-bug/git-bug/api/http"
"github.com/git-bug/git-bug/api/repoctx"
"github.com/git-bug/git-bug/cache"
"github.com/git-bug/git-bug/commands/execenv"
"github.com/git-bug/git-bug/entities/identity"
Expand All @@ -37,6 +41,12 @@ type webUIOptions struct {
readOnly bool
logErrors bool
query string
// Multi-repo mode: register each path given to --repo (repeatable), or
// scan --root for repos matching <root>/*/*/ (owner/name). When more than
// one repo is registered, read-only is forced (auth currently needs one
// user identity, which is per-repo).
repos []string
root string
}

func newWebUICommand(env *execenv.Env) *cobra.Command {
Expand Down Expand Up @@ -66,6 +76,10 @@ Available git config:
flags.BoolVar(&options.readOnly, "read-only", false, "Whether to run the web UI in read-only mode")
flags.BoolVar(&options.logErrors, "log-errors", false, "Whether to log errors")
flags.StringVarP(&options.query, "query", "q", "", "The query to open in the web UI bug list")
flags.StringSliceVar(&options.repos, "repo", nil,
"Additional repository path to serve (repeatable). The repo name is derived from its parent directory (e.g. /a/b/myorg/myrepo -> myorg/myrepo).")
flags.StringVar(&options.root, "root", "",
"Scan this directory for <org>/<repo>/.git siblings and register each as a repo. Multi-repo mode forces --read-only.")

return cmd
}
Expand All @@ -74,9 +88,22 @@ Available git config:
func setupRoutes(env *execenv.Env, opts webUIOptions) (*mux.Router, func() error, error) {
router := mux.NewRouter()

// If the webUI is not read-only, use an authentication middleware with a
// fixed identity: the default user of the repo
// TODO: support dynamic authentication with OAuth
mrc := cache.NewMultiRepoCache()

// Discover repos for multi-repo mode.
extraRepos, err := discoverExtraRepos(opts)
if err != nil {
return nil, nil, err
}
multi := len(extraRepos) > 0

if multi && !opts.readOnly {
// Authoring across many repos needs per-repo identity selection which
// the current auth middleware doesn't support. Force read-only.
env.Err.Println("multi-repo mode: forcing --read-only (per-repo identity not yet supported)")
opts.readOnly = true
}

if !opts.readOnly {
author, err := identity.GetUserIdentity(env.Repo)
if err != nil {
Expand All @@ -85,17 +112,59 @@ func setupRoutes(env *execenv.Env, opts webUIOptions) (*mux.Router, func() error
router.Use(auth.Middleware(author.Id()))
}

mrc := cache.NewMultiRepoCache()
_, events := mrc.RegisterDefaultRepository(env.Repo)
if err := execenv.CacheBuildProgressBar(env, events); err != nil {
// In multi-repo mode, register the cwd repo under its derived org/repo
// name so it appears in the landing list alongside the scanned siblings.
// In single-repo mode, keep the existing RegisterDefaultRepository
// behaviour — no name, served at /.
cwdPath, err := os.Getwd()
if err != nil {
return nil, nil, err
}
cwdAbs, _ := filepath.Abs(cwdPath)

var cwdName string
if multi {
cwdName = filepath.Base(filepath.Dir(cwdAbs)) + "/" + filepath.Base(cwdAbs)
env.Out.Printf(" + %s (cwd)\n", cwdName)
_, events := mrc.RegisterRepository(env.Repo, cwdName)
if err := execenv.CacheBuildProgressBar(env, events); err != nil {
return nil, nil, err
}
} else {
_, events := mrc.RegisterDefaultRepository(env.Repo)
if err := execenv.CacheBuildProgressBar(env, events); err != nil {
return nil, nil, err
}
}

// Register each extra repo by its derived name. Skip the cwd path if it
// showed up in the --root scan — the RepoCache lockfile would clash.
for _, er := range extraRepos {
if er.path == cwdAbs {
continue
}
r, err := repository.OpenGoGitRepo(er.path, "git-bug", nil)
if err != nil {
env.Err.Printf("skipping %s: %v\n", er.path, err)
continue
}
env.Out.Printf(" + %s\n", er.name)
_, events := mrc.RegisterRepository(r, er.name)
if err := execenv.CacheBuildProgressBar(env, events); err != nil {
return nil, nil, err
}
}

var errOut io.Writer
if opts.logErrors {
errOut = env.Err
}

// Header middleware: when X-Repo-Name is set on an incoming request, the
// rootQueryResolver.Repository fallback uses it to resolve the default
// repo instead of erroring on "not unique".
router.Use(repoNameHeaderMiddleware())

router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
router.Path("/graphql").Handler(graphql.NewHandler(mrc, errOut))
router.Path("/gitfile/{repo}/{rest:.+}").Handler(httpapi.NewGitFileHandler(mrc))
Expand All @@ -105,6 +174,86 @@ func setupRoutes(env *execenv.Env, opts webUIOptions) (*mux.Router, func() error
return router, mrc.Close, nil
}

// extraRepo is a (name, path) pair discovered for multi-repo mode.
type extraRepo struct {
name string
path string
}

// discoverExtraRepos resolves --repo and --root flags into a list of repos to
// register beyond the cwd repo. A repo is only included if it has a git-bug
// namespace present (refs/bugs/*).
func discoverExtraRepos(opts webUIOptions) ([]extraRepo, error) {
var out []extraRepo
seen := make(map[string]bool)

add := func(path string) {
abs, err := filepath.Abs(path)
if err != nil || seen[abs] {
return
}
seen[abs] = true
// Skip hidden dirs at either the org or repo level — ".github",
// ".DS_Store", ".hidden-org", etc. never contain a user-facing repo.
if strings.HasPrefix(filepath.Base(abs), ".") ||
strings.HasPrefix(filepath.Base(filepath.Dir(abs)), ".") {
return
}
if !hasGitBugData(abs) {
return
}
// Name is the parent-dir-basename + basename (<owner>/<repo>).
name := filepath.Base(filepath.Dir(abs)) + "/" + filepath.Base(abs)
out = append(out, extraRepo{name: name, path: abs})
}

for _, p := range opts.repos {
add(p)
}
if opts.root != "" {
matches, err := filepath.Glob(filepath.Join(opts.root, "*", "*"))
if err != nil {
return nil, err
}
for _, m := range matches {
if info, statErr := os.Stat(m); statErr == nil && info.IsDir() {
add(m)
}
}
}
return out, nil
}

// hasGitBugData reports whether the repo at path has any git-bug refs. Used
// to skip sibling dirs that happen to be git repos but aren't synced.
func hasGitBugData(path string) bool {
for _, p := range []string{
filepath.Join(path, ".git", "refs", "bugs"),
filepath.Join(path, ".git", "packed-refs"),
} {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}

// repoNameHeaderMiddleware extracts X-Repo-Name from incoming HTTP requests
// and stashes it in the request context so the GraphQL layer can use it as
// the default repo when no explicit ref is supplied.
func repoNameHeaderMiddleware() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := r.Header.Get("X-Repo-Name")
if name != "" {
ctx := repoctx.WithName(r.Context(), name)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}

func runWebUI(env *execenv.Env, opts webUIOptions) error {
router, closeRoutes, err := setupRoutes(env, opts)
if err != nil {
Expand Down
25 changes: 24 additions & 1 deletion repository/index_bleve.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package repository
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"unicode/utf8"
Expand All @@ -23,7 +24,19 @@ type bleveIndex struct {
func openBleveIndex(path string) (*bleveIndex, error) {
index, err := bleve.Open(path)
if err != nil {
// likely we have no index yet, we make one.
// bleve.Open returns an error for three distinct cases:
// a) no index yet (path missing or empty) — we just build one
// b) path contains an index in a format this bleve can't read
// (kvstore migration, moltdb -> boltdb, old boltdb -> scorch, ...)
// c) path exists but is corrupt
// For (b) and (c), the index_meta.json is typically present and we
// need to wipe the stale content before bleve.New will succeed — it
// refuses with "cannot create new index, path already exists".
if hasStaleBleveData(path) {
if rmErr := os.RemoveAll(path); rmErr != nil {
return nil, fmt.Errorf("bleve: removing stale index at %s: %w (open: %v)", path, rmErr, err)
}
}
b := &bleveIndex{path: path}
return b, b.makeIndex()
}
Expand All @@ -49,6 +62,16 @@ func openBleveIndex(path string) (*bleveIndex, error) {
return &bleveIndex{path: path, index: index}, nil
}

// hasStaleBleveData reports whether path looks like a bleve index dir that
// this process can't open (presence of index_meta.json without the matching
// store). This is the canonical trigger for a "wipe and rebuild" path.
func hasStaleBleveData(path string) bool {
if _, err := os.Stat(filepath.Join(path, "index_meta.json")); err == nil {
return true
}
return false
}

func (b *bleveIndex) makeIndex() error {
err := os.MkdirAll(b.path, os.ModePerm)
if err != nil {
Expand Down
51 changes: 50 additions & 1 deletion webui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
import { gql, useQuery } from '@apollo/client';
import * as React from 'react';
import { Route, Routes } from 'react-router';

import Layout from './components/Header';
import RepoContextBinder from './components/RepoContext';
import BugPage from './pages/bug';
import IdentityPage from './pages/identity';
import ListPage from './pages/list';
import NewBugPage from './pages/new/NewBugPage';
import NotFoundPage from './pages/notfound/NotFoundPage';
import ReposLanding from './pages/repos/ReposLanding';

// IS_MULTI_REPO_QUERY decides which component lives at "/". When the server
// hosts more than one repo, "/" is a landing page listing them; in single-
// repo mode "/" is the bug list as before.
const IS_MULTI_REPO_QUERY = gql`
query IsMultiRepo {
repositories {
totalCount
nodes {
name
}
}
}
`;

interface IsMultiRepoData {
repositories: { totalCount: number; nodes: Array<{ name: string | null }> };
}

function RootRoute() {
const { data, loading } = useQuery<IsMultiRepoData>(IS_MULTI_REPO_QUERY);
if (loading) return null;
const named =
data?.repositories.nodes.filter((n) => n.name && n.name !== '__default') ??
[];
return named.length > 0 ? <ReposLanding /> : <ListPage />;
}

export default function App() {
return (
<React.StrictMode>
<Layout>
<Routes>
<Route path="/" element={<ListPage />} />
{/* Multi-repo scoped routes. RepoContextBinder reads :repoName and
sets Apollo's X-Repo-Name header so existing queries (which use
`repository { ... }` without a ref) land on the right repo. */}
<Route
path="/r/:repoName/*"
element={
<RepoContextBinder>
<Routes>
<Route path="/" element={<ListPage />} />
<Route path="/new" element={<NewBugPage />} />
<Route path="/bug/:id" element={<BugPage />} />
<Route path="/user/:id" element={<IdentityPage />} />
<Route element={<NotFoundPage />} />
</Routes>
</RepoContextBinder>
}
/>

{/* Single-repo routes (unchanged behaviour) + root that adapts. */}
<Route path="/" element={<RootRoute />} />
<Route path="/new" element={<NewBugPage />} />
<Route path="/bug/:id" element={<BugPage />} />
<Route path="/user/:id" element={<IdentityPage />} />
Expand Down
Loading