Skip to content

Commit fb61d63

Browse files
author
Ismar Iljazovic
committed
feat: add API command for direct Jira API access (from PR ankitpokhrel#887)
Cherry-picked from jmartasek/add-api-command Original author: jmartasek
1 parent a54831b commit fb61d63

File tree

3 files changed

+289
-0
lines changed

3 files changed

+289
-0
lines changed

internal/cmd/api/api.go

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"os"
11+
"regexp"
12+
"strings"
13+
14+
"github.com/spf13/cobra"
15+
"github.com/spf13/viper"
16+
17+
"github.com/ankitpokhrel/jira-cli/api"
18+
"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
19+
jiraConfig "github.com/ankitpokhrel/jira-cli/internal/config"
20+
"github.com/ankitpokhrel/jira-cli/pkg/jira"
21+
)
22+
23+
const (
24+
helpText = `Send authenticated requests to arbitrary Jira API endpoints.
25+
26+
You can use this command to make authenticated API requests to any endpoint in the
27+
Jira REST API using the currently configured authentication settings.
28+
29+
The endpoint path (like /rest/api/3/project) will be appended to your configured
30+
Jira server URL.`
31+
examples = `# Send a GET request to a custom endpoint
32+
$ jira api /rest/api/3/project
33+
34+
# Send a POST request with a JSON payload
35+
$ jira api -X POST /rest/api/3/issue -d '{"fields":{"project":{"key":"DEMO"},"summary":"Test issue","issuetype":{"name":"Task"}}}'
36+
37+
# Use a file as the request body
38+
$ jira api -X POST /rest/api/3/issue --file payload.json
39+
40+
# Translate customfield_* IDs to their friendly names from config
41+
$ jira api /rest/api/3/issue/PROJ-123 --translate-fields`
42+
)
43+
44+
// NewCmdAPI is an api command.
45+
func NewCmdAPI() *cobra.Command {
46+
cmd := cobra.Command{
47+
Use: "api [endpoint]",
48+
Short: "Make authenticated requests to the Jira API",
49+
Long: helpText,
50+
Example: examples,
51+
Annotations: map[string]string{
52+
"cmd:main": "true",
53+
"help:args": "[endpoint]\tEndpoint path to send the request to, will be appended to the configured Jira server URL",
54+
},
55+
Args: cobra.ExactArgs(1),
56+
Run: runAPI,
57+
}
58+
59+
cmd.Flags().StringP("method", "X", "GET", "HTTP method to use (GET, POST, PUT, DELETE)")
60+
cmd.Flags().StringP("data", "d", "", "JSON payload to send with the request")
61+
cmd.Flags().String("file", "", "File containing JSON payload to send with the request")
62+
cmd.Flags().Bool("raw", false, "Output raw response body without formatting")
63+
cmd.Flags().Bool("translate-fields", false, "Translate customfield_* IDs to their friendly names from config")
64+
65+
return &cmd
66+
}
67+
68+
// translateCustomFields replaces customfield_* IDs with their friendly names from config
69+
func translateCustomFields(data []byte, debug bool) []byte {
70+
// Get the custom fields from the config
71+
configuredFields, err := getCustomFieldsMapping()
72+
if err != nil && debug {
73+
fmt.Fprintf(os.Stderr, "Warning: Error getting custom field mapping: %s\n", err)
74+
return data
75+
}
76+
77+
if len(configuredFields) == 0 {
78+
if debug {
79+
fmt.Fprintf(os.Stderr, "No custom field mappings found in config. No translations will be applied.\n")
80+
}
81+
return data
82+
}
83+
84+
if debug {
85+
fmt.Fprintf(os.Stderr, "Found %d custom field mappings in config.\n", len(configuredFields))
86+
}
87+
88+
// Try to detect any customfield_* patterns in the response that aren't in our config
89+
var unrecognizedFields []string
90+
re := regexp.MustCompile(`"(customfield_\d+)"`)
91+
matches := re.FindAllStringSubmatch(string(data), -1)
92+
93+
fieldSet := make(map[string]bool)
94+
for _, match := range matches {
95+
if len(match) >= 2 {
96+
fieldID := match[1]
97+
if _, exists := configuredFields[fieldID]; !exists {
98+
fieldSet[fieldID] = true
99+
}
100+
}
101+
}
102+
103+
for field := range fieldSet {
104+
unrecognizedFields = append(unrecognizedFields, field)
105+
}
106+
107+
if len(unrecognizedFields) > 0 && debug {
108+
fmt.Fprintf(os.Stderr, "Found %d custom fields in the response that aren't mapped in the config:\n", len(unrecognizedFields))
109+
for _, field := range unrecognizedFields {
110+
fmt.Fprintf(os.Stderr, " - %s\n", field)
111+
}
112+
}
113+
114+
dataStr := string(data)
115+
replacements := 0
116+
117+
// Replace all occurrences of customfield_* with their friendly names
118+
for id, name := range configuredFields {
119+
// Replace the field in keys (like "customfield_12345":)
120+
pattern := fmt.Sprintf("\"%s\":", id)
121+
replacement := fmt.Sprintf("\"%s\":", name)
122+
newStr := strings.ReplaceAll(dataStr, pattern, replacement)
123+
124+
if newStr != dataStr {
125+
replacements++
126+
}
127+
128+
dataStr = newStr
129+
}
130+
131+
if debug {
132+
fmt.Fprintf(os.Stderr, "Translated %d custom field occurrences in the response.\n", replacements)
133+
}
134+
135+
return []byte(dataStr)
136+
}
137+
138+
// getCustomFieldsMapping returns a map of custom field IDs to their friendly names
139+
func getCustomFieldsMapping() (map[string]string, error) {
140+
var configuredFields []jira.IssueTypeField
141+
142+
err := viper.UnmarshalKey("issue.fields.custom", &configuredFields)
143+
if err != nil {
144+
return nil, err
145+
}
146+
147+
// Create a map of custom field IDs to their friendly names
148+
fieldsMap := make(map[string]string)
149+
for _, field := range configuredFields {
150+
// Extract the field ID from the key - typically in the format "customfield_XXXXX"
151+
if field.Key != "" && strings.HasPrefix(field.Key, "customfield_") {
152+
fieldsMap[field.Key] = field.Name
153+
}
154+
}
155+
156+
return fieldsMap, nil
157+
}
158+
159+
func runAPI(cmd *cobra.Command, args []string) {
160+
// Check if the environment is initialized properly
161+
configFile := viper.ConfigFileUsed()
162+
if configFile == "" || !jiraConfig.Exists(configFile) {
163+
cmdutil.Failed("Jira CLI is not initialized. Run 'jira init' first.")
164+
}
165+
166+
server := viper.GetString("server")
167+
if server == "" {
168+
cmdutil.Failed("Jira server URL is not configured. Run 'jira init' with the --server flag.")
169+
}
170+
171+
debug, err := cmd.Flags().GetBool("debug")
172+
cmdutil.ExitIfError(err)
173+
174+
endpoint := args[0]
175+
176+
method, err := cmd.Flags().GetString("method")
177+
cmdutil.ExitIfError(err)
178+
179+
data, err := cmd.Flags().GetString("data")
180+
cmdutil.ExitIfError(err)
181+
182+
file, err := cmd.Flags().GetString("file")
183+
cmdutil.ExitIfError(err)
184+
185+
raw, err := cmd.Flags().GetBool("raw")
186+
cmdutil.ExitIfError(err)
187+
188+
var payload []byte
189+
190+
if file != "" && data != "" {
191+
cmdutil.Failed("Cannot use both --data and --file")
192+
}
193+
194+
if file != "" {
195+
fmt.Printf("Reading payload from file: %s\n", file)
196+
payload, err = os.ReadFile(file)
197+
cmdutil.ExitIfError(err)
198+
} else if data != "" {
199+
payload = []byte(data)
200+
if debug {
201+
fmt.Printf("Request payload: %s\n", data)
202+
}
203+
}
204+
205+
// Show a progress spinner during the request
206+
s := cmdutil.Info("Sending request to Jira API...")
207+
defer s.Stop()
208+
209+
client := api.Client(jira.Config{Debug: debug})
210+
211+
var resp *http.Response
212+
ctx := context.Background()
213+
headers := jira.Header{
214+
"Accept": "application/json",
215+
"Content-Type": "application/json",
216+
}
217+
218+
// Ensure endpoint starts with a slash
219+
if !strings.HasPrefix(endpoint, "/") {
220+
endpoint = "/" + endpoint
221+
}
222+
223+
// Combine server URL with the endpoint
224+
targetURL := server + endpoint
225+
if debug {
226+
fmt.Printf("Sending %s request to: %s\n", method, targetURL)
227+
}
228+
resp, err = client.RequestURL(ctx, method, targetURL, payload, headers)
229+
230+
s.Stop()
231+
232+
if err != nil {
233+
cmdutil.Failed("Request failed: %s", err)
234+
}
235+
236+
defer resp.Body.Close()
237+
238+
body, err := io.ReadAll(resp.Body)
239+
cmdutil.ExitIfError(err)
240+
241+
translateFields, err := cmd.Flags().GetBool("translate-fields")
242+
cmdutil.ExitIfError(err)
243+
244+
// Try to pretty print JSON if the response appears to be JSON and raw mode is not enabled
245+
if !raw && len(body) > 0 {
246+
// Check if the response looks like JSON
247+
trimmedBody := strings.TrimSpace(string(body))
248+
isJSON := (strings.HasPrefix(trimmedBody, "{") && strings.HasSuffix(trimmedBody, "}")) ||
249+
(strings.HasPrefix(trimmedBody, "[") && strings.HasSuffix(trimmedBody, "]"))
250+
251+
if isJSON {
252+
// If we need to translate custom fields, do that before pretty printing
253+
if translateFields {
254+
body = translateCustomFields(body, debug)
255+
}
256+
257+
var prettyJSON bytes.Buffer
258+
err = json.Indent(&prettyJSON, body, "", " ")
259+
if err == nil {
260+
body = prettyJSON.Bytes()
261+
}
262+
}
263+
}
264+
265+
fmt.Printf("HTTP/%d %s\n", resp.StatusCode, resp.Status)
266+
267+
// Print response headers if debug mode is enabled
268+
if debug {
269+
fmt.Println("\nResponse Headers:")
270+
for k, v := range resp.Header {
271+
fmt.Printf("%s: %s\n", k, strings.Join(v, ", "))
272+
}
273+
fmt.Println()
274+
}
275+
276+
fmt.Println(string(body))
277+
}

internal/cmd/root/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/spf13/cobra"
99
"github.com/spf13/viper"
1010

11+
"github.com/ankitpokhrel/jira-cli/internal/cmd/api"
1112
"github.com/ankitpokhrel/jira-cli/internal/cmd/board"
1213
"github.com/ankitpokhrel/jira-cli/internal/cmd/completion"
1314
"github.com/ankitpokhrel/jira-cli/internal/cmd/epic"
@@ -145,6 +146,7 @@ func addChildCommands(cmd *cobra.Command) {
145146
release.NewCmdRelease(),
146147
man.NewCmdMan(),
147148
refresh.NewCmdRefresh(),
149+
api.NewCmdAPI(),
148150
)
149151
}
150152

pkg/jira/client.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,16 @@ func (c *Client) DeleteV2(ctx context.Context, path string, headers Header) (*ht
244244
return c.request(ctx, http.MethodDelete, c.server+baseURLv2+path, nil, headers)
245245
}
246246

247+
// Delete sends DELETE request to v3 version of the jira api.
248+
func (c *Client) Delete(ctx context.Context, path string, headers Header) (*http.Response, error) {
249+
return c.request(ctx, http.MethodDelete, c.server+baseURLv3+path, nil, headers)
250+
}
251+
252+
// RequestURL sends a request to an absolute URL with the specified method.
253+
func (c *Client) RequestURL(ctx context.Context, method, url string, body []byte, headers Header) (*http.Response, error) {
254+
return c.request(ctx, method, url, body, headers)
255+
}
256+
247257
func (c *Client) request(ctx context.Context, method, endpoint string, body []byte, headers Header) (*http.Response, error) {
248258
var (
249259
req *http.Request

0 commit comments

Comments
 (0)