clairo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +254 -0
- package/dist/cli.js +902 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# clairo
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
clairo is a terminal-based user interface (TUI) application that consolidates GitHub pull requests, Jira ticket management, and personal daily logging into a single keyboard-driven interface. Built with TypeScript, it auto-detects your current git context and eliminates context-switching between web UIs, preventing tasks from falling through the cracks.
|
|
6
|
+
|
|
7
|
+
## Problem Statement
|
|
8
|
+
|
|
9
|
+
Software engineers working with GitHub and Jira face constant context-switching:
|
|
10
|
+
|
|
11
|
+
- Opening GitHub web UI to create/manage PRs
|
|
12
|
+
- Navigating to Jira web UI to update ticket statuses
|
|
13
|
+
- Maintaining personal work logs in separate text files
|
|
14
|
+
- Forgetting to update systems (e.g., merged PR but ticket still "In Progress")
|
|
15
|
+
|
|
16
|
+
This fragmented workflow leads to:
|
|
17
|
+
|
|
18
|
+
- Wasted time switching between tools
|
|
19
|
+
- Incomplete/outdated ticket statuses
|
|
20
|
+
- Lost context about what was worked on
|
|
21
|
+
- Mental overhead tracking multiple systems
|
|
22
|
+
|
|
23
|
+
## Target User
|
|
24
|
+
|
|
25
|
+
Software engineers (particularly full-stack developers) who:
|
|
26
|
+
|
|
27
|
+
- Work with both GitHub and Jira daily
|
|
28
|
+
- Prefer terminal/keyboard-driven workflows
|
|
29
|
+
- Maintain personal work logs
|
|
30
|
+
- Want to reduce context-switching overhead
|
|
31
|
+
|
|
32
|
+
## User Stories
|
|
33
|
+
|
|
34
|
+
### Core Workflows
|
|
35
|
+
|
|
36
|
+
1. **As a developer**, I want clairo to auto-detect my current git branch and repository, so it knows what I'm working on without manual input
|
|
37
|
+
2. **As a developer**, I want to see if a PR already exists for my current branch, so I don't accidentally create duplicates
|
|
38
|
+
3. **As a developer**, I want to create GitHub PRs linked to Jira tickets from the TUI, so I don't have to open a browser
|
|
39
|
+
4. **As a developer**, I want to view and modify existing PRs for my branch, so I can update them without leaving the terminal
|
|
40
|
+
5. **As a developer**, I want to browse my Jira tickets using saved views/filters, so I can quickly find relevant work
|
|
41
|
+
6. **As a developer**, I want to maintain daily work logs in markdown, so I have a record of what I accomplished
|
|
42
|
+
7. **As a developer**, I want optional prompts to log PR/ticket actions, so I can choose when to document my work
|
|
43
|
+
8. **As a developer**, I want to view previous logs easily, so I can reference what I did on past days
|
|
44
|
+
|
|
45
|
+
### Quality of Life
|
|
46
|
+
|
|
47
|
+
9. **As a developer**, I want all navigation to be keyboard-driven with tabs, so I can stay in flow
|
|
48
|
+
10. **As a developer**, I want the tool to handle GitHub 2FA via PAT, so authentication is seamless
|
|
49
|
+
11. **As a developer**, I want my credentials stored securely locally, so I don't re-authenticate constantly
|
|
50
|
+
12. **As a developer**, I want clairo to handle multiple PRs from the same branch, so I can work with different base branches
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
### MVP (v1.0)
|
|
55
|
+
|
|
56
|
+
#### Core Interface Structure
|
|
57
|
+
|
|
58
|
+
clairo uses a tab-based navigation system with three main tabs:
|
|
59
|
+
|
|
60
|
+
**Tab 1: PR View**
|
|
61
|
+
|
|
62
|
+
- Auto-detects current git branch and repository (via git remote parsing)
|
|
63
|
+
- Shows different states:
|
|
64
|
+
- **No git repo**: Display "Not in a git repository" message
|
|
65
|
+
- **No PR exists**: Show "Create PR" interface
|
|
66
|
+
- **Single PR exists**: Display PR details with edit capabilities
|
|
67
|
+
- **Multiple PRs exist**: List all PRs from current branch (different base branches), allow selection
|
|
68
|
+
- Create PR flow:
|
|
69
|
+
- Pre-populate branch information
|
|
70
|
+
- Link to Jira ticket (searchable dropdown)
|
|
71
|
+
- Title and description fields
|
|
72
|
+
- Optional: auto-fill title/description from selected Jira ticket
|
|
73
|
+
- View/Edit PR:
|
|
74
|
+
- PR title, description, status
|
|
75
|
+
- CI/CD checks status
|
|
76
|
+
- Reviewers
|
|
77
|
+
- Option to update PR details
|
|
78
|
+
|
|
79
|
+
**Tab 2: Jira View**
|
|
80
|
+
|
|
81
|
+
- Display saved Jira views/filters (configured in setup)
|
|
82
|
+
- Browse tickets in a scrollable list (title, key, status, assignee)
|
|
83
|
+
- Select ticket to view details:
|
|
84
|
+
- Full description
|
|
85
|
+
- Current status
|
|
86
|
+
- Comments (read-only for v1)
|
|
87
|
+
- Available transitions
|
|
88
|
+
- Update ticket status (transition to different states)
|
|
89
|
+
- Search/filter within current view
|
|
90
|
+
|
|
91
|
+
**Tab 3: Logs View**
|
|
92
|
+
|
|
93
|
+
- Split-pane layout:
|
|
94
|
+
- **Left pane**: Today's log file (editable markdown)
|
|
95
|
+
- **Right pane**: List of previous log files (by date)
|
|
96
|
+
- Navigate to previous days by selecting from right pane
|
|
97
|
+
- Auto-create today's log if it doesn't exist
|
|
98
|
+
- Optional prompt after PR/ticket actions: "Log this? (y/n)"
|
|
99
|
+
- Auto-append timestamped entries to today's log
|
|
100
|
+
- Format: `## HH:MM - Action description`
|
|
101
|
+
|
|
102
|
+
#### Git Context Detection
|
|
103
|
+
|
|
104
|
+
- Parse `git remote get-url origin` to extract owner/repo
|
|
105
|
+
- Fallback handling:
|
|
106
|
+
- If `origin` doesn't exist, list all remotes
|
|
107
|
+
- Filter for GitHub URLs
|
|
108
|
+
- Prompt user to select if multiple GitHub remotes exist
|
|
109
|
+
- Cache repo context per session to avoid repeated parsing
|
|
110
|
+
- Handle non-GitHub repositories gracefully
|
|
111
|
+
|
|
112
|
+
#### GitHub Integration
|
|
113
|
+
|
|
114
|
+
- Query PRs by head branch: `GET /repos/{owner}/{repo}/pulls?head={user}:{branch}&state=open`
|
|
115
|
+
- Create new PRs: `POST /repos/{owner}/{repo}/pulls`
|
|
116
|
+
- Update PR details: `PATCH /repos/{owner}/{repo}/pulls/{number}`
|
|
117
|
+
- Fetch PR status, checks, and reviewers
|
|
118
|
+
- Handle authentication via Personal Access Token (PAT)
|
|
119
|
+
|
|
120
|
+
#### Jira Integration
|
|
121
|
+
|
|
122
|
+
- Authenticate via API token
|
|
123
|
+
- Fetch saved views/filters (JQL queries configured in setup)
|
|
124
|
+
- Search issues: `GET /rest/api/3/search`
|
|
125
|
+
- Get issue details: `GET /rest/api/3/issue/{issueKey}`
|
|
126
|
+
- Get available transitions: `GET /rest/api/3/issue/{issueKey}/transitions`
|
|
127
|
+
- Transition issue: `POST /rest/api/3/issue/{issueKey}/transitions`
|
|
128
|
+
- **Note**: Transition IDs are fetched dynamically (workflow-dependent)
|
|
129
|
+
|
|
130
|
+
#### Daily Logging
|
|
131
|
+
|
|
132
|
+
- Markdown files stored at `~/.clairo/logs/YYYY-MM-DD.md`
|
|
133
|
+
- Auto-create file for current day if missing
|
|
134
|
+
- Simple text editor within TUI (Ink text input component)
|
|
135
|
+
- Optional auto-logging after actions:
|
|
136
|
+
|
|
137
|
+
- Prompt: "Log this? (y/n)"
|
|
138
|
+
- Auto-generated format:
|
|
139
|
+
|
|
140
|
+
```markdown
|
|
141
|
+
## 14:23 - Created PR #234
|
|
142
|
+
|
|
143
|
+
Fixed auth bug in user service
|
|
144
|
+
Jira: PROJ-123
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- Navigate historical logs via right pane
|
|
148
|
+
|
|
149
|
+
#### Configuration & Setup
|
|
150
|
+
|
|
151
|
+
- First-run setup wizard:
|
|
152
|
+
- GitHub PAT creation guide
|
|
153
|
+
- Jira API token creation guide
|
|
154
|
+
- Jira instance URL
|
|
155
|
+
- Configure Jira saved views (JQL queries)
|
|
156
|
+
- Set default auto-log behavior (prompt/always/never)
|
|
157
|
+
- Configuration stored at `~/.clairo/config.json`
|
|
158
|
+
- Settings accessible via settings menu in TUI
|
|
159
|
+
|
|
160
|
+
### Future Enhancements (Post-MVP)
|
|
161
|
+
|
|
162
|
+
- **CLI quick actions**: `clairo pr create`, `clairo ticket update PROJ-123 done`
|
|
163
|
+
- Notifications for PR review requests
|
|
164
|
+
- PR templates
|
|
165
|
+
- Inline PR review workflow
|
|
166
|
+
- GitHub Actions status visualization
|
|
167
|
+
- Batch ticket operations
|
|
168
|
+
- Export logs to different formats
|
|
169
|
+
- Customizable keybindings
|
|
170
|
+
- Support for GitLab, Bitbucket
|
|
171
|
+
- Support for Linear, Asana
|
|
172
|
+
- Multi-repo workspace mode
|
|
173
|
+
- Offline mode with cache
|
|
174
|
+
|
|
175
|
+
## Technical Architecture
|
|
176
|
+
|
|
177
|
+
### Tech Stack
|
|
178
|
+
|
|
179
|
+
- **Language**: TypeScript
|
|
180
|
+
- **TUI Framework**: Ink (React for CLIs)
|
|
181
|
+
- **GitHub API**: `@octokit/rest`
|
|
182
|
+
- **Jira API**: `axios` or `node-fetch` (direct REST calls)
|
|
183
|
+
- **Configuration**: `conf` package or custom JSON file handling
|
|
184
|
+
- **File System**: Node.js built-in `fs/promises`
|
|
185
|
+
|
|
186
|
+
### Data Storage
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
~/.clairo/
|
|
190
|
+
├── config.json # API tokens, settings, preferences
|
|
191
|
+
└── logs/
|
|
192
|
+
├── 2026-02-07.md # Daily log files
|
|
193
|
+
├── 2026-02-06.md
|
|
194
|
+
└── ...
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### API Integration Details
|
|
198
|
+
|
|
199
|
+
#### Git Context Detection
|
|
200
|
+
|
|
201
|
+
- Parse git remote URL using `git remote get-url origin`
|
|
202
|
+
- Extract owner/repo from URLs:
|
|
203
|
+
- HTTPS: `https://github.com/owner/repo.git`
|
|
204
|
+
- SSH: `git@github.com:owner/repo.git`
|
|
205
|
+
- Fallback strategy:
|
|
206
|
+
1. Try `origin` remote first
|
|
207
|
+
2. If not found, run `git remote` to list all remotes
|
|
208
|
+
3. Filter for GitHub URLs
|
|
209
|
+
4. If multiple GitHub remotes, prompt user to select
|
|
210
|
+
5. Cache selection for current session
|
|
211
|
+
- Get current branch: `git rev-parse --abbrev-ref HEAD`
|
|
212
|
+
|
|
213
|
+
#### GitHub
|
|
214
|
+
|
|
215
|
+
- Authentication: Personal Access Token (PAT)
|
|
216
|
+
- Permissions needed: `repo` scope
|
|
217
|
+
- Key endpoints:
|
|
218
|
+
- List PRs by branch: `GET /repos/{owner}/{repo}/pulls?head={user}:{branch}&state=open`
|
|
219
|
+
- Create PR: `POST /repos/{owner}/{repo}/pulls`
|
|
220
|
+
- Get PR details: `GET /repos/{owner}/{repo}/pulls/{number}`
|
|
221
|
+
- Update PR: `PATCH /repos/{owner}/{repo}/pulls/{number}`
|
|
222
|
+
|
|
223
|
+
#### Jira
|
|
224
|
+
|
|
225
|
+
- Authentication: API Token
|
|
226
|
+
- Key endpoints:
|
|
227
|
+
- Search issues (using JQL): `GET /rest/api/3/search?jql={query}`
|
|
228
|
+
- Get issue: `GET /rest/api/3/issue/{issueKey}`
|
|
229
|
+
- Get transitions: `GET /rest/api/3/issue/{issueKey}/transitions`
|
|
230
|
+
- Transition issue: `POST /rest/api/3/issue/{issueKey}/transitions`
|
|
231
|
+
|
|
232
|
+
**Note**: Jira transition IDs vary by workflow configuration and must be fetched dynamically.
|
|
233
|
+
|
|
234
|
+
### Security Considerations
|
|
235
|
+
|
|
236
|
+
- Store credentials in `~/.clairo/config.json` with restrictive file permissions (0600)
|
|
237
|
+
- Never log tokens/credentials
|
|
238
|
+
- Consider using system keychain in future iterations
|
|
239
|
+
- # Validate all user inputs before API calls
|
|
240
|
+
|
|
241
|
+
### Important (High Priority)
|
|
242
|
+
|
|
243
|
+
3. **Jira Saved Views Configuration**: How should users configure their saved views during setup? Manual JQL entry, or UI to build queries?
|
|
244
|
+
4. **PR Title Auto-fill**: When creating a PR linked to a Jira ticket, should title come from ticket title, ticket key + title, or be fully manual?
|
|
245
|
+
5. **Multiple PRs Handling**: When multiple PRs exist for a branch, should there be a "primary" PR concept, or treat all equally?
|
|
246
|
+
6. **Log Entry Format**: Finalize fields for auto-generated entries (currently: timestamp, action, PR #, Jira key)
|
|
247
|
+
|
|
248
|
+
## Out of Scope (v1.0)
|
|
249
|
+
|
|
250
|
+
- CLI quick actions (saved for v2)
|
|
251
|
+
- Real-time notifications
|
|
252
|
+
- Inline code review within TUI
|
|
253
|
+
- Comment creation on Jira tickets (read-only for v1)
|
|
254
|
+
- PR merge functionality (use `gh` CLI or web UI)
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.tsx
|
|
4
|
+
import meow from "meow";
|
|
5
|
+
|
|
6
|
+
// src/app.tsx
|
|
7
|
+
import { Box as Box7 } from "ink";
|
|
8
|
+
|
|
9
|
+
// src/components/github/GitHubView.tsx
|
|
10
|
+
import { useCallback, useEffect as useEffect3, useState as useState4 } from "react";
|
|
11
|
+
import { TitledBox as TitledBox4 } from "@mishieck/ink-titled-box";
|
|
12
|
+
import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
|
|
13
|
+
|
|
14
|
+
// src/lib/config/index.ts
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { dirname, join } from "path";
|
|
18
|
+
var CONFIG_PATH = join(homedir(), ".clairo", "config.json");
|
|
19
|
+
var DEFAULT_CONFIG = {};
|
|
20
|
+
function loadConfig() {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
23
|
+
return DEFAULT_CONFIG;
|
|
24
|
+
}
|
|
25
|
+
const content = readFileSync(CONFIG_PATH, "utf-8");
|
|
26
|
+
return JSON.parse(content);
|
|
27
|
+
} catch {
|
|
28
|
+
return DEFAULT_CONFIG;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function saveConfig(config) {
|
|
32
|
+
const dir = dirname(CONFIG_PATH);
|
|
33
|
+
if (!existsSync(dir)) {
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/lib/github/config.ts
|
|
40
|
+
function getRepoConfig(repoPath) {
|
|
41
|
+
const config = loadConfig();
|
|
42
|
+
const repos = config.repositories ?? {};
|
|
43
|
+
return repos[repoPath] ?? {};
|
|
44
|
+
}
|
|
45
|
+
function updateRepoConfig(repoPath, updates) {
|
|
46
|
+
const config = loadConfig();
|
|
47
|
+
if (!config.repositories) {
|
|
48
|
+
config.repositories = {};
|
|
49
|
+
}
|
|
50
|
+
config.repositories[repoPath] = {
|
|
51
|
+
...config.repositories[repoPath],
|
|
52
|
+
...updates
|
|
53
|
+
};
|
|
54
|
+
saveConfig(config);
|
|
55
|
+
}
|
|
56
|
+
function getSelectedRemote(repoPath, availableRemotes) {
|
|
57
|
+
const repoConfig = getRepoConfig(repoPath);
|
|
58
|
+
if (repoConfig.selectedRemote && availableRemotes.includes(repoConfig.selectedRemote)) {
|
|
59
|
+
return repoConfig.selectedRemote;
|
|
60
|
+
}
|
|
61
|
+
if (availableRemotes.includes("origin")) {
|
|
62
|
+
return "origin";
|
|
63
|
+
}
|
|
64
|
+
return availableRemotes[0] ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/lib/github/git.ts
|
|
68
|
+
import { execSync } from "child_process";
|
|
69
|
+
function isGitRepo() {
|
|
70
|
+
try {
|
|
71
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
72
|
+
encoding: "utf-8",
|
|
73
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function getRepoRoot() {
|
|
81
|
+
try {
|
|
82
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
83
|
+
encoding: "utf-8",
|
|
84
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
85
|
+
}).trim();
|
|
86
|
+
return { success: true, data: root };
|
|
87
|
+
} catch {
|
|
88
|
+
return { success: false, error: "Not a git repository" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function listRemotes() {
|
|
92
|
+
try {
|
|
93
|
+
const output = execSync("git remote -v", {
|
|
94
|
+
encoding: "utf-8",
|
|
95
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
96
|
+
});
|
|
97
|
+
const remotes = [];
|
|
98
|
+
const seen = /* @__PURE__ */ new Set();
|
|
99
|
+
for (const line of output.trim().split("\n")) {
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
102
|
+
if (match && match[3] === "fetch" && !seen.has(match[1])) {
|
|
103
|
+
seen.add(match[1]);
|
|
104
|
+
remotes.push({ name: match[1], url: match[2] });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { success: true, data: remotes };
|
|
108
|
+
} catch {
|
|
109
|
+
return { success: false, error: "Failed to list remotes" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function getCurrentBranch() {
|
|
113
|
+
try {
|
|
114
|
+
const branch = execSync("git branch --show-current", {
|
|
115
|
+
encoding: "utf-8",
|
|
116
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
117
|
+
}).trim();
|
|
118
|
+
if (!branch) {
|
|
119
|
+
return { success: false, error: "Detached HEAD state" };
|
|
120
|
+
}
|
|
121
|
+
return { success: true, data: branch };
|
|
122
|
+
} catch {
|
|
123
|
+
return { success: false, error: "Failed to get current branch" };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/lib/github/index.ts
|
|
128
|
+
import { exec } from "child_process";
|
|
129
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
130
|
+
import { join as join2 } from "path";
|
|
131
|
+
import { promisify } from "util";
|
|
132
|
+
var execAsync = promisify(exec);
|
|
133
|
+
async function isGhInstalled() {
|
|
134
|
+
try {
|
|
135
|
+
await execAsync("gh --version");
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function isGhAuthenticated() {
|
|
142
|
+
try {
|
|
143
|
+
await execAsync("gh auth status");
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function getRepoFromRemote(remoteUrl) {
|
|
150
|
+
const sshMatch = remoteUrl.match(/git@github\.com:(.+?)(?:\.git)?$/);
|
|
151
|
+
if (sshMatch) return sshMatch[1].replace(/\.git$/, "");
|
|
152
|
+
const httpsMatch = remoteUrl.match(
|
|
153
|
+
/https:\/\/github\.com\/(.+?)(?:\.git)?$/
|
|
154
|
+
);
|
|
155
|
+
if (httpsMatch) return httpsMatch[1].replace(/\.git$/, "");
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
async function listPRsForBranch(branch, repo) {
|
|
159
|
+
if (!await isGhInstalled()) {
|
|
160
|
+
return {
|
|
161
|
+
success: false,
|
|
162
|
+
error: "GitHub CLI (gh) is not installed. Install from https://cli.github.com",
|
|
163
|
+
errorType: "not_installed"
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (!await isGhAuthenticated()) {
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
error: "Not authenticated. Run 'gh auth login' to authenticate.",
|
|
170
|
+
errorType: "not_authenticated"
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const fields = "number,title,state,author,createdAt,isDraft";
|
|
175
|
+
const { stdout } = await execAsync(
|
|
176
|
+
`gh pr list --head "${branch}" --json ${fields} --repo "${repo}"`
|
|
177
|
+
);
|
|
178
|
+
const prs = JSON.parse(stdout);
|
|
179
|
+
return { success: true, data: prs };
|
|
180
|
+
} catch {
|
|
181
|
+
return { success: false, error: "Failed to fetch PRs", errorType: "api_error" };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function getPRDetails(prNumber, repo) {
|
|
185
|
+
if (!await isGhInstalled()) {
|
|
186
|
+
return {
|
|
187
|
+
success: false,
|
|
188
|
+
error: "GitHub CLI (gh) is not installed",
|
|
189
|
+
errorType: "not_installed"
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (!await isGhAuthenticated()) {
|
|
193
|
+
return {
|
|
194
|
+
success: false,
|
|
195
|
+
error: "Not authenticated. Run 'gh auth login'",
|
|
196
|
+
errorType: "not_authenticated"
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const fields = [
|
|
201
|
+
"number",
|
|
202
|
+
"title",
|
|
203
|
+
"body",
|
|
204
|
+
"state",
|
|
205
|
+
"author",
|
|
206
|
+
"createdAt",
|
|
207
|
+
"updatedAt",
|
|
208
|
+
"isDraft",
|
|
209
|
+
"mergeable",
|
|
210
|
+
"reviewDecision",
|
|
211
|
+
"commits",
|
|
212
|
+
"assignees",
|
|
213
|
+
"reviewRequests",
|
|
214
|
+
"reviews",
|
|
215
|
+
"statusCheckRollup"
|
|
216
|
+
].join(",");
|
|
217
|
+
const { stdout } = await execAsync(
|
|
218
|
+
`gh pr view ${prNumber} --json ${fields} --repo "${repo}"`
|
|
219
|
+
);
|
|
220
|
+
const pr = JSON.parse(stdout);
|
|
221
|
+
return { success: true, data: pr };
|
|
222
|
+
} catch {
|
|
223
|
+
return {
|
|
224
|
+
success: false,
|
|
225
|
+
error: "Failed to fetch PR details",
|
|
226
|
+
errorType: "api_error"
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function getPRTemplate(repoPath) {
|
|
231
|
+
const templatePaths = [
|
|
232
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
233
|
+
".github/pull_request_template.md",
|
|
234
|
+
"PULL_REQUEST_TEMPLATE.md",
|
|
235
|
+
"pull_request_template.md",
|
|
236
|
+
"docs/PULL_REQUEST_TEMPLATE.md"
|
|
237
|
+
];
|
|
238
|
+
for (const templatePath of templatePaths) {
|
|
239
|
+
const fullPath = join2(repoPath, templatePath);
|
|
240
|
+
if (existsSync2(fullPath)) {
|
|
241
|
+
try {
|
|
242
|
+
return readFileSync2(fullPath, "utf-8");
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
async function createPR(repo, title, body, baseBranch) {
|
|
251
|
+
if (!await isGhInstalled()) {
|
|
252
|
+
return {
|
|
253
|
+
success: false,
|
|
254
|
+
error: "GitHub CLI (gh) is not installed",
|
|
255
|
+
errorType: "not_installed"
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (!await isGhAuthenticated()) {
|
|
259
|
+
return {
|
|
260
|
+
success: false,
|
|
261
|
+
error: "Not authenticated. Run 'gh auth login'",
|
|
262
|
+
errorType: "not_authenticated"
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const baseArg = baseBranch ? `--base "${baseBranch}"` : "";
|
|
267
|
+
const fields = "number,title,state,author,createdAt,isDraft";
|
|
268
|
+
const escapedTitle = title.replace(/"/g, '\\"');
|
|
269
|
+
const escapedBody = body.replace(/"/g, '\\"');
|
|
270
|
+
const { stdout } = await execAsync(
|
|
271
|
+
`gh pr create --title "${escapedTitle}" --body "${escapedBody}" ${baseArg} --repo "${repo}" --json ${fields}`
|
|
272
|
+
);
|
|
273
|
+
const pr = JSON.parse(stdout);
|
|
274
|
+
return { success: true, data: pr };
|
|
275
|
+
} catch (err) {
|
|
276
|
+
const message = err instanceof Error ? err.message : "Failed to create PR";
|
|
277
|
+
return {
|
|
278
|
+
success: false,
|
|
279
|
+
error: message,
|
|
280
|
+
errorType: "api_error"
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// src/components/github/CreatePRModal.tsx
|
|
286
|
+
import { spawnSync } from "child_process";
|
|
287
|
+
import { mkdtempSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
288
|
+
import { tmpdir } from "os";
|
|
289
|
+
import { join as join3 } from "path";
|
|
290
|
+
import { useState } from "react";
|
|
291
|
+
import { Box, Text, useInput } from "ink";
|
|
292
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
293
|
+
function openInEditor(content, filename) {
|
|
294
|
+
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
|
|
295
|
+
const tempDir = mkdtempSync(join3(tmpdir(), "clairo-"));
|
|
296
|
+
const tempFile = join3(tempDir, filename);
|
|
297
|
+
try {
|
|
298
|
+
writeFileSync2(tempFile, content);
|
|
299
|
+
const result = spawnSync(editor, [tempFile], {
|
|
300
|
+
stdio: "inherit"
|
|
301
|
+
});
|
|
302
|
+
process.stdout.write("\x1B[2J\x1B[H");
|
|
303
|
+
if (result.status !== 0) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return readFileSync3(tempFile, "utf-8");
|
|
307
|
+
} finally {
|
|
308
|
+
try {
|
|
309
|
+
rmSync(tempDir, { recursive: true });
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function CreatePRModal({ template, onSubmit, onCancel, loading, error }) {
|
|
315
|
+
const [title, setTitle] = useState("");
|
|
316
|
+
const [body, setBody] = useState(template ?? "");
|
|
317
|
+
const [selectedItem, setSelectedItem] = useState("title");
|
|
318
|
+
const items = ["title", "body", "submit"];
|
|
319
|
+
useInput(
|
|
320
|
+
(input, key) => {
|
|
321
|
+
if (loading) return;
|
|
322
|
+
if (key.escape) {
|
|
323
|
+
onCancel();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (key.upArrow || input === "k") {
|
|
327
|
+
setSelectedItem((prev) => {
|
|
328
|
+
const idx = items.indexOf(prev);
|
|
329
|
+
return items[Math.max(0, idx - 1)];
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (key.downArrow || input === "j") {
|
|
334
|
+
setSelectedItem((prev) => {
|
|
335
|
+
const idx = items.indexOf(prev);
|
|
336
|
+
return items[Math.min(items.length - 1, idx + 1)];
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (key.return) {
|
|
341
|
+
if (selectedItem === "title") {
|
|
342
|
+
const newTitle = openInEditor(title, "PR_TITLE.txt");
|
|
343
|
+
if (newTitle !== null) {
|
|
344
|
+
setTitle(newTitle.split("\n")[0].trim());
|
|
345
|
+
}
|
|
346
|
+
} else if (selectedItem === "body") {
|
|
347
|
+
const newBody = openInEditor(body, "PR_DESCRIPTION.md");
|
|
348
|
+
if (newBody !== null) {
|
|
349
|
+
setBody(newBody);
|
|
350
|
+
}
|
|
351
|
+
} else if (selectedItem === "submit") {
|
|
352
|
+
if (title.trim()) {
|
|
353
|
+
onSubmit(title.trim(), body);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
{ isActive: !loading }
|
|
359
|
+
);
|
|
360
|
+
const renderItem = (item, label, value) => {
|
|
361
|
+
const isSelected = selectedItem === item;
|
|
362
|
+
const prefix = isSelected ? "> " : " ";
|
|
363
|
+
const color = isSelected ? "cyan" : void 0;
|
|
364
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
365
|
+
/* @__PURE__ */ jsxs(Text, { color, bold: isSelected, children: [
|
|
366
|
+
prefix,
|
|
367
|
+
label
|
|
368
|
+
] }),
|
|
369
|
+
value !== void 0 && /* @__PURE__ */ jsx(Box, { marginLeft: 4, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: value || "(empty - press Enter to edit)" }) })
|
|
370
|
+
] });
|
|
371
|
+
};
|
|
372
|
+
const truncatedBody = body ? body.split("\n").slice(0, 2).join(" ").slice(0, 60) + (body.length > 60 ? "..." : "") : "";
|
|
373
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, paddingY: 1, children: [
|
|
374
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }),
|
|
375
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "Up/Down to select, Enter to edit, Esc to cancel" }),
|
|
376
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1 }),
|
|
377
|
+
error && /* @__PURE__ */ jsx(Box, { marginBottom: 1, children: /* @__PURE__ */ jsx(Text, { color: "red", children: error }) }),
|
|
378
|
+
renderItem("title", "Title", title),
|
|
379
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1 }),
|
|
380
|
+
renderItem("body", "Description", truncatedBody),
|
|
381
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1 }),
|
|
382
|
+
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { color: selectedItem === "submit" ? "green" : void 0, bold: selectedItem === "submit", children: [
|
|
383
|
+
selectedItem === "submit" ? "> " : " ",
|
|
384
|
+
title.trim() ? "[Submit PR]" : "[Enter title first]"
|
|
385
|
+
] }) }),
|
|
386
|
+
loading && /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "Creating PR..." }) })
|
|
387
|
+
] });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/components/github/PRDetailsBox.tsx
|
|
391
|
+
import { TitledBox } from "@mishieck/ink-titled-box";
|
|
392
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
393
|
+
import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
394
|
+
function getCheckColor(check) {
|
|
395
|
+
const conclusion = check.conclusion ?? check.state;
|
|
396
|
+
if (conclusion === "SUCCESS") return "green";
|
|
397
|
+
if (conclusion === "FAILURE" || conclusion === "ERROR") return "red";
|
|
398
|
+
if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") return "gray";
|
|
399
|
+
if (conclusion === "PENDING" || check.status === "IN_PROGRESS" || check.status === "QUEUED" || check.status === "WAITING")
|
|
400
|
+
return "yellow";
|
|
401
|
+
if (check.status === "COMPLETED") return "green";
|
|
402
|
+
return void 0;
|
|
403
|
+
}
|
|
404
|
+
function getCheckIcon(check) {
|
|
405
|
+
const conclusion = check.conclusion ?? check.state;
|
|
406
|
+
if (conclusion === "SUCCESS") return "\u2713";
|
|
407
|
+
if (conclusion === "FAILURE" || conclusion === "ERROR") return "\u2717";
|
|
408
|
+
if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") return "\u25CB";
|
|
409
|
+
if (conclusion === "PENDING" || check.status === "IN_PROGRESS" || check.status === "QUEUED" || check.status === "WAITING")
|
|
410
|
+
return "\u25CF";
|
|
411
|
+
if (check.status === "COMPLETED") return "\u2713";
|
|
412
|
+
return "?";
|
|
413
|
+
}
|
|
414
|
+
function PRDetailsBox({ pr, loading, error, isFocused }) {
|
|
415
|
+
var _a, _b, _c, _d, _e, _f;
|
|
416
|
+
const title = "3 PR Details";
|
|
417
|
+
const borderColor = isFocused ? "cyan" : void 0;
|
|
418
|
+
const displayTitle = pr ? `${title} - #${pr.number}` : title;
|
|
419
|
+
const reviewStatus = (pr == null ? void 0 : pr.reviewDecision) ?? "PENDING";
|
|
420
|
+
const reviewColor = reviewStatus === "APPROVED" ? "green" : reviewStatus === "CHANGES_REQUESTED" ? "red" : "yellow";
|
|
421
|
+
const mergeableColor = (pr == null ? void 0 : pr.mergeable) === "MERGEABLE" ? "green" : (pr == null ? void 0 : pr.mergeable) === "CONFLICTING" ? "red" : "yellow";
|
|
422
|
+
return /* @__PURE__ */ jsx2(TitledBox, { borderStyle: "round", titles: [displayTitle], borderColor, flexGrow: 2, children: /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingX: 1, children: [
|
|
423
|
+
loading && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Loading details..." }),
|
|
424
|
+
error && /* @__PURE__ */ jsx2(Text2, { color: "red", children: error }),
|
|
425
|
+
!loading && !error && !pr && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Select a PR to view details" }),
|
|
426
|
+
!loading && !error && pr && /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
427
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: pr.title }),
|
|
428
|
+
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
429
|
+
"by ",
|
|
430
|
+
((_a = pr.author) == null ? void 0 : _a.login) ?? "unknown",
|
|
431
|
+
" | ",
|
|
432
|
+
((_b = pr.commits) == null ? void 0 : _b.length) ?? 0,
|
|
433
|
+
" commits"
|
|
434
|
+
] }),
|
|
435
|
+
/* @__PURE__ */ jsxs2(Box2, { marginTop: 1, children: [
|
|
436
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Review: " }),
|
|
437
|
+
/* @__PURE__ */ jsx2(Text2, { color: reviewColor, children: reviewStatus }),
|
|
438
|
+
/* @__PURE__ */ jsx2(Text2, { children: " | " }),
|
|
439
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Merge: " }),
|
|
440
|
+
/* @__PURE__ */ jsx2(Text2, { color: mergeableColor, children: pr.mergeable ?? "UNKNOWN" })
|
|
441
|
+
] }),
|
|
442
|
+
(((_c = pr.assignees) == null ? void 0 : _c.length) ?? 0) > 0 && /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, children: [
|
|
443
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Assignees: " }),
|
|
444
|
+
/* @__PURE__ */ jsx2(Text2, { children: pr.assignees.map((a) => a.login).join(", ") })
|
|
445
|
+
] }),
|
|
446
|
+
(((_d = pr.reviewRequests) == null ? void 0 : _d.length) ?? 0) > 0 && /* @__PURE__ */ jsxs2(Box2, { children: [
|
|
447
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Reviewers: " }),
|
|
448
|
+
/* @__PURE__ */ jsx2(Text2, { children: pr.reviewRequests.map((r) => r.login ?? r.name ?? r.slug ?? "Team").join(", ") })
|
|
449
|
+
] }),
|
|
450
|
+
(((_e = pr.statusCheckRollup) == null ? void 0 : _e.length) ?? 0) > 0 && /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", children: [
|
|
451
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Checks:" }),
|
|
452
|
+
(_f = pr.statusCheckRollup) == null ? void 0 : _f.map((check, idx) => /* @__PURE__ */ jsxs2(Text2, { color: getCheckColor(check), children: [
|
|
453
|
+
" ",
|
|
454
|
+
getCheckIcon(check),
|
|
455
|
+
" ",
|
|
456
|
+
check.name ?? check.context
|
|
457
|
+
] }, idx))
|
|
458
|
+
] }),
|
|
459
|
+
pr.body && /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, flexDirection: "column", children: [
|
|
460
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Description:" }),
|
|
461
|
+
/* @__PURE__ */ jsx2(Text2, { children: pr.body })
|
|
462
|
+
] })
|
|
463
|
+
] })
|
|
464
|
+
] }) });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/components/github/PullRequestsBox.tsx
|
|
468
|
+
import { useEffect, useState as useState2 } from "react";
|
|
469
|
+
import { TitledBox as TitledBox2 } from "@mishieck/ink-titled-box";
|
|
470
|
+
import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
|
|
471
|
+
import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
472
|
+
function PullRequestsBox({
|
|
473
|
+
prs,
|
|
474
|
+
selectedPR,
|
|
475
|
+
onSelect,
|
|
476
|
+
onCreatePR,
|
|
477
|
+
loading,
|
|
478
|
+
error,
|
|
479
|
+
branch,
|
|
480
|
+
isFocused
|
|
481
|
+
}) {
|
|
482
|
+
const [highlightedIndex, setHighlightedIndex] = useState2(0);
|
|
483
|
+
const totalItems = prs.length + 1;
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
const idx = prs.findIndex((p) => p.number === (selectedPR == null ? void 0 : selectedPR.number));
|
|
486
|
+
if (idx >= 0) setHighlightedIndex(idx);
|
|
487
|
+
}, [selectedPR, prs]);
|
|
488
|
+
useInput2(
|
|
489
|
+
(input, key) => {
|
|
490
|
+
if (!isFocused) return;
|
|
491
|
+
if (key.upArrow || input === "k") {
|
|
492
|
+
setHighlightedIndex((prev) => Math.max(0, prev - 1));
|
|
493
|
+
}
|
|
494
|
+
if (key.downArrow || input === "j") {
|
|
495
|
+
setHighlightedIndex((prev) => Math.min(totalItems - 1, prev + 1));
|
|
496
|
+
}
|
|
497
|
+
if (key.return) {
|
|
498
|
+
if (highlightedIndex === prs.length) {
|
|
499
|
+
onCreatePR();
|
|
500
|
+
} else if (prs[highlightedIndex]) {
|
|
501
|
+
onSelect(prs[highlightedIndex]);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
{ isActive: isFocused }
|
|
506
|
+
);
|
|
507
|
+
const title = "2 Pull Requests";
|
|
508
|
+
const subtitle = branch ? ` (${branch})` : "";
|
|
509
|
+
const borderColor = isFocused ? "cyan" : void 0;
|
|
510
|
+
return /* @__PURE__ */ jsx3(TitledBox2, { borderStyle: "round", titles: [`${title}${subtitle}`], borderColor, children: /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 1, children: [
|
|
511
|
+
loading && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Loading PRs..." }),
|
|
512
|
+
error && /* @__PURE__ */ jsx3(Text3, { color: "red", children: error }),
|
|
513
|
+
!loading && !error && /* @__PURE__ */ jsxs3(Fragment2, { children: [
|
|
514
|
+
prs.length === 0 && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No PRs for this branch" }),
|
|
515
|
+
prs.map((pr, idx) => {
|
|
516
|
+
const isHighlighted = isFocused && idx === highlightedIndex;
|
|
517
|
+
const isSelected = pr.number === (selectedPR == null ? void 0 : selectedPR.number);
|
|
518
|
+
const prefix = isHighlighted ? "> " : isSelected ? "\u25CF " : " ";
|
|
519
|
+
return /* @__PURE__ */ jsxs3(Text3, { color: isSelected ? "green" : void 0, children: [
|
|
520
|
+
prefix,
|
|
521
|
+
"#",
|
|
522
|
+
pr.number,
|
|
523
|
+
" ",
|
|
524
|
+
pr.isDraft ? "[Draft] " : "",
|
|
525
|
+
pr.title
|
|
526
|
+
] }, pr.number);
|
|
527
|
+
}),
|
|
528
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "blue", children: [
|
|
529
|
+
isFocused && highlightedIndex === prs.length ? "> " : " ",
|
|
530
|
+
"+ Create new PR"
|
|
531
|
+
] })
|
|
532
|
+
] })
|
|
533
|
+
] }) });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/components/github/RemotesBox.tsx
|
|
537
|
+
import { useEffect as useEffect2, useState as useState3 } from "react";
|
|
538
|
+
import { TitledBox as TitledBox3 } from "@mishieck/ink-titled-box";
|
|
539
|
+
import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
|
|
540
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
541
|
+
function RemotesBox({ remotes, selectedRemote, onSelect, loading, error, isFocused }) {
|
|
542
|
+
const [highlightedIndex, setHighlightedIndex] = useState3(0);
|
|
543
|
+
useEffect2(() => {
|
|
544
|
+
const idx = remotes.findIndex((r) => r.name === selectedRemote);
|
|
545
|
+
if (idx >= 0) setHighlightedIndex(idx);
|
|
546
|
+
}, [selectedRemote, remotes]);
|
|
547
|
+
useInput3(
|
|
548
|
+
(input, key) => {
|
|
549
|
+
if (!isFocused || remotes.length === 0) return;
|
|
550
|
+
if (key.upArrow || input === "k") {
|
|
551
|
+
setHighlightedIndex((prev) => Math.max(0, prev - 1));
|
|
552
|
+
}
|
|
553
|
+
if (key.downArrow || input === "j") {
|
|
554
|
+
setHighlightedIndex((prev) => Math.min(remotes.length - 1, prev + 1));
|
|
555
|
+
}
|
|
556
|
+
if (key.return) {
|
|
557
|
+
onSelect(remotes[highlightedIndex].name);
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
{ isActive: isFocused }
|
|
561
|
+
);
|
|
562
|
+
const title = "1 Remotes";
|
|
563
|
+
const borderColor = isFocused ? "cyan" : void 0;
|
|
564
|
+
return /* @__PURE__ */ jsx4(TitledBox3, { borderStyle: "round", titles: [title], borderColor, children: /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingX: 1, children: [
|
|
565
|
+
loading && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Loading..." }),
|
|
566
|
+
error && /* @__PURE__ */ jsx4(Text4, { color: "red", children: error }),
|
|
567
|
+
!loading && !error && remotes.length === 0 && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "No remotes configured" }),
|
|
568
|
+
!loading && !error && remotes.map((remote, idx) => {
|
|
569
|
+
const isHighlighted = isFocused && idx === highlightedIndex;
|
|
570
|
+
const isSelected = remote.name === selectedRemote;
|
|
571
|
+
const prefix = isHighlighted ? "> " : isSelected ? "\u25CF " : " ";
|
|
572
|
+
return /* @__PURE__ */ jsxs4(Text4, { color: isSelected ? "green" : void 0, children: [
|
|
573
|
+
prefix,
|
|
574
|
+
remote.name,
|
|
575
|
+
" (",
|
|
576
|
+
remote.url,
|
|
577
|
+
")"
|
|
578
|
+
] }, remote.name);
|
|
579
|
+
})
|
|
580
|
+
] }) });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/components/github/GitHubView.tsx
|
|
584
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
585
|
+
function GitHubView() {
|
|
586
|
+
const [isRepo, setIsRepo] = useState4(null);
|
|
587
|
+
const [repoPath, setRepoPath] = useState4(null);
|
|
588
|
+
const [remotes, setRemotes] = useState4([]);
|
|
589
|
+
const [currentBranch, setCurrentBranch] = useState4(null);
|
|
590
|
+
const [currentRepoSlug, setCurrentRepoSlug] = useState4(null);
|
|
591
|
+
const [selectedRemote, setSelectedRemote] = useState4(null);
|
|
592
|
+
const [selectedPR, setSelectedPR] = useState4(null);
|
|
593
|
+
const [prs, setPrs] = useState4([]);
|
|
594
|
+
const [prDetails, setPrDetails] = useState4(null);
|
|
595
|
+
const [loading, setLoading] = useState4({
|
|
596
|
+
remotes: true,
|
|
597
|
+
prs: false,
|
|
598
|
+
details: false,
|
|
599
|
+
createPR: false
|
|
600
|
+
});
|
|
601
|
+
const [errors, setErrors] = useState4({});
|
|
602
|
+
const [showCreatePR, setShowCreatePR] = useState4(false);
|
|
603
|
+
const [prTemplate, setPrTemplate] = useState4(null);
|
|
604
|
+
const [focusedBox, setFocusedBox] = useState4("remotes");
|
|
605
|
+
useEffect3(() => {
|
|
606
|
+
const gitRepoCheck = isGitRepo();
|
|
607
|
+
setIsRepo(gitRepoCheck);
|
|
608
|
+
if (!gitRepoCheck) {
|
|
609
|
+
setLoading((prev) => ({ ...prev, remotes: false }));
|
|
610
|
+
setErrors((prev) => ({ ...prev, remotes: "Not a git repository" }));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const rootResult = getRepoRoot();
|
|
614
|
+
if (rootResult.success) {
|
|
615
|
+
setRepoPath(rootResult.data);
|
|
616
|
+
const template = getPRTemplate(rootResult.data);
|
|
617
|
+
setPrTemplate(template);
|
|
618
|
+
}
|
|
619
|
+
const branchResult = getCurrentBranch();
|
|
620
|
+
if (branchResult.success) {
|
|
621
|
+
setCurrentBranch(branchResult.data);
|
|
622
|
+
}
|
|
623
|
+
const remotesResult = listRemotes();
|
|
624
|
+
if (remotesResult.success) {
|
|
625
|
+
setRemotes(remotesResult.data);
|
|
626
|
+
const remoteNames = remotesResult.data.map((r) => r.name);
|
|
627
|
+
const defaultRemote = getSelectedRemote(rootResult.success ? rootResult.data : "", remoteNames);
|
|
628
|
+
setSelectedRemote(defaultRemote);
|
|
629
|
+
} else {
|
|
630
|
+
setErrors((prev) => ({ ...prev, remotes: remotesResult.error }));
|
|
631
|
+
}
|
|
632
|
+
setLoading((prev) => ({ ...prev, remotes: false }));
|
|
633
|
+
}, []);
|
|
634
|
+
useEffect3(() => {
|
|
635
|
+
if (!selectedRemote || !currentBranch) return;
|
|
636
|
+
const remote = remotes.find((r) => r.name === selectedRemote);
|
|
637
|
+
if (!remote) return;
|
|
638
|
+
const repo = getRepoFromRemote(remote.url);
|
|
639
|
+
if (!repo) return;
|
|
640
|
+
setCurrentRepoSlug(repo);
|
|
641
|
+
setLoading((prev) => ({ ...prev, prs: true }));
|
|
642
|
+
setPrs([]);
|
|
643
|
+
setSelectedPR(null);
|
|
644
|
+
const fetchPRs = async () => {
|
|
645
|
+
try {
|
|
646
|
+
const result = await listPRsForBranch(currentBranch, repo);
|
|
647
|
+
if (result.success) {
|
|
648
|
+
setPrs(result.data);
|
|
649
|
+
if (result.data.length > 0) {
|
|
650
|
+
setSelectedPR(result.data[0]);
|
|
651
|
+
}
|
|
652
|
+
setErrors((prev) => ({ ...prev, prs: void 0 }));
|
|
653
|
+
} else {
|
|
654
|
+
setErrors((prev) => ({ ...prev, prs: result.error }));
|
|
655
|
+
}
|
|
656
|
+
} catch (err) {
|
|
657
|
+
setErrors((prev) => ({ ...prev, prs: String(err) }));
|
|
658
|
+
} finally {
|
|
659
|
+
setLoading((prev) => ({ ...prev, prs: false }));
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
fetchPRs();
|
|
663
|
+
}, [selectedRemote, currentBranch, remotes]);
|
|
664
|
+
useEffect3(() => {
|
|
665
|
+
if (!selectedPR || !currentRepoSlug) {
|
|
666
|
+
setPrDetails(null);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
setLoading((prev) => ({ ...prev, details: true }));
|
|
670
|
+
const fetchDetails = async () => {
|
|
671
|
+
try {
|
|
672
|
+
const result = await getPRDetails(selectedPR.number, currentRepoSlug);
|
|
673
|
+
if (result.success) {
|
|
674
|
+
setPrDetails(result.data);
|
|
675
|
+
setErrors((prev) => ({ ...prev, details: void 0 }));
|
|
676
|
+
} else {
|
|
677
|
+
setErrors((prev) => ({ ...prev, details: result.error }));
|
|
678
|
+
}
|
|
679
|
+
} catch (err) {
|
|
680
|
+
setErrors((prev) => ({ ...prev, details: String(err) }));
|
|
681
|
+
} finally {
|
|
682
|
+
setLoading((prev) => ({ ...prev, details: false }));
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
fetchDetails();
|
|
686
|
+
}, [selectedPR, currentRepoSlug]);
|
|
687
|
+
const handleRemoteSelect = useCallback(
|
|
688
|
+
(remoteName) => {
|
|
689
|
+
setSelectedRemote(remoteName);
|
|
690
|
+
if (repoPath) {
|
|
691
|
+
updateRepoConfig(repoPath, { selectedRemote: remoteName });
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
[repoPath]
|
|
695
|
+
);
|
|
696
|
+
const handlePRSelect = useCallback((pr) => {
|
|
697
|
+
setSelectedPR(pr);
|
|
698
|
+
}, []);
|
|
699
|
+
const handleCreatePR = useCallback(() => {
|
|
700
|
+
setShowCreatePR(true);
|
|
701
|
+
setErrors((prev) => ({ ...prev, createPR: void 0 }));
|
|
702
|
+
}, []);
|
|
703
|
+
const handleCreatePRSubmit = useCallback(
|
|
704
|
+
async (title, body) => {
|
|
705
|
+
if (!currentRepoSlug) return;
|
|
706
|
+
setLoading((prev) => ({ ...prev, createPR: true }));
|
|
707
|
+
setErrors((prev) => ({ ...prev, createPR: void 0 }));
|
|
708
|
+
try {
|
|
709
|
+
const result = await createPR(currentRepoSlug, title, body);
|
|
710
|
+
if (result.success) {
|
|
711
|
+
setShowCreatePR(false);
|
|
712
|
+
if (currentBranch) {
|
|
713
|
+
const prsResult = await listPRsForBranch(currentBranch, currentRepoSlug);
|
|
714
|
+
if (prsResult.success) {
|
|
715
|
+
setPrs(prsResult.data);
|
|
716
|
+
const newPR = prsResult.data.find((p) => p.number === result.data.number);
|
|
717
|
+
if (newPR) {
|
|
718
|
+
setSelectedPR(newPR);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
} else {
|
|
723
|
+
setErrors((prev) => ({ ...prev, createPR: result.error }));
|
|
724
|
+
}
|
|
725
|
+
} catch (err) {
|
|
726
|
+
setErrors((prev) => ({ ...prev, createPR: String(err) }));
|
|
727
|
+
} finally {
|
|
728
|
+
setLoading((prev) => ({ ...prev, createPR: false }));
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
[currentRepoSlug, currentBranch]
|
|
732
|
+
);
|
|
733
|
+
const handleCreatePRCancel = useCallback(() => {
|
|
734
|
+
setShowCreatePR(false);
|
|
735
|
+
setErrors((prev) => ({ ...prev, createPR: void 0 }));
|
|
736
|
+
}, []);
|
|
737
|
+
useInput4(
|
|
738
|
+
(input) => {
|
|
739
|
+
if (showCreatePR) return;
|
|
740
|
+
if (input === "1") setFocusedBox("remotes");
|
|
741
|
+
if (input === "2") setFocusedBox("prs");
|
|
742
|
+
if (input === "3") setFocusedBox("details");
|
|
743
|
+
},
|
|
744
|
+
{ isActive: !showCreatePR }
|
|
745
|
+
);
|
|
746
|
+
if (isRepo === false) {
|
|
747
|
+
return /* @__PURE__ */ jsx5(TitledBox4, { borderStyle: "round", titles: ["Error"], flexGrow: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "red", children: "Current directory is not a git repository" }) });
|
|
748
|
+
}
|
|
749
|
+
if (showCreatePR) {
|
|
750
|
+
return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", flexGrow: 1, children: /* @__PURE__ */ jsx5(
|
|
751
|
+
CreatePRModal,
|
|
752
|
+
{
|
|
753
|
+
template: prTemplate,
|
|
754
|
+
onSubmit: handleCreatePRSubmit,
|
|
755
|
+
onCancel: handleCreatePRCancel,
|
|
756
|
+
loading: loading.createPR,
|
|
757
|
+
error: errors.createPR
|
|
758
|
+
}
|
|
759
|
+
) });
|
|
760
|
+
}
|
|
761
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", flexGrow: 1, children: [
|
|
762
|
+
/* @__PURE__ */ jsx5(
|
|
763
|
+
RemotesBox,
|
|
764
|
+
{
|
|
765
|
+
remotes,
|
|
766
|
+
selectedRemote,
|
|
767
|
+
onSelect: handleRemoteSelect,
|
|
768
|
+
loading: loading.remotes,
|
|
769
|
+
error: errors.remotes,
|
|
770
|
+
isFocused: focusedBox === "remotes"
|
|
771
|
+
}
|
|
772
|
+
),
|
|
773
|
+
/* @__PURE__ */ jsx5(
|
|
774
|
+
PullRequestsBox,
|
|
775
|
+
{
|
|
776
|
+
prs,
|
|
777
|
+
selectedPR,
|
|
778
|
+
onSelect: handlePRSelect,
|
|
779
|
+
onCreatePR: handleCreatePR,
|
|
780
|
+
loading: loading.prs,
|
|
781
|
+
error: errors.prs,
|
|
782
|
+
branch: currentBranch,
|
|
783
|
+
isFocused: focusedBox === "prs"
|
|
784
|
+
}
|
|
785
|
+
),
|
|
786
|
+
/* @__PURE__ */ jsx5(
|
|
787
|
+
PRDetailsBox,
|
|
788
|
+
{
|
|
789
|
+
pr: prDetails,
|
|
790
|
+
loading: loading.details,
|
|
791
|
+
error: errors.details,
|
|
792
|
+
isFocused: focusedBox === "details"
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
] });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// src/components/ui/Tabs.tsx
|
|
799
|
+
import React, { useState as useState5 } from "react";
|
|
800
|
+
import { Box as Box6, Text as Text6, useInput as useInput5 } from "ink";
|
|
801
|
+
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
802
|
+
function TabItem({ children }) {
|
|
803
|
+
return /* @__PURE__ */ jsx6(Box6, { flexGrow: 1, children });
|
|
804
|
+
}
|
|
805
|
+
function Tabs({ children, defaultTab }) {
|
|
806
|
+
const childArray = React.Children.toArray(children);
|
|
807
|
+
const tabNames = childArray.map((child) => child.props.name);
|
|
808
|
+
const [activeTab, setActiveTab] = useState5(defaultTab ?? tabNames[0]);
|
|
809
|
+
useInput5((_input, key) => {
|
|
810
|
+
if (key.tab && activeTab) {
|
|
811
|
+
const currentIndex = tabNames.indexOf(activeTab);
|
|
812
|
+
const newIndex = key.shift ? currentIndex === 0 ? tabNames.length - 1 : currentIndex - 1 : (currentIndex + 1) % tabNames.length;
|
|
813
|
+
setActiveTab(tabNames[newIndex]);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: [
|
|
817
|
+
/* @__PURE__ */ jsx6(Box6, { paddingX: 1, gap: 1, flexShrink: 0, children: tabNames.map((name) => /* @__PURE__ */ jsx6(Text6, { inverse: activeTab === name, bold: activeTab === name, children: `${name} ` }, name)) }),
|
|
818
|
+
/* @__PURE__ */ jsx6(Box6, { flexGrow: 1, marginTop: 1, overflow: "hidden", children: childArray.map((child) => /* @__PURE__ */ jsx6(Box6, { display: child.props.name === activeTab ? "flex" : "none", flexGrow: 1, children: child }, child.props.name)) })
|
|
819
|
+
] });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// src/app.tsx
|
|
823
|
+
import { jsx as jsx7 } from "react/jsx-runtime";
|
|
824
|
+
function App() {
|
|
825
|
+
return /* @__PURE__ */ jsx7(Box7, { flexGrow: 1, flexDirection: "column", overflow: "hidden", children: /* @__PURE__ */ jsx7(Tabs, { children: /* @__PURE__ */ jsx7(TabItem, { name: "GitHub", children: /* @__PURE__ */ jsx7(GitHubView, {}) }) }) });
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// src/lib/render.tsx
|
|
829
|
+
import { render as inkRender } from "ink";
|
|
830
|
+
|
|
831
|
+
// src/lib/Screen.tsx
|
|
832
|
+
import { Box as Box8, useStdout } from "ink";
|
|
833
|
+
import { useCallback as useCallback2, useEffect as useEffect4, useState as useState6 } from "react";
|
|
834
|
+
import { jsx as jsx8 } from "react/jsx-runtime";
|
|
835
|
+
function Screen({ children }) {
|
|
836
|
+
const { stdout } = useStdout();
|
|
837
|
+
const getSize = useCallback2(
|
|
838
|
+
() => ({ height: stdout.rows, width: stdout.columns }),
|
|
839
|
+
[stdout]
|
|
840
|
+
);
|
|
841
|
+
const [size, setSize] = useState6(getSize);
|
|
842
|
+
useEffect4(() => {
|
|
843
|
+
const onResize = () => setSize(getSize());
|
|
844
|
+
stdout.on("resize", onResize);
|
|
845
|
+
return () => {
|
|
846
|
+
stdout.off("resize", onResize);
|
|
847
|
+
};
|
|
848
|
+
}, [stdout, getSize]);
|
|
849
|
+
return /* @__PURE__ */ jsx8(Box8, { height: size.height, width: size.width, children });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/lib/render.tsx
|
|
853
|
+
import { jsx as jsx9 } from "react/jsx-runtime";
|
|
854
|
+
var ENTER_ALT_BUFFER = "\x1B[?1049h";
|
|
855
|
+
var EXIT_ALT_BUFFER = "\x1B[?1049l";
|
|
856
|
+
var CLEAR_SCREEN = "\x1B[2J\x1B[H";
|
|
857
|
+
function render(node, options) {
|
|
858
|
+
process.stdout.write(ENTER_ALT_BUFFER + CLEAR_SCREEN);
|
|
859
|
+
const element = /* @__PURE__ */ jsx9(Screen, { children: node });
|
|
860
|
+
const instance = inkRender(element, options);
|
|
861
|
+
setImmediate(() => instance.rerender(element));
|
|
862
|
+
const cleanup = () => process.stdout.write(EXIT_ALT_BUFFER);
|
|
863
|
+
const originalWaitUntilExit = instance.waitUntilExit.bind(instance);
|
|
864
|
+
instance.waitUntilExit = async () => {
|
|
865
|
+
await originalWaitUntilExit();
|
|
866
|
+
cleanup();
|
|
867
|
+
};
|
|
868
|
+
process.on("exit", cleanup);
|
|
869
|
+
const handleSignal = () => {
|
|
870
|
+
cleanup();
|
|
871
|
+
instance.unmount();
|
|
872
|
+
process.exit(0);
|
|
873
|
+
};
|
|
874
|
+
process.on("SIGINT", handleSignal);
|
|
875
|
+
process.on("SIGTERM", handleSignal);
|
|
876
|
+
return instance;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// src/cli.tsx
|
|
880
|
+
import { jsx as jsx10 } from "react/jsx-runtime";
|
|
881
|
+
meow(
|
|
882
|
+
`
|
|
883
|
+
Usage
|
|
884
|
+
$ clairo
|
|
885
|
+
|
|
886
|
+
Options
|
|
887
|
+
--name Your name
|
|
888
|
+
|
|
889
|
+
Examples
|
|
890
|
+
$ clairo --name=Jane
|
|
891
|
+
Hello, Jane
|
|
892
|
+
`,
|
|
893
|
+
{
|
|
894
|
+
importMeta: import.meta,
|
|
895
|
+
flags: {
|
|
896
|
+
name: {
|
|
897
|
+
type: "string"
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
);
|
|
902
|
+
render(/* @__PURE__ */ jsx10(App, {}));
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clairo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"bin": "dist/cli.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=16"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup",
|
|
12
|
+
"dev": "tsx --watch src/cli.tsx",
|
|
13
|
+
"start": "node dist/cli.js",
|
|
14
|
+
"lint": "prettier --write . && eslint . --fix"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@mishieck/ink-titled-box": "^0.4.2",
|
|
21
|
+
"ink": "^6.6.0",
|
|
22
|
+
"meow": "^11.0.0",
|
|
23
|
+
"react": "^19.2.4"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"@types/react": "^19.2.13",
|
|
29
|
+
"chalk": "^5.2.0",
|
|
30
|
+
"eslint": "^9.0.0",
|
|
31
|
+
"eslint-plugin-react": "^7.32.2",
|
|
32
|
+
"prettier": "^2.8.7",
|
|
33
|
+
"tsup": "^8.5.1",
|
|
34
|
+
"tsx": "^4.21.0",
|
|
35
|
+
"typescript": "^5.0.3",
|
|
36
|
+
"typescript-eslint": "^8.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|