Change name and prepare for push
This commit is contained in:
26
README.md
26
README.md
@@ -1,4 +1,4 @@
|
|||||||
# gh-threads
|
# diple
|
||||||
|
|
||||||
A terminal UI for people receiving GitHub pull-request reviews. It
|
A terminal UI for people receiving GitHub pull-request reviews. It
|
||||||
shows open PRs and a scrollable PR dashboard with the description, branches,
|
shows open PRs and a scrollable PR dashboard with the description, branches,
|
||||||
@@ -20,7 +20,7 @@ Requires Go 1.24+, Git 2.38+, and an authenticated GitHub CLI:
|
|||||||
```sh
|
```sh
|
||||||
go install .
|
go install .
|
||||||
gh auth login
|
gh auth login
|
||||||
gh-threads
|
diple
|
||||||
```
|
```
|
||||||
|
|
||||||
To run directly from a source checkout instead:
|
To run directly from a source checkout instead:
|
||||||
@@ -41,34 +41,38 @@ authenticated user and groups the results by repository. Use `--repo` to limit
|
|||||||
the picker to one repository:
|
the picker to one repository:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gh-threads --repo owner/repository
|
diple --repo owner/repository
|
||||||
```
|
```
|
||||||
|
|
||||||
With a repository selected, pass `--all` to include every open PR in that
|
With a repository selected, pass `--all` to include every open PR in that
|
||||||
repository:
|
repository:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gh-threads --repo owner/repository --all --poll 15s
|
diple --repo owner/repository --all --poll 15s
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub Enterprise Server can be used after authenticating that host:
|
GitHub Enterprise Server can be used after authenticating that host:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gh auth login --hostname github.example.com
|
gh auth login --hostname github.example.com
|
||||||
gh-threads --repo owner/repository \
|
diple --repo owner/repository \
|
||||||
--endpoint https://github.example.com/api/graphql
|
--endpoint https://github.example.com/api/graphql
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The optional TOML configuration is loaded from
|
The optional TOML configuration is loaded from
|
||||||
`$GH_THREADS_CONFIG`, `$XDG_CONFIG_HOME/gh-threads/config.toml`, or the
|
`$DIPLE_CONFIG`, `$XDG_CONFIG_HOME/diple/config.toml`, or the operating
|
||||||
operating system's user configuration directory at `gh-threads/config.toml`.
|
system's user configuration directory at `diple/config.toml`.
|
||||||
On Linux this is normally `~/.config/gh-threads/config.toml`. On macOS,
|
On Linux this is normally `~/.config/diple/config.toml`. On macOS,
|
||||||
`~/Library/Application Support/gh-threads/config.toml` is preferred, with
|
`~/Library/Application Support/diple/config.toml` is preferred, with
|
||||||
`~/.config/gh-threads/config.toml` automatically used as a fallback when it
|
`~/.config/diple/config.toml` automatically used as a fallback when it
|
||||||
exists.
|
exists.
|
||||||
|
|
||||||
|
For migration, `GH_THREADS_CONFIG` and existing `gh-threads` configuration or
|
||||||
|
cache directories remain fallback locations when their new `diple`
|
||||||
|
counterparts do not yet exist.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
theme = "dark" # dark, light, high-contrast, or no-color
|
theme = "dark" # dark, light, high-contrast, or no-color
|
||||||
refresh_interval = "10s"
|
refresh_interval = "10s"
|
||||||
@@ -221,7 +225,7 @@ avoid synchronized clients. Opening another PR or starting another refresh
|
|||||||
cancels the superseded request.
|
cancels the superseded request.
|
||||||
|
|
||||||
GitHub's public APIs report whether a PR conflicts but do not expose its
|
GitHub's public APIs report whether a PR conflicts but do not expose its
|
||||||
conflicting file paths. For conflicting PRs only, `gh-threads` performs a
|
conflicting file paths. For conflicting PRs only, `diple` performs a
|
||||||
read-only `git merge-tree` analysis in a temporary bare repository. It never
|
read-only `git merge-tree` analysis in a temporary bare repository. It never
|
||||||
touches or inspects the current checkout, so Git, Jujutsu (`jj`), and directories
|
touches or inspects the current checkout, so Git, Jujutsu (`jj`), and directories
|
||||||
without a local repository behave identically. The analysis fetches the exact
|
without a local repository behave identically. The analysis fetches the exact
|
||||||
|
|||||||
36
config.go
36
config.go
@@ -96,31 +96,46 @@ func defaultConfig() Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configPath() (string, error) {
|
func configPath() (string, error) {
|
||||||
|
if path := os.Getenv("DIPLE_CONFIG"); path != "" {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
// Preserve the old override during the rename so existing scripts do not
|
||||||
|
// silently start with a fresh configuration.
|
||||||
if path := os.Getenv("GH_THREADS_CONFIG"); path != "" {
|
if path := os.Getenv("GH_THREADS_CONFIG"); path != "" {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
if base := os.Getenv("XDG_CONFIG_HOME"); base != "" {
|
if base := os.Getenv("XDG_CONFIG_HOME"); base != "" {
|
||||||
return filepath.Join(base, "gh-threads", "config.toml"), nil
|
return firstExistingOrDefault(
|
||||||
|
filepath.Join(base, "diple", "config.toml"),
|
||||||
|
filepath.Join(base, "gh-threads", "config.toml"),
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
base, err := os.UserConfigDir()
|
base, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("find user config directory: %w", err)
|
return "", fmt.Errorf("find user config directory: %w", err)
|
||||||
}
|
}
|
||||||
preferred := filepath.Join(base, "gh-threads", "config.toml")
|
preferred := filepath.Join(base, "diple", "config.toml")
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("find home directory: %w", err)
|
return "", fmt.Errorf("find home directory: %w", err)
|
||||||
}
|
}
|
||||||
fallback := filepath.Join(home, ".config", "gh-threads", "config.toml")
|
dotConfig := filepath.Join(home, ".config", "diple", "config.toml")
|
||||||
return existingConfigPath(preferred, fallback), nil
|
legacyPreferred := filepath.Join(base, "gh-threads", "config.toml")
|
||||||
|
legacyDotConfig := filepath.Join(home, ".config", "gh-threads", "config.toml")
|
||||||
|
return firstExistingOrDefault(
|
||||||
|
preferred, dotConfig, legacyPreferred, legacyDotConfig,
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func existingConfigPath(preferred, fallback string) string {
|
func existingConfigPath(preferred, fallback string) string {
|
||||||
if _, err := os.Stat(preferred); err == nil || !errors.Is(err, os.ErrNotExist) {
|
return firstExistingOrDefault(preferred, fallback)
|
||||||
return preferred
|
}
|
||||||
|
|
||||||
|
func firstExistingOrDefault(preferred string, alternatives ...string) string {
|
||||||
|
for _, candidate := range append([]string{preferred}, alternatives...) {
|
||||||
|
if _, err := os.Stat(candidate); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return candidate
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(fallback); err == nil {
|
|
||||||
return fallback
|
|
||||||
}
|
}
|
||||||
return preferred
|
return preferred
|
||||||
}
|
}
|
||||||
@@ -200,7 +215,10 @@ func defaultCacheDir() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("find user cache directory: %w", err)
|
return "", fmt.Errorf("find user cache directory: %w", err)
|
||||||
}
|
}
|
||||||
return filepath.Join(base, "gh-threads"), nil
|
return firstExistingOrDefault(
|
||||||
|
filepath.Join(base, "diple"),
|
||||||
|
filepath.Join(base, "gh-threads"),
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateThreadStatusOrder(order []string) error {
|
func validateThreadStatusOrder(order []string) error {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ within_status = "timestamp"
|
|||||||
[cache]
|
[cache]
|
||||||
enabled = false
|
enabled = false
|
||||||
max_age = "48h"
|
max_age = "48h"
|
||||||
directory = "/tmp/gh-threads-cache"
|
directory = "/tmp/diple-cache"
|
||||||
|
|
||||||
[editing]
|
[editing]
|
||||||
mode = "standard"
|
mode = "standard"
|
||||||
@@ -79,7 +79,7 @@ up = ["ctrl+k"]
|
|||||||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
||||||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
|
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
|
||||||
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
|
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
|
||||||
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" ||
|
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/diple-cache" ||
|
||||||
got.Editing.Mode != "standard" ||
|
got.Editing.Mode != "standard" ||
|
||||||
strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" ||
|
strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" ||
|
||||||
strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" {
|
strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" {
|
||||||
@@ -149,20 +149,33 @@ func TestLoadConfigRejectsUnknownSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigPathHonorsEnvironmentOverride(t *testing.T) {
|
func TestConfigPathHonorsEnvironmentOverride(t *testing.T) {
|
||||||
t.Setenv("GH_THREADS_CONFIG", "/tmp/custom-gh-threads.toml")
|
t.Setenv("DIPLE_CONFIG", "/tmp/custom-diple.toml")
|
||||||
|
t.Setenv("GH_THREADS_CONFIG", "")
|
||||||
got, err := configPath()
|
got, err := configPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got != "/tmp/custom-gh-threads.toml" {
|
if got != "/tmp/custom-diple.toml" {
|
||||||
t.Fatalf("config path = %q", got)
|
t.Fatalf("config path = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigPathHonorsLegacyEnvironmentOverride(t *testing.T) {
|
||||||
|
t.Setenv("DIPLE_CONFIG", "")
|
||||||
|
t.Setenv("GH_THREADS_CONFIG", "/tmp/legacy-gh-threads.toml")
|
||||||
|
got, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "/tmp/legacy-gh-threads.toml" {
|
||||||
|
t.Fatalf("legacy config path = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
|
func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
preferred := filepath.Join(root, "Library", "Application Support", "gh-threads", "config.toml")
|
preferred := filepath.Join(root, "Library", "Application Support", "diple", "config.toml")
|
||||||
fallback := filepath.Join(root, ".config", "gh-threads", "config.toml")
|
fallback := filepath.Join(root, ".config", "diple", "config.toml")
|
||||||
if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -184,14 +197,30 @@ func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFirstExistingConfigPathFallsBackToLegacyName(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
current := filepath.Join(root, "diple", "config.toml")
|
||||||
|
legacy := filepath.Join(root, "gh-threads", "config.toml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(legacy, []byte("theme = \"dark\"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := firstExistingOrDefault(current, legacy); got != legacy {
|
||||||
|
t.Fatalf("migration config path = %q, want %q", got, legacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigPathHonorsXDGConfigHome(t *testing.T) {
|
func TestConfigPathHonorsXDGConfigHome(t *testing.T) {
|
||||||
t.Setenv("GH_THREADS_CONFIG", "")
|
t.Setenv("GH_THREADS_CONFIG", "")
|
||||||
|
t.Setenv("DIPLE_CONFIG", "")
|
||||||
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
|
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
|
||||||
got, err := configPath()
|
got, err := configPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
want := "/tmp/xdg-config/gh-threads/config.toml"
|
want := "/tmp/xdg-config/diple/config.toml"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("config path = %q, want %q", got, want)
|
t.Fatalf("config path = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
10
conflicts.go
10
conflicts.go
@@ -57,7 +57,7 @@ func analyzeConflictFiles(
|
|||||||
if repositoryURL == "" || baseRef == "" || number <= 0 {
|
if repositoryURL == "" || baseRef == "" || number <= 0 {
|
||||||
return nil, errors.New("missing repository merge metadata")
|
return nil, errors.New("missing repository merge metadata")
|
||||||
}
|
}
|
||||||
gitDir, err := os.MkdirTemp("", "gh-threads-conflicts-*")
|
gitDir, err := os.MkdirTemp("", "diple-conflicts-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create temporary merge repository: %w", err)
|
return nil, fmt.Errorf("create temporary merge repository: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,8 +83,8 @@ func analyzeConflictFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
refspecs := []string{
|
refspecs := []string{
|
||||||
"+refs/heads/" + baseRef + ":refs/gh-threads/base",
|
"+refs/heads/" + baseRef + ":refs/diple/base",
|
||||||
"+refs/pull/" + strconv.Itoa(number) + "/head:refs/gh-threads/head",
|
"+refs/pull/" + strconv.Itoa(number) + "/head:refs/diple/head",
|
||||||
}
|
}
|
||||||
fetch := func(depthArgs ...string) error {
|
fetch := func(depthArgs ...string) error {
|
||||||
args := []string{"-C", gitDir, "fetch", "--quiet", "--no-tags", "--filter=blob:none"}
|
args := []string{"-C", gitDir, "fetch", "--quiet", "--no-tags", "--filter=blob:none"}
|
||||||
@@ -113,7 +113,7 @@ func analyzeConflictFiles(
|
|||||||
|
|
||||||
output, runErr := run(
|
output, runErr := run(
|
||||||
"-C", gitDir, "merge-tree", "--write-tree", "--name-only", "--no-messages", "-z",
|
"-C", gitDir, "merge-tree", "--write-tree", "--name-only", "--no-messages", "-z",
|
||||||
"refs/gh-threads/base", "refs/gh-threads/head",
|
"refs/diple/base", "refs/diple/head",
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -130,7 +130,7 @@ func mergeBaseExists(
|
|||||||
gitDir string,
|
gitDir string,
|
||||||
) bool {
|
) bool {
|
||||||
_, err := run(
|
_, err := run(
|
||||||
"-C", gitDir, "merge-base", "refs/gh-threads/base", "refs/gh-threads/head",
|
"-C", gitDir, "merge-base", "refs/diple/base", "refs/diple/head",
|
||||||
)
|
)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func (c *GitHubClient) query(
|
|||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", "gh-threads")
|
req.Header.Set("User-Agent", "diple")
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module git.pablu.de/Pablu/gh-threads
|
module git.pablu.de/Pablu/diple
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
|||||||
8
main.go
8
main.go
@@ -39,7 +39,11 @@ func main() {
|
|||||||
|
|
||||||
visited := map[string]bool{}
|
visited := map[string]bool{}
|
||||||
flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })
|
flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })
|
||||||
config, err := loadConfig(*configFile, visited["config"] || os.Getenv("GH_THREADS_CONFIG") != "")
|
config, err := loadConfig(
|
||||||
|
*configFile,
|
||||||
|
visited["config"] || os.Getenv("DIPLE_CONFIG") != "" ||
|
||||||
|
os.Getenv("GH_THREADS_CONFIG") != "",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exitf("configuration: %v", err)
|
exitf("configuration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -167,7 +171,7 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func exitf(format string, args ...any) {
|
func exitf(format string, args ...any) {
|
||||||
fmt.Fprintf(os.Stderr, "gh-threads: "+format+"\n", args...)
|
fmt.Fprintf(os.Stderr, "diple: "+format+"\n", args...)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func atomicWriteJSON(path string, value any, mode os.FileMode) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
temp, err := os.CreateTemp(dir, ".gh-threads-*")
|
temp, err := os.CreateTemp(dir, ".diple-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
4
tui.go
4
tui.go
@@ -1802,7 +1802,7 @@ func (m App) viewWritePopup() string {
|
|||||||
strings.ToLower(m.mergeMethod),
|
strings.ToLower(m.mergeMethod),
|
||||||
)),
|
)),
|
||||||
"",
|
"",
|
||||||
badStyle.Render("This action cannot be undone from gh-threads."),
|
badStyle.Render("This action cannot be undone from diple."),
|
||||||
"",
|
"",
|
||||||
warnStyle.Render(fmt.Sprintf(
|
warnStyle.Render(fmt.Sprintf(
|
||||||
"%s merge now • %s cancel",
|
"%s merge now • %s cancel",
|
||||||
@@ -2126,7 +2126,7 @@ func paneStyle(active bool) lipgloss.Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m App) viewPRs() string {
|
func (m App) viewPRs() string {
|
||||||
header := titleStyle.Render("gh-threads")
|
header := titleStyle.Render("diple")
|
||||||
if m.owner != "" {
|
if m.owner != "" {
|
||||||
header += " " + m.owner + "/" + m.repo
|
header += " " + m.owner + "/" + m.repo
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user