Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
730befa
bridge, commands: handle a dangling user identity gracefully
mcepl Oct 3, 2026
d06f79f
bridge/core/auth: list credentials newest first
mcepl Oct 3, 2026
e17a620
commands/bridge: add --base-url to "bridge auth add-token"
mcepl Oct 1, 2026
2fc700e
bridge/{github,gitlab}: use iterated credential when caching export c…
mcepl Oct 4, 2026
2c5ed84
bridge/core/auth, commands/bridge: normalize base URLs for credential…
mcepl Oct 4, 2026
876837e
bridge/core: add an option to push bugs from other bug trackers
mcepl Oct 5, 2026
6a61bbf
commands/bridge: add --foreign to bridge push
mcepl Oct 5, 2026
fdf6c95
Merge branch 'foreign-push' into fix/bridge-identity-creds
mcepl Oct 5, 2026
cadf630
commands/bridge: exit non-zero when push or pull reports errors
mcepl Oct 5, 2026
2a5f907
commands/bridge: add --verbose to explain why push skipped issues
mcepl Oct 5, 2026
66217c7
bridge/github: report every issue push leaves alone
mcepl Oct 5, 2026
89eb2d3
bridge/gitlab: report every issue push leaves alone
mcepl Oct 5, 2026
1f15c0f
bridge/jira: keep pushing after an issue without author credentials
mcepl Oct 5, 2026
66368ca
commands/bridge: test how push and pull report results
mcepl Oct 5, 2026
c4c5911
bridge/gitea: add typed paginated issue and timeline iterator
mcepl Oct 4, 2026
0c0a122
bridge/gitea: register and implement the Gitea importer
mcepl Oct 4, 2026
b7b38bc
bridge/gitea: test configuration, iteration and import
mcepl Oct 4, 2026
410d9a4
commands, doc: expose Gitea setup and document importer support
mcepl Oct 4, 2026
8044870
bridge/gitea: make resumed imports complete and isolate remote users
mcepl Oct 5, 2026
78872ac
doc: explain Gitea importer identity and history limits
mcepl Oct 5, 2026
9ba62f5
bridge/gitea: reuse the configured user and ignore status echoes on i…
mcepl Oct 5, 2026
12d2ba8
bridge/gitea: support comment creation and edits in the fake API
mcepl Oct 5, 2026
f73955e
bridge/gitea: implement export
mcepl Oct 5, 2026
89e0315
doc: document Gitea push support and token scopes
mcepl Oct 5, 2026
f728555
bridge/gitea: refuse repositories without a usable issue tracker
mcepl Oct 5, 2026
2a2b93f
bridge/gitea: explain why push leaves issues alone
mcepl Oct 5, 2026
7a30ec3
bridge: address review findings in Gitea import and export
mcepl Oct 5, 2026
94032c2
bridge: preserve instance URLs and support Jira token credentials
mcepl Oct 5, 2026
f6ba9e9
bridge: explain foreign issues before checking actor credentials
mcepl Oct 5, 2026
811984a
bridge/gitea: fix persistence, status and renamed label handling
mcepl Oct 5, 2026
2e5e6f6
bridge/jira: respect configured authentication mode
mcepl Oct 5, 2026
7a95415
bridge/jira: scope setup credentials and prefer API tokens
mcepl Oct 5, 2026
a514f8d
bridge/gitea: discard superseded label identity mappings
mcepl Oct 5, 2026
14509c6
bridge/jira: retain the validated credential for synchronization
mcepl Oct 5, 2026
ce4604d
test(bridge/jira): separate credential timestamps on Windows
mcepl Oct 5, 2026
4c74cbd
bridge/jira: preserve password prompts and allow token rotation
mcepl Oct 5, 2026
b5e53ea
bridge: preserve and recognize wrapped pull cancellation
mcepl Oct 5, 2026
906dfd8
bridge/jira: require explicit rotation of validated credentials
mcepl Oct 5, 2026
f5cfd06
Merge trunk and adapt bridge tests to change-source cache API
mcepl Oct 6, 2026
1faf595
bridge/jira: retain legacy token mode and normalize request URLs
mcepl Oct 6, 2026
0d030dc
bridge/jira: allow explicit authentication mode for legacy setup
mcepl Oct 6, 2026
1af8557
bridge/jira: apply explicit auth mode before selecting credentials
mcepl Oct 6, 2026
1d1840c
Merge trunk into gitea_bridge
mcepl Oct 11, 2026
52694c6
bridge/core: update config tests for gitconfig API
mcepl Oct 11, 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
2 changes: 2 additions & 0 deletions bridge/bridges.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package bridge

import (
"github.com/git-bug/git-bug/bridge/core"
"github.com/git-bug/git-bug/bridge/gitea"
"github.com/git-bug/git-bug/bridge/github"
"github.com/git-bug/git-bug/bridge/gitlab"
"github.com/git-bug/git-bug/bridge/jira"
Expand All @@ -12,6 +13,7 @@ import (
)

func init() {
core.Register(&gitea.Gitea{})
core.Register(&github.Github{})
core.Register(&gitlab.Gitlab{})
core.Register(&launchpad.Launchpad{})
Expand Down
44 changes: 42 additions & 2 deletions bridge/core/auth/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"net/url"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -173,6 +174,18 @@ func List(repo repository.RepoKeyring, opts ...ListOption) ([]Credential, error)
}
}

// The keyring returns keys in an arbitrary (backend dependent) order.
// Callers commonly pick the first matching credential, so make that
// choice deterministic and sensible: the most recently created one first.
// This way, adding a fresh credential supersedes older (possibly revoked)
// ones instead of being shadowed by them.
slices.SortStableFunc(credentials, func(a, b Credential) int {
if c := b.CreateTime().Compare(a.CreateTime()); c != 0 {
return c
}
return strings.Compare(a.ID().String(), b.ID().String())
})

return credentials, nil
}

Expand All @@ -198,7 +211,7 @@ func Store(repo repository.RepoKeyring, cred Credential) error {

confs[keyringKeyKind] = string(cred.Kind())
confs[keyringKeyTarget] = cred.Target()
confs[keyringKeyCreateTime] = strconv.Itoa(int(cred.CreateTime().Unix()))
confs[keyringKeyCreateTime] = cred.CreateTime().UTC().Format(time.RFC3339Nano)
confs[keyringKeySalt] = base64.StdEncoding.EncodeToString(cred.Salt())

for key, val := range cred.Metadata() {
Expand Down Expand Up @@ -238,3 +251,30 @@ func (b ById) Less(i, j int) bool {
func (b ById) Swap(i, j int) {
b[i], b[j] = b[j], b[i]
}

// NormalizeBaseURL canonicalizes a base URL by lowercasing the scheme and host,
// removing trailing slashes.
func NormalizeBaseURL(rawURL string) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" {
return strings.TrimRight(rawURL, "/")
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
u.Path = strings.TrimRight(u.Path, "/")
u.RawPath = strings.TrimRight(u.RawPath, "/")
return u.String()
}

// ValidateBaseURL checks a supplied instance URL before storing credentials.
func ValidateBaseURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil || u.Hostname() == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("base URL must be an absolute HTTP(S) URL without credentials, query, or fragment")
}
return nil
}
130 changes: 130 additions & 0 deletions bridge/core/auth/credential_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package auth

import (
"encoding/base64"
"encoding/json"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -122,3 +125,130 @@ func testCredentialSerial(t *testing.T, original Credential) Credential {

return loaded
}

func TestListNewestFirst(t *testing.T) {
repo := repository.NewMockRepo()

now := time.Now()
var stored []*Token
// store out of chronological order on purpose
for _, age := range []time.Duration{2 * time.Hour, 0, 3 * time.Hour, time.Hour} {
token := NewToken("gitea", "value")
token.createTime = now.Add(-age)
require.NoError(t, Store(repo, token))
stored = append(stored, token)
}

creds, err := List(repo, WithTarget("gitea"))
require.NoError(t, err)
require.Len(t, creds, 4)

// expected order: age 0, 1h, 2h, 3h
expected := []entity.Id{stored[1].ID(), stored[3].ID(), stored[0].ID(), stored[2].ID()}
for i, cred := range creds {
assert.Equal(t, expected[i], cred.ID(), "position %d", i)
}
}

func TestListNewestFirstSameSecond(t *testing.T) {
repo := repository.NewMockRepo()

now := time.Now().Truncate(time.Second)
var stored []*Token
// store out of chronological order within the same second
for _, subSec := range []time.Duration{500 * time.Millisecond, 0, 750 * time.Millisecond, 250 * time.Millisecond} {
token := NewToken("gitea", "value")
token.createTime = now.Add(subSec)
require.NoError(t, Store(repo, token))
stored = append(stored, token)
}

creds, err := List(repo, WithTarget("gitea"))
require.NoError(t, err)
require.Len(t, creds, 4)

// expected order: 750ms, 500ms, 250ms, 0ms
expected := []entity.Id{stored[2].ID(), stored[0].ID(), stored[3].ID(), stored[1].ID()}
for i, cred := range creds {
assert.Equal(t, expected[i], cred.ID(), "position %d", i)
}
}

func TestListLegacyTimestamp(t *testing.T) {
repo := repository.NewMockRepo()

tokenOld := NewToken("gitea", "old-value")
rawItem := map[string]string{
keyringKeyKind: string(tokenOld.Kind()),
keyringKeyTarget: tokenOld.Target(),
keyringKeyCreateTime: "1700000000",
keyringKeySalt: base64.StdEncoding.EncodeToString(tokenOld.Salt()),
keyringKeyTokenValue: tokenOld.Value,
}
data, err := json.Marshal(rawItem)
require.NoError(t, err)
require.NoError(t, repo.Keyring().Set(repository.Item{
Key: keyringKeyPrefix + tokenOld.ID().String(),
Data: data,
}))

tokenNew := NewToken("gitea", "new-value")
tokenNew.createTime = time.Unix(1700001000, 500000000)
require.NoError(t, Store(repo, tokenNew))

creds, err := List(repo, WithTarget("gitea"))
require.NoError(t, err)
require.Len(t, creds, 2)
assert.Equal(t, tokenNew.ID(), creds[0].ID())
assert.Equal(t, tokenOld.ID(), creds[1].ID())
}

func TestListBaseURLNormalization(t *testing.T) {
repo := repository.NewMockRepo()

tokenWithoutSlash := NewToken("gitlab", "value-1")
tokenWithoutSlash.SetMetadata(MetaKeyBaseURL, "https://gitlab.com")
require.NoError(t, Store(repo, tokenWithoutSlash))

tokenWithSlash := NewToken("gitlab", "value-2")
tokenWithSlash.SetMetadata(MetaKeyBaseURL, "https://gitlab.example.com/")
require.NoError(t, Store(repo, tokenWithSlash))

// Query with trailing slash against token without trailing slash
creds, err := List(repo, WithTarget("gitlab"), WithMeta(MetaKeyBaseURL, "https://gitlab.com/"))
require.NoError(t, err)
require.Len(t, creds, 1)
assert.Equal(t, tokenWithoutSlash.ID(), creds[0].ID())

// Query without trailing slash against token with trailing slash
creds, err = List(repo, WithTarget("gitlab"), WithMeta(MetaKeyBaseURL, "https://gitlab.example.com"))
require.NoError(t, err)
require.Len(t, creds, 1)
assert.Equal(t, tokenWithSlash.ID(), creds[0].ID())
}

func TestNormalizeBaseURL(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", ""},
{" ", ""},
{"https://gitlab.com", "https://gitlab.com"},
{"https://gitlab.com/", "https://gitlab.com"},
{"https://gitlab.com///", "https://gitlab.com"},
{"HTTPS://GITLAB.COM/", "https://gitlab.com"},
{"https://gitlab.example.com/sub/group/", "https://gitlab.example.com/sub/group"},
{"https://gitlab.example.com/sub/group", "https://gitlab.example.com/sub/group"},
{"http://jira.local:8080/jira/", "http://jira.local:8080/jira"},
{"http://jira.local:8080/jira", "http://jira.local:8080/jira"},
{"jira.example.com/", "jira.example.com"},
{"jira.example.com", "jira.example.com"},
}

for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
assert.Equal(t, tc.want, NormalizeBaseURL(tc.input))
})
}
}
10 changes: 9 additions & 1 deletion bridge/core/auth/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ func (opts *listOptions) Match(cred Credential) bool {
}

for key, val := range opts.meta {
if v, ok := cred.GetMetadata(key); !ok || v != val {
v, ok := cred.GetMetadata(key)
if !ok {
return false
}
if key == MetaKeyBaseURL {
if NormalizeBaseURL(v) != NormalizeBaseURL(val) {
return false
}
} else if v != val {
return false
}
}
Expand Down
18 changes: 18 additions & 0 deletions bridge/core/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,11 +388,29 @@ func (b *Bridge) ImportAll(ctx context.Context) (<-chan ImportResult, error) {
}

func (b *Bridge) ExportAll(ctx context.Context, since time.Time) (<-chan ExportResult, error) {
return b.ExportAllWithOptions(ctx, since, ExportOptions{})
}

// ExportAllWithOptions is ExportAll with per-push options.
func (b *Bridge) ExportAllWithOptions(ctx context.Context, since time.Time, opts ExportOptions) (<-chan ExportResult, error) {
exporter := b.getExporter()
if exporter == nil {
return nil, ErrExportNotSupported
}

if opts.Foreign {
fe, ok := exporter.(ForeignExporter)
if !ok {
return nil, fmt.Errorf("the %s bridge cannot push bugs from other bug trackers", b.impl.Target())
}
// Must happen before Init, which checks that the remote tracker
// can receive foreign bugs.
if b.initExportDone {
return nil, fmt.Errorf("foreign push must be requested before the exporter is initialized")
}
fe.EnableForeign()
}

err := b.ensureConfig()
if err != nil {
return nil, err
Expand Down
36 changes: 27 additions & 9 deletions bridge/core/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,42 @@ import (
)

func FinishConfig(repo *cache.RepoCache, metaKey string, login string) error {
// if no user exist with the given login metadata
_, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKey, login)
// Check the configured user identity first so dangling identities are handled.
user, err := repo.GetUserIdentity()
danglingUser := false
switch {
case err == nil, err == identity.ErrNoIdentitySet:
case entity.IsErrNotFound(err):
// The configured user identity points to an identity that doesn't
// exist (anymore) in this repository. Don't fail the whole bridge
// configuration for that, just create or adopt a valid identity below,
// which will replace the dangling one as the current user.
fmt.Printf("The configured user identity doesn't exist in this repository, ignoring it\n")
danglingUser = true
default:
// real error
return err
}

// if a user exists with the given login metadata
existing, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKey, login)
if err != nil && !entity.IsErrNotFound(err) {
// real error
return err
}
if err == nil {
// found an already valid user, all good
// found an already valid user
if danglingUser {
if err := repo.SetUserIdentity(existing); err != nil {
return err
}
fmt.Printf("Identity %v set as current\n", existing.Id().Human())
}
return nil
}

// if a default user exist, tag it with the login
user, err := repo.GetUserIdentity()
if err != nil && err != identity.ErrNoIdentitySet {
// real error
return err
}
if err == nil {
if user != nil {
fmt.Printf("Current identity %v tagged with login %v\n", user.Id().Human(), login)
// found one
user.SetMetadata(metaKey, login)
Expand Down
Loading
Loading