Skip to content

Commit

Permalink
Add delinquency notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
sbosio committed Jun 11, 2024
1 parent 4011b1e commit 1739152
Show file tree
Hide file tree
Showing 9 changed files with 458 additions and 18 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"heroku-client": "^3.1.0",
"http-call": "^5.3.0",
"netrc-parser": "^3.1.6",
"open": "^6.2.0",
"open": "^8.4.2",
"uuid": "^8.3.0"
},
"devDependencies": {
Expand Down Expand Up @@ -43,6 +43,7 @@
"np": "^7.7.0",
"proxyquire": "^2.1.3",
"sinon": "^14.0.2",
"stdout-stderr": "^0.1.13",
"ts-node": "^10.9.1",
"tslint": "^6.1.3",
"typescript": "^4.8.4"
Expand Down
83 changes: 81 additions & 2 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {Login} from './login'
import {Mutex} from './mutex'
import {RequestId, requestIdHeader} from './request-id'
import {vars} from './vars'
import {ParticleboardClient, IDelinquencyInfo} from './particleboard-client'

export namespace APIClient {
export interface Options extends HTTPRequestOptions {
Expand All @@ -29,6 +30,13 @@ export interface IHerokuAPIErrorOptions {
url?: string
}

interface IDelinquencyConfig {
fetch_delinquency: boolean
warning_shown: boolean
resource_type?: 'account' | 'team'
fetch_url?: string
}

export class HerokuAPIError extends CLIError {
http: HTTPError
body: IHerokuAPIErrorOptions
Expand All @@ -55,9 +63,11 @@ export class APIClient {
private readonly _login = new Login(this.config, this)
private _twoFactorMutex: Mutex<string> | undefined
private _auth?: string
private _particleboard!: ParticleboardClient

constructor(protected config: Interfaces.Config, public options: IOptions = {}) {
this.config = config

if (options.required === undefined) options.required = true
options.preauth = options.preauth !== false
this.options = options
Expand All @@ -75,6 +85,7 @@ export class APIClient {
...envHeaders,
},
}
const delinquencyConfig: IDelinquencyConfig = {fetch_delinquency: false, warning_shown: false}
this.http = class APIHTTPClient<T> extends deps.HTTP.HTTP.create(opts)<T> {
static async twoFactorRetry(
err: HTTPError,
Expand Down Expand Up @@ -107,17 +118,79 @@ export class APIClient {
}
}

static configDelinquency(url: string, opts: APIClient.Options): void {
if (opts.method?.toUpperCase() !== 'GET' || (opts.hostname && opts.hostname !== apiUrl.hostname)) {
delinquencyConfig.fetch_delinquency = false
return
}

if (/^\/account$/i.test(url)) {
delinquencyConfig.fetch_url = '/account'
delinquencyConfig.fetch_delinquency = true
delinquencyConfig.resource_type = 'account'
return
}

const match = url.match(/^\/teams\/([^#/?]+)/i)
if (match) {
delinquencyConfig.fetch_url = `/teams/${match[1]}`
delinquencyConfig.fetch_delinquency = true
delinquencyConfig.resource_type = 'team'
return
}

delinquencyConfig.fetch_delinquency = false
}

// eslint-disable-next-line complexity
static async request<T>(url: string, opts: APIClient.Options = {}, retries = 3): Promise<APIHTTPClient<T>> {
opts.headers = opts.headers || {}
opts.headers[requestIdHeader] = RequestId.create() && RequestId.headerValue

if (!Object.keys(opts.headers).find(h => h.toLowerCase() === 'authorization')) {
if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
opts.headers.authorization = `Bearer ${self.auth}`
}

this.configDelinquency(url, opts)

retries--
try {
const response = await super.request<T>(url, opts)
let response: HTTP<T>
let particleboardResponse: HTTP<IDelinquencyInfo> | undefined
const particleboardClient: ParticleboardClient = self.particleboard

if (delinquencyConfig.fetch_delinquency && !delinquencyConfig.warning_shown) {
self._particleboard.auth = self.auth
const settledResponses = await Promise.allSettled([
super.request<T>(url, opts),
particleboardClient.get<IDelinquencyInfo>(delinquencyConfig.fetch_url as string),
])

// Platform API request
if (settledResponses[0].status === 'fulfilled')
response = settledResponses[0].value
else
throw settledResponses[0].reason

// Particleboard request (ignore errors)
if (settledResponses[1].status === 'fulfilled') {
particleboardResponse = settledResponses[1].value
}
} else {
response = await super.request<T>(url, opts)
}

const delinquencyInfo: IDelinquencyInfo = particleboardResponse?.body || {}
if (delinquencyInfo.scheduled_suspension_time) {
warn(`This ${delinquencyConfig.resource_type} is delinquent with payment and we‘ll suspend it on ${new Date(delinquencyInfo.scheduled_suspension_time)}.`)
delinquencyConfig.warning_shown = true
}

if (delinquencyInfo.scheduled_deletion_time) {
warn(`This ${delinquencyConfig.resource_type} is delinquent with payment and we‘ll delete it on ${new Date(delinquencyInfo.scheduled_deletion_time)}.`)
delinquencyConfig.warning_shown = true
}

this.trackRequestIds<T>(response)
return response
} catch (error) {
Expand Down Expand Up @@ -145,6 +218,12 @@ export class APIClient {
}
}

get particleboard(): ParticleboardClient {
if (this._particleboard) return this._particleboard
this._particleboard = new deps.ParticleboardClient(this.config)
return this._particleboard
}

get twoFactorMutex(): Mutex<string> {
if (!this._twoFactorMutex) {
this._twoFactorMutex = new deps.Mutex()
Expand Down
4 changes: 4 additions & 0 deletions src/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import HTTP = require('http-call')
import netrc = require('netrc-parser')

import apiClient = require('./api-client')
import particleboardClient = require('./particleboard-client')
import file = require('./file')
import flags = require('./flags')
import git = require('./git')
Expand Down Expand Up @@ -34,6 +35,9 @@ export const deps = {
get APIClient(): typeof apiClient.APIClient {
return fetch('./api-client').APIClient
},
get ParticleboardClient(): typeof particleboardClient.ParticleboardClient {
return fetch('./particleboard-client').ParticleboardClient
},
get file(): typeof file {
return fetch('./file')
},
Expand Down
4 changes: 2 additions & 2 deletions src/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as Heroku from '@heroku-cli/schema'
import {Interfaces, ux} from '@oclif/core'
import HTTP from 'http-call'
import Netrc from 'netrc-parser'
import open = require('open')
import open from 'open'
import * as os from 'os'

import {APIClient, HerokuAPIError} from './api-client'
Expand Down Expand Up @@ -152,7 +152,7 @@ export class Login {
}

// ux.warn(`If browser does not open, visit ${color.greenBright(url)}`)
const cp = await open(url, {app: browser, wait: false})
const cp = await open(url, {wait: false, ...(browser ? {app: {name: browser}} : {})})
cp.on('error', err => {
ux.warn(err)
showUrl()
Expand Down
72 changes: 72 additions & 0 deletions src/particleboard-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {Interfaces} from '@oclif/core'
import {HTTP, HTTPRequestOptions} from 'http-call'
import * as url from 'url'

import deps from './deps'
import {RequestId, requestIdHeader} from './request-id'
import {vars} from './vars'

export interface IDelinquencyInfo {
scheduled_suspension_time?: string | null
scheduled_deletion_time?: string | null
}

export class ParticleboardClient {
http: typeof HTTP
private _auth?: string

constructor(protected config: Interfaces.Config) {
this.config = config
const particleboardUrl = url.URL ? new url.URL(vars.particleboardUrl) : url.parse(vars.particleboardUrl)
const self = this as any
const envParticleboardHeaders = JSON.parse(process.env.HEROKU_PARTICLEBOARD_HEADERS || '{}')
const particleboardOpts = {
host: particleboardUrl.hostname,
port: particleboardUrl.port,
protocol: particleboardUrl.protocol,
headers: {
accept: 'application/vnd.heroku+json; version=3',
'user-agent': `heroku-cli/${self.config.version} ${self.config.platform}`,
...envParticleboardHeaders,
},
}
this.http = class ParticleboardHTTPClient<T> extends deps.HTTP.HTTP.create(particleboardOpts)<T> {
static trackRequestIds<T>(response: HTTP<T>) {
const responseRequestIdHeader = response.headers[requestIdHeader]
if (responseRequestIdHeader) {
const requestIds = Array.isArray(responseRequestIdHeader) ? responseRequestIdHeader : responseRequestIdHeader.split(',')
RequestId.track(...requestIds)
}
}

static async request<T>(url: string, opts: HTTPRequestOptions = {}): Promise<ParticleboardHTTPClient<T>> {
opts.headers = opts.headers || {}
opts.headers[requestIdHeader] = RequestId.create() && RequestId.headerValue

if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
opts.headers.authorization = `Bearer ${self.auth}`
}

const response = await super.request<T>(url, opts)
this.trackRequestIds<T>(response)
return response
}
}
}

get auth(): string | undefined {
return this._auth
}

set auth(token: string | undefined) {
this._auth = token
}

get<T>(url: string, options: HTTPRequestOptions = {}) {
return this.http.get<T>(url, options)
}

get defaults(): typeof HTTP.defaults {
return this.http.defaults
}
}
13 changes: 13 additions & 0 deletions src/vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ export class Vars {
get gitPrefixes(): string[] {
return [`git@${this.gitHost}:`, `ssh://git@${this.gitHost}/`, `https://${this.httpGitHost}/`]
}

get envParticleboardUrl(): string | undefined {
return process.env.HEROKU_PARTICLEBOARD_URL
}

// This should be fixed after we make our staging hostnames consistent throughout all services
// changing the staging cloud URL to `particleboard.staging.herokudev.com`.
get particleboardUrl(): string {
if (this.envParticleboardUrl) return this.envParticleboardUrl
return process.env.HEROKU_CLOUD === 'staging' ?
'https://particleboard-staging-cloud.herokuapp.com' :
'https://particleboard.heroku.com'
}
}

export const vars = new Vars()
Loading

0 comments on commit 1739152

Please sign in to comment.