From 527ce02602d69b3d4f8b1b6dfe74c280beaec8c5 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Mon, 18 May 2026 16:25:13 -0700 Subject: [PATCH 1/3] refactor: use @heroku/sdk for pipelines commands Replace direct Platform API calls in pipelines (list), pipelines:create, pipelines:info (via disambiguate), and pipelines:promote with @heroku/sdk equivalents: - pipeline.list() / pipeline.info() / pipeline.create() - pipelineCoupling.create() - account.infoByUser() / team.info() - promotePipeline() composition with onReleaseStream callback The release-command output streaming previously implemented inline in promote.ts now flows through the SDK's onReleaseStream hook, which hands a web ReadableStream to the CLI to pipe to stdout. The local poll/stream/2FA helpers are removed. Promote.promotePipeline is exposed as a static reference so tests can stub the SDK call (matches the prior Cmd.sleep convention). Adds a local PipelineCreateBody type that extends @heroku/types' PipelineCreateOpts with the undocumented `generation` request field the platform accepts but the schema doesn't declare. Adds tmp/ to the eslint ignore list. --- eslint.config.js | 2 +- package-lock.json | 2 +- src/commands/pipelines/create.ts | 41 ++-- src/commands/pipelines/index.ts | 5 +- src/commands/pipelines/promote.ts | 155 +++----------- src/lib/pipelines/disambiguate.ts | 10 +- .../commands/pipelines/promote.unit.test.ts | 197 ++++++------------ 7 files changed, 129 insertions(+), 283 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 27173bfa9a..c5e1a30e33 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,6 @@ export default [ }, // Ignore patterns (in addition to shared ignores) { - ignores: ['**/test/**/*.js', '**/*.d.ts', '.github/**'], + ignores: ['**/test/**/*.js', '**/*.d.ts', '.github/**', 'tmp/**/*'], }, ] diff --git a/package-lock.json b/package-lock.json index 60b4066189..438412e69f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2669,7 +2669,7 @@ }, "node_modules/@heroku/sdk": { "version": "0.2.0", - "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#c00ebbecdaff16260fa0a1b66d4ae82af8196e7c", + "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#1c6c15552856c6bdcb6f634884d2fba5c88a49c3", "license": "Apache-2.0", "dependencies": { "@heroku/api-client": "github:heroku/heroku-fetch", diff --git a/src/commands/pipelines/create.ts b/src/commands/pipelines/create.ts index 45c5d0f494..25e7318fa3 100644 --- a/src/commands/pipelines/create.ts +++ b/src/commands/pipelines/create.ts @@ -1,21 +1,23 @@ import {Command, flags} from '@heroku-cli/command' import {StageCompletion} from '@heroku-cli/command/lib/completions.js' import * as color from '@heroku/heroku-cli-util/color' +import {createPlatformClient} from '@heroku/sdk/platform' +import {PipelineCreateOpts} from '@heroku/types/3.sdk' import {Args, ux} from '@oclif/core' import {type Answers, type InputQuestion, type ListQuestion} from 'inquirer' -import { - createCoupling, - createPipeline, - getAccountInfo, - getTeam, - Owner, -} from '../../lib/api.js' import {getGenerationByAppId} from '../../lib/apps/generation.js' import {lazyModuleLoader} from '../../lib/lazy-module-loader.js' import infer from '../../lib/pipelines/infer.js' import {inferrableStageNames as stages} from '../../lib/pipelines/stages.js' +// The Heroku API accepts an undocumented `generation` field on POST /pipelines +// that is not declared in heroku/api's schema, so the @heroku/types +// PipelineCreateOpts shape doesn't include it. +type PipelineCreateBody = PipelineCreateOpts & { + generation?: {name: string} +} + export default class Create extends Command { static args = { name: Args.string({ @@ -54,7 +56,6 @@ export default class Create extends Command { let name let stage - let owner: Owner const guesses = infer(app) const questions: (InputQuestion | ListQuestion)[] = [] @@ -83,12 +84,13 @@ export default class Create extends Command { const ownerType = teamName ? 'team' : 'user' - // If team or org is not specified, we assign ownership to the user creating - const response = teamName ? await getTeam(this.heroku, teamName) : await getAccountInfo(this.heroku) - owner = response.body - const ownerID = owner.id + const heroku = createPlatformClient() - owner = {id: ownerID, type: ownerType} + // If team or org is not specified, we assign ownership to the user creating + const ownerRecord = teamName + ? await heroku.team.info(teamName) + : await heroku.account.infoByUser('~') + const ownerID = ownerRecord.id! const answers: Answers = await inquirer.prompt(questions) if (answers.name) name = answers.name @@ -96,11 +98,20 @@ export default class Create extends Command { ux.action.start(`Creating ${name} pipeline`) const generation = await getGenerationByAppId(app, this.heroku) - const {body: pipeline} = await createPipeline(this.heroku, name, owner, generation) + const body: PipelineCreateBody = { + generation: {name: generation ?? 'cedar'}, + name, + owner: {id: ownerID, type: ownerType}, + } + const pipeline = await heroku.pipeline.create(body) ux.action.stop() ux.action.start(`Adding ${color.app(app)} to ${color.pipeline(pipeline.name || '')} pipeline as ${stage}`) - await createCoupling(this.heroku, pipeline, app, stage) + await heroku.pipelineCoupling.create({ + app, + pipeline: pipeline.id!, + stage, + }) ux.action.stop() } } diff --git a/src/commands/pipelines/index.ts b/src/commands/pipelines/index.ts index eb7da1e162..e49e5229de 100644 --- a/src/commands/pipelines/index.ts +++ b/src/commands/pipelines/index.ts @@ -1,6 +1,6 @@ import {Command, flags} from '@heroku-cli/command' -import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' +import {createPlatformClient} from '@heroku/sdk/platform' import {ux} from '@oclif/core/ux' export default class Pipelines extends Command { @@ -15,7 +15,8 @@ export default class Pipelines extends Command { async run() { const {flags} = await this.parse(Pipelines) - const {body: pipelines} = await this.heroku.get('/pipelines') + const heroku = createPlatformClient() + const pipelines = await heroku.pipeline.list() if (flags.json) { hux.styledJSON(pipelines) diff --git a/src/commands/pipelines/promote.ts b/src/commands/pipelines/promote.ts index da43f38a8d..5b6faf6258 100644 --- a/src/commands/pipelines/promote.ts +++ b/src/commands/pipelines/promote.ts @@ -1,11 +1,9 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' +import {promotePipeline, type ReleaseStreamContext} from '@heroku/sdk/compositions/pipeline' import {ux} from '@oclif/core/ux' -import fetch from 'node-fetch' import assert from 'node:assert' -import * as Stream from 'node:stream' -import {promisify} from 'node:util' import {AppWithPipelineCoupling, listPipelineApps} from '../../lib/api.js' import keyBy from '../../lib/pipelines/key-by.js' @@ -23,8 +21,6 @@ function findAppInPipeline(apps: Array, target: string) const PROMOTION_ORDER = ['development', 'staging', 'production'] -const wait = (ms = 100) => new Promise(resolve => setTimeout(resolve, ms)) - export default class Promote extends Command { static description = 'promote the latest release of this app to its downstream app(s)' static examples = [ @@ -40,10 +36,9 @@ export default class Promote extends Command { description: 'comma separated list of apps to promote to', }), } - - public static sleep(time: number): Promise { - return new Promise(resolve => setTimeout(resolve, time)) - } + // Static reference so tests can stub the SDK call without changing the + // command's behavior in production. + public static promotePipeline = promotePipeline async run() { const {flags} = await this.parse(Promote) @@ -87,21 +82,36 @@ export default class Promote extends Command { promotionActionName = `Starting promotion to ${targetStage}` } - const promotion = await promote(this.heroku, promotionActionName, coupling.pipeline!.id!, coupling.app!.id!, targetApps) - - const pollLoop = pollPromotionStatus(this.heroku, promotion.id!, true) + ux.stdout(`${promotionActionName}...`) ux.stdout('Waiting for promotion to complete...') - let promotionTargets = await pollLoop - try { - promotionTargets = await streamReleaseCommand(this.heroku, promotionTargets, promotion) - } catch (error: any) { - ux.error(error) + let releaseStreamError: unknown + const onReleaseStream = async ({stream}: ReleaseStreamContext) => { + ux.stdout('Running release command...') + try { + await stream.pipeTo(new WritableStream({ + write(chunk) { + process.stdout.write(Buffer.from(chunk)) + }, + })) + } catch (error) { + releaseStreamError = error + } + } + + const {targets: promotionTargets} = await Promote.promotePipeline({ + pipeline: {id: coupling.pipeline!.id!}, + source: {app: {id: coupling.app!.id!}}, + targets: targetApps.map(app => ({app: {id: app.id}})), + }, {onReleaseStream}) + + if (releaseStreamError) { + ux.error(releaseStreamError as Error) } const appsByID = keyBy(allApps, 'id') - const styledTargets = promotionTargets.reduce((memo: Heroku.App, target: Heroku.App) => { + const styledTargets = promotionTargets.reduce((memo: Heroku.App, target: any) => { const app = appsByID[target.app.id] const details = [target.status] @@ -141,16 +151,6 @@ async function getCoupling(heroku: APIClient, app: string): Promise { - ux.stdout('Fetching release info...') - const {body: release} = await heroku.get(`/apps/${app}/releases/${releaseId}`) - return release -} - -function isComplete(promotionTarget: Heroku.PipelinePromotionTarget) { - return promotionTarget.status !== 'pending' -} - function isFailed(promotionTarget: Heroku.PipelinePromotionTarget) { return promotionTarget.status === 'failed' } @@ -158,102 +158,3 @@ function isFailed(promotionTarget: Heroku.PipelinePromotionTarget) { function isSucceeded(promotionTarget: Heroku.PipelinePromotionTarget) { return promotionTarget.status === 'succeeded' } - -function pollPromotionStatus(heroku: APIClient, id: string, needsReleaseCommand: boolean): Promise> { - return heroku.get>(`/pipeline-promotions/${id}/promotion-targets`).then(({body: targets}) => { - if (targets.every(isComplete)) { - return targets - } - - // - // With only one target, we can return as soon as the release is created. - // The command will then read the release phase output - // - // `needsReleaseCommand` allows us to keep polling, as it can take a few - // seconds to get the release to succeeded after the release command - // finished. - // - if (needsReleaseCommand && targets.length === 1 && targets[0].release !== null) { - return targets - } - - return wait(1000).then(pollPromotionStatus.bind(null, heroku, id, needsReleaseCommand)) - }) -} - -async function promote(heroku: APIClient, label: string, id: string, sourceAppId: string, targetApps: Array, secondFactor?: string): Promise { - const options = { - body: { - pipeline: {id}, - source: {app: {id: sourceAppId}}, - targets: targetApps.map(app => ({app: {id: app.id}})), - }, - headers: {}, - } - - if (secondFactor) { - options.headers = {'Heroku-Two-Factor-Code': secondFactor} - } - - try { - ux.stdout(`${label}...`) - const {body: promotions} = await heroku.post('/pipeline-promotions', options) - return promotions - } catch (error: any) { - if (!error.body || error.body.id !== 'two_factor') { - throw error - } - - const secondFactor = await heroku.twoFactorPrompt() - return promote(heroku, label, id, sourceAppId, targetApps, secondFactor) - } -} - -async function streamReleaseCommand(heroku: APIClient, targets: Array, promotion: any) { - if (targets.length !== 1 || targets.every(isComplete)) { - return pollPromotionStatus(heroku, promotion.id, false) - } - - const target = targets[0] - const release = await getRelease(heroku, target.app.id, target.release.id) - - if (!release.output_stream_url) { - return pollPromotionStatus(heroku, promotion.id, false) - } - - ux.stdout('Running release command...') - - async function streamReleaseOutput(releaseStreamUrl: string) { - const finished = promisify(Stream.finished) - const fetchResponse = await fetch(releaseStreamUrl) - - if (fetchResponse.status >= 400) { - throw new Error('stream release output not available') - } - - fetchResponse.body.pipe(process.stdout) - - await finished(fetchResponse.body) - } - - async function retry(maxAttempts: number, fn: () => Promise) { - let currentAttempt = 0 - - while (true) { - try { - await fn() - return - } catch (error) { - if (++currentAttempt === maxAttempts) { - throw error - } - - await Promote.sleep(1000) - } - } - } - - await retry(100, () => streamReleaseOutput(release.output_stream_url!)) - - return pollPromotionStatus(heroku, promotion.id, false) -} diff --git a/src/lib/pipelines/disambiguate.ts b/src/lib/pipelines/disambiguate.ts index f545087381..5361a943db 100644 --- a/src/lib/pipelines/disambiguate.ts +++ b/src/lib/pipelines/disambiguate.ts @@ -1,19 +1,17 @@ import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {createPlatformClient} from '@heroku/sdk/platform' import inquirer from 'inquirer' -import { - findPipelineByName, - getPipeline, -} from '../api.js' +import {findPipelineByName} from '../api.js' import {uuidValidate} from '../utils/uuid-validate.js' export default async function disambiguate(heroku: APIClient, pipelineIDOrName: string): Promise { let pipeline: Heroku.Pipeline if (uuidValidate(pipelineIDOrName)) { - const result = (await getPipeline(heroku, pipelineIDOrName)) - pipeline = result.body + const sdk = createPlatformClient() + pipeline = await sdk.pipeline.info(pipelineIDOrName) } else { const {body: pipelines} = await findPipelineByName(heroku, pipelineIDOrName) diff --git a/test/unit/commands/pipelines/promote.unit.test.ts b/test/unit/commands/pipelines/promote.unit.test.ts index 7372adfa28..7e28fdb345 100644 --- a/test/unit/commands/pipelines/promote.unit.test.ts +++ b/test/unit/commands/pipelines/promote.unit.test.ts @@ -29,11 +29,6 @@ describe('pipelines:promote', function () { pipeline, } - const targetReleaseWithOutput = { - id: '123-target-release-456', - output_stream_url: 'https://busl.example/release', - } - const sourceCoupling = { app: sourceApp, id: '123-source-app-456', @@ -73,33 +68,6 @@ describe('pipelines:promote', function () { restore() }) - function mockPromotionTargets() { - let pollCount = 0 - api - .get(`/pipeline-promotions/${promotion.id}/promotion-targets`) - .thrice() - .reply(function () { - pollCount++ - - return [ - 200, - [ - { - app: {id: targetApp1.id}, - error_message: null, - status: 'successful', - }, - { - app: {id: targetApp2.id}, - error_message: pollCount > 1 ? 'Because reasons' : null, - // Return failed on the second poll loop - status: pollCount > 1 ? 'failed' : 'pending', - }, - ], - ] - }) - } - function setupNock() { api .get(`/apps/${sourceApp.name}/pipeline-couplings`) @@ -110,23 +78,39 @@ describe('pipelines:promote', function () { .reply(200, [sourceApp, targetApp1, targetApp2]) } + function stubPromote(targets: any[]) { + return stub(Cmd, 'promotePipeline').resolves({ + promotion, + targets, + } as any) + } + it('promotes to all apps in the next stage', async function () { setupNock() - mockPromotionTargets() - - api - .post('/pipeline-promotions', { - pipeline: {id: pipeline.id}, - source: {app: {id: sourceApp.id}}, - targets: [ - {app: {id: targetApp1.id}}, - {app: {id: targetApp2.id}}, - ], - }) - .reply(201, promotion) + const promoteStub = stubPromote([ + { + app: {id: targetApp1.id}, + error_message: null, + status: 'succeeded', + }, + { + app: {id: targetApp2.id}, + error_message: 'Because reasons', + status: 'failed', + }, + ]) const {stdout} = await runCommand(Cmd, [`--app=${sourceApp.name}`]) + expect(promoteStub.calledOnce).to.be.true + expect(promoteStub.firstCall.args[0]).to.deep.equal({ + pipeline: {id: pipeline.id}, + source: {app: {id: sourceApp.id}}, + targets: [ + {app: {id: targetApp1.id}}, + {app: {id: targetApp2.id}}, + ], + }) expect(stdout).to.contain('failed') expect(stdout).to.contain('Because reasons') }) @@ -134,128 +118,79 @@ describe('pipelines:promote', function () { context('passing a `to` flag', function () { it('can promote by app name', async function () { setupNock() - mockPromotionTargets() - - api - .post('/pipeline-promotions', { - pipeline: {id: pipeline.id}, - source: {app: {id: sourceApp.id}}, - targets: [ - {app: {id: targetApp1.id}}, - ], - }) - .reply(201, promotion) + const promoteStub = stubPromote([ + { + app: {id: targetApp1.id}, + error_message: 'Because reasons', + status: 'failed', + }, + ]) const {stdout} = await runCommand(Cmd, [`--app=${sourceApp.name}`, `--to=${targetApp1.name}`]) + expect(promoteStub.firstCall.args[0].targets).to.deep.equal([{app: {id: targetApp1.id}}]) expect(stdout).to.contain('failed') expect(stdout).to.contain('Because reasons') }) it('can promote by app id', async function () { setupNock() - mockPromotionTargets() - - api - .post('/pipeline-promotions', { - pipeline: {id: pipeline.id}, - source: {app: {id: sourceApp.id}}, - targets: [ - {app: {id: targetApp1.id}}, - ], - }) - .reply(201, promotion) + const promoteStub = stubPromote([ + { + app: {id: targetApp1.id}, + error_message: 'Because reasons', + status: 'failed', + }, + ]) const {stdout} = await runCommand(Cmd, [`--app=${sourceApp.name}`, `--to=${targetApp1.id}`]) + expect(promoteStub.firstCall.args[0].targets).to.deep.equal([{app: {id: targetApp1.id}}]) expect(stdout).to.contain('failed') expect(stdout).to.contain('Because reasons') }) }) context('with release phase', function () { - it('streams the release command output', async function () { + it('streams the release command output to stdout', async function () { setupNock() - - let pollCount = 0 - - api - .post('/pipeline-promotions', { - pipeline: {id: pipeline.id}, - source: {app: {id: sourceApp.id}}, - targets: [ - {app: {id: targetApp1.id}}, - {app: {id: targetApp2.id}}, - ], + const streamBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('Release Command Output')) + controller.close() + }, + }) + const promoteStub = stub(Cmd, 'promotePipeline').callsFake(async (_body, options) => { + await options!.onReleaseStream!({ + stream: streamBody, + target: {app: {id: targetApp1.id}, status: 'pending'}, }) - .reply(201, promotion) - .get(`/apps/${targetApp1.id}/releases/${targetReleaseWithOutput.id}`) - .reply(200, targetReleaseWithOutput) - .get(`/pipeline-promotions/${promotion.id}/promotion-targets`) - .twice() - .reply(200, function () { - pollCount++ - - return [{ + return { + promotion, + targets: [{ app: {id: targetApp1.id}, error_message: null, - release: {id: targetReleaseWithOutput.id}, - status: pollCount > 1 ? 'successful' : 'pending', - }] - }) - - nock('https://busl.example') - .get('/release') - .reply(200, 'Release Command Output') + status: 'succeeded', + }], + } as any + }) const {stdout} = await runCommand(Cmd, [`--app=${sourceApp.name}`]) + expect(promoteStub.calledOnce).to.be.true expect(stdout).to.contain('Running release command') expect(stdout).to.contain('Release Command Output') - expect(stdout).to.contain('successful') + expect(stdout).to.contain('succeeded') }) }) context('with release phase that errors', function () { - it('attempts stream and returns error', async function () { - stub(Cmd, 'sleep').resolves() - + it('surfaces the SDK stream error', async function () { setupNock() - - let pollCount = 0 - - api - .post('/pipeline-promotions', { - pipeline: {id: pipeline.id}, - source: {app: {id: sourceApp.id}}, - targets: [ - {app: {id: targetApp1.id}}, - {app: {id: targetApp2.id}}, - ], - }) - .reply(201, promotion) - .get(`/apps/${targetApp1.id}/releases/${targetReleaseWithOutput.id}`) - .reply(200, targetReleaseWithOutput) - .get(`/pipeline-promotions/${promotion.id}/promotion-targets`) - .reply(200, function () { - pollCount++ - - return [{ - app: {id: targetApp1.id}, - error_message: null, - release: {id: targetReleaseWithOutput.id}, - status: pollCount > 1 ? 'successful' : 'pending', - }] - }) - - nock('https://busl.example') - .get('/release') - .times(100) - .reply(404, 'Release Command Output') + stub(Cmd, 'promotePipeline').rejects(new Error('stream release output not available')) const {error} = await runCommand(Cmd, [`--app=${sourceApp.name}`]) - expect(error?.oclif?.exit).to.equal(2) expect(error?.message).to.equal('stream release output not available') }) }) From 12fb69b6eac68f38fee4b44c2e70e19de1029e20 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Tue, 19 May 2026 14:03:41 -0700 Subject: [PATCH 2/3] refactor: use SDK for destroy/update + adopt listPipelineApps composition - pipelines:destroy now calls SDK pipeline.delete(). - pipelines:update now calls SDK pipelineCoupling.infoByApp() + pipelineCoupling.update(). - Drop the lib/api.ts listPipelineApps wrapper and have the four consumers (info, diff, transfer, promote) import the SDK composition directly. AppWithPipelineCoupling type also moves to the SDK; render-pipeline.ts widens it locally to satisfy hux.table's Record row constraint. - Removes the now-unused getAppFilter helper, listCouplings helper, and FILTERS_HEADER constant from lib/api.ts. - Bumps @heroku/sdk to the eb/feat/list-pipeline-apps branch which exposes the new listPipelineApps composition. --- package-lock.json | 4 ++-- package.json | 2 +- src/commands/pipelines/destroy.ts | 5 +++-- src/commands/pipelines/diff.ts | 4 ++-- src/commands/pipelines/info.ts | 4 ++-- src/commands/pipelines/promote.ts | 10 ++++++--- src/commands/pipelines/transfer.ts | 4 ++-- src/commands/pipelines/update.ts | 10 ++++----- src/lib/api.ts | 32 +--------------------------- src/lib/pipelines/ownership.ts | 4 +++- src/lib/pipelines/render-pipeline.ts | 11 +++++++--- 11 files changed, 36 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 438412e69f..7635c488d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#main", + "@heroku/sdk": "github:heroku/heroku-sdk#eb/feat/list-pipeline-apps", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", @@ -2669,7 +2669,7 @@ }, "node_modules/@heroku/sdk": { "version": "0.2.0", - "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#1c6c15552856c6bdcb6f634884d2fba5c88a49c3", + "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#1f40b639e0d4e66986f4b7fb76c6ebf362721725", "license": "Apache-2.0", "dependencies": { "@heroku/api-client": "github:heroku/heroku-fetch", diff --git a/package.json b/package.json index 287808bca9..03de616c8c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#main", + "@heroku/sdk": "github:heroku/heroku-sdk#eb/feat/list-pipeline-apps", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", diff --git a/src/commands/pipelines/destroy.ts b/src/commands/pipelines/destroy.ts index 269a8ab706..28b7cf8cae 100644 --- a/src/commands/pipelines/destroy.ts +++ b/src/commands/pipelines/destroy.ts @@ -1,9 +1,9 @@ import {Command} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {createPlatformClient} from '@heroku/sdk/platform' import {Args, ux} from '@oclif/core' -import {destroyPipeline} from '../../lib/api.js' import disambiguate from '../../lib/pipelines/disambiguate.js' export default class PipelinesDestroy extends Command { @@ -23,7 +23,8 @@ export default class PipelinesDestroy extends Command { const pipeline: Heroku.Pipeline = await disambiguate(this.heroku, args.pipeline) ux.action.start(`Destroying ${color.pipeline(pipeline.name!)} pipeline`) - await destroyPipeline(this.heroku, pipeline.name, pipeline.id) + const heroku = createPlatformClient() + await heroku.pipeline.delete(pipeline.id!) ux.action.stop() } } diff --git a/src/commands/pipelines/diff.ts b/src/commands/pipelines/diff.ts index f20580dd73..f1ce5cc0a6 100644 --- a/src/commands/pipelines/diff.ts +++ b/src/commands/pipelines/diff.ts @@ -1,6 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' import {HTTP} from '@heroku/http-call' +import {listPipelineApps} from '@heroku/sdk/compositions/pipeline' import {ux} from '@oclif/core/ux' import type {OciImage, PipelineCoupling, Slug} from '../../lib/types/fir.js' @@ -10,7 +11,6 @@ import { getCoupling, getPipeline, getReleases, - listPipelineApps, SDK_HEADER, } from '../../lib/api.js' import {GenerationKind, getGeneration} from '../../lib/apps/generation.js' @@ -87,7 +87,7 @@ export default class PipelinesDiff extends Command { const generation = getGeneration(pipeline)! ux.action.start('Fetching apps from pipeline') - const allApps = await listPipelineApps(this.heroku, coupling!.pipeline!.id!) + const allApps = await listPipelineApps(coupling!.pipeline!.id!) ux.action.stop() const sourceStage = coupling.stage diff --git a/src/commands/pipelines/info.ts b/src/commands/pipelines/info.ts index 09c05d855d..cf5246a1e1 100644 --- a/src/commands/pipelines/info.ts +++ b/src/commands/pipelines/info.ts @@ -1,9 +1,9 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' +import {listPipelineApps} from '@heroku/sdk/compositions/pipeline' import {Args} from '@oclif/core' -import {listPipelineApps} from '../../lib/api.js' import disambiguate from '../../lib/pipelines/disambiguate.js' import renderPipeline from '../../lib/pipelines/render-pipeline.js' @@ -31,7 +31,7 @@ export default class PipelinesInfo extends Command { async run() { const {args, flags} = await this.parse(PipelinesInfo) const pipeline: Heroku.Pipeline = await disambiguate(this.heroku, args.pipeline) - const pipelineApps = await listPipelineApps(this.heroku, pipeline.id!) + const pipelineApps = await listPipelineApps(pipeline.id!) if (flags.json) { // eslint-disable-next-line perfectionist/sort-objects diff --git a/src/commands/pipelines/promote.ts b/src/commands/pipelines/promote.ts index 5b6faf6258..003ee22226 100644 --- a/src/commands/pipelines/promote.ts +++ b/src/commands/pipelines/promote.ts @@ -1,11 +1,15 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' -import {promotePipeline, type ReleaseStreamContext} from '@heroku/sdk/compositions/pipeline' +import { + type AppWithPipelineCoupling, + listPipelineApps, + promotePipeline, + type ReleaseStreamContext, +} from '@heroku/sdk/compositions/pipeline' import {ux} from '@oclif/core/ux' import assert from 'node:assert' -import {AppWithPipelineCoupling, listPipelineApps} from '../../lib/api.js' import keyBy from '../../lib/pipelines/key-by.js' function assertNotPromotingToSelf(source: string, target: string) { @@ -45,7 +49,7 @@ export default class Promote extends Command { const appNameOrId = flags.app const coupling = await getCoupling(this.heroku, appNameOrId) ux.stdout(`Fetching apps from ${color.pipeline(coupling.pipeline!.name)}...`) - const allApps = await listPipelineApps(this.heroku, coupling.pipeline!.id!) + const allApps = await listPipelineApps(coupling.pipeline!.id!) const sourceStage = coupling.stage let promotionActionName = '' diff --git a/src/commands/pipelines/transfer.ts b/src/commands/pipelines/transfer.ts index 00d483150f..276172e7d5 100644 --- a/src/commands/pipelines/transfer.ts +++ b/src/commands/pipelines/transfer.ts @@ -1,12 +1,12 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' +import {listPipelineApps} from '@heroku/sdk/compositions/pipeline' import {Args, ux} from '@oclif/core' import { createPipelineTransfer, getAccountInfo, getTeam, - listPipelineApps, } from '../../lib/api.js' import disambiguate from '../../lib/pipelines/disambiguate.js' import renderPipeline from '../../lib/pipelines/render-pipeline.js' @@ -50,7 +50,7 @@ export default class PipelinesTransfer extends Command { const {args, flags} = await this.parse(PipelinesTransfer) const pipeline = await disambiguate(this.heroku, flags.pipeline) const newOwner = await getOwner(this.heroku, args.owner) - const apps = await listPipelineApps(this.heroku, pipeline.id!) + const apps = await listPipelineApps(pipeline.id!) const displayType = newOwner.type === 'user' ? 'account' : newOwner.type let confirmName = flags.confirm diff --git a/src/commands/pipelines/update.ts b/src/commands/pipelines/update.ts index b4b42b5669..f7d9055e6b 100644 --- a/src/commands/pipelines/update.ts +++ b/src/commands/pipelines/update.ts @@ -1,10 +1,9 @@ import {Command, flags} from '@heroku-cli/command' import {StageCompletion} from '@heroku-cli/command/lib/completions.js' import * as color from '@heroku/heroku-cli-util/color' +import {createPlatformClient} from '@heroku/sdk/platform' import {ux} from '@oclif/core/ux' -import {updateCoupling} from '../../lib/api.js' - export default class PipelinesUpdate extends Command { static description = 'update the app\'s stage in a pipeline' static examples = [ @@ -24,11 +23,12 @@ export default class PipelinesUpdate extends Command { async run() { const {flags} = await this.parse(PipelinesUpdate) - const {app} = flags - const {stage} = flags + const {app, stage} = flags + const heroku = createPlatformClient() ux.action.start(`Changing ${color.app(app)} to ${stage}`) - await updateCoupling(this.heroku, app, stage) + const coupling = await heroku.pipelineCoupling.infoByApp(app) + await heroku.pipelineCoupling.update(coupling.id!, {stage: stage as 'development' | 'production' | 'review' | 'staging' | 'test'}) ux.action.stop() } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 930fc0c093..ac46be9c84 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,11 +1,10 @@ import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' -import {App, Pipeline, PipelineCoupling} from './types/fir.js' +import {Pipeline, PipelineCoupling} from './types/fir.js' export const V3_HEADER = 'application/vnd.heroku+json; version=3' export const SDK_HEADER = 'application/vnd.heroku+json; version=3.sdk' -export const FILTERS_HEADER = `${V3_HEADER}.filters` export const PIPELINES_HEADER = `${V3_HEADER}.pipelines` const CI_HEADER = `${V3_HEADER}.ci` @@ -81,14 +80,6 @@ export function getTeam(heroku: APIClient, teamId: any) { return heroku.get(`/teams/${teamId}`) } -function getAppFilter(heroku: APIClient, appIds: Array) { - return heroku.request>('/filters/apps', { - body: {in: {id: appIds}}, - headers: {Accept: FILTERS_HEADER, Range: 'id ..; max=1000;'}, - method: 'POST', - }) -} - export function getAccountInfo(heroku: APIClient, id = '~') { return heroku.get(`/users/${id}`) } @@ -97,27 +88,6 @@ export function getAppSetup(heroku: APIClient, buildId: any) { return heroku.get(`/app-setups/${buildId}`) } -function listCouplings(heroku: APIClient, pipelineId: string) { - return heroku.get>(`/pipelines/${pipelineId}/pipeline-couplings`, { - headers: {Accept: SDK_HEADER}, - }) -} - -export interface AppWithPipelineCoupling extends App { - [k: string]: unknown - pipelineCoupling: PipelineCoupling -} - -export async function listPipelineApps(heroku: APIClient, pipelineId: string): Promise> { - const {body: couplings} = await listCouplings(heroku, pipelineId) - const appIds = couplings.map(coupling => coupling.app!.id || '') - const {body: apps} = await getAppFilter(heroku, appIds) - return apps.map(app => ({ - ...app, - pipelineCoupling: couplings.find(coupling => coupling.app!.id === app.id), - }) as AppWithPipelineCoupling) -} - export function patchCoupling(heroku: APIClient, id: string, stage: string) { return heroku.patch(`/pipeline-couplings/${id}`, {body: {stage}}) } diff --git a/src/lib/pipelines/ownership.ts b/src/lib/pipelines/ownership.ts index 1cb4ffc8e1..ba9c4e69a8 100644 --- a/src/lib/pipelines/ownership.ts +++ b/src/lib/pipelines/ownership.ts @@ -1,9 +1,11 @@ +import type {AppWithPipelineCoupling} from '@heroku/sdk/compositions/pipeline' + import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' import {ux} from '@oclif/core/ux' -import {AppWithPipelineCoupling, getTeam} from '../api.js' +import {getTeam} from '../api.js' export function getOwner(heroku: APIClient, apps: Array, pipeline: Heroku.Pipeline) { let owner diff --git a/src/lib/pipelines/render-pipeline.ts b/src/lib/pipelines/render-pipeline.ts index e01119dd54..97fc15f25b 100644 --- a/src/lib/pipelines/render-pipeline.ts +++ b/src/lib/pipelines/render-pipeline.ts @@ -1,11 +1,16 @@ +import type {AppWithPipelineCoupling} from '@heroku/sdk/compositions/pipeline' + import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' -import {AppWithPipelineCoupling} from '../api.js' import {getOwner, warnMixedOwnership} from './ownership.js' +// hux.table requires its row type to satisfy Record; the SDK +// type doesn't carry that index signature, so widen it locally for table use. +type IndexedAppWithPipelineCoupling = AppWithPipelineCoupling & Record + export default async function renderPipeline( heroku: APIClient, pipeline: Heroku.Pipeline, @@ -24,7 +29,7 @@ export default async function renderPipeline( ux.stdout('') /* eslint-disable perfectionist/sort-objects */ - const columns: Parameters>[1] = { + const columns: Parameters>[1] = { name: { get(row) { return color.app(row.name || '') @@ -73,7 +78,7 @@ export default async function renderPipeline( .sort(sortByName) const apps = developmentApps.concat(reviewApps).concat(stagingApps).concat(productionApps) - hux.table(apps, columns) + hux.table(apps as IndexedAppWithPipelineCoupling[], columns) if (showOwnerWarning && pipeline.owner && owner) { warnMixedOwnership(pipelineApps, pipeline, owner) From af74b4624bd6ddbbc7258571de264d960f37ea14 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Tue, 19 May 2026 15:22:46 -0700 Subject: [PATCH 3/3] chore: point @heroku/sdk back at main now that #17 is merged --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7635c488d9..332ee54152 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#eb/feat/list-pipeline-apps", + "@heroku/sdk": "github:heroku/heroku-sdk#main", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", @@ -2669,7 +2669,7 @@ }, "node_modules/@heroku/sdk": { "version": "0.2.0", - "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#1f40b639e0d4e66986f4b7fb76c6ebf362721725", + "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#34fe268d74bfabad2dcbc62278ac70a624154420", "license": "Apache-2.0", "dependencies": { "@heroku/api-client": "github:heroku/heroku-fetch", diff --git a/package.json b/package.json index 03de616c8c..287808bca9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#eb/feat/list-pipeline-apps", + "@heroku/sdk": "github:heroku/heroku-sdk#main", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4",