ya-git-jira 1.5.0 → 2.0.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.
Files changed (89) hide show
  1. package/.opencode/skills/architecture/SKILL.md +45 -0
  2. package/.opencode/skills/code-style/SKILL.md +76 -0
  3. package/.opencode/skills/git-confluence/SKILL.md +82 -0
  4. package/.opencode/skills/git-jira/SKILL.md +63 -0
  5. package/.opencode/skills/git-lab/SKILL.md +102 -0
  6. package/AGENTS.md +50 -0
  7. package/README.md +121 -71
  8. package/bin/git-api.ts +70 -0
  9. package/bin/git-confluence-page-search.ts +58 -0
  10. package/bin/git-confluence-page-show.ts +61 -0
  11. package/bin/git-confluence-page-update.ts +77 -0
  12. package/bin/git-confluence-page.ts +28 -0
  13. package/bin/git-confluence-space-list.ts +34 -0
  14. package/bin/git-confluence-space.ts +24 -0
  15. package/bin/git-confluence-whoami.ts +33 -0
  16. package/bin/git-confluence.ts +27 -0
  17. package/bin/git-jira-start.ts +1 -1
  18. package/bin/git-jira-whoami.ts +32 -0
  19. package/bin/git-jira.ts +2 -0
  20. package/bin/git-lab-project-mr-list.ts +57 -0
  21. package/bin/git-lab-project-mr.ts +24 -0
  22. package/bin/git-lab-project-pipeline-jobs.ts +46 -0
  23. package/bin/git-lab-project-pipeline-latest.ts +47 -0
  24. package/bin/git-lab-project-pipeline-log.ts +49 -0
  25. package/bin/git-lab-project-pipeline.ts +6 -0
  26. package/bin/git-lab-project.ts +5 -1
  27. package/bin/gitj-install-skills.ts +126 -0
  28. package/bin/gitj.ts +12 -0
  29. package/dist/bin/git-api.js +2156 -0
  30. package/dist/bin/git-bump.js +136 -125
  31. package/dist/bin/git-confluence-page-search.js +2079 -0
  32. package/dist/bin/{git-lab-projects.js → git-confluence-page-show.js} +294 -250
  33. package/dist/bin/{git-lab-projects-whereami.js → git-confluence-page-update.js} +300 -206
  34. package/dist/bin/git-confluence-page.js +2186 -0
  35. package/dist/bin/{git-lab-groups.js → git-confluence-space-list.js} +279 -210
  36. package/dist/bin/git-confluence-space.js +2073 -0
  37. package/dist/bin/git-confluence-whoami.js +2060 -0
  38. package/dist/bin/git-confluence.js +2251 -0
  39. package/dist/bin/git-jira-issue-list.js +144 -129
  40. package/dist/bin/git-jira-issue-show.js +144 -129
  41. package/dist/bin/git-jira-issue.js +148 -133
  42. package/dist/bin/git-jira-start.js +146 -131
  43. package/dist/bin/{git-lab-namespaces.js → git-jira-whoami.js} +214 -226
  44. package/dist/bin/git-jira.js +178 -143
  45. package/dist/bin/git-lab-group-list.js +326 -394
  46. package/dist/bin/git-lab-group.js +328 -396
  47. package/dist/bin/git-lab-merge-active.js +326 -394
  48. package/dist/bin/git-lab-merge-todo.js +326 -394
  49. package/dist/bin/git-lab-merge-train-list.js +294 -388
  50. package/dist/bin/git-lab-merge-train.js +296 -390
  51. package/dist/bin/git-lab-merge.js +335 -403
  52. package/dist/bin/git-lab-namespace-list.js +145 -135
  53. package/dist/bin/git-lab-namespace.js +147 -137
  54. package/dist/bin/git-lab-project-list.js +293 -387
  55. package/dist/bin/git-lab-project-mr-list.js +2740 -0
  56. package/dist/bin/git-lab-project-mr.js +2752 -0
  57. package/dist/bin/git-lab-project-pipeline-jobs.js +2734 -0
  58. package/dist/bin/git-lab-project-pipeline-latest.js +2736 -0
  59. package/dist/bin/git-lab-project-pipeline-list.js +328 -396
  60. package/dist/bin/git-lab-project-pipeline-log.js +2739 -0
  61. package/dist/bin/git-lab-project-pipeline.js +442 -407
  62. package/dist/bin/git-lab-project-whereami.js +297 -391
  63. package/dist/bin/git-lab-project.js +568 -403
  64. package/dist/bin/git-lab-whoami.js +149 -139
  65. package/dist/bin/git-lab.js +581 -454
  66. package/dist/bin/{git-lab-projects-list.js → gitj-install-skills.js} +226 -268
  67. package/dist/bin/gitj.js +1384 -578
  68. package/dist/index.js +379 -300
  69. package/index.ts +1 -0
  70. package/lib/api.ts +177 -0
  71. package/lib/confluence/api.ts +132 -0
  72. package/lib/confluence/config.ts +25 -0
  73. package/lib/confluence/index.ts +3 -0
  74. package/lib/confluence/types.ts +59 -0
  75. package/lib/git.ts +3 -3
  76. package/lib/gitlab/config.ts +5 -5
  77. package/lib/gitlab/index.ts +1 -0
  78. package/lib/gitlab/job.ts +31 -0
  79. package/lib/gitlab/merge-request.ts +20 -0
  80. package/lib/gitlab/pipeline.ts +28 -1
  81. package/lib/gitlab/project.ts +14 -5
  82. package/lib/help.ts +40 -0
  83. package/lib/jira.ts +11 -6
  84. package/lib/spawn.ts +3 -3
  85. package/package.json +18 -2
  86. package/tests/all-help.test.ts +6 -1
  87. package/tests/gitj.test.ts +1 -1
  88. package/tests/help-all.test.ts +29 -0
  89. package/bun.lockb +0 -0
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./lib/json.ts"
2
+ export * from "./lib/confluence"
2
3
  export * from "./lib/git.ts"
3
4
  export * from "./lib/gitlab"
4
5
  export * from "./lib/is_main.ts"
package/lib/api.ts ADDED
@@ -0,0 +1,177 @@
1
+ import type { JSONValue } from './json'
2
+ import { getGitlabConfig } from './gitlab/config'
3
+ import { getJiraConfig } from './jira'
4
+ import { getConfluenceConfig } from './confluence/config'
5
+
6
+ export interface ApiResponse {
7
+ status: number
8
+ headers: Headers
9
+ body: JSONValue
10
+ }
11
+
12
+ interface ServiceDef {
13
+ getConfig: () => Promise<{ host: string; token: string }>
14
+ basePath: string
15
+ authHeader: (token: string) => [string, string]
16
+ }
17
+
18
+ const services: Record<string, ServiceDef> = {
19
+ gitlab: {
20
+ getConfig: getGitlabConfig,
21
+ basePath: '/api/v4',
22
+ authHeader: (token) => ['Private-Token', token],
23
+ },
24
+ jira: {
25
+ getConfig: getJiraConfig,
26
+ basePath: '/rest/api/3',
27
+ authHeader: (token) => ['Authorization', `Basic ${token}`],
28
+ },
29
+ confluence: {
30
+ getConfig: getConfluenceConfig,
31
+ basePath: '/wiki/api/v2',
32
+ authHeader: (token) => ['Authorization', `Basic ${token}`],
33
+ },
34
+ }
35
+
36
+ export const serviceNames = Object.keys(services)
37
+
38
+ export function getService(name: string): ServiceDef {
39
+ const svc = services[name]
40
+ if (!svc) {
41
+ throw new Error(`Unknown service: ${name}. Expected one of: ${serviceNames.join(', ')}`)
42
+ }
43
+ return svc
44
+ }
45
+
46
+ export interface RequestOptions {
47
+ raw?: boolean
48
+ data?: string
49
+ }
50
+
51
+ export async function apiRequest(
52
+ serviceName: string,
53
+ method: string,
54
+ endpoint: string,
55
+ options?: RequestOptions,
56
+ ): Promise<ApiResponse> {
57
+ const svc = getService(serviceName)
58
+ const { host, token } = await svc.getConfig()
59
+ const [headerName, headerValue] = svc.authHeader(token)
60
+
61
+ let path = endpoint
62
+ if (path.startsWith('/')) {
63
+ path = path.slice(1)
64
+ }
65
+
66
+ let url: string
67
+ if (options?.raw) {
68
+ url = `https://${host}/${path}`
69
+ } else {
70
+ url = `https://${host}${svc.basePath}/${path}`
71
+ }
72
+
73
+ const headers = new Headers()
74
+ headers.append(headerName, headerValue)
75
+ headers.append('Accept', 'application/json')
76
+
77
+ const fetchOptions: RequestInit = { method, headers }
78
+ if (options?.data) {
79
+ headers.append('Content-Type', 'application/json')
80
+ fetchOptions.body = options.data
81
+ }
82
+
83
+ const request = new Request(url, fetchOptions)
84
+ const response = await fetch(request)
85
+ const body = await response.json() as JSONValue
86
+
87
+ return {
88
+ status: response.status,
89
+ headers: response.headers,
90
+ body,
91
+ }
92
+ }
93
+
94
+ function getNextLink(link: string | null): string | undefined {
95
+ if (!link) {
96
+ return undefined
97
+ }
98
+ const regex = /<([^>]+)>; rel="next"/
99
+ const match = link.match(regex)
100
+ return match ? match[1] : undefined
101
+ }
102
+
103
+ export async function apiPaginate(
104
+ serviceName: string,
105
+ endpoint: string,
106
+ options?: RequestOptions,
107
+ ): Promise<ApiResponse> {
108
+ const svc = getService(serviceName)
109
+ const { host, token } = await svc.getConfig()
110
+ const [headerName, headerValue] = svc.authHeader(token)
111
+
112
+ let path = endpoint
113
+ if (path.startsWith('/')) {
114
+ path = path.slice(1)
115
+ }
116
+
117
+ let url: string
118
+ if (options?.raw) {
119
+ url = `https://${host}/${path}`
120
+ } else {
121
+ url = `https://${host}${svc.basePath}/${path}`
122
+ }
123
+
124
+ const headers = new Headers()
125
+ headers.append(headerName, headerValue)
126
+ headers.append('Accept', 'application/json')
127
+ const fetchOptions = { method: 'GET', headers }
128
+
129
+ const origin = `https://${host}`
130
+ let allResults: Array<JSONValue> = []
131
+ let lastStatus = 0
132
+ let lastHeaders: Headers = new Headers()
133
+
134
+ while (url) {
135
+ const request = new Request(url, fetchOptions)
136
+ const response = await fetch(request)
137
+ lastStatus = response.status
138
+ lastHeaders = response.headers
139
+
140
+ if (!response.ok) {
141
+ const body = await response.json() as JSONValue
142
+ return { status: response.status, headers: response.headers, body }
143
+ }
144
+
145
+ const body = await response.json() as JSONValue
146
+
147
+ // handle array responses (gitlab style)
148
+ if (Array.isArray(body)) {
149
+ allResults = allResults.concat(body)
150
+ // handle envelope responses with .results (confluence style)
151
+ } else if (body && typeof body === 'object' && 'results' in body) {
152
+ const envelope = body as { results?: Array<JSONValue>; _links?: { next?: string } }
153
+ if (Array.isArray(envelope.results)) {
154
+ allResults = allResults.concat(envelope.results)
155
+ }
156
+ // confluence v1 pagination via _links.next
157
+ const linksNext = envelope._links?.next
158
+ if (linksNext) {
159
+ url = linksNext.startsWith('/') ? `${origin}${linksNext}` : linksNext
160
+ continue
161
+ }
162
+ } else {
163
+ // single object response -- no pagination possible
164
+ return { status: response.status, headers: response.headers, body }
165
+ }
166
+
167
+ // standard Link header pagination
168
+ const nextLink = getNextLink(response.headers.get('Link'))
169
+ if (nextLink) {
170
+ url = nextLink.startsWith('/') ? `${origin}${nextLink}` : nextLink
171
+ } else {
172
+ url = ''
173
+ }
174
+ }
175
+
176
+ return { status: lastStatus, headers: lastHeaders, body: allResults }
177
+ }
@@ -0,0 +1,132 @@
1
+ import type { JSONValue } from '../json'
2
+ import { getConfluenceConfig } from './config'
3
+
4
+ function getNextLink(link: string | null): string | undefined {
5
+ if (!link) {
6
+ return undefined
7
+ }
8
+ const regex = /<([^>]+)>; rel="next"/
9
+ const match = link.match(regex)
10
+ const next = match ? match[1] : undefined
11
+ return next
12
+ }
13
+
14
+ export async function confluenceApi(endpoint: string): Promise<JSONValue> {
15
+ if (endpoint.startsWith('/')) {
16
+ console.warn(`confluenceApi: endpoint ${endpoint} starts with /, removing it`)
17
+ endpoint = endpoint.slice(1)
18
+ }
19
+ const method = 'GET'
20
+ const { host, token } = await getConfluenceConfig()
21
+ const base = `https://${host}/wiki/api/v2`
22
+ const uri = `${base}/${endpoint}`
23
+ const auth = `Basic ${token}`
24
+ const headers = new Headers()
25
+ headers.append('Authorization', auth)
26
+ headers.append('Accept', 'application/json')
27
+ const options = {
28
+ method,
29
+ headers,
30
+ }
31
+ let request = new Request(uri, options)
32
+ const response = await fetch(request)
33
+ let link = getNextLink(response.headers.get('Link'))
34
+ const body = await response.json() as JSONValue & { results?: Array<JSONValue> }
35
+ if (!body.results) {
36
+ return body
37
+ }
38
+ let result: Array<JSONValue> = body.results
39
+ const origin = `https://${host}`
40
+ while (link) {
41
+ const url = link.startsWith('/') ? `${origin}${link}` : link
42
+ let request = new Request(url, options)
43
+ const next_response = await fetch(request)
44
+ link = getNextLink(next_response.headers.get('Link'))
45
+ const next_body = await next_response.json() as JSONValue & { results?: Array<JSONValue> }
46
+ if (next_body.results) {
47
+ result = result.concat(next_body.results)
48
+ }
49
+ }
50
+ return result
51
+ }
52
+
53
+ export async function confluenceApiWrite(endpoint: string, method: string, body: JSONValue): Promise<JSONValue> {
54
+ if (endpoint.startsWith('/')) {
55
+ console.warn(`confluenceApiWrite: endpoint ${endpoint} starts with /, removing it`)
56
+ endpoint = endpoint.slice(1)
57
+ }
58
+ const { host, token } = await getConfluenceConfig()
59
+ const base = `https://${host}/wiki/api/v2`
60
+ const uri = `${base}/${endpoint}`
61
+ const auth = `Basic ${token}`
62
+ const headers = new Headers()
63
+ headers.append('Authorization', auth)
64
+ headers.append('Accept', 'application/json')
65
+ headers.append('Content-Type', 'application/json')
66
+ const options = {
67
+ method,
68
+ headers,
69
+ body: JSON.stringify(body),
70
+ }
71
+ const request = new Request(uri, options)
72
+ const response = await fetch(request)
73
+ if (!response.ok) {
74
+ const text = await response.text()
75
+ throw new Error(`Confluence API ${method} ${endpoint} failed (${response.status}): ${text}`)
76
+ }
77
+ const result = await response.json()
78
+ return result
79
+ }
80
+
81
+ export async function confluenceSearch(cql: string): Promise<JSONValue> {
82
+ const { host, token } = await getConfluenceConfig()
83
+ const base = `https://${host}/wiki/rest/api`
84
+ const auth = `Basic ${token}`
85
+ const headers = new Headers()
86
+ headers.append('Authorization', auth)
87
+ headers.append('Accept', 'application/json')
88
+ const options = { method: 'GET', headers }
89
+ const origin = `https://${host}`
90
+
91
+ let uri = `${base}/search?cql=${encodeURIComponent(cql)}&limit=25`
92
+ let allResults: Array<JSONValue> = []
93
+
94
+ while (uri) {
95
+ const request = new Request(uri, options)
96
+ const response = await fetch(request)
97
+ const body = await response.json() as JSONValue & {
98
+ results?: Array<JSONValue>
99
+ _links?: { next?: string }
100
+ }
101
+ if (body.results) {
102
+ allResults = allResults.concat(body.results)
103
+ }
104
+ const next = body._links?.next
105
+ uri = next ? (next.startsWith('/') ? `${origin}${next}` : next) : ''
106
+ }
107
+
108
+ return allResults
109
+ }
110
+
111
+ export async function confluenceApiV1(endpoint: string): Promise<JSONValue> {
112
+ if (endpoint.startsWith('/')) {
113
+ console.warn(`confluenceApiV1: endpoint ${endpoint} starts with /, removing it`)
114
+ endpoint = endpoint.slice(1)
115
+ }
116
+ const method = 'GET'
117
+ const { host, token } = await getConfluenceConfig()
118
+ const base = `https://${host}/wiki/rest/api`
119
+ const uri = `${base}/${endpoint}`
120
+ const auth = `Basic ${token}`
121
+ const headers = new Headers()
122
+ headers.append('Authorization', auth)
123
+ headers.append('Accept', 'application/json')
124
+ const options = {
125
+ method,
126
+ headers,
127
+ }
128
+ const request = new Request(uri, options)
129
+ const response = await fetch(request)
130
+ const result = await response.json()
131
+ return result
132
+ }
@@ -0,0 +1,25 @@
1
+ import { getConfig } from '../git'
2
+
3
+ export interface ConfluenceConfig {
4
+ host: string
5
+ token: string
6
+ }
7
+
8
+ const gitEmailP = getConfig('user.email')
9
+ const jiraEmailP = getConfig('jira.user')
10
+ const confluenceEmailP = getConfig('confluence.user', { expectQuiet: true })
11
+ const jiraHostP = getConfig('jira.host')
12
+ const confluenceHostP = getConfig('confluence.host', { expectQuiet: true })
13
+ const jiraTokenP = getConfig('jira.token')
14
+ const confluenceTokenP = getConfig('confluence.token', { expectQuiet: true })
15
+
16
+ export async function getConfluenceConfig(): Promise<ConfluenceConfig> {
17
+ const host = await confluenceHostP || await jiraHostP
18
+ if (!host) throw new Error('confluence.host or jira.host not in git config')
19
+ const user = await confluenceEmailP || await jiraEmailP || await gitEmailP
20
+ if (!user) throw new Error('confluence.user, jira.user, or user.email not in git config')
21
+ const pat = await confluenceTokenP || await jiraTokenP
22
+ if (!pat) throw new Error('confluence.token or jira.token not in git config')
23
+ const token = Buffer.from(`${user}:${pat}`).toString('base64')
24
+ return { host, token }
25
+ }
@@ -0,0 +1,3 @@
1
+ export * from './api'
2
+ export * from './config'
3
+ export * from './types'
@@ -0,0 +1,59 @@
1
+ import type { JSONValue } from '../json'
2
+
3
+ export type ConfluenceUser = JSONValue & {
4
+ type: string
5
+ accountId: string
6
+ accountType: string
7
+ email: string
8
+ publicName: string
9
+ displayName: string
10
+ }
11
+
12
+ export type Space = JSONValue & {
13
+ id: string
14
+ key: string
15
+ name: string
16
+ type: string
17
+ status: string
18
+ }
19
+
20
+ export type SearchResult = JSONValue & {
21
+ content: {
22
+ id: string
23
+ type: string
24
+ status: string
25
+ title: string
26
+ }
27
+ title: string
28
+ excerpt: string
29
+ url: string
30
+ resultGlobalContainer: {
31
+ title: string
32
+ displayUrl: string
33
+ }
34
+ }
35
+
36
+ export type Page = JSONValue & {
37
+ id: string
38
+ status: string
39
+ title: string
40
+ spaceId: string
41
+ parentId: string
42
+ parentType: string
43
+ position: number
44
+ version: {
45
+ number: number
46
+ message: string
47
+ createdAt: string
48
+ }
49
+ body?: {
50
+ storage?: {
51
+ representation: string
52
+ value: string
53
+ }
54
+ atlas_doc_format?: {
55
+ representation: string
56
+ value: string
57
+ }
58
+ }
59
+ }
package/lib/git.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { doCommand } from "./spawn"
1
+ import { doCommand, defaultOptions } from "./spawn"
2
2
 
3
- export async function getConfig(key: string): Promise<string> {
4
- return doCommand(["git", "config", "--get", key])
3
+ export async function getConfig(key: string, options = defaultOptions): Promise<string> {
4
+ return doCommand(["git", "config", "--get", key], options)
5
5
  }
6
6
 
7
7
  export async function createBranch(name: string): Promise<string> {
@@ -6,15 +6,15 @@ export interface GitlabConfig {
6
6
  token: string
7
7
  }
8
8
 
9
+ const gitEmailP = getConfig("user.email")
10
+ const gitlabEmailP = getConfig("gitlab.user", { expectQuiet: true})
9
11
  const hostP = getConfig("gitlab.host")
10
- const userP = getConfig("user.email")
11
12
  const tokenP = getConfig("gitlab.token")
12
13
 
13
14
  export async function getGitlabConfig(): Promise<GitlabConfig> {
14
- const host = await hostP
15
- if (!host) throw new Error("gitlab.host not in git config")
16
- const user = await userP
17
- if (!user) throw new Error("user.email not in git config")
15
+ const host = await hostP || 'gitlab.com'
16
+ const user = await gitEmailP || await gitlabEmailP
17
+ if (!user) throw new Error("Neither user.email nor gitlab.email in git config")
18
18
  const token = await tokenP
19
19
  if (!token) throw new Error("gitlab.token not in git config")
20
20
  return { host, user, token }
@@ -1,6 +1,7 @@
1
1
  export * from './api'
2
2
  export * from './config'
3
3
  export * from './group'
4
+ export * from './job'
4
5
  export * from './merge-request'
5
6
  export * from './namespace'
6
7
  export * from './pipeline'
@@ -0,0 +1,31 @@
1
+ import { dlog } from "./dlog"
2
+ import { projectScopedGet, projectScopedGetText } from "./project"
3
+ import type { JSONValue } from "../json"
4
+
5
+ export type Job = JSONValue & {
6
+ id: number
7
+ name: string
8
+ status: string
9
+ stage: string
10
+ ref: string
11
+ web_url: string
12
+ duration: number | null
13
+ finished_at: string | null
14
+ failure_reason: string | null
15
+ pipeline: { id: number }
16
+ }
17
+
18
+ export async function getPipelineJobs(pipelineId: number): Promise<Array<Job>> {
19
+ dlog(`getPipelineJobs pipelineId: ${pipelineId}`)
20
+ return await projectScopedGet(`pipelines/${pipelineId}/jobs`) as Array<Job>
21
+ }
22
+
23
+ export async function getJob(jobId: number): Promise<Job> {
24
+ dlog(`getJob jobId: ${jobId}`)
25
+ return await projectScopedGet(`jobs/${jobId}`) as Job
26
+ }
27
+
28
+ export async function getJobLog(jobId: number): Promise<string> {
29
+ dlog(`getJobLog jobId: ${jobId}`)
30
+ return await projectScopedGetText(`jobs/${jobId}/trace`)
31
+ }
@@ -5,13 +5,17 @@ import { gitlabApi } from './api'
5
5
 
6
6
  export type MergeRequest = JSONValue & {
7
7
  id: number
8
+ iid: number
8
9
  title : string
9
10
  description : string
10
11
  state : string
12
+ draft: boolean
11
13
  source_branch: string
12
14
  target_branch: string
13
15
  web_url: string
14
16
  merge_status: string
17
+ author: { username: string }
18
+ labels: string[]
15
19
  }
16
20
 
17
21
  export async function getMergeRequest(id: string): Promise<MergeRequest> {
@@ -29,3 +33,19 @@ export async function getMyMergeRequestsToReview() : Promise<Array<MergeRequest>
29
33
  const me = await whoami()
30
34
  return await gitlabApi(`merge_requests?state=opened&reviewer_id=${me.id}`) as Array<MergeRequest>
31
35
  }
36
+
37
+ /**
38
+ * List open merge requests for a specific project and source branch.
39
+ * @param projectPath - Full project path (e.g. "etagen-internal/eta-lib/base")
40
+ * @param sourceBranch - Source branch name to filter on
41
+ */
42
+ export async function getMergeRequestsByBranch(
43
+ projectPath: string,
44
+ sourceBranch: string,
45
+ ): Promise<Array<MergeRequest>> {
46
+ const project = encodeURIComponent(projectPath)
47
+ const branch = encodeURIComponent(sourceBranch)
48
+ return await gitlabApi(
49
+ `projects/${project}/merge_requests?state=opened&source_branch=${branch}`
50
+ ) as Array<MergeRequest>
51
+ }
@@ -1,6 +1,8 @@
1
1
  import { dlog } from "./dlog"
2
2
  import { projectScopedGet } from "./project"
3
3
  import { whoami } from "./user"
4
+ import { gitlabApi } from "./api"
5
+ import { getCurrentBranch } from "../git"
4
6
  import type { JSONValue } from "../json"
5
7
 
6
8
  export type Pipeline = JSONValue & {
@@ -12,7 +14,7 @@ export type Pipeline = JSONValue & {
12
14
  updated_at: string // datetime string like "2021-03-18T15:00:00.000Z"
13
15
  }
14
16
 
15
- export type PipelineStatus = 'success' | 'running'
17
+ export type PipelineStatus = 'success' | 'running' | 'failed' | 'canceled' | 'pending' | 'created'
16
18
 
17
19
  export interface GetPipelineOptions {
18
20
  days: number
@@ -30,3 +32,28 @@ export async function getProjectPipelines(options: GetPipelineOptions): Promise<
30
32
  dlog(`updated: ${updated}`)
31
33
  return await projectScopedGet(`pipelines?status=${status}&username=${username}&updated_after=${updated}`) as Array<Pipeline>
32
34
  }
35
+
36
+ /**
37
+ * Get pipelines for a specific merge request.
38
+ * Returns pipelines sorted by ID descending (most recent first).
39
+ * @param projectPath - Full project path (e.g. "etagen-internal/eta-lib/base")
40
+ * @param mrIid - The merge request IID (project-scoped ID)
41
+ */
42
+ export async function getMergeRequestPipelines(
43
+ projectPath: string,
44
+ mrIid: number,
45
+ ): Promise<Array<Pipeline>> {
46
+ const project = encodeURIComponent(projectPath)
47
+ return await gitlabApi(
48
+ `projects/${project}/merge_requests/${mrIid}/pipelines`
49
+ ) as Array<Pipeline>
50
+ }
51
+
52
+ export async function getLatestPipeline(): Promise<Pipeline | undefined> {
53
+ const ref = await getCurrentBranch()
54
+ dlog(`getLatestPipeline ref: ${ref}`)
55
+ const pipelines = await projectScopedGet(
56
+ `pipelines?ref=${encodeURIComponent(ref)}&per_page=1&order_by=id&sort=desc`
57
+ ) as Array<Pipeline>
58
+ return pipelines.length > 0 ? pipelines[0] : undefined
59
+ }
@@ -50,7 +50,7 @@ export async function findProject(ssh_url: string): Promise<Project | undefined>
50
50
  return project
51
51
  }
52
52
 
53
- export async function projectScopedGet(endpoint: string): Promise<JSONValue> {
53
+ async function projectScopedRequest(endpoint: string): Promise<Response> {
54
54
  if (endpoint.startsWith("/")) {
55
55
  console.warn(`gitlabApi: endpoint ${endpoint} starts with /, removing it`)
56
56
  endpoint = endpoint.slice(1)
@@ -63,16 +63,25 @@ export async function projectScopedGet(endpoint: string): Promise<JSONValue> {
63
63
  throw new Error(`Could not find project for remote ${remote}`)
64
64
  }
65
65
  const base = `https://${host}/api/v4/projects/${project.id}`
66
- const uri = `${base}/${endpoint}`
67
- dlog(`projectScopedGet uri: ${uri}`)
66
+ const sep = endpoint.includes('?') ? '&' : '?'
67
+ const uri = `${base}/${endpoint}${sep}per_page=100`
68
+ dlog(`projectScopedRequest uri: ${uri}`)
68
69
  const headers = new Headers()
69
- headers.append("Accept", "application/json")
70
70
  headers.append('Private-Token', token)
71
71
  const options = {
72
72
  method,
73
73
  headers,
74
74
  }
75
75
  const request = new Request(uri, options)
76
- const response = await fetch(request)
76
+ return await fetch(request)
77
+ }
78
+
79
+ export async function projectScopedGet(endpoint: string): Promise<JSONValue> {
80
+ const response = await projectScopedRequest(endpoint)
77
81
  return await response.json()
78
82
  }
83
+
84
+ export async function projectScopedGetText(endpoint: string): Promise<string> {
85
+ const response = await projectScopedRequest(endpoint)
86
+ return await response.text()
87
+ }
package/lib/help.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { Command } from 'commander'
2
+
3
+ // Recursively format a Commander command tree using box-drawing characters
4
+ export function formatCommandTree(cmd: Command): string {
5
+ const lines: Array<string> = []
6
+ const name = cmd.name() || 'gitj'
7
+ lines.push(name)
8
+ appendChildren(lines, cmd, '')
9
+ return lines.join('\n')
10
+ }
11
+
12
+ function appendChildren(lines: Array<string>, cmd: Command, prefix: string): void {
13
+ const children = cmd.commands as Array<Command>
14
+ if (!children || children.length === 0) return
15
+
16
+ const descWidth = longestNameChain(children)
17
+
18
+ for (let i = 0; i < children.length; i++) {
19
+ const child = children[i]
20
+ const isLast = i === children.length - 1
21
+ const connector = isLast ? '└── ' : '├── '
22
+ const childPrefix = isLast ? ' ' : '│ '
23
+ const name = child.name()
24
+ const desc = child.description()
25
+ const padding = desc ? ' '.repeat(Math.max(1, descWidth - name.length + 2)) : ''
26
+ const descPart = desc ? padding + desc : ''
27
+ lines.push(`${prefix}${connector}${name}${descPart}`)
28
+ appendChildren(lines, child, prefix + childPrefix)
29
+ }
30
+ }
31
+
32
+ // Find the longest command name among siblings for alignment
33
+ function longestNameChain(commands: Array<Command>): number {
34
+ let max = 0
35
+ for (const cmd of commands) {
36
+ const len = cmd.name().length
37
+ if (len > max) max = len
38
+ }
39
+ return max
40
+ }
package/lib/jira.ts CHANGED
@@ -14,13 +14,18 @@ export interface JiraConfig {
14
14
  token: string
15
15
  }
16
16
 
17
+ const gitEmailP = getConfig("user.email")
18
+ const jiraEmailP = getConfig("jira.user")
19
+ const hostP = getConfig("jira.host")
20
+ const tokenP = getConfig("jira.token")
21
+
17
22
  export async function getJiraConfig(): Promise<JiraConfig> {
18
- const host = await getConfig("jira.host")
23
+ const host = await hostP
19
24
  if (!host) throw new Error("jira.host not in git config")
20
- const user = await getConfig("jira.user") || await getConfig("user.email")
25
+ const user = await jiraEmailP || await gitEmailP
21
26
  if (!user) throw new Error("jira.user or user.email not in git config")
22
- const pat = await getConfig("jira.pat")
23
- if (!pat) throw new Error("jira.pat not in git config")
27
+ const pat = await tokenP
28
+ if (!pat) throw new Error("jira.token not in git config")
24
29
  const token = Buffer.from(`${user}:${pat}`).toString('base64')
25
30
  return { host, token }
26
31
  }
@@ -57,7 +62,7 @@ type Myself = JSONValue & {
57
62
  }
58
63
 
59
64
  export async function getMyself(): Promise<Myself> {
60
- return await jiraApi("/myself") as Myself
65
+ return await jiraApi("myself") as Myself
61
66
  }
62
67
 
63
68
  type SearchResponse = JSONValue & {
@@ -68,6 +73,6 @@ export async function myUnresolvedIssues(): Promise<Array<Issue>> {
68
73
  const myself = await getMyself()
69
74
  const myselfId = myself.accountId
70
75
  const jql = `assignee = ${myselfId} AND resolution = Unresolved`
71
- const issues = await jiraApi(`/search?jql=${encodeURIComponent(jql)}`) as SearchResponse
76
+ const issues = await jiraApi(`search/jql?jql=${encodeURIComponent(jql)}&fields=summary`) as SearchResponse
72
77
  return issues.issues
73
78
  }