Skip to content

Commit ec61dbe

Browse files
Merge pull request #6668 from Shopify/jtv/add-list-support-to-status-subcommand
If `--id` is not provided to `shopify app bulk status`, show all bulk operations
2 parents 328e987 + be16198 commit ec61dbe

File tree

7 files changed

+362
-18
lines changed

7 files changed

+362
-18
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */
2+
import * as Types from './types.js'
3+
4+
import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'
5+
6+
export type ListBulkOperationsQueryVariables = Types.Exact<{
7+
query?: Types.InputMaybe<Types.Scalars['String']['input']>
8+
first: Types.Scalars['Int']['input']
9+
sortKey: Types.BulkOperationsSortKeys
10+
reverse: Types.Scalars['Boolean']['input']
11+
}>
12+
13+
export type ListBulkOperationsQuery = {
14+
bulkOperations: {
15+
nodes: {
16+
id: string
17+
status: Types.BulkOperationStatus
18+
errorCode?: Types.BulkOperationErrorCode | null
19+
objectCount: unknown
20+
createdAt: unknown
21+
completedAt?: unknown | null
22+
url?: string | null
23+
partialDataUrl?: string | null
24+
}[]
25+
}
26+
}
27+
28+
export const ListBulkOperations = {
29+
kind: 'Document',
30+
definitions: [
31+
{
32+
kind: 'OperationDefinition',
33+
operation: 'query',
34+
name: {kind: 'Name', value: 'ListBulkOperations'},
35+
variableDefinitions: [
36+
{
37+
kind: 'VariableDefinition',
38+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'query'}},
39+
type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}},
40+
},
41+
{
42+
kind: 'VariableDefinition',
43+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'first'}},
44+
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'Int'}}},
45+
},
46+
{
47+
kind: 'VariableDefinition',
48+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'sortKey'}},
49+
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'BulkOperationsSortKeys'}}},
50+
},
51+
{
52+
kind: 'VariableDefinition',
53+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'reverse'}},
54+
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'Boolean'}}},
55+
},
56+
],
57+
selectionSet: {
58+
kind: 'SelectionSet',
59+
selections: [
60+
{
61+
kind: 'Field',
62+
name: {kind: 'Name', value: 'bulkOperations'},
63+
arguments: [
64+
{
65+
kind: 'Argument',
66+
name: {kind: 'Name', value: 'first'},
67+
value: {kind: 'Variable', name: {kind: 'Name', value: 'first'}},
68+
},
69+
{
70+
kind: 'Argument',
71+
name: {kind: 'Name', value: 'query'},
72+
value: {kind: 'Variable', name: {kind: 'Name', value: 'query'}},
73+
},
74+
{
75+
kind: 'Argument',
76+
name: {kind: 'Name', value: 'sortKey'},
77+
value: {kind: 'Variable', name: {kind: 'Name', value: 'sortKey'}},
78+
},
79+
{
80+
kind: 'Argument',
81+
name: {kind: 'Name', value: 'reverse'},
82+
value: {kind: 'Variable', name: {kind: 'Name', value: 'reverse'}},
83+
},
84+
],
85+
selectionSet: {
86+
kind: 'SelectionSet',
87+
selections: [
88+
{
89+
kind: 'Field',
90+
name: {kind: 'Name', value: 'nodes'},
91+
selectionSet: {
92+
kind: 'SelectionSet',
93+
selections: [
94+
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
95+
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
96+
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
97+
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
98+
{kind: 'Field', name: {kind: 'Name', value: 'createdAt'}},
99+
{kind: 'Field', name: {kind: 'Name', value: 'completedAt'}},
100+
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
101+
{kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}},
102+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
103+
],
104+
},
105+
},
106+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
107+
],
108+
},
109+
},
110+
],
111+
},
112+
},
113+
],
114+
} as unknown as DocumentNode<ListBulkOperationsQuery, ListBulkOperationsQueryVariables>

packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@ export type BulkOperationUserErrorCode =
197197
/** A bulk operation is already in progress. */
198198
| 'OPERATION_IN_PROGRESS'
199199

200+
/** The set of valid sort keys for the BulkOperations query. */
201+
export type BulkOperationsSortKeys =
202+
/** Sort by the `completed_at` value. */
203+
| 'COMPLETED_AT'
204+
/** Sort by the `created_at` value. */
205+
| 'CREATED_AT'
206+
200207
/**
201208
* The possible HTTP methods that can be used when sending a request to upload a file using information from a
202209
* [StagedMediaUploadTarget](https://shopify.dev/api/admin-graphql/latest/objects/StagedMediaUploadTarget).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
query ListBulkOperations($query: String, $first: Int!, $sortKey: BulkOperationsSortKeys!, $reverse: Boolean!) {
2+
bulkOperations(first: $first, query: $query, sortKey: $sortKey, reverse: $reverse) {
3+
nodes {
4+
id
5+
status
6+
errorCode
7+
objectCount
8+
createdAt
9+
completedAt
10+
url
11+
partialDataUrl
12+
}
13+
}
14+
}

packages/app/src/cli/commands/app/bulk/status.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,25 @@ import {appFlags} from '../../../flags.js'
22
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
33
import {linkedAppContext} from '../../../services/app-context.js'
44
import {storeContext} from '../../../services/store-context.js'
5-
import {getBulkOperationStatus} from '../../../services/bulk-operations/bulk-operation-status.js'
5+
import {getBulkOperationStatus, listBulkOperations} from '../../../services/bulk-operations/bulk-operation-status.js'
66
import {Flags} from '@oclif/core'
77
import {globalFlags} from '@shopify/cli-kit/node/cli'
88
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
99

1010
export default class BulkStatus extends AppLinkedCommand {
11-
static summary = 'Check the status of a bulk operation.'
11+
static summary = 'Check the status of bulk operations.'
1212

13-
static description = 'Check the status of a bulk operation by ID.'
13+
static description =
14+
'Check the status of a specific bulk operation by ID, or list all bulk operations in the last 7 days.'
1415

1516
static hidden = true
1617

1718
static flags = {
1819
...globalFlags,
1920
...appFlags,
2021
id: Flags.string({
21-
description: 'The bulk operation ID.',
22+
description: 'The bulk operation ID. If not provided, lists all bulk operations in the last 7 days.',
2223
env: 'SHOPIFY_FLAG_ID',
23-
required: true,
2424
}),
2525
store: Flags.string({
2626
char: 's',
@@ -46,11 +46,18 @@ export default class BulkStatus extends AppLinkedCommand {
4646
forceReselectStore: flags.reset,
4747
})
4848

49-
await getBulkOperationStatus({
50-
storeFqdn: store.shopDomain,
51-
operationId: flags.id,
52-
remoteApp: appContextResult.remoteApp,
53-
})
49+
if (flags.id) {
50+
await getBulkOperationStatus({
51+
storeFqdn: store.shopDomain,
52+
operationId: flags.id,
53+
remoteApp: appContextResult.remoteApp,
54+
})
55+
} else {
56+
await listBulkOperations({
57+
storeFqdn: store.shopDomain,
58+
remoteApp: appContextResult.remoteApp,
59+
})
60+
}
5461

5562
return {app: appContextResult.app}
5663
}

packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import {getBulkOperationStatus} from './bulk-operation-status.js'
1+
import {getBulkOperationStatus, listBulkOperations} from './bulk-operation-status.js'
22
import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
33
import {OrganizationApp} from '../../models/organization.js'
4+
import {ListBulkOperationsQuery} from '../../api/graphql/bulk-operations/generated/list-bulk-operations.js'
45
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
56
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
67
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
@@ -150,3 +151,126 @@ describe('getBulkOperationStatus', () => {
150151
})
151152
})
152153
})
154+
155+
describe('listBulkOperations', () => {
156+
function mockBulkOperationsList(
157+
operations: Partial<NonNullable<ListBulkOperationsQuery['bulkOperations']['nodes'][0]>>[],
158+
): ListBulkOperationsQuery {
159+
return {
160+
bulkOperations: {
161+
nodes: operations.map((op) => ({
162+
id: 'gid://shopify/BulkOperation/123',
163+
status: 'RUNNING',
164+
errorCode: null,
165+
objectCount: 100,
166+
createdAt: new Date().toISOString(),
167+
completedAt: null,
168+
url: null,
169+
partialDataUrl: null,
170+
...op,
171+
})),
172+
},
173+
}
174+
}
175+
176+
test('renders table with bulk operations', async () => {
177+
vi.mocked(adminRequestDoc).mockResolvedValue(
178+
mockBulkOperationsList([
179+
{
180+
id: 'gid://shopify/BulkOperation/1',
181+
status: 'COMPLETED',
182+
objectCount: 123500,
183+
createdAt: '2025-11-10T12:37:52Z',
184+
completedAt: '2025-11-10T16:37:12Z',
185+
url: 'https://example.com/results.jsonl',
186+
},
187+
{
188+
id: 'gid://shopify/BulkOperation/2',
189+
status: 'RUNNING',
190+
objectCount: 100,
191+
createdAt: '2025-11-11T15:37:52Z',
192+
},
193+
]),
194+
)
195+
196+
const output = mockAndCaptureOutput()
197+
await listBulkOperations({storeFqdn, remoteApp})
198+
199+
const outputLinesWithoutTrailingWhitespace = output
200+
.output()
201+
.split('\n')
202+
.map((line) => line.trimEnd())
203+
.join('\n')
204+
205+
// terminal width in test environment is quite narrow, so values in the snapshot get wrapped
206+
expect(outputLinesWithoutTrailingWhitespace).toMatchInlineSnapshot(`
207+
"ID STATUS COU DATE CREATED DATE RESULTS
208+
T FINISHED
209+
210+
──────────────── ────── ─── ──────────── ─────────── ───────────────────────────
211+
──────────── ── ── ─────── ─────── ───────────────────
212+
gid://shopify/Bu COMPLE 123 2025-11-10 2025-11-10 download ( https://example.
213+
kOperation/1 ED 5K 12:37:52 16:37:12 com/results.jsonl )
214+
gid://shopify/Bu RUNNIN 100 2025-11-11
215+
kOperation/2 15:37:52"
216+
`)
217+
})
218+
219+
test('formats large counts as thousands or millions for readability', async () => {
220+
vi.mocked(adminRequestDoc).mockResolvedValue(
221+
mockBulkOperationsList([{objectCount: 1200000}, {objectCount: 5500}, {objectCount: 42}]),
222+
)
223+
224+
const output = mockAndCaptureOutput()
225+
await listBulkOperations({storeFqdn, remoteApp})
226+
227+
expect(output.output()).toContain('1.2M')
228+
expect(output.output()).toContain('5.5K')
229+
expect(output.output()).toContain('42')
230+
})
231+
232+
test('shows download for failed operations with partial results', async () => {
233+
vi.mocked(adminRequestDoc).mockResolvedValue(
234+
mockBulkOperationsList([
235+
{
236+
status: 'FAILED',
237+
errorCode: 'ACCESS_DENIED',
238+
partialDataUrl: 'https://example.com/partial.jsonl',
239+
completedAt: '2025-11-10T16:37:12Z',
240+
},
241+
]),
242+
)
243+
244+
const output = mockAndCaptureOutput()
245+
await listBulkOperations({storeFqdn, remoteApp})
246+
247+
expect(output.output()).toContain('download')
248+
expect(output.output()).toContain('partial.jsonl')
249+
})
250+
251+
test('shows download for completed operations with results', async () => {
252+
vi.mocked(adminRequestDoc).mockResolvedValue(
253+
mockBulkOperationsList([
254+
{
255+
status: 'COMPLETED',
256+
url: 'https://example.com/results.jsonl',
257+
},
258+
]),
259+
)
260+
261+
const output = mockAndCaptureOutput()
262+
await listBulkOperations({storeFqdn, remoteApp})
263+
264+
expect(output.output()).toContain('download')
265+
expect(output.output()).toContain('results.jsonl')
266+
})
267+
268+
test('shows empty state when no bulk operations found', async () => {
269+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([]))
270+
271+
const output = mockAndCaptureOutput()
272+
await listBulkOperations({storeFqdn, remoteApp})
273+
274+
expect(output.info()).toContain('no bulk operations found in the last 7 days')
275+
})
276+
})

0 commit comments

Comments
 (0)