bjira 0.0.23 → 0.0.25

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 CHANGED
@@ -1,73 +1,17 @@
1
- # Jira cli
1
+ # Jira CLI
2
2
 
3
- This is a simple Jira CLI tool. License: MIT
3
+ A simple Jira CLI tool. License: MIT.
4
4
 
5
- ## How to install it
5
+ ## Install
6
6
 
7
7
  ```
8
- $ npm install -g bjira
8
+ npm install -g bjira
9
9
  ```
10
10
 
11
- ## How to configure it
11
+ ## Documentation
12
12
 
13
- Run `bjira init` to set up the tool. You can optain the API token from your
14
- jira settings.
13
+ Full documentation is available in the [docs](./docs/index.md) folder.
15
14
 
16
- ```
17
- $ bjira init
18
- ? Provide your jira host: your-domain.atlassian.net
19
- ? Please provide your jira username: username
20
- ? API token: [hidden]
21
- ? Enable HTTPS Protocol? Yes
22
- Config file succesfully created in: /home/<username>/.bjira.json
23
- ```
24
-
25
- ## How to use it
26
-
27
- Run `bjira help` to see the main help menu. Each command is well documented.
28
-
29
- There are 2 main concepts to know:
30
- - presets
31
- - custom fields.
32
-
33
- ### Presets
34
-
35
- Let's say you want to retrieve all the open issues assigned to you for project
36
- FOO. The query is something like this:
37
-
38
- ```
39
- bjira query 'project = "FOO" AND status != "Done" AND status != "Cancelled" AND assignee = currentUser()'
40
- ```
41
-
42
- You can save this query as a preset:
43
- ```
44
- bjira create mine 'project = "FOO" AND status != "Done" AND status != "Cancelled" AND assignee = currentUser()'
45
- ```
46
-
47
- Then, you can run it using its query name:
48
- ```
49
- bjira run mine
50
- ```
51
-
52
- If you want to have parameters in your query, use `$$$` as placeholder. For instance:
53
- ```
54
- bjira preset create search 'project = "FOO" AND text ~ "$$$" ORDER BY created DESC'
55
- bjira run search -q "hello world"
56
- ```
57
-
58
-
59
- ### Custom fields
60
- Jira is strongly configurable via custom fields. You can retrieve the list of custom fields using:
61
-
62
- ```
63
- bjira field listall
64
- ```
65
-
66
- If you want to see some of them in the issue report, add them for the project (FOO) and the issue type (Story):
67
-
68
- ```
69
- bjira field add FOO Story "Story Points"
70
- ```
15
+ ## License
71
16
 
72
- Any custom fields added to the list will be shown in the issue report (See `bjira show`).
73
- You can also set custom fields using `bira set custom `Story Points' ISSUE-ID`.
17
+ MIT see [LICENSE.md](./LICENSE.md).
@@ -0,0 +1,53 @@
1
+ ## Advanced Examples
2
+
3
+ ### 1) Grouping results by parent (Epics/Subtasks)
4
+
5
+ ```
6
+ # See epics with their child stories/tasks in a tree
7
+ bjira query -g "project = \"FOO\" ORDER BY updated DESC"
8
+ ```
9
+
10
+ ### 2) Parameterized presets for quick search
11
+
12
+ ```
13
+ bjira preset create text-search "project = \"FOO\" AND text ~ \"$$$\" ORDER BY created DESC"
14
+ bjira run text-search -q "payment gateway timeout"
15
+ ```
16
+
17
+ ### 3) Managing custom fields
18
+
19
+ ```
20
+ # Discover fields for a project and type
21
+ bjira field listall
22
+
23
+ # Track a field so it appears in show and is editable
24
+ bjira field add FOO Story "Story Points"
25
+
26
+ # Set it on an issue
27
+ bjira set custom 'Story Points' FOO-123
28
+ ```
29
+
30
+ ### 4) Linking work to Epics during creation
31
+
32
+ In `bjira create`, answer "yes" to set a parent epic, then pick an epic from the same project or search-as-you-type.
33
+
34
+ ### 5) Sprints overview for your issues
35
+
36
+ ```
37
+ # Select board and active/future sprint, then see your issues by status
38
+ bjira sprint show
39
+
40
+ # Show all users
41
+ bjira sprint show --all
42
+ ```
43
+
44
+ ### 6) Quick actions
45
+
46
+ ```
47
+ # Assign to yourself
48
+ bjira set assignee --me FOO-123
49
+
50
+ # Open in browser
51
+ bjira open FOO-123
52
+ ```
53
+
@@ -0,0 +1,19 @@
1
+ ## attachment
2
+
3
+ Work with attachments.
4
+
5
+ Subcommands:
6
+
7
+ - `attachment get <ISSUE-ID> <ATTACHMENT-ID>`: download the attachment to stdout.
8
+
9
+ Examples:
10
+
11
+ ```
12
+ # List attachments via show, then download by id
13
+ bjira show -a ISSUE-123 | less
14
+ bjira attachment get ISSUE-123 10001 > file.bin
15
+ ```
16
+
17
+ Notes:
18
+ - Upload and delete are not implemented yet.
19
+
@@ -0,0 +1,12 @@
1
+ ## comment
2
+
3
+ Add a comment to an issue.
4
+
5
+ Usage:
6
+
7
+ ```
8
+ bjira comment <ISSUE-ID>
9
+ ```
10
+
11
+ This opens your `$EDITOR`. Save and close to post the comment. Empty comments are ignored.
12
+
@@ -0,0 +1,22 @@
1
+ ## create
2
+
3
+ Create a new issue via interactive prompts.
4
+
5
+ Usage:
6
+
7
+ ```
8
+ bjira create
9
+ ```
10
+
11
+ Flow:
12
+ - Pick a project.
13
+ - Pick an issue type.
14
+ - Enter summary and optionally description (opens `$EDITOR`).
15
+ - Provide required custom fields (if any).
16
+ - Optionally set parent Epic (search-as-you-type, defaults to epics in project).
17
+ - After creation, optionally assign, set status, set configured custom fields, and add to a sprint.
18
+
19
+ Notes:
20
+ - Custom field prompts are limited to supported types (string, number, select/multiselect via `allowedValues`).
21
+ - Use `field add` to make custom fields visible and manageable in `show`/`set`.
22
+
@@ -0,0 +1,14 @@
1
+ ## field
2
+
3
+ Manage custom fields visibility and discovery.
4
+
5
+ Subcommands:
6
+
7
+ - `field listall`: show all projects, issue types, and available fields, marking supported ones.
8
+ - `field add <PROJECT> <ISSUE-TYPE> <FIELD-NAME>`: track a field so it appears in `show` and can be set with `set custom`.
9
+ - `field remove <PROJECT> <ISSUE-TYPE> <FIELD-NAME>`: stop tracking the field.
10
+ - `field list`: list tracked fields from your config.
11
+
12
+ Notes:
13
+ - Supported field types: `string`, `number`, and select-like fields exposing `allowedValues`.
14
+
@@ -0,0 +1,14 @@
1
+ ## init
2
+
3
+ Create the initial configuration file interactively.
4
+
5
+ Usage:
6
+
7
+ ```
8
+ bjira init
9
+ ```
10
+
11
+ Prompts: Jira host, protocol (HTTPS recommended), username, and API token.
12
+
13
+ The file is saved to the path specified by `-c, --config` (defaults to `~/.bjira.json`).
14
+
@@ -0,0 +1,17 @@
1
+ ## issue (show)
2
+
3
+ Display details for a single issue.
4
+
5
+ Usage:
6
+
7
+ ```
8
+ bjira show [options] <ISSUE-ID>
9
+ ```
10
+
11
+ Options:
12
+ - `-a, --attachments`: include attachments details.
13
+ - `-C, --comments`: include comments.
14
+ - `-s, --subissues`: list sub-issues; for Epics, also lists child issues.
15
+
16
+ The output includes key metadata, custom fields you have added via `field add`, timestamps, and counts for attachments and comments.
17
+
@@ -0,0 +1,12 @@
1
+ ## label
2
+
3
+ Add or remove labels on an issue.
4
+
5
+ Subcommands:
6
+
7
+ - `label add <ISSUE-ID>`: adds a label after interactive autocomplete. Prevents duplicates.
8
+ - `label remove <ISSUE-ID>`: removes one of the existing labels.
9
+
10
+ Notes:
11
+ - If the issue type does not support labels, the command exits with a message.
12
+
@@ -0,0 +1,13 @@
1
+ ## open
2
+
3
+ Open an issue in the default browser and print its URL.
4
+
5
+ Usage:
6
+
7
+ ```
8
+ bjira open <ISSUE-ID>
9
+ ```
10
+
11
+ Notes:
12
+ - Uses `open` (macOS), `xdg-open` (Linux), or `cmd /c start` (Windows). If automatic open fails, the URL is printed so you can copy/paste.
13
+
@@ -0,0 +1,22 @@
1
+ ## preset
2
+
3
+ Manage named JQL presets.
4
+
5
+ Subcommands:
6
+
7
+ - `preset create <NAME> <QUERY>`: create a new preset. Use `$$$` to mark positional parameters.
8
+ - `preset remove <NAME>`: delete a preset.
9
+ - `preset list`: list presets with their JQL.
10
+
11
+ Examples:
12
+
13
+ ```
14
+ # Save a reusable search for your work
15
+ bjira preset create mine "project = \"FOO\" AND statusCategory != Done AND assignee = currentUser()"
16
+
17
+ # Parameterized search (one placeholder)
18
+ bjira preset create search "project = \"FOO\" AND text ~ \"$$$\" ORDER BY created DESC"
19
+ ```
20
+
21
+ Run presets using the `run` command.
22
+
@@ -0,0 +1,10 @@
1
+ ## project
2
+
3
+ Interact with projects.
4
+
5
+ Subcommands:
6
+
7
+ - `project list`: list available projects (key and name).
8
+
9
+ Project selection prompts in other commands prioritize your latest used project.
10
+
@@ -0,0 +1,24 @@
1
+ ## query
2
+
3
+ Run an ad-hoc JQL query.
4
+
5
+ Usage:
6
+
7
+ ```
8
+ bjira query [options] <JQL>
9
+ ```
10
+
11
+ Options:
12
+ - `-l, --limit <N>`: maximum number of issues to fetch (default 20; pagination continues until limit or last page).
13
+ - `-g, --grouped`: group results by parent (Epic or Parent) and render a tree.
14
+
15
+ Examples:
16
+
17
+ ```
18
+ # My open issues in project FOO
19
+ bjira query "project = \"FOO\" AND statusCategory != Done AND assignee = currentUser()"
20
+
21
+ # Group by parent
22
+ bjira query -g "project = \"FOO\" ORDER BY updated DESC"
23
+ ```
24
+
@@ -0,0 +1,22 @@
1
+ ## run
2
+
3
+ Run a saved preset.
4
+
5
+ Usage:
6
+
7
+ ```
8
+ bjira run [options] <NAME>
9
+ ```
10
+
11
+ Options:
12
+ - `-q, --query <arg...>`: supply positional arguments for `$$$` placeholders in the preset JQL. Repeat for multiple placeholders in order.
13
+ - `-l, --limit <N>`: maximum number of issues to fetch (default 20).
14
+ - `-g, --grouped`: group by parent and render a tree.
15
+
16
+ Examples:
17
+
18
+ ```
19
+ bjira run mine
20
+ bjira run search -q "login bug"
21
+ ```
22
+
@@ -0,0 +1,14 @@
1
+ ## set
2
+
3
+ Update fields and transitions of issues.
4
+
5
+ Subcommands:
6
+
7
+ - `set assignee [--me] <ISSUE-ID>`: assign to a user. With `--me`, assign to the current user without a prompt.
8
+ - `set unassign <ISSUE-ID>`: clear the assignee.
9
+ - `set status <ISSUE-ID>`: change status via available transitions; required transition fields are prompted if supported.
10
+ - `set custom <FIELD-NAME> <ISSUE-ID>`: set a tracked custom field (see `field add`).
11
+
12
+ Notes:
13
+ - Supported field types for `set custom`: string, number, select/multi-select fields exposing `allowedValues`.
14
+
@@ -0,0 +1,13 @@
1
+ ## sprint
2
+
3
+ Work with Agile sprints (boards).
4
+
5
+ Subcommands:
6
+
7
+ - `sprint add <ISSUE-ID>`: add the issue to an active or future sprint (you pick board and sprint if multiple).
8
+ - `sprint remove <ISSUE-ID>`: move the issue back to the backlog.
9
+ - `sprint show [-a|--all]`: show sprint issues grouped by assignee and status columns. By default only your issues are shown; with `--all` show all users.
10
+
11
+ Notes:
12
+ - This uses Jira Agile (board/sprint) APIs; ensure your user has permissions.
13
+
@@ -0,0 +1,19 @@
1
+ ## Commands Overview
2
+
3
+ Per-command guides:
4
+
5
+ - [attachment](commands/attachment.md)
6
+ - [comment](commands/comment.md)
7
+ - [create](commands/create.md)
8
+ - [field](commands/field.md)
9
+ - [init](commands/init.md)
10
+ - [issue (show)](commands/issue.md)
11
+ - [label](commands/label.md)
12
+ - [open](commands/open.md)
13
+ - [preset](commands/preset.md)
14
+ - [project](commands/project.md)
15
+ - [query](commands/query.md)
16
+ - [run](commands/run.md)
17
+ - [set](commands/set.md)
18
+ - [sprint](commands/sprint.md)
19
+
@@ -0,0 +1,29 @@
1
+ ## Concepts
2
+
3
+ ### Presets
4
+
5
+ Presets are named JQL queries saved in your config file and executed via `bjira run`.
6
+
7
+ - Use `$$$` as a positional placeholder; supply values with `-q` in the same order.
8
+
9
+ Examples:
10
+
11
+ ```
12
+ bjira preset create mine "project = \"FOO\" AND statusCategory != Done AND assignee = currentUser()"
13
+ bjira preset create search "project = \"FOO\" AND text ~ \"$$$\" ORDER BY created DESC"
14
+ bjira run mine
15
+ bjira run search -q "security vulnerability"
16
+ ```
17
+
18
+ ### Custom Fields
19
+
20
+ Jira projects often define custom fields (e.g., Story Points). To surface and manage them in bjira:
21
+
22
+ 1. Discover supported fields using `bjira field listall`.
23
+ 2. Add fields to your config using `bjira field add <PROJECT> <ISSUE-TYPE> <FIELD-NAME>`.
24
+ 3. View these fields in `bjira show <ISSUE-ID>`.
25
+ 4. Update them using `bjira set custom '<FIELD-NAME>' <ISSUE-ID>`.
26
+
27
+ Supported field types:
28
+ - `string`, `number`, and select/multi-select fields exposing `allowedValues`.
29
+
@@ -0,0 +1,38 @@
1
+ ## Configuration
2
+
3
+ Run the interactive setup to create your config file:
4
+
5
+ ```
6
+ bjira init
7
+ ```
8
+
9
+ You will be prompted for Jira host, username, and API token. By default the configuration is stored in `~/.bjira.json`. You can change the location using the global `-c, --config <file>` option in any command.
10
+
11
+ Example config file:
12
+
13
+ ```json
14
+ {
15
+ "jira": {
16
+ "host": "your-domain.atlassian.net",
17
+ "protocol": "https",
18
+ "username": "you@example.com",
19
+ "password": "<api token>",
20
+ "apiVersion": 3,
21
+ "strictSSL": true
22
+ },
23
+ "presets": {
24
+ "mine": "project = \"FOO\" AND statusCategory != Done AND assignee = currentUser()",
25
+ "search": "project = \"FOO\" AND text ~ \"$$$\" ORDER BY created DESC"
26
+ },
27
+ "fields": [
28
+ { "projectName": "FOO", "issueTypeName": "Story", "fieldName": "Story Points" }
29
+ ],
30
+ "latestProject": "FOO"
31
+ }
32
+ ```
33
+
34
+ Notes:
35
+ - `presets` hold reusable JQL, with `$$$` placeholders for positional parameters.
36
+ - `fields` config controls which custom fields are shown in `show` output and usable via `set custom`.
37
+ - `latestProject` is managed automatically to prioritize project picks.
38
+
@@ -0,0 +1,9 @@
1
+ ## Global Options
2
+
3
+ - `-c, --config <file>`: path to the configuration file (defaults to `~/.bjira.json`).
4
+ - `--version`: print version.
5
+ - `help`: per-command help.
6
+
7
+ Environment variables:
8
+ - `EDITOR`: editor binary used to write descriptions and comments in interactive flows.
9
+
package/docs/index.md ADDED
@@ -0,0 +1,18 @@
1
+ # bjira — Documentation
2
+
3
+ This documentation covers installation, configuration, and usage of the `bjira` CLI to interact with Jira.
4
+
5
+ - Quick start: see `installation.md` and `configuration.md`.
6
+ - Concepts: `concepts.md` (presets and custom fields).
7
+ - Global options: `globals.md`.
8
+ - Commands: index in `commands.md` with links to per-command guides.
9
+ - Advanced usage: `advanced.md`.
10
+
11
+ Index:
12
+
13
+ - [Installation](installation.md)
14
+ - [Configuration](configuration.md)
15
+ - [Global Options](globals.md)
16
+ - [Concepts (Presets, Custom Fields)](concepts.md)
17
+ - [Commands](commands.md)
18
+ - [Advanced Examples](advanced.md)
@@ -0,0 +1,22 @@
1
+ ## Installation
2
+
3
+ - Requirements: Node.js >= 13.2.0, Jira Cloud account with API token.
4
+ - Install globally:
5
+
6
+ ```
7
+ npm install -g bjira
8
+ ```
9
+
10
+ - Verify:
11
+
12
+ ```
13
+ bjira --version
14
+ bjira help
15
+ ```
16
+
17
+ If you use commands that open an editor (e.g., `comment`, description during `create`), set the `EDITOR` environment variable, for example:
18
+
19
+ ```
20
+ export EDITOR=vim
21
+ ```
22
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bjira",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "A simple jira CLI tool",
5
5
  "main": "src/index.js",
6
6
  "author": {
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "engineStrict": true,
26
26
  "dependencies": {
27
+ "chalk": "^5.3.0",
27
28
  "commander": "^8.2.0",
28
29
  "execa": "^5.1.1",
29
30
  "fuzzy": "^0.1.3",
@@ -32,6 +33,7 @@
32
33
  "inquirer-checkbox-plus-prompt": "^1.0.1",
33
34
  "jira-client": "^6.23.0",
34
35
  "ora": "^6.0.1",
36
+ "string-width": "^5.1.2",
35
37
  "temp": "^0.9.4"
36
38
  }
37
39
  }
package/src/comment.js CHANGED
@@ -13,7 +13,7 @@ class Comment extends Command {
13
13
  .argument('<id>', 'The issue ID')
14
14
  .action(async id => {
15
15
  const comment = await Utils.writeInTempFile();
16
- if (!comment === null) {
16
+ if (comment === null) {
17
17
  return;
18
18
  }
19
19
 
package/src/create.js CHANGED
@@ -58,8 +58,7 @@ class Create extends Command {
58
58
  .issuetypes.find(i => i.name === issueType.name).fields;
59
59
  const requiredFields = Object.keys(issueFields).filter(
60
60
  key => issueFields[key].required &&
61
- Field.isSupported(issueFields[key]) &&
62
- !["issuetype", "summary", "description", "project"].includes(key)).map(key => issueFields[key]);
61
+ Field.isSupported(issueFields[key]) && ["issuetype", "summary", "description", "project"].includes(key) === false).map(key => issueFields[key]);
63
62
 
64
63
  for (const field of requiredFields) {
65
64
  const fieldData = await Field.askFieldIfSupported(field);
@@ -68,18 +67,49 @@ class Create extends Command {
68
67
 
69
68
  if (issueType.name !== 'Epic' &&
70
69
  await Ask.askBoolean('Do you want to set a parent epic?')) {
71
- const result = await Query.runQuery(jira, `project = "${project.key}" and type = "epic"`, 999999);
72
- if (!result.issues || result.issues.length === 0) {
73
- console.log("No epics yet.");
74
- } else {
75
- const resultFields = await Field.listFields(jira);
76
- result.issues.forEach(epic => Issue.replaceFields(epic, resultFields));
77
-
78
- const parentKey = await Ask.askList('Choose the epic:',
79
- result.issues.map(epic => ({
80
- name: `${epic.key} - ${epic.fields['Summary']}`,
70
+ const parentKey = await Ask.askCallback('Choose the epic:', async (input) => {
71
+ // Default suggestions: epics in the same project, most recently updated, not done.
72
+ if (!input) {
73
+ const jql = `project = \"${project.key}\" AND issuetype = \"Epic\" AND statusCategory != Done ORDER BY updated DESC`;
74
+ const result = await jira.apiRequest('/search/jql', {
75
+ method: 'POST',
76
+ followAllRedirects: true,
77
+ body: {
78
+ jql,
79
+ fields: ['summary', 'key'],
80
+ maxResults: 20
81
+ }
82
+ });
83
+
84
+ if (!result.issues) return [];
85
+ return result.issues.map(epic => ({
86
+ name: `${epic.key} - ${epic.fields.summary}`,
81
87
  value: epic.key
82
- })));
88
+ }));
89
+ }
90
+
91
+ // As-you-type search: allow partial matches via wildcard.
92
+ const inputWithWildcard = `${input}*`;
93
+ // Search epics by key or summary across projects, matching prefixes.
94
+ const jql = `issuetype = \"Epic\" AND (summary ~ \"${inputWithWildcard}\" OR key ~ \"${inputWithWildcard}\") ORDER BY updated DESC`;
95
+ const result = await jira.apiRequest('/search/jql', {
96
+ method: 'POST',
97
+ followAllRedirects: true,
98
+ body: {
99
+ jql,
100
+ fields: ['summary', 'key'],
101
+ maxResults: 20
102
+ }
103
+ });
104
+
105
+ if (!result.issues) return [];
106
+ return result.issues.map(epic => ({
107
+ name: `${epic.key} - ${epic.fields.summary}`,
108
+ value: epic.key
109
+ }));
110
+ });
111
+
112
+ if (parentKey) {
83
113
  newIssue.fields.parent = {
84
114
  key: parentKey
85
115
  };
@@ -5,7 +5,7 @@
5
5
  import Table from './table.js';
6
6
 
7
7
  class ErrorHandler {
8
- static showError(jira, e) {
8
+ static showError(e) {
9
9
  const table = new Table({
10
10
  head: ['Errors']
11
11
  });
@@ -35,7 +35,7 @@ class ErrorHandler {
35
35
  console.log(table.toString());
36
36
  }
37
37
 
38
- static showWarningMessages(jira, messages) {
38
+ static showWarningMessages(messages) {
39
39
  const table = new Table({
40
40
  head: ['Warnings']
41
41
  });
package/src/index.js CHANGED
@@ -22,6 +22,7 @@ import Query from './query.js';
22
22
  import Run from './run.js';
23
23
  import Set from './set.js';
24
24
  import Sprint from './sprint.js';
25
+ import Open from './open.js';
25
26
 
26
27
  const DEFAULT_CONFIG_FILE = path.join(os.homedir(), ".bjira.json")
27
28
 
@@ -39,6 +40,7 @@ const commands = [
39
40
  new Run(),
40
41
  new Set(),
41
42
  new Sprint(),
43
+ new Open(),
42
44
  ];
43
45
 
44
46
  const pjson = JSON.parse(fs.readFileSync(new URL("../package.json",
package/src/init.js CHANGED
@@ -21,7 +21,7 @@ class Init extends Command {
21
21
  protocol: await Ask.askBoolean('Enable HTTPS Protocol?') ? 'https' : 'http',
22
22
  username: (await Ask.askString('Please provide your jira username:')).trim(),
23
23
  password: (await Ask.askPassword('API token:')).trim(),
24
- apiVersion: '3',
24
+ apiVersion: 3,
25
25
  strictSSL: true,
26
26
  },
27
27
  presets: {},
package/src/issue.js CHANGED
@@ -21,7 +21,7 @@ class Issue extends Command {
21
21
  .description('Show an issue')
22
22
  .option('-a, --attachments', 'Show the attachments too')
23
23
  .option('-C, --comments', 'Show the comments too')
24
- .option('-s, --subissues', 'Show the comments too')
24
+ .option('-s, --subissues', 'Show the subissues too')
25
25
  .argument('<id>', 'The issue ID')
26
26
  .action(async id => {
27
27
  const jira = new Jira(program);
@@ -77,6 +77,12 @@ class Issue extends Command {
77
77
  [
78
78
  'Labels', issue.fields['Labels'].join(', ')
79
79
  ],
80
+ [
81
+ 'Parent', {
82
+ color: "yellow",
83
+ text: issue.fields['Parent']?.key
84
+ }
85
+ ],
80
86
  [
81
87
  'Sprint', {
82
88
  color: "yellow",
@@ -182,7 +188,7 @@ class Issue extends Command {
182
188
  'Updated on', comment['Updated']
183
189
  ],
184
190
  [
185
- 'Body', comment.body
191
+ 'Body', Issue.showComment(comment.body)
186
192
  ],
187
193
  ]);
188
194
  });
@@ -192,13 +198,13 @@ class Issue extends Command {
192
198
 
193
199
  if (cmd.opts().subissues) {
194
200
  console.log("\nSub-issues:");
195
- const children = await Query.runQuery(jira, `parent = "${id}"`, 999999);
196
- await Query.showIssues(jira, children.issues, children.total, resultFields, false);
201
+ const children = await Query.runQuery(jira, `parent = "${id}"`);
202
+ await Query.showIssues(jira, children.issues, children.isLast, resultFields, false);
197
203
 
198
204
  if (issue.fields['Issue Type'].name === 'Epic') {
199
205
  console.log("\nEpic issues:");
200
206
  const children = await jira.spin('Fetching child issues...', jira.api.getIssuesForEpic(id));
201
- await Query.showIssues(jira, children.issues, children.total, resultFields, false);
207
+ await Query.showIssues(jira, children.issues, children.isLast, resultFields, false);
202
208
  }
203
209
  }
204
210
 
@@ -208,7 +214,7 @@ class Issue extends Command {
208
214
  static replaceFields(obj, fields) {
209
215
  if (Array.isArray(obj)) {
210
216
  obj.forEach((o, pos) => {
211
- obj[o] = Issue.replaceFields(o, fields);
217
+ obj[pos] = Issue.replaceFields(o, fields);
212
218
  });
213
219
  } else if (obj && typeof obj === "object") {
214
220
  Object.keys(obj).forEach(key => {
@@ -237,6 +243,37 @@ class Issue extends Command {
237
243
  return str;
238
244
  }
239
245
 
246
+ static showComment(obj) {
247
+ if (typeof obj === "string") return obj;
248
+
249
+ switch (obj.type) {
250
+ case 'doc':
251
+ case 'paragraph':
252
+ return obj.content.map(a => Issue.showComment(a)).join("");
253
+
254
+ case 'text':
255
+ return obj.text;
256
+
257
+ case 'inlineCard':
258
+ return obj.attrs.url;
259
+
260
+ case 'status':
261
+ case 'mention':
262
+ case 'emoji':
263
+ return obj.attrs.text;
264
+
265
+ case 'hardBreak':
266
+ return '\n';
267
+
268
+ case 'date':
269
+ const date = new Date(obj.attrs.timestamp * 1000);
270
+ return date.toLocaleString();
271
+
272
+ default:
273
+ return '';
274
+ }
275
+ }
276
+
240
277
  showEpicIssue(issue) {
241
278
  if (!issue) return "";
242
279
  return `${issue.key} (${issue.fields['Summary'].trim()})`;
package/src/jira.js CHANGED
@@ -15,7 +15,7 @@ class Jira {
15
15
 
16
16
  if (!fs.existsSync(this.configFile)) {
17
17
  console.log(`Config file ${this.configFile} does not exist.`);
18
- process.exit();
18
+ process.exit(1);
19
19
  return;
20
20
  }
21
21
 
@@ -100,7 +100,7 @@ class Jira {
100
100
  return result;
101
101
  } catch (e) {
102
102
  spinner.stop();
103
- ErrorHandler.showError(this, e);
103
+ ErrorHandler.showError(e);
104
104
  process.exit(1);
105
105
  }
106
106
  }
package/src/open.js ADDED
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2021-2022 Andrea Marchesini <baku@bnode.dev>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+
5
+ import execa from 'execa';
6
+ import color from 'chalk';
7
+
8
+ import Command from './command.js';
9
+ import Jira from './jira.js';
10
+ import Issue from './issue.js';
11
+
12
+ class Open extends Command {
13
+ addOptions(program) {
14
+ program.command('open')
15
+ .description('Open an issue in the default browser')
16
+ .argument('<id>', 'The issue ID')
17
+ .action(async id => {
18
+ const jira = new Jira(program);
19
+ const url = Issue.url(jira, id);
20
+
21
+ const {
22
+ cmd,
23
+ args
24
+ } = this._browserOpener(url);
25
+
26
+ try {
27
+ const subprocess = execa(cmd, args, {
28
+ detached: true,
29
+ stdio: 'ignore'
30
+ });
31
+ subprocess.unref?.();
32
+ console.log(color.blue(url));
33
+ } catch (e) {
34
+ console.log('Unable to open the browser automatically. Open this URL:');
35
+ console.log(color.blue(url));
36
+ }
37
+ });
38
+ }
39
+
40
+ _browserOpener(url) {
41
+ switch (process.platform) {
42
+ case 'darwin':
43
+ return {
44
+ cmd: 'open', args: [url]
45
+ };
46
+ case 'win32':
47
+ return {
48
+ cmd: 'cmd', args: ['/c', 'start', '', url]
49
+ };
50
+ default:
51
+ return {
52
+ cmd: 'xdg-open', args: [url]
53
+ };
54
+ }
55
+ }
56
+ };
57
+
58
+ export default Open;
package/src/query.js CHANGED
@@ -29,12 +29,12 @@ class Query extends Command {
29
29
  const resultFields = await Field.listFields(jira);
30
30
  const result = await Query.runQuery(jira, query, opts.limit);
31
31
 
32
- await Query.showIssues(jira, result.issues, result.total, resultFields, opts.grouped);
32
+ await Query.showIssues(jira, result.issues, result.isLast, resultFields, opts.grouped);
33
33
  });
34
34
  }
35
35
 
36
- static async showIssues(jira, issues, total, fields, grouped) {
37
- console.log(`Showing ${color.bold(issues.length)} issues of ${color.bold(total)}`);
36
+ static async showIssues(jira, issues, isLast, fields, grouped) {
37
+ console.log(`Showing ${color.bold(issues.length)} issues of ${isLast ? color.bold(issues.length) : "more"}`);
38
38
 
39
39
  issues = issues.map(issue => Issue.replaceFields(issue, fields)).map(issue => ({
40
40
  children: [],
@@ -150,6 +150,7 @@ class Query extends Command {
150
150
  static async runQuery(jira, query, expectedResult) {
151
151
  let issues = [];
152
152
  let nextPageToken = null;
153
+ let isLast = true;
153
154
  while (issues.length < (expectedResult === undefined ? issues.length + 1 : expectedResult)) {
154
155
  const result = await jira.spin('Running query...',
155
156
  jira.apiRequest('/search/jql', {
@@ -159,23 +160,27 @@ class Query extends Command {
159
160
  jql: query,
160
161
  nextPageToken,
161
162
  fields: ['*all'],
162
- maxResults: Math.min(5000, expectedResult - issues.length)
163
+ maxResults: 20,
163
164
  }
164
165
  }));
165
166
 
166
167
  if (result.warningMessages) {
167
168
  ErrorHandler.showWarningMessages(result.warningMessages);
168
- return;
169
+ return {
170
+ isLast: true,
171
+ issues: []
172
+ };
169
173
  }
170
174
 
171
175
  issues = issues.concat(result.issues);
172
176
  nextPageToken = result.nextPageToken;
177
+ isLast = result.isLast;
173
178
 
174
- if (result.isLast) break;
179
+ if (isLast) break;
175
180
  }
176
181
 
177
182
  return {
178
- total: issues.length,
183
+ isLast,
179
184
  issues
180
185
  };
181
186
  }
package/src/run.js CHANGED
@@ -47,7 +47,7 @@ class Run extends Command {
47
47
  const resultFields = await Field.listFields(jira);
48
48
  const result = await Query.runQuery(jira, query, opts.limit);
49
49
 
50
- await Query.showIssues(jira, result.issues, result.total, resultFields, opts.grouped);
50
+ await Query.showIssues(jira, result.issues, result.isLast, resultFields, opts.grouped);
51
51
  });
52
52
  }
53
53
 
package/src/set.js CHANGED
@@ -13,12 +13,18 @@ class Set extends Command {
13
13
  addOptions(program) {
14
14
  const setCmd = program.command('set')
15
15
  .description('Update fields in an issue');
16
- setCmd.command('assignee')
16
+ const assigneeCmd = setCmd.command('assignee')
17
17
  .description('Assign the issue to somebody')
18
+ .option('--me', 'Assign the issue to the current user')
18
19
  .argument('<ID>', 'The issue ID')
19
20
  .action(async id => {
20
21
  const jira = new Jira(program);
21
- await Set.assignIssue(jira, id);
22
+ const opts = assigneeCmd.opts();
23
+ if (opts.me) {
24
+ await Set.assignToMe(jira, id);
25
+ } else {
26
+ await Set.assignIssue(jira, id);
27
+ }
22
28
  });
23
29
 
24
30
  setCmd.command('unassign')
@@ -90,6 +96,19 @@ class Set extends Command {
90
96
  await jira.spin(`Updating issue ${id}...`, jira.api.updateIssue(id, issue));
91
97
  }
92
98
 
99
+ static async assignToMe(jira, id) {
100
+ const me = await jira.spin('Retrieving current user...', jira.api.getCurrentUser());
101
+ const issue = {
102
+ fields: {
103
+ assignee: {
104
+ accountId: me.accountId
105
+ }
106
+ }
107
+ };
108
+
109
+ await jira.spin(`Updating issue ${id}...`, jira.api.updateIssue(id, issue));
110
+ }
111
+
93
112
  static async setCustomField(jira, customField, id, defaultValue = null) {
94
113
  const field = await Field.fetchAndAskFieldIfSupported(jira, customField, defaultValue);
95
114
  if (field === null) {
package/src/sprint.js CHANGED
@@ -110,7 +110,7 @@ class Sprint extends Command {
110
110
  if (showCmd.opts().all) {
111
111
  filteredUsers = User.sortUsers(currentUser, users);
112
112
  } else {
113
- filteredUsers = [users.find(user => user.accountId === currentUser.accountId)];
113
+ filteredUsers = users.filter(user => user.accountId === currentUser.accountId);
114
114
  }
115
115
 
116
116
  filteredUsers.forEach(user => {
@@ -131,7 +131,7 @@ class Sprint extends Command {
131
131
  for (let i = 0; i < maxIssues; ++i) {
132
132
  const line = statuses.map(status => {
133
133
  if (status.issues.length > i) {
134
- return `${status.issues[i].key} ${status.issues[i].fields['Summary'].trim()}`
134
+ return `${color.yellow(status.issues[i].key)} ${status.issues[i].fields['Summary'].trim()}`
135
135
  }
136
136
  return ""
137
137
  });
package/src/table.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
 
5
5
  import color from 'chalk';
6
+ import stringWidth from 'string-width';
6
7
 
7
8
  class Table {
8
9
  constructor(options) {
@@ -25,11 +26,15 @@ class Table {
25
26
  if (typeof field !== "object") field = {
26
27
  text: field
27
28
  };
28
- field.text = ("" + field.text).split("\n");
29
+ field.text = ("" + (field.text === undefined ? "" : field.text)).split("\n");
29
30
  this._columns[pos].rows.push(field);
30
31
  });
31
32
 
32
- for (let i = row.length; i < this._columns.length; ++i) this._columns[i].rows.push({});
33
+ for (let i = row.length; i < this._columns.length; ++i) {
34
+ this._columns[i].rows.push({
35
+ text: [""]
36
+ });
37
+ }
33
38
 
34
39
  ++this._rows;
35
40
  }
@@ -46,8 +51,9 @@ class Table {
46
51
  this._columns.forEach(column => this._computeColumnWidth(column));
47
52
 
48
53
  const totalWidth = this._columns.reduce((x, column) => x + column.width, this._columns.length - 1);
49
- if (totalWidth > process.stdout.columns) {
50
- this._resizeWidthOf(totalWidth - process.stdout.columns + (this._columns.length - 1));
54
+ const availableColumns = (process.stdout && process.stdout.columns) ? process.stdout.columns : 120;
55
+ if (totalWidth > availableColumns) {
56
+ this._resizeWidthOf(totalWidth - availableColumns + (this._columns.length - 1));
51
57
  }
52
58
 
53
59
  const lines = [];
@@ -77,10 +83,10 @@ class Table {
77
83
 
78
84
  _computeColumnWidth(column) {
79
85
  column.width = Math.max(...column.rows.map(row =>
80
- Math.max(...row.text.map(line => line.length))
86
+ Math.max(...row.text.map(line => this._visibleLength(line)))
81
87
  ));
82
88
 
83
- if (column.head) column.width = Math.max(column.width, column.head.length);
89
+ if (column.head) column.width = Math.max(column.width, this._visibleLength(column.head));
84
90
  }
85
91
 
86
92
  _computeRowHeight(column, row) {
@@ -90,13 +96,13 @@ class Table {
90
96
  _toWidth(str, width, spaces) {
91
97
  str = str || "";
92
98
 
93
- let strLength = str.length;
99
+ let strLength = this._visibleLength(str);
94
100
  if (strLength > width) {
95
- return this._truncate(str, width - 1) + "…";
101
+ return this._truncateVisible(str, width - 1) + "…";
96
102
  }
97
103
 
98
104
  if (spaces) {
99
- for (let strLength = str.length; strLength < width; ++strLength) str += " ";
105
+ while (this._visibleLength(str) < width) str += " ";
100
106
  }
101
107
  return str;
102
108
  }
@@ -122,14 +128,14 @@ class Table {
122
128
  }
123
129
 
124
130
  _maybeSplitRow(row, width) {
125
- if (row.length <= width) return [row];
131
+ if (this._visibleLength(row) <= width) return [row];
126
132
 
127
133
  const rows = [];
128
134
  let currentRow = "";
129
135
  for (let part of row.split(" ")) {
130
136
  if (currentRow.length === 0) {
131
137
  currentRow = part;
132
- } else if ((currentRow.length + part.length + 1) < width) {
138
+ } else if ((this._visibleLength(currentRow) + this._visibleLength(part) + 1) < width) {
133
139
  currentRow += " " + part;
134
140
  } else {
135
141
  rows.push(currentRow);
@@ -146,9 +152,9 @@ class Table {
146
152
 
147
153
  _resizeWidthOfOne() {
148
154
  const max = Math.max(...this._columns.map(column => column.width));
149
- for (let columnId in this._columns) {
150
- const column = this._columns[columnId];
151
- if (!this._unresizableColumns.includes(columnId) && column.width === max) {
155
+ for (let i = 0; i < this._columns.length; ++i) {
156
+ const column = this._columns[i];
157
+ if (!this._unresizableColumns.includes(i) && column.width === max) {
152
158
  --column.width;
153
159
  break;
154
160
  }
@@ -156,7 +162,7 @@ class Table {
156
162
  }
157
163
 
158
164
  _truncate(str, width) {
159
- return str.slice(0, width);
165
+ return this._truncateVisible(str, width);
160
166
  }
161
167
 
162
168
  _stylize(row, text) {
@@ -174,6 +180,51 @@ class Table {
174
180
 
175
181
  return color[row.color].apply(null, [text]);
176
182
  }
183
+
184
+ // Returns the display width of a string (ANSI-aware, CJK/emoji aware).
185
+ _visibleLength(str) {
186
+ if (!str) return 0;
187
+ return stringWidth("" + str);
188
+ }
189
+
190
+ // Truncate a string to a target visible width, preserving ANSI codes and
191
+ // ensuring styles are reset if we cut before a reset sequence.
192
+ _truncateVisible(str, width) {
193
+ if (width <= 0) return "";
194
+ const input = "" + (str || "");
195
+ const ansi = this._ansiRegex();
196
+ let out = "";
197
+ let i = 0;
198
+ while (i < input.length) {
199
+ const ch = input[i];
200
+ if (ch === "\u001b") {
201
+ const match = input.slice(i).match(ansi);
202
+ if (match && match.index === 0) {
203
+ out += match[0];
204
+ i += match[0].length;
205
+ continue;
206
+ }
207
+ out += ch;
208
+ i++;
209
+ continue;
210
+ }
211
+ const candidate = out + ch;
212
+ if (stringWidth(candidate) > width) break;
213
+ out = candidate;
214
+ i++;
215
+ }
216
+
217
+ // If we truncated before natural end, ensure we reset styles.
218
+ if (i < input.length) {
219
+ out += "\u001b[0m";
220
+ }
221
+ return out;
222
+ }
223
+
224
+ _ansiRegex() {
225
+ // eslint-disable-next-line no-control-regex
226
+ return /\u001B\[[0-?]*[ -\/]*[@-~]/g;
227
+ }
177
228
  };
178
229
 
179
230
  export default Table;