nitor 1.4.2 → 1.6.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/CHANGELOG.md +30 -0
- package/README.md +19 -0
- package/docs/MR_STATUS.md +74 -0
- package/docs/README.md +4 -0
- package/package.json +1 -1
- package/services/autocomplete.js +8 -0
- package/services/enums/actions.enum.js +1 -0
- package/services/mr-status.js +306 -0
- package/services/process-commands.js +63 -15
- package/services/task-stats.js +22 -3
- package/services/utils.js +40 -10
package/CHANGELOG.md
CHANGED
|
@@ -38,3 +38,33 @@ For a complete list of changes and discussion, see [Issue #1](https://github.com
|
|
|
38
38
|
- Added support for task stats and get task commands.
|
|
39
39
|
|
|
40
40
|
- [Issue #7](https://github.com/codebynithin/nitor/issues/7)
|
|
41
|
+
|
|
42
|
+
## [1.5.0] - 2026-04-10
|
|
43
|
+
|
|
44
|
+
### New Features
|
|
45
|
+
|
|
46
|
+
- Add table display for get-task command.
|
|
47
|
+
|
|
48
|
+
- [Issue #9](https://github.com/codebynithin/nitor/issues/9)
|
|
49
|
+
|
|
50
|
+
## [1.6.0] - 2026-04-20
|
|
51
|
+
|
|
52
|
+
### New Features
|
|
53
|
+
|
|
54
|
+
- Added `mr-status` command to list GitLab repositories you contribute to and
|
|
55
|
+
view their merge requests rendered as a branch tree (with filtering by state
|
|
56
|
+
and repository search).
|
|
57
|
+
- `task-stats` and `get-task` output now render GitLab issue IDs, merge request
|
|
58
|
+
IDs, and Zoho task IDs as clickable terminal hyperlinks (OSC 8) for
|
|
59
|
+
supporting terminals.
|
|
60
|
+
- New env variables `ZOHO_WORKSPACE` and `ZOHO_PROJECT_PREFIX` for building
|
|
61
|
+
Zoho Sprints task URLs used by the clickable `Task` column.
|
|
62
|
+
|
|
63
|
+
### Enhancements
|
|
64
|
+
|
|
65
|
+
- `printTable` is now ANSI/hyperlink-aware and computes visible column widths
|
|
66
|
+
correctly when cell values contain escape sequences.
|
|
67
|
+
- Autocomplete updated to include the new `mr-status` command and its
|
|
68
|
+
`-state` / `-search` options.
|
|
69
|
+
|
|
70
|
+
- [Issue #10](https://github.com/codebynithin/nitor/issues/10)
|
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ A CLI utility toolkit for automating and managing build, deploy, and status oper
|
|
|
29
29
|
- Task statistics with merge request details
|
|
30
30
|
- Extract Zoho task IDs from GitLab issue descriptions
|
|
31
31
|
- Merge request status tracking for active tasks
|
|
32
|
+
- Branch-tree view of GitLab merge requests across repositories (`mr-status`)
|
|
33
|
+
- Clickable terminal hyperlinks for task IDs, GitLab issues and merge requests in stats output
|
|
32
34
|
|
|
33
35
|
## Requirements
|
|
34
36
|
|
|
@@ -57,6 +59,8 @@ A CLI utility toolkit for automating and managing build, deploy, and status oper
|
|
|
57
59
|
- `ZOHO_TOKEN` - Zoho CSRF token
|
|
58
60
|
- `ZOHO_URI` - Zoho API URL
|
|
59
61
|
- `ZOHO_USERID` - Zoho user ID
|
|
62
|
+
- `ZOHO_WORKSPACE` - Zoho workspace name (used to build clickable task links)
|
|
63
|
+
- `ZOHO_PROJECT_PREFIX` - Zoho project prefix, e.g. `P29` (used to build clickable task links)
|
|
60
64
|
|
|
61
65
|
## Installation
|
|
62
66
|
|
|
@@ -184,6 +188,18 @@ Once enabled, you can use Tab to autocomplete:
|
|
|
184
188
|
```bash
|
|
185
189
|
nitor get-task -task <task numbers with space>
|
|
186
190
|
```
|
|
191
|
+
- **Merge Request Status (Tree View):**
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
# Interactive: pick a repository, then view MRs as a branch tree
|
|
195
|
+
nitor mr-status
|
|
196
|
+
|
|
197
|
+
# Filter by MR state (opened | merged | closed | all)
|
|
198
|
+
nitor mr-status -state opened
|
|
199
|
+
|
|
200
|
+
# Pre-filter repositories by name
|
|
201
|
+
nitor mr-status -search <text>
|
|
202
|
+
```
|
|
187
203
|
|
|
188
204
|
### Command Reference
|
|
189
205
|
|
|
@@ -211,6 +227,7 @@ Once enabled, you can use Tab to autocomplete:
|
|
|
211
227
|
- `time-switch` : Switch between default projects
|
|
212
228
|
- `task-stats` : View task statistics with GitLab merge request details
|
|
213
229
|
- `get-task` : Extract Zoho task IDs from GitLab issue descriptions
|
|
230
|
+
- `mr-status` : List GitLab repositories you contribute to and view merge requests as a branch tree
|
|
214
231
|
|
|
215
232
|
## MongoDB Backup & Restore
|
|
216
233
|
|
|
@@ -359,6 +376,8 @@ The backup service is designed to work on Windows, macOS, and Linux:
|
|
|
359
376
|
- `-mergeId` or `-mId` : Merge ID
|
|
360
377
|
- `-source` or `-so` : Source branch name
|
|
361
378
|
- `-target` or `-ta` : Target branch name
|
|
379
|
+
- `-state` or `-st` : MR state filter for `mr-status` (`opened`, `merged`, `closed`, `all`)
|
|
380
|
+
- `-search` or `-q` : Repository search text for `mr-status`
|
|
362
381
|
|
|
363
382
|
#### Time Entry Options
|
|
364
383
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# mr-status
|
|
2
|
+
|
|
3
|
+
List GitLab repositories you are a member of, pick one, and view its merge
|
|
4
|
+
requests rendered as a branch tree. Useful for quickly seeing the shape of a
|
|
5
|
+
stacked/chained set of MRs, what targets what, and who owns each one.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
nitor mr-status [-state <opened|merged|closed|all>] [-search <text>]
|
|
11
|
+
nitor mr-status [-st <state>] [-q <text>]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Running `nitor mr-status` with no arguments will:
|
|
15
|
+
|
|
16
|
+
1. Fetch the repositories you are a member of (ordered by last activity).
|
|
17
|
+
2. Prompt you to pick one by number.
|
|
18
|
+
3. Prompt you to pick an MR state (defaults to `opened`).
|
|
19
|
+
4. Print the selected repository's merge requests grouped by target branch as
|
|
20
|
+
a tree, showing source → target, state, author, last update time and URL.
|
|
21
|
+
|
|
22
|
+
## Options
|
|
23
|
+
|
|
24
|
+
- `-st`, `-state <state>` — MR state filter. One of `opened` (default),
|
|
25
|
+
`merged`, `closed`, `all`.
|
|
26
|
+
- `-q`, `-search <text>` — Pre-filter the repository list by name before the
|
|
27
|
+
interactive picker is shown.
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Fully interactive
|
|
33
|
+
nitor mr-status
|
|
34
|
+
|
|
35
|
+
# Jump straight to opened MRs after picking a repo
|
|
36
|
+
nitor mr-status -state opened
|
|
37
|
+
|
|
38
|
+
# Narrow the repo picker to repos whose names contain "portal"
|
|
39
|
+
nitor mr-status -search portal
|
|
40
|
+
|
|
41
|
+
# Short aliases
|
|
42
|
+
nitor mr-status -st merged -q gateway
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## How the tree is built
|
|
46
|
+
|
|
47
|
+
- MRs are grouped by their `target_branch`.
|
|
48
|
+
- A target branch that is **not** any MR's source branch is treated as a root
|
|
49
|
+
(e.g. `master`, `development`).
|
|
50
|
+
- For each MR, if its `source_branch` is itself a target of another MR, that
|
|
51
|
+
MR is nested underneath as a child — producing a readable stack of MRs.
|
|
52
|
+
- Cycles (rare, but possible with odd branch setups) are detected and marked
|
|
53
|
+
with a `(cycle)` label instead of recursing forever.
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
The command uses the GitLab REST API and requires the following env vars in
|
|
58
|
+
your `.env.nu`:
|
|
59
|
+
|
|
60
|
+
- `GITLAB_URI` — e.g. `https://gitlab.com/`
|
|
61
|
+
- `GITLAB_TOKEN` — personal access token with `read_api` scope
|
|
62
|
+
|
|
63
|
+
If either is missing, the command exits with a clear message.
|
|
64
|
+
|
|
65
|
+
## Output legend
|
|
66
|
+
|
|
67
|
+
- `⎇ <branch>` — a target branch heading with the number of MRs targeting it
|
|
68
|
+
- `!<iid> <title>` — the merge request, colored by state:
|
|
69
|
+
- green: `opened`
|
|
70
|
+
- blue: `merged`
|
|
71
|
+
- red: `closed`
|
|
72
|
+
- `[draft]` — shown when the MR is a draft / WIP
|
|
73
|
+
- Second line: `source → target [state] author updated_at`
|
|
74
|
+
- Third line: the MR web URL (clickable in most modern terminals)
|
package/docs/README.md
CHANGED
|
@@ -54,6 +54,10 @@ nitor <command> -help
|
|
|
54
54
|
|
|
55
55
|
- **[backup](./BACKUP.md)** - MongoDB database backups
|
|
56
56
|
|
|
57
|
+
### GitLab Insights
|
|
58
|
+
|
|
59
|
+
- **[mr-status](./MR_STATUS.md)** - List GitLab repositories you contribute to and view merge requests as a branch tree
|
|
60
|
+
|
|
57
61
|
### Time Entry Commands
|
|
58
62
|
|
|
59
63
|
- **[time-entry](./TIME_ENTRY.md)** - Complete time tracking and management guide
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nitor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "A comprehensive CLI toolkit for automating GitLab operations, AI-powered code review, build/deploy automation, MongoDB backup/restore, and developer productivity tools",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Nithin V <mails2nithin@gmail.com>",
|
package/services/autocomplete.js
CHANGED
|
@@ -228,6 +228,14 @@ const setupAutocomplete = () => {
|
|
|
228
228
|
'-help': [],
|
|
229
229
|
'--h': [],
|
|
230
230
|
},
|
|
231
|
+
[ACTIONS.MR_STATUS]: {
|
|
232
|
+
'-state': ['opened', 'merged', 'closed', 'all'],
|
|
233
|
+
'-st': ['opened', 'merged', 'closed', 'all'],
|
|
234
|
+
'-search': [],
|
|
235
|
+
'-q': [],
|
|
236
|
+
'-help': [],
|
|
237
|
+
'--h': [],
|
|
238
|
+
},
|
|
231
239
|
[ACTIONS.TIME_INIT]: [],
|
|
232
240
|
[ACTIONS.TIME_SWITCH]: [],
|
|
233
241
|
[ACTIONS.TIME_ADD]: [],
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const readline = require('readline');
|
|
3
|
+
const { gitlabConfig, createSpinner } = require('./utils');
|
|
4
|
+
|
|
5
|
+
const STATES = ['opened', 'merged', 'closed', 'all'];
|
|
6
|
+
|
|
7
|
+
const C = {
|
|
8
|
+
reset: '\x1b[0m',
|
|
9
|
+
bold: '\x1b[1m',
|
|
10
|
+
dim: '\x1b[2m',
|
|
11
|
+
cyan: '\x1b[36m',
|
|
12
|
+
green: '\x1b[32m',
|
|
13
|
+
yellow: '\x1b[33m',
|
|
14
|
+
magenta: '\x1b[35m',
|
|
15
|
+
blue: '\x1b[34m',
|
|
16
|
+
red: '\x1b[31m',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const apiBase = () => `${(gitlabConfig.url || '').replace(/\/+$/, '')}/api/v4`;
|
|
20
|
+
|
|
21
|
+
const gitlabGet = async (path, params = {}) => {
|
|
22
|
+
const response = await axios.get(`${apiBase()}${path}`, {
|
|
23
|
+
headers: { 'PRIVATE-TOKEN': gitlabConfig.token },
|
|
24
|
+
params,
|
|
25
|
+
timeout: 20000,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return response.data;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const fetchProjects = async (search = '') => {
|
|
32
|
+
return gitlabGet('/projects', {
|
|
33
|
+
membership: true,
|
|
34
|
+
order_by: 'last_activity_at',
|
|
35
|
+
sort: 'desc',
|
|
36
|
+
per_page: 100,
|
|
37
|
+
simple: true,
|
|
38
|
+
search: search || undefined,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const fetchMergeRequests = async (projectId, state = 'opened') => {
|
|
43
|
+
return gitlabGet(`/projects/${encodeURIComponent(String(projectId))}/merge_requests`, {
|
|
44
|
+
state,
|
|
45
|
+
per_page: 100,
|
|
46
|
+
order_by: 'updated_at',
|
|
47
|
+
sort: 'desc',
|
|
48
|
+
scope: 'all',
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const buildTree = (mrs) => {
|
|
53
|
+
const byTarget = new Map();
|
|
54
|
+
const sourceSet = new Set(mrs.map((m) => m.source_branch));
|
|
55
|
+
|
|
56
|
+
for (const mr of mrs) {
|
|
57
|
+
if (!byTarget.has(mr.target_branch)) {
|
|
58
|
+
byTarget.set(mr.target_branch, []);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
byTarget.get(mr.target_branch).push(mr);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rootTargets = [...byTarget.keys()].filter((t) => !sourceSet.has(t));
|
|
65
|
+
const roots = rootTargets.length ? rootTargets : [...byTarget.keys()];
|
|
66
|
+
|
|
67
|
+
const buildNode = (branch, visited = new Set()) => {
|
|
68
|
+
if (visited.has(branch)) {
|
|
69
|
+
return { branch, mrs: [], cyclic: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
visited.add(branch);
|
|
73
|
+
|
|
74
|
+
const list = byTarget.get(branch) || [];
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
branch,
|
|
78
|
+
mrs: list.map((mr) => ({
|
|
79
|
+
mr,
|
|
80
|
+
child: byTarget.has(mr.source_branch)
|
|
81
|
+
? buildNode(mr.source_branch, new Set(visited))
|
|
82
|
+
: null,
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return roots.sort().map((b) => buildNode(b));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const stateColor = (state) => {
|
|
91
|
+
switch (state) {
|
|
92
|
+
case 'opened':
|
|
93
|
+
return C.green;
|
|
94
|
+
case 'merged':
|
|
95
|
+
return C.blue;
|
|
96
|
+
case 'closed':
|
|
97
|
+
return C.red;
|
|
98
|
+
default:
|
|
99
|
+
return C.dim;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const fmtDate = (s) => {
|
|
104
|
+
if (!s) return '';
|
|
105
|
+
|
|
106
|
+
return new Date(s).toISOString().replace('T', ' ').slice(0, 16);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const TREE = {
|
|
110
|
+
branch: '├── ',
|
|
111
|
+
lastBranch: '└── ',
|
|
112
|
+
vertical: '│ ',
|
|
113
|
+
space: ' ',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Render tree connectors at normal brightness (not dim) so the hierarchy is
|
|
117
|
+
// clearly visible against the rest of the output.
|
|
118
|
+
const treeStyle = (s) => s;
|
|
119
|
+
|
|
120
|
+
const printMR = (mr, prefix, connector, continuation) => {
|
|
121
|
+
const draft = mr.draft || mr.work_in_progress ? ` ${C.yellow}[draft]${C.reset}` : '';
|
|
122
|
+
const state = `${stateColor(mr.state)}${mr.state}${C.reset}`;
|
|
123
|
+
const author = mr.author?.name || mr.author?.username || '-';
|
|
124
|
+
const linePrefix = `${prefix}${treeStyle(continuation)}`;
|
|
125
|
+
|
|
126
|
+
console.log(
|
|
127
|
+
`${prefix}${treeStyle(connector)}${C.cyan}!${mr.iid}${C.reset} ${C.bold}${mr.title}${C.reset}${draft}`,
|
|
128
|
+
);
|
|
129
|
+
console.log(
|
|
130
|
+
`${linePrefix} ${C.dim}${mr.source_branch} → ${mr.target_branch}${C.reset} [${state}] ${C.magenta}${author}${C.reset} ${C.dim}${fmtDate(mr.updated_at)}${C.reset}`,
|
|
131
|
+
);
|
|
132
|
+
console.log(`${linePrefix} ${C.dim}${mr.web_url}${C.reset}`);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const printBranchNode = (node, prefix = '') => {
|
|
136
|
+
const count = node.mrs.length;
|
|
137
|
+
const cyclic = node.cyclic ? ` ${C.yellow}(cycle)${C.reset}` : '';
|
|
138
|
+
|
|
139
|
+
console.log(
|
|
140
|
+
`${prefix}${C.bold}${C.blue}⎇ ${node.branch}${C.reset} ${C.dim}(${count} MR${count === 1 ? '' : 's'})${C.reset}${cyclic}`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
node.mrs.forEach(({ mr, child }, idx) => {
|
|
144
|
+
const isLast = idx === node.mrs.length - 1;
|
|
145
|
+
const connector = isLast ? TREE.lastBranch : TREE.branch;
|
|
146
|
+
// When an MR has a child branch, keep the vertical in the continuation so
|
|
147
|
+
// the child is visibly connected to the MR even if this MR is the last
|
|
148
|
+
// sibling at its level.
|
|
149
|
+
const continuation = isLast ? TREE.space : TREE.vertical;
|
|
150
|
+
// const continuation = isLast && !child ? TREE.space : TREE.vertical;
|
|
151
|
+
|
|
152
|
+
printMR(mr, prefix, connector, continuation);
|
|
153
|
+
|
|
154
|
+
if (child) {
|
|
155
|
+
printBranchNode(child, `${prefix}${treeStyle(continuation)}`);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const askQuestion = (question) =>
|
|
161
|
+
new Promise((resolve) => {
|
|
162
|
+
// terminal: false keeps stdin in cooked mode so the terminal itself handles
|
|
163
|
+
// echo. Using the default (terminal: true) alongside tooling that tweaks
|
|
164
|
+
// the TTY (e.g. the spinner's cursor-hide escapes) can cause each typed
|
|
165
|
+
// character to render twice (e.g. "1" appearing as "11").
|
|
166
|
+
const rl = readline.createInterface({
|
|
167
|
+
input: process.stdin,
|
|
168
|
+
output: process.stdout,
|
|
169
|
+
terminal: false,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
process.stdout.write(question);
|
|
173
|
+
|
|
174
|
+
rl.question('', (answer) => {
|
|
175
|
+
rl.close();
|
|
176
|
+
resolve((answer || '').trim());
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const selectRepo = async (projects) => {
|
|
181
|
+
console.log('');
|
|
182
|
+
|
|
183
|
+
projects.forEach((p, i) => {
|
|
184
|
+
console.log(
|
|
185
|
+
` ${C.cyan}${String(i + 1).padStart(3)}${C.reset}. ${C.bold}${p.name}${C.reset} ${C.dim}(${p.path_with_namespace})${C.reset}`,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
console.log('');
|
|
190
|
+
|
|
191
|
+
for (;;) {
|
|
192
|
+
const answer = await askQuestion(`Select repository (1-${projects.length}) [default: 1]: `);
|
|
193
|
+
|
|
194
|
+
if (!answer) {
|
|
195
|
+
return projects[0];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const idx = parseInt(answer, 10);
|
|
199
|
+
|
|
200
|
+
if (Number.isInteger(idx) && idx >= 1 && idx <= projects.length) {
|
|
201
|
+
return projects[idx - 1];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(`${C.yellow}Invalid selection. Try again.${C.reset}`);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const selectState = async (preset) => {
|
|
209
|
+
const lower = (preset || '').toLowerCase();
|
|
210
|
+
|
|
211
|
+
if (STATES.includes(lower)) {
|
|
212
|
+
return lower;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log('');
|
|
216
|
+
|
|
217
|
+
STATES.forEach((s, i) => console.log(` ${C.cyan}${i + 1}${C.reset}. ${s}`));
|
|
218
|
+
|
|
219
|
+
const answer = await askQuestion(`Select MR state (1-${STATES.length}) [default: 1=opened]: `);
|
|
220
|
+
|
|
221
|
+
if (!answer) {
|
|
222
|
+
return 'opened';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const idx = parseInt(answer, 10);
|
|
226
|
+
|
|
227
|
+
return Number.isInteger(idx) && idx >= 1 && idx <= STATES.length ? STATES[idx - 1] : 'opened';
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const getMr = async (values = {}) => {
|
|
231
|
+
if (!gitlabConfig.url || !gitlabConfig.token) {
|
|
232
|
+
console.log(
|
|
233
|
+
`${C.yellow}GitLab configuration missing.${C.reset} Set GITLAB_URI and GITLAB_TOKEN in your env file.`,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const repoSpinner = createSpinner('Fetching repositories');
|
|
240
|
+
|
|
241
|
+
repoSpinner.start();
|
|
242
|
+
|
|
243
|
+
let projects;
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
projects = await fetchProjects(values.search || '');
|
|
247
|
+
|
|
248
|
+
repoSpinner.stop(
|
|
249
|
+
`Loaded ${projects.length} repositor${projects.length === 1 ? 'y' : 'ies'}`,
|
|
250
|
+
projects.length > 0,
|
|
251
|
+
);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
repoSpinner.stop('Failed to fetch repositories', false);
|
|
254
|
+
console.error(error.response?.data?.message || error.message);
|
|
255
|
+
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!projects.length) {
|
|
260
|
+
console.log('No repositories found.');
|
|
261
|
+
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const selected = await selectRepo(projects);
|
|
266
|
+
const state = await selectState(values.state);
|
|
267
|
+
|
|
268
|
+
const mrSpinner = createSpinner(`Fetching ${state} merge requests for ${selected.name}`);
|
|
269
|
+
|
|
270
|
+
mrSpinner.start();
|
|
271
|
+
|
|
272
|
+
let mrs;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
mrs = await fetchMergeRequests(selected.id, state);
|
|
276
|
+
mrSpinner.stop(
|
|
277
|
+
`Found ${mrs.length} merge request${mrs.length === 1 ? '' : 's'}`,
|
|
278
|
+
mrs.length > 0,
|
|
279
|
+
);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
mrSpinner.stop('Failed to fetch merge requests', false);
|
|
282
|
+
console.error(error.response?.data?.message || error.message);
|
|
283
|
+
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!mrs.length) {
|
|
288
|
+
console.log(`${C.dim}No ${state} merge requests.${C.reset}`);
|
|
289
|
+
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log('');
|
|
294
|
+
console.log(
|
|
295
|
+
`${C.bold}${selected.name}${C.reset} ${C.dim}${selected.path_with_namespace}${C.reset}`,
|
|
296
|
+
);
|
|
297
|
+
console.log('');
|
|
298
|
+
|
|
299
|
+
const tree = buildTree(mrs);
|
|
300
|
+
|
|
301
|
+
tree.forEach((node) => printBranchNode(node));
|
|
302
|
+
|
|
303
|
+
console.log('');
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
module.exports = { getMr };
|
|
@@ -2,13 +2,14 @@ const axios = require('axios');
|
|
|
2
2
|
const { build, buildStatus } = require('./build');
|
|
3
3
|
const { deploy } = require('./deploy');
|
|
4
4
|
const { ACTIONS } = require('./enums/actions.enum');
|
|
5
|
-
const { convertParamsToMap, wait, printTable, createSpinner } = require('./utils');
|
|
5
|
+
const { convertParamsToMap, wait, printTable, createSpinner, makeLink } = require('./utils');
|
|
6
6
|
const { createBranch } = require('./create-branch');
|
|
7
7
|
const { mrAIReview } = require('./review');
|
|
8
8
|
const { refactor } = require('./refactor');
|
|
9
9
|
const { backup } = require('./mongodb-backup');
|
|
10
10
|
const { merge } = require('./merge');
|
|
11
11
|
const { cleanup } = require('./cleanup');
|
|
12
|
+
const { getMr } = require('./mr-status');
|
|
12
13
|
|
|
13
14
|
// Time entry imports
|
|
14
15
|
const { getSprints } = require('./time-entry/utils');
|
|
@@ -23,6 +24,7 @@ const {
|
|
|
23
24
|
getGitIdStats,
|
|
24
25
|
getGitlabIssueMergeRequests,
|
|
25
26
|
getGitMergeRequestDetails,
|
|
27
|
+
getTaskBrowserUrl,
|
|
26
28
|
} = require('./task-stats');
|
|
27
29
|
|
|
28
30
|
const processArgs = async (type, value) => {
|
|
@@ -333,8 +335,10 @@ Options:
|
|
|
333
335
|
|
|
334
336
|
const mrDetails = await getGitlabIssueMergeRequests(gitIdDetails);
|
|
335
337
|
|
|
338
|
+
const taskUrl = getTaskBrowserUrl(task);
|
|
339
|
+
|
|
336
340
|
return mrDetails.map((mrDetail) => ({
|
|
337
|
-
Task: task,
|
|
341
|
+
Task: taskUrl ? makeLink(taskUrl, task) : task,
|
|
338
342
|
Owner: taskDetail.owner,
|
|
339
343
|
...mrDetail,
|
|
340
344
|
}));
|
|
@@ -355,6 +359,25 @@ Options:
|
|
|
355
359
|
break;
|
|
356
360
|
}
|
|
357
361
|
|
|
362
|
+
case ACTIONS.MR_STATUS: {
|
|
363
|
+
if (value === '-help' || value === '--h') {
|
|
364
|
+
console.log(`usage: \tnu mr-status [-state <opened|merged|closed|all>] [-search <text>]
|
|
365
|
+
\tnu mr-status [-st <state>] [-q <text>]
|
|
366
|
+
|
|
367
|
+
List GitLab repositories you are a member of, select one, and view its merge requests in a branch tree view.
|
|
368
|
+
|
|
369
|
+
Options:
|
|
370
|
+
-st, --state <state> MR state filter | opened (default), merged, closed, all
|
|
371
|
+
-q, --search <text> Pre-filter repositories by name`);
|
|
372
|
+
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
await getMr(values || {});
|
|
377
|
+
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
358
381
|
case ACTIONS.GET_TASK: {
|
|
359
382
|
if (value === '-help' || value === '--h') {
|
|
360
383
|
console.log(`usage: \tnu get-task [-task <task numbers with space>]
|
|
@@ -374,25 +397,47 @@ Options:
|
|
|
374
397
|
process.exit(1);
|
|
375
398
|
}
|
|
376
399
|
|
|
377
|
-
const
|
|
400
|
+
const taskNumbers = values.task.split(' ').filter(Boolean);
|
|
378
401
|
|
|
379
|
-
|
|
380
|
-
|
|
402
|
+
const spinner = createSpinner('Fetching merge requests');
|
|
403
|
+
spinner.start();
|
|
381
404
|
|
|
382
|
-
|
|
383
|
-
|
|
405
|
+
try {
|
|
406
|
+
spinner.update(`Fetching ${taskNumbers.length} task(s)`);
|
|
384
407
|
|
|
385
|
-
|
|
386
|
-
|
|
408
|
+
const taskDetailsList = await Promise.all(
|
|
409
|
+
taskNumbers.map((task) =>
|
|
410
|
+
getGitMergeRequestDetails(task).then((details) => ({ task, details })),
|
|
411
|
+
),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
spinner.stop('Loaded merge request data', true);
|
|
415
|
+
|
|
416
|
+
const rows = [];
|
|
417
|
+
|
|
418
|
+
for (const { task, details: taskDetails } of taskDetailsList) {
|
|
419
|
+
if (taskDetails.error) {
|
|
420
|
+
rows.push({ GitLab: task, Zoho: 'No merge requests' });
|
|
421
|
+
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
387
424
|
|
|
388
|
-
|
|
389
|
-
const zohoTaskMatch = taskDetail.description?.match(/itemdetails\/(I\d+)\)/);
|
|
425
|
+
let zoho = 'Not found';
|
|
390
426
|
|
|
391
|
-
|
|
427
|
+
for (const taskDetail of taskDetails) {
|
|
428
|
+
const zohoTaskMatch = taskDetail.description?.match(/itemdetails\/(I\d+)\)/);
|
|
429
|
+
|
|
430
|
+
zoho = zohoTaskMatch ? zohoTaskMatch[1] : 'Not found';
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
rows.push({ GitLab: task, Zoho: zoho });
|
|
392
434
|
}
|
|
393
|
-
}
|
|
394
435
|
|
|
395
|
-
|
|
436
|
+
printTable(rows, { columns: ['GitLab', 'Zoho'] });
|
|
437
|
+
} catch (error) {
|
|
438
|
+
spinner.stop('Failed to fetch merge requests', false);
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
396
441
|
|
|
397
442
|
break;
|
|
398
443
|
}
|
|
@@ -517,7 +562,7 @@ Options:
|
|
|
517
562
|
\t[${ACTIONS.BUILD}] [${ACTIONS.DEPLOY}] [${ACTIONS.BUILD_DEPLOY}]
|
|
518
563
|
\t[${ACTIONS.CREATE_BRANCH}] [${ACTIONS.REVIEW}] [${ACTIONS.MERGE}]
|
|
519
564
|
\t[${ACTIONS.CLEANUP}] [${ACTIONS.BACKUP}] [${ACTIONS.REFACTOR}]
|
|
520
|
-
\t[${ACTIONS.TASK_STATS}] [${ACTIONS.GET_TASK}]
|
|
565
|
+
\t[${ACTIONS.TASK_STATS}] [${ACTIONS.GET_TASK}] [${ACTIONS.MR_STATUS}]
|
|
521
566
|
\t[${ACTIONS.TIME_INIT}] [${ACTIONS.TIME_ADD}] [${ACTIONS.TIME_STATUS}]\n
|
|
522
567
|
Available commands:\n
|
|
523
568
|
backup : Backup MongoDB databases
|
|
@@ -527,6 +572,7 @@ Available commands:\n
|
|
|
527
572
|
completion : Setup shell autocomplete
|
|
528
573
|
create-branch : Create git branch
|
|
529
574
|
deploy : Deploy specified components
|
|
575
|
+
mr-status : List GitLab repos you contribute to and view merge requests as a tree
|
|
530
576
|
get-task : Get task details from GitLab merge requests
|
|
531
577
|
merge : Merge source branch into target branch
|
|
532
578
|
refactor : Refactor the provided text for improved clarity, conciseness, and professional quality
|
|
@@ -555,6 +601,8 @@ Example usage:\n
|
|
|
555
601
|
nitor cleanup
|
|
556
602
|
nitor create-branch -task <task number> -type <feat|fix> -description <description> -project <project short name>
|
|
557
603
|
nitor deploy -project <project> -components <components> -instance <instance>
|
|
604
|
+
nitor mr-status
|
|
605
|
+
nitor mr-status -state opened
|
|
558
606
|
nitor get-task -task <task numbers with space>
|
|
559
607
|
nitor merge -source <source branch> -target <target branch>
|
|
560
608
|
nitor refactor <text>
|
package/services/task-stats.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
|
-
const { zohoConfig, gitlabConfig } = require('./utils');
|
|
2
|
+
const { zohoConfig, gitlabConfig, makeLink } = require('./utils');
|
|
3
3
|
|
|
4
4
|
const getZohoUrl = (type, taskNumber) => {
|
|
5
5
|
switch (type) {
|
|
@@ -11,6 +11,20 @@ const getZohoUrl = (type, taskNumber) => {
|
|
|
11
11
|
}
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
// Browser URL for a Zoho Sprints task, e.g.
|
|
15
|
+
// https://externalusers.zohosprints.com/workspace/4medica/client/wmoku#P29/itemdetails/I8856
|
|
16
|
+
// Requires ZOHO_WORKSPACE (e.g. "4medica") and ZOHO_PROJECT_PREFIX (e.g. "P29")
|
|
17
|
+
// to be set in the env file; returns an empty string if either is missing.
|
|
18
|
+
const getTaskBrowserUrl = (taskNumber) => {
|
|
19
|
+
const { url, workspace, projectPrefix } = zohoConfig;
|
|
20
|
+
|
|
21
|
+
if (!url || !workspace || !projectPrefix || !taskNumber) {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return `${url}/workspace/${workspace}/client/wmoku#${projectPrefix}/itemdetails/I${taskNumber}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
14
28
|
const getZohoConfig = (type, taskNumber) => {
|
|
15
29
|
return {
|
|
16
30
|
method: 'get',
|
|
@@ -153,10 +167,14 @@ const getGitlabIssueMergeRequests = async (gitDetails) => {
|
|
|
153
167
|
const approvalResponse = await axios.request(approvalConfig);
|
|
154
168
|
const approvalData = approvalResponse.data;
|
|
155
169
|
|
|
170
|
+
// Build browser URLs for IDs so they render as clickable links.
|
|
171
|
+
const issueUrl = gitDetail.web_url;
|
|
172
|
+
const mrUrl = mrData.web_url;
|
|
173
|
+
|
|
156
174
|
// Merge approval data with MR data
|
|
157
175
|
return {
|
|
158
|
-
GitID: gitDetail.iid,
|
|
159
|
-
MRID: mrData.iid,
|
|
176
|
+
GitID: issueUrl ? makeLink(issueUrl, gitDetail.iid) : gitDetail.iid,
|
|
177
|
+
MRID: mrUrl ? makeLink(mrUrl, mrData.iid) : mrData.iid,
|
|
160
178
|
MRStatus: mrData.state,
|
|
161
179
|
MRAssignedTo: mrData.assignee?.name,
|
|
162
180
|
MRTarget: mrData.target_branch,
|
|
@@ -202,4 +220,5 @@ module.exports = {
|
|
|
202
220
|
getGitlabIssueDetails,
|
|
203
221
|
getGitlabIssueMergeRequests,
|
|
204
222
|
getGitMergeRequestDetails,
|
|
223
|
+
getTaskBrowserUrl,
|
|
205
224
|
};
|
package/services/utils.js
CHANGED
|
@@ -31,6 +31,8 @@ const zohoConfig = {
|
|
|
31
31
|
token: process.env.ZOHO_TOKEN,
|
|
32
32
|
url: process.env.ZOHO_URI,
|
|
33
33
|
userId: process.env.ZOHO_USERID,
|
|
34
|
+
workspace: process.env.ZOHO_WORKSPACE,
|
|
35
|
+
projectPrefix: process.env.ZOHO_PROJECT_PREFIX,
|
|
34
36
|
};
|
|
35
37
|
const keyMap = {
|
|
36
38
|
components: 'components',
|
|
@@ -61,6 +63,10 @@ const keyMap = {
|
|
|
61
63
|
do: 'docker',
|
|
62
64
|
sprint: 'sprint',
|
|
63
65
|
s: 'sprint',
|
|
66
|
+
state: 'state',
|
|
67
|
+
st: 'state',
|
|
68
|
+
search: 'search',
|
|
69
|
+
q: 'search',
|
|
64
70
|
};
|
|
65
71
|
const projectMap = {
|
|
66
72
|
portal: 'medica-portal',
|
|
@@ -388,6 +394,23 @@ const box = {
|
|
|
388
394
|
// Loading spinner frames
|
|
389
395
|
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
390
396
|
|
|
397
|
+
// Match CSI (SGR etc.) and OSC 8 hyperlink escape sequences so we can compute
|
|
398
|
+
// the visible width of strings that contain ANSI styling or terminal links.
|
|
399
|
+
// eslint-disable-next-line no-control-regex
|
|
400
|
+
const ANSI_REGEX = /\x1B\]8;[^\x07\x1B]*(?:\x07|\x1B\\)|\x1B\[[0-9;]*[A-Za-z]/g;
|
|
401
|
+
const stripAnsi = (s) => String(s ?? '').replace(ANSI_REGEX, '');
|
|
402
|
+
const visibleLength = (s) => stripAnsi(s).length;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Wrap text in an OSC 8 terminal hyperlink so supporting terminals render it
|
|
406
|
+
* as a clickable link.
|
|
407
|
+
*/
|
|
408
|
+
const makeLink = (url, text = url) => {
|
|
409
|
+
if (!url) return String(text ?? '');
|
|
410
|
+
|
|
411
|
+
return `\x1B]8;;${url}\x07${text}\x1B]8;;\x07`;
|
|
412
|
+
};
|
|
413
|
+
|
|
391
414
|
/**
|
|
392
415
|
* Create a loading spinner for async operations
|
|
393
416
|
* @param {string} message - Loading message to display
|
|
@@ -491,24 +514,27 @@ const printTable = (data, options = {}) => {
|
|
|
491
514
|
});
|
|
492
515
|
}
|
|
493
516
|
|
|
494
|
-
// Calculate column widths
|
|
517
|
+
// Calculate column widths (ignore ANSI/hyperlink escapes when measuring)
|
|
495
518
|
const colWidths = {};
|
|
496
519
|
columns.forEach((col) => {
|
|
497
520
|
const headerLen = col.length;
|
|
498
|
-
const maxDataLen = Math.max(
|
|
499
|
-
...filteredData.map((row) => String(row[col] ?? '').length),
|
|
500
|
-
headerLen,
|
|
501
|
-
);
|
|
521
|
+
const maxDataLen = Math.max(...filteredData.map((row) => visibleLength(row[col])), headerLen);
|
|
502
522
|
colWidths[col] = Math.min(maxDataLen, maxColWidth);
|
|
503
523
|
});
|
|
504
524
|
|
|
505
|
-
// Helper to truncate and pad text
|
|
525
|
+
// Helper to truncate and pad text while preserving ANSI/hyperlink escapes
|
|
506
526
|
const formatCell = (text, width) => {
|
|
507
527
|
const str = String(text ?? '');
|
|
508
|
-
|
|
509
|
-
|
|
528
|
+
const visible = visibleLength(str);
|
|
529
|
+
|
|
530
|
+
if (visible > width) {
|
|
531
|
+
// Only values without embedded escapes are safely truncatable; for
|
|
532
|
+
// links/colored values, fall back to stripped text to keep widths sane.
|
|
533
|
+
const plain = stripAnsi(str);
|
|
534
|
+
return plain.substring(0, width - 2) + '..';
|
|
510
535
|
}
|
|
511
|
-
|
|
536
|
+
|
|
537
|
+
return str + ' '.repeat(width - visible);
|
|
512
538
|
};
|
|
513
539
|
|
|
514
540
|
// Top border
|
|
@@ -560,8 +586,11 @@ const printTable = (data, options = {}) => {
|
|
|
560
586
|
} else if (col === 'MRStatus') {
|
|
561
587
|
cellColor =
|
|
562
588
|
value === 'merged' ? colors.green : value === 'opened' ? colors.yellow : colors.dim;
|
|
563
|
-
} else if (col === 'Task' || col === 'MRID' || col === 'GitID') {
|
|
589
|
+
} else if (col === 'Task' || col === 'MRID' || col === 'GitID' || col === 'GitLab') {
|
|
564
590
|
cellColor = colors.cyan;
|
|
591
|
+
} else if (col === 'Zoho') {
|
|
592
|
+
cellColor =
|
|
593
|
+
value === 'Not found' || value === 'No merge requests' ? colors.dim : colors.green;
|
|
565
594
|
} else if (col === 'Owner' || col === 'MRAssignedTo') {
|
|
566
595
|
cellColor = colors.magenta;
|
|
567
596
|
} else if (col === 'Repo') {
|
|
@@ -620,4 +649,5 @@ module.exports = {
|
|
|
620
649
|
gitlabConfig,
|
|
621
650
|
printTable,
|
|
622
651
|
createSpinner,
|
|
652
|
+
makeLink,
|
|
623
653
|
};
|