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 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.4.2",
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>",
@@ -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]: [],
@@ -13,6 +13,7 @@ module.exports = {
13
13
  VERSION: 'version',
14
14
  TASK_STATS: 'task-stats',
15
15
  GET_TASK: 'get-task',
16
+ MR_STATUS: 'mr-status',
16
17
 
17
18
  // Time entry commands
18
19
  TIME_INIT: 'time-init',
@@ -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 tasks = {};
400
+ const taskNumbers = values.task.split(' ').filter(Boolean);
378
401
 
379
- for (const task of values.task.split(' ')) {
380
- const taskDetails = await getGitMergeRequestDetails(task);
402
+ const spinner = createSpinner('Fetching merge requests');
403
+ spinner.start();
381
404
 
382
- if (taskDetails.error) {
383
- console.log(`No merge requests found for task ${task}`);
405
+ try {
406
+ spinner.update(`Fetching ${taskNumbers.length} task(s)`);
384
407
 
385
- continue;
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
- for (const taskDetail of taskDetails) {
389
- const zohoTaskMatch = taskDetail.description?.match(/itemdetails\/(I\d+)\)/);
425
+ let zoho = 'Not found';
390
426
 
391
- tasks[task] = zohoTaskMatch ? zohoTaskMatch[1] : 'Not found';
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
- console.log(tasks);
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>
@@ -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
- if (str.length > width) {
509
- return str.substring(0, width - 2) + '..';
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
- return str.padEnd(width);
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
  };