onair-cli 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e4350eed5352315ed4c6591c3ae03bdfb6648986d844a749051fed27506427b9
4
+ data.tar.gz: 9193566d1ea45893c68ff3d10340d80b6c1845a9e5f71eb21ece94df935e4a22
5
+ SHA512:
6
+ metadata.gz: a72e23588254686e5154cf43fc8253a67fd16b524f67e1d75fcd3088281af2ba23272045a14f0f6517007ab1f9d4a7c790425fa8c33d4f21834219d414ddb999
7
+ data.tar.gz: f95b6452f90d13d024f8a4d432ea2bddb39b805149a6918cc78b4274e02aa0a965246c6758d20acb71531fc69b44def03d496afa3ea41e7bea0cd2998848ec5d
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0]
6
+
7
+ - Initial release: Heroku adapter with netrc auth, full behavior parity with
8
+ the reference bash script, TTY renderer with ANSI colors and OSC 8 links.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Eugene
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,158 @@
1
+ <p align="center">
2
+ <img src="logo.svg" alt="onair" width="460">
3
+ </p>
4
+
5
+ <div align="center">
6
+
7
+ ### See what's on air — which commit is actually running in production.
8
+
9
+ [![Gem Version](https://img.shields.io/gem/v/onair-cli)](https://rubygems.org/gems/onair-cli)
10
+ [![CI](https://github.com/amberpixels/onair-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/amberpixels/onair-cli/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE.txt)
12
+
13
+ </div>
14
+
15
+ ---
16
+
17
+ <p align="center">
18
+ <img src="assets/demo.svg"
19
+ alt="onair report: a pending build, the deployed release one commit behind origin/main, and your commit absorbed by the current deploy"
20
+ width="760">
21
+ </p>
22
+
23
+ `onair` answers one question fast and truthfully. The deployed commit comes
24
+ from the running release's *slug*, never from "newest build" — after a
25
+ rollback those differ, and the whole point of this tool is to not lie in that
26
+ case.
27
+
28
+ ## Principles
29
+
30
+ 1. **Truth over convenience.** What's *running*, not what was built last.
31
+ 2. **Zero runtime dependencies.** Ruby stdlib only.
32
+ 3. **Read-only.** Never deploys, never rolls back, never writes to git except
33
+ an explicit lazy `git fetch`.
34
+ 4. **Platform-agnostic core.** Heroku is the first adapter, not the
35
+ architecture.
36
+ 5. **Degrade gracefully.** Offline, missing commits, no color support — every
37
+ failure mode renders something useful, never a stack trace.
38
+
39
+ ## Installation
40
+
41
+ ```sh
42
+ gem install onair-cli
43
+ ```
44
+
45
+ Requires Ruby >= 3.2. Auth reuses the Heroku CLI's token from `~/.netrc`
46
+ (written by `heroku login`), falling back to `heroku auth:token`.
47
+
48
+ ## Usage
49
+
50
+ ```
51
+ onair # status report for the current repo
52
+ onair init # write .onair.yml; --justfile appends a `prod` recipe
53
+ onair --json # machine-readable status
54
+ onair --app NAME # override the app for one invocation
55
+ onair --no-fetch # never fetch git objects from the remote
56
+ ```
57
+
58
+ Exit codes: `0` success, `1` operational error. Color is disabled when stdout
59
+ is not a TTY or `NO_COLOR` is set.
60
+
61
+ ### Speed
62
+
63
+ The remote head of `origin/<branch>` is resolved through the GitHub API
64
+ (~0.4s) when a token is ambient — `GH_TOKEN`, `GITHUB_TOKEN`, or a logged-in
65
+ `gh` CLI — with `git ls-remote` always racing alongside as the fallback
66
+ (no token, non-GitHub remote, API failure). Both report the live remote ref;
67
+ the API is a faster transport, never a cache.
68
+
69
+ ### What the report shows
70
+
71
+ - **Deployed** — the commit the running release was built from, its age,
72
+ author, and a delta against `origin/main`: `★ current` or
73
+ `↓ N commits behind`. If the relationship is unknown (diverged history,
74
+ commits unavailable), no marker is shown — silence over speculation.
75
+ - **Pending** — an in-flight build that's about to replace the deploy.
76
+ - **⏸ pinned** — a newer build succeeded but is *not* what's running
77
+ (rollback / pinned release).
78
+ - **Yours** — when someone else's commit is deployed but yours sits just
79
+ below it: your merge made it to prod, absorbed by a later deploy.
80
+
81
+ ## Configuration
82
+
83
+ `.onair.yml` (searched upward from the working directory to the git root):
84
+
85
+ ```yaml
86
+ platform: heroku
87
+ app: acme-prod
88
+ repo: acme/widgets # for PR/commit links; default: inferred from origin
89
+ branch: main # comparison branch; default: main
90
+ ```
91
+
92
+ Resolution order: CLI flags → env vars (`HEROKU_APP`, `GITHUB_REPO`) →
93
+ `.onair.yml`.
94
+
95
+ ### Task links (optional)
96
+
97
+ If your commit subjects carry task ids (Jira, Linear, Notion, anything), tell
98
+ onair how to recognize and link them:
99
+
100
+ ```yaml
101
+ task:
102
+ pattern: 'ABC-\d+' # any Ruby regex
103
+ url: 'https://acme.atlassian.net/browse/{task}' # {task} = the matched id
104
+ ```
105
+
106
+ Matched ids in commit subjects become clickable terminal hyperlinks (the text
107
+ stays identical), and `--json` gains a `task` object (`{ "id", "url" }`) per
108
+ commit. No `task:` section — no behavior, nothing to opt out of.
109
+
110
+ ## JSON schema
111
+
112
+ `onair --json` emits one object; the schema is a public API and only changes
113
+ additively:
114
+
115
+ ```json
116
+ {
117
+ "app": "acme-prod",
118
+ "platform": "heroku",
119
+ "branch": "main",
120
+ "repo": "acme/widgets",
121
+ "remote_head": "<sha>",
122
+ "deployed": {
123
+ "sha": "<sha>", "version": 1234, "description": "Deploy a1b2c3d4",
124
+ "deployed_at": "2026-06-12T10:00:00Z", "subject": "Fix the thing (#1234)",
125
+ "author": "Eugene", "task": null
126
+ },
127
+ "pending": null,
128
+ "delta": { "status": "current", "behind_by": 0 },
129
+ "pinned": null,
130
+ "yours": null
131
+ }
132
+ ```
133
+
134
+ - `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
135
+ - `pending`: `{ "sha", "started_at", "subject", "author" }` when a build is
136
+ in flight.
137
+ - `pinned`: `{ "version", "description", "latest_built_sha" }` when a newer
138
+ build succeeded but is not running.
139
+ - `yours`: `{ "sha", "had_own_build", "subject", "author" }` when your commit
140
+ sits just below the deployed head.
141
+ - `subject`/`author` are `null` when the commit isn't in the local repo.
142
+ - `task`: `{ "id", "url" }` when a `task:` matcher is configured and the
143
+ subject contains a match, otherwise `null`.
144
+
145
+ ## Development
146
+
147
+ ```sh
148
+ bundle install
149
+ bundle exec rake # specs + rubocop
150
+ ```
151
+
152
+ The behavioral source of truth is `reference/prod-release.sh`, the
153
+ battle-tested bash script this gem was extracted from. The behavior spec in
154
+ `spec/onair/report_spec.rb` ports its rules one by one.
155
+
156
+ ## License
157
+
158
+ MIT
data/exe/onair ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "onair"
5
+
6
+ exit Onair::CLI.run(ARGV)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Onair
6
+ module Auth
7
+ # Ambient GitHub token for the fast remote-head lookup: env vars first
8
+ # (free), then the gh CLI (~50ms Go binary boot). Never prompts.
9
+ module GithubToken
10
+ def self.token(env: ENV)
11
+ env_token = env["GH_TOKEN"] || env["GITHUB_TOKEN"]
12
+ return env_token unless env_token.to_s.empty?
13
+
14
+ cli_token
15
+ end
16
+
17
+ def self.cli_token
18
+ stdout, _stderr, status = Open3.capture3("gh", "auth", "token")
19
+ return nil unless status.success?
20
+
21
+ token = stdout.strip
22
+ token.empty? ? nil : token
23
+ rescue SystemCallError
24
+ nil
25
+ end
26
+ private_class_method :cli_token
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Onair
6
+ module Auth
7
+ # Fallback when ~/.netrc yields nothing: boot the Heroku CLI. Slow
8
+ # (1-2s of Node startup) — that's why netrc is tried first.
9
+ module HerokuCli
10
+ def self.token
11
+ stdout, _stderr, status = Open3.capture3("heroku", "auth:token")
12
+ return nil unless status.success?
13
+
14
+ token = stdout.strip
15
+ token.empty? ? nil : token
16
+ rescue SystemCallError
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onair
4
+ module Auth
5
+ # Minimal ~/.netrc reader — just enough to pull the token the Heroku CLI
6
+ # stores under `machine api.heroku.com`. Handles both same-line and
7
+ # multi-line entry styles (the format is whitespace-token based).
8
+ module Netrc
9
+ def self.token(host, path: File.join(Dir.home, ".netrc"))
10
+ return nil unless File.readable?(path)
11
+
12
+ tokens = File.read(path).split(/\s+/)
13
+ tokens.each_with_index do |word, index|
14
+ return password_after(tokens, index + 2) if word == "machine" && tokens[index + 1] == host
15
+ end
16
+ nil
17
+ end
18
+
19
+ def self.password_after(tokens, start)
20
+ tokens[start..].each_slice(2) do |key, value|
21
+ return nil if ["machine", "default", nil].include?(key)
22
+ return value if key == "password"
23
+ end
24
+ nil
25
+ end
26
+ private_class_method :password_after
27
+ end
28
+ end
29
+ end
data/lib/onair/cli.rb ADDED
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Onair
6
+ # Errors are rescued here and printed as a single friendly line — no
7
+ # backtraces (--debug re-raises). Returns the process exit code.
8
+ class CLI
9
+ def self.run(argv)
10
+ new.run(argv)
11
+ end
12
+
13
+ def run(argv)
14
+ flags, command = parse(argv)
15
+ case command
16
+ when nil then status(flags)
17
+ when "init" then init(flags)
18
+ else raise Error, "unknown command: #{command} (try `onair --help`)"
19
+ end
20
+ 0
21
+ rescue OptionParser::ParseError => e
22
+ warn_error(e.message)
23
+ 1
24
+ rescue Error => e
25
+ raise if flags&.[](:debug)
26
+
27
+ warn_error(e.message)
28
+ 1
29
+ end
30
+
31
+ private
32
+
33
+ def parse(argv)
34
+ flags = {}
35
+ parser = OptionParser.new do |opts|
36
+ opts.banner = <<~BANNER
37
+ Usage: onair [command] [options]
38
+
39
+ Commands:
40
+ (none) status report for the configured app
41
+ init write .onair.yml for this repo
42
+
43
+ Options:
44
+ BANNER
45
+ opts.on("--app NAME", "override the app for this invocation") { |value| flags[:app] = value }
46
+ opts.on("--json", "machine-readable status") { flags[:json] = true }
47
+ opts.on("--no-fetch", "never fetch git objects from the remote") { flags[:no_fetch] = true }
48
+ opts.on("--justfile", "with init: append a `prod` recipe to the justfile") { flags[:justfile] = true }
49
+ opts.on("--debug", "re-raise errors with backtraces") { flags[:debug] = true }
50
+ opts.on("--version", "print version") do
51
+ puts VERSION
52
+ exit 0
53
+ end
54
+ opts.on("-h", "--help", "print this help") do
55
+ puts opts
56
+ exit 0
57
+ end
58
+ end
59
+ [flags, parser.parse(argv).first]
60
+ end
61
+
62
+ def status(flags)
63
+ config = Config.resolve(flags)
64
+ git = Git.new(fetch_allowed: config.fetch)
65
+ adapter = Platform.build(config)
66
+ repo = config.repo || git.origin_repo
67
+ report = Orchestrator.new(config: config, adapter: adapter, git: git, repo: repo).run
68
+
69
+ if flags[:json]
70
+ puts Renderer::Json.new(report: report, app: config.app, platform: config.platform,
71
+ branch: config.branch, repo: repo, task: config.task).render
72
+ else
73
+ tty = $stdout.tty?
74
+ print Renderer::Tty.new(report: report, app: config.app, platform_label: adapter.display_name,
75
+ branch: config.branch, repo: repo, task: config.task,
76
+ color: tty && !no_color?, hyperlinks: tty, now: Time.now).render
77
+ end
78
+ end
79
+
80
+ def init(flags)
81
+ path = File.join(Dir.pwd, Config::FILENAME)
82
+ raise Error, "#{Config::FILENAME} already exists" if File.exist?(path)
83
+
84
+ app = flags[:app] || ENV.fetch("HEROKU_APP", nil)
85
+ raise Error, "pass --app NAME to seed the config" if app.nil?
86
+
87
+ git = Git.new
88
+ lines = ["platform: heroku", "app: #{app}"]
89
+ repo = git.origin_repo
90
+ lines << "repo: #{repo}" if repo
91
+ lines << "branch: main"
92
+ File.write(path, "#{lines.join("\n")}\n")
93
+ puts "wrote #{Config::FILENAME}"
94
+ append_justfile if flags[:justfile]
95
+ end
96
+
97
+ def append_justfile
98
+ path = %w[justfile Justfile .justfile].find { |name| File.exist?(name) } || "justfile"
99
+ existing = File.exist?(path) ? File.read(path) : ""
100
+ raise Error, "#{path} already has a `prod` recipe" if existing.match?(/^prod:/)
101
+
102
+ recipe = "prod:\n @onair\n"
103
+ content = existing.empty? ? recipe : "#{existing.chomp}\n\n#{recipe}"
104
+ File.write(path, content)
105
+ puts "added `prod` recipe to #{path}"
106
+ end
107
+
108
+ def warn_error(message)
109
+ if $stderr.tty? && !no_color?
110
+ warn "\n \e[1;33merror: #{message}\e[0m"
111
+ else
112
+ warn "\n error: #{message}"
113
+ end
114
+ end
115
+
116
+ def no_color?
117
+ !ENV["NO_COLOR"].to_s.empty?
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Onair
6
+ # Resolution order: CLI flags → env vars → .onair.yml (searched upward
7
+ # from cwd to the git root) → error with a hint to run `onair init`.
8
+ class Config
9
+ FILENAME = ".onair.yml"
10
+
11
+ attr_reader :platform, :app, :repo, :branch, :fetch, :task
12
+
13
+ def initialize(platform:, app:, repo:, branch:, fetch:, task: nil)
14
+ @platform = platform
15
+ @app = app
16
+ @repo = repo
17
+ @branch = branch
18
+ @fetch = fetch
19
+ @task = task
20
+ end
21
+
22
+ def self.resolve(flags = {}, env: ENV, dir: Dir.pwd)
23
+ file = find_file(dir) || {}
24
+ app = flags[:app] || env["HEROKU_APP"] || file["app"]
25
+ raise Error, "no app configured — pass --app NAME, set HEROKU_APP, or run `onair init`" if app.nil?
26
+
27
+ new(
28
+ platform: file["platform"] || "heroku",
29
+ app: app,
30
+ repo: env["GITHUB_REPO"] || file["repo"],
31
+ branch: file["branch"] || "main",
32
+ fetch: !flags[:no_fetch],
33
+ task: TaskLink.from_config(file["task"])
34
+ )
35
+ end
36
+
37
+ def self.find_file(start)
38
+ dir = File.expand_path(start)
39
+ loop do
40
+ path = File.join(dir, FILENAME)
41
+ return YAML.safe_load_file(path) || {} if File.file?(path)
42
+ return nil if File.exist?(File.join(dir, ".git"))
43
+
44
+ parent = File.dirname(dir)
45
+ return nil if parent == dir
46
+
47
+ dir = parent
48
+ end
49
+ end
50
+ end
51
+ end
data/lib/onair/git.rb ADDED
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Onair
6
+ # The only place that shells out to git. Everything else takes an instance
7
+ # as a dependency — this is the seam unit tests fake. All methods degrade
8
+ # to nil/false/[] on failure; they never raise.
9
+ class Git
10
+ Identity = Data.define(:name, :email)
11
+
12
+ def initialize(fetch_allowed: true, dir: nil)
13
+ @fetch_allowed = fetch_allowed
14
+ @dir = dir
15
+ @fetched = false
16
+ end
17
+
18
+ # Tip of origin/<branch> straight from the remote — accurate without a
19
+ # pull. Falls back to the local origin/<branch> ref when offline.
20
+ def remote_head(branch)
21
+ out = capture("ls-remote", "origin", "refs/heads/#{branch}")
22
+ sha = out.to_s[/\A\h+/]
23
+ return sha unless sha.nil? || sha.empty?
24
+
25
+ capture("rev-parse", "origin/#{branch}")&.strip
26
+ end
27
+
28
+ def has_commit?(sha)
29
+ success?("cat-file", "-e", "#{sha}^{commit}")
30
+ end
31
+
32
+ # Best-effort, at most once per process, skipped under --no-fetch. The
33
+ # commit may still be absent afterward (force-pushed away); callers
34
+ # degrade via the "(commit not found)" fallback.
35
+ def fetch_once!
36
+ return if @fetched || !@fetch_allowed
37
+
38
+ @fetched = true
39
+ success?("fetch", "--quiet", "origin")
40
+ end
41
+
42
+ def commit_info(sha)
43
+ out = capture("log", "-1", "--format=%s%x09%an%x09%ae%x09%ct", sha)
44
+ return nil if out.nil?
45
+
46
+ subject, name, email, epoch = out.chomp.split("\t", 4)
47
+ CommitInfo.new(subject:, author_name: name, author_email: email, committed_at: Time.at(epoch.to_i))
48
+ end
49
+
50
+ def ancestor?(ancestor_sha, descendant_sha)
51
+ success?("merge-base", "--is-ancestor", ancestor_sha, descendant_sha)
52
+ end
53
+
54
+ def count_between(from_sha, to_sha)
55
+ capture("rev-list", "--count", "#{from_sha}..#{to_sha}")&.strip&.to_i
56
+ end
57
+
58
+ # The <count> first-parent commits just below the given head (head itself
59
+ # excluded), as [sha, author_name, author_email] tuples.
60
+ def first_parent_below(sha, count)
61
+ out = capture("log", "--first-parent", "--skip=1", "-#{count}", "--format=%H%x09%an%x09%ae", sha)
62
+ return [] if out.nil?
63
+
64
+ out.lines.map { |line| line.chomp.split("\t", 3) }
65
+ end
66
+
67
+ def identity
68
+ Identity.new(name: capture("config", "user.name")&.strip, email: capture("config", "user.email")&.strip)
69
+ end
70
+
71
+ # "owner/name" parsed from the origin remote, or nil.
72
+ def origin_repo
73
+ url = capture("remote", "get-url", "origin")&.strip
74
+ url&.match(%r{github\.com[:/](?<repo>[^/]+/[^/\s]+?)(?:\.git)?/?\z})&.[](:repo)
75
+ end
76
+
77
+ private
78
+
79
+ def capture(*)
80
+ stdout, _stderr, status = Open3.capture3("git", *, **opts)
81
+ status.success? ? stdout : nil
82
+ rescue SystemCallError
83
+ nil
84
+ end
85
+
86
+ def success?(*)
87
+ _stdout, _stderr, status = Open3.capture3("git", *, **opts)
88
+ status.success?
89
+ rescue SystemCallError
90
+ false
91
+ end
92
+
93
+ def opts
94
+ @dir ? { chdir: @dir } : {}
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onair
4
+ # Runs the platform adapter and git concurrently, lazily fetches missing
5
+ # commits at most once, and assembles the Report.
6
+ class Orchestrator
7
+ def initialize(config:, adapter:, git:, repo: nil)
8
+ @config = config
9
+ @adapter = adapter
10
+ @git = git
11
+ @repo = repo
12
+ end
13
+
14
+ def run
15
+ snapshot_thread = quiet_thread { @adapter.snapshot }
16
+ head_thread = quiet_thread { RemoteHead.resolve(git: @git, branch: @config.branch, repo: @repo) }
17
+ snapshot = snapshot_thread.value
18
+ remote_head = head_thread.value
19
+
20
+ needed = [snapshot.deployed&.sha, snapshot.pending&.sha, remote_head].compact
21
+ @git.fetch_once! if needed.any? { |sha| !@git.has_commit?(sha) }
22
+
23
+ Report.build(snapshot: snapshot, remote_head: remote_head, git: @git)
24
+ end
25
+
26
+ private
27
+
28
+ # Exceptions surface via #value; without this the dying thread also
29
+ # prints its own backtrace to stderr.
30
+ def quiet_thread(&block)
31
+ Thread.new do
32
+ Thread.current.report_on_exception = false
33
+ block.call
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onair
4
+ module Platform
5
+ @registry = {}
6
+
7
+ class << self
8
+ def register(name, klass)
9
+ @registry[name] = klass
10
+ end
11
+
12
+ def build(config)
13
+ klass = @registry[config.platform]
14
+ if klass.nil?
15
+ raise Error,
16
+ "unknown platform #{config.platform.inspect} (available: #{@registry.keys.join(', ')})"
17
+ end
18
+
19
+ klass.new(config)
20
+ end
21
+ end
22
+
23
+ # Adapter contract: implement #snapshot returning a Snapshot.
24
+ #
25
+ # deployed: the release CURRENTLY RUNNING (nil sha only if
26
+ # truly unresolvable)
27
+ # pending: newest in-flight build, or nil
28
+ # latest_built_sha: newest successfully built sha, or nil
29
+ # succeeded_shas: recent succeeded build shas, newest first
30
+ #
31
+ # Adapters own their internal concurrency and auth. Everything above
32
+ # (delta, pinned, mine, rendering) is platform-agnostic and must not
33
+ # reference any specific platform.
34
+ class Base
35
+ def initialize(config)
36
+ @config = config
37
+ end
38
+
39
+ def snapshot
40
+ raise NotImplementedError
41
+ end
42
+
43
+ def display_name
44
+ self.class.name.split("::").last
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Onair
8
+ module Platform
9
+ class Heroku < Base
10
+ HOST = "api.heroku.com"
11
+
12
+ def snapshot
13
+ token = resolve_token
14
+ release_thread = quiet_thread { deployed(token) }
15
+ builds_thread = quiet_thread { builds(token) }
16
+ deployed = release_thread.value
17
+ pending, succeeded_shas = builds_thread.value
18
+ Snapshot.new(deployed: deployed, pending: pending,
19
+ latest_built_sha: succeeded_shas.first, succeeded_shas: succeeded_shas)
20
+ end
21
+
22
+ private
23
+
24
+ def app
25
+ @config.app
26
+ end
27
+
28
+ def quiet_thread(&block)
29
+ Thread.new do
30
+ Thread.current.report_on_exception = false
31
+ block.call
32
+ end
33
+ end
34
+
35
+ def resolve_token
36
+ token = Auth::Netrc.token(HOST)
37
+ token = Auth::HerokuCli.token if token.nil? || token.empty?
38
+ raise Error, "no Heroku credentials found — run `heroku login`" if token.nil? || token.empty?
39
+
40
+ token
41
+ end
42
+
43
+ # The slug records the commit the running release was built from — the
44
+ # only reliable source after a rollback, when the builds list still
45
+ # shows the newer (no-longer-running) build on top.
46
+ def deployed(token)
47
+ with_http do |http|
48
+ release = get(http, token, "/apps/#{app}/releases", range: "version ..; order=desc, max=1").first
49
+ raise Error, "no releases found for app #{app}" if release.nil?
50
+
51
+ Deployed.new(
52
+ sha: slug_commit(http, token, release.dig("slug", "id")),
53
+ version: release["version"],
54
+ description: release["description"],
55
+ deployed_at: parse_time(release["created_at"])
56
+ )
57
+ end
58
+ end
59
+
60
+ def slug_commit(http, token, slug_id)
61
+ return nil if slug_id.nil?
62
+
63
+ get(http, token, "/apps/#{app}/slugs/#{slug_id}")["commit"]
64
+ rescue Error
65
+ nil
66
+ end
67
+
68
+ # A failed builds call degrades to "no pending, nothing built" rather
69
+ # than killing the whole report; a failed releases call is fatal.
70
+ def builds(token)
71
+ builds = with_http do |http|
72
+ get(http, token, "/apps/#{app}/builds", range: "created_at ..; order=desc, max=10")
73
+ end
74
+ pending_build = builds.find { |build| build["status"] == "pending" }
75
+ pending_sha = pending_build&.dig("source_blob", "version")
76
+ pending = pending_sha && Pending.new(sha: pending_sha, started_at: parse_time(pending_build["created_at"]))
77
+ succeeded = builds.select { |build| build["status"] == "succeeded" }
78
+ .filter_map { |build| build.dig("source_blob", "version") }
79
+ [pending, succeeded]
80
+ rescue Error
81
+ [nil, []]
82
+ end
83
+
84
+ # One connection per thread — the dependent releases → slug pair must
85
+ # not pay a second TLS handshake.
86
+ def with_http(&)
87
+ Net::HTTP.start(HOST, 443, use_ssl: true, open_timeout: 5, read_timeout: 15, &)
88
+ rescue Net::OpenTimeout, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
89
+ raise Error, "Heroku API request failed: #{e.message}"
90
+ end
91
+
92
+ def get(http, token, path, range: nil)
93
+ request = Net::HTTP::Get.new(path)
94
+ request["Authorization"] = "Bearer #{token}"
95
+ request["Accept"] = "application/vnd.heroku+json; version=3"
96
+ if range
97
+ request["Range"] = range
98
+ # Net::HTTP won't auto-decompress a response to a ranged request,
99
+ # but its default Accept-Encoding still invites gzip — ask for an
100
+ # uncompressed body instead. (Heroku's Range is pagination, not bytes.)
101
+ request["Accept-Encoding"] = "identity"
102
+ end
103
+ response = http.request(request)
104
+ raise Error, error_message(response, path) unless response.is_a?(Net::HTTPSuccess)
105
+
106
+ JSON.parse(response.body)
107
+ rescue JSON::ParserError
108
+ raise Error, "Heroku API returned invalid JSON for #{path}"
109
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
110
+ raise Error, "Heroku API request failed: #{e.message}"
111
+ end
112
+
113
+ def error_message(response, path)
114
+ case response.code.to_i
115
+ when 401 then "Heroku rejected the token (401) — run `heroku login`"
116
+ when 404 then "Heroku app not found: #{app}"
117
+ else "Heroku API returned #{response.code} for #{path}"
118
+ end
119
+ end
120
+
121
+ def parse_time(value)
122
+ value && Time.iso8601(value)
123
+ rescue ArgumentError
124
+ nil
125
+ end
126
+
127
+ Platform.register("heroku", self)
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module Onair
7
+ # Resolves the tip of origin/<branch>. `git ls-remote` over SSH costs ~2s
8
+ # in handshakes; the GitHub API answers the same question in ~0.4s when a
9
+ # token is ambient. Both report the live remote ref — this is a faster
10
+ # transport, never a cache. The ls-remote thread always starts so a failed
11
+ # API attempt costs nothing extra.
12
+ module RemoteHead
13
+ API_HOST = "api.github.com"
14
+
15
+ def self.resolve(git:, branch:, repo:)
16
+ ls_remote = Thread.new do
17
+ Thread.current.report_on_exception = false
18
+ git.remote_head(branch)
19
+ end
20
+ api_head(repo, branch) || ls_remote.value
21
+ end
22
+
23
+ # Any failure (no repo, no token, 404, timeout, rate limit, offline)
24
+ # falls back to ls-remote — this path must never break the report.
25
+ def self.api_head(repo, branch)
26
+ return nil if repo.nil?
27
+
28
+ token = Auth::GithubToken.token
29
+ return nil if token.nil?
30
+
31
+ response = Net::HTTP.start(API_HOST, 443, use_ssl: true, open_timeout: 2, read_timeout: 3) do |http|
32
+ request = Net::HTTP::Get.new("/repos/#{repo}/git/ref/heads/#{branch}")
33
+ request["Authorization"] = "Bearer #{token}"
34
+ request["Accept"] = "application/vnd.github+json"
35
+ http.request(request)
36
+ end
37
+ return nil unless response.is_a?(Net::HTTPSuccess)
38
+
39
+ sha = JSON.parse(response.body).dig("object", "sha")
40
+ sha&.match?(/\A\h{40}\z/) ? sha : nil
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ private_class_method :api_head
45
+ end
46
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Onair
7
+ module Renderer
8
+ # Machine-readable status. The schema is a public API documented in the
9
+ # README — additive changes only.
10
+ class Json
11
+ def initialize(report:, app:, platform:, branch:, repo:, task: nil)
12
+ @report = report
13
+ @app = app
14
+ @platform = platform
15
+ @branch = branch
16
+ @repo = repo
17
+ @task = task
18
+ end
19
+
20
+ def render
21
+ ::JSON.generate(payload)
22
+ end
23
+
24
+ def payload
25
+ {
26
+ app: @app,
27
+ platform: @platform,
28
+ branch: @branch,
29
+ repo: @repo,
30
+ remote_head: @report.remote_head,
31
+ deployed: deployed_payload,
32
+ pending: pending_payload,
33
+ delta: delta_payload,
34
+ pinned: pinned_payload,
35
+ yours: yours_payload
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def snapshot
42
+ @report.snapshot
43
+ end
44
+
45
+ def deployed_payload
46
+ deployed = snapshot.deployed
47
+ return nil if deployed.nil?
48
+
49
+ {
50
+ sha: deployed.sha,
51
+ version: deployed.version,
52
+ description: deployed.description,
53
+ deployed_at: iso(deployed.deployed_at)
54
+ }.merge(commit_fields(deployed.sha))
55
+ end
56
+
57
+ def pending_payload
58
+ pending = snapshot.pending
59
+ return nil if pending.nil?
60
+
61
+ { sha: pending.sha, started_at: iso(pending.started_at) }.merge(commit_fields(pending.sha))
62
+ end
63
+
64
+ def delta_payload
65
+ case @report.delta
66
+ when :current then { status: "current", behind_by: 0 }
67
+ when Integer then { status: "behind", behind_by: @report.delta }
68
+ else { status: "unknown", behind_by: nil }
69
+ end
70
+ end
71
+
72
+ def pinned_payload
73
+ return nil unless @report.pinned
74
+
75
+ deployed = snapshot.deployed
76
+ {
77
+ version: deployed.version,
78
+ description: deployed.description,
79
+ latest_built_sha: snapshot.latest_built_sha
80
+ }
81
+ end
82
+
83
+ def yours_payload
84
+ mine = @report.mine
85
+ return nil if mine.nil?
86
+
87
+ { sha: mine.sha, had_own_build: mine.had_own_build }.merge(commit_fields(mine.sha))
88
+ end
89
+
90
+ def commit_fields(sha)
91
+ info = sha && @report.commits[sha]
92
+ { subject: info&.subject, author: info&.author_name, task: task_fields(info&.subject) }
93
+ end
94
+
95
+ def task_fields(subject)
96
+ task_id = @task&.find(subject)
97
+ return nil if task_id.nil?
98
+
99
+ { id: task_id, url: @task.url_for(task_id) }
100
+ end
101
+
102
+ def iso(time)
103
+ time&.utc&.iso8601
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onair
4
+ module Renderer
5
+ # Pure function of Report + flags: no IO, snapshot-testable.
6
+ class Tty
7
+ COLORS = {
8
+ bold: "\e[1m",
9
+ green: "\e[0;32m",
10
+ yellow: "\e[1;33m",
11
+ dim: "\e[2m",
12
+ purple: "\e[38;5;176m",
13
+ cyan: "\e[0;36m",
14
+ reset: "\e[0m"
15
+ }.freeze
16
+
17
+ NOT_FOUND_SUBJECT = "(commit not found in local git)"
18
+
19
+ def initialize(report:, app:, platform_label:, branch:, repo:, color:, hyperlinks:, now:, task: nil)
20
+ @report = report
21
+ @app = app
22
+ @platform_label = platform_label
23
+ @branch = branch
24
+ @repo = repo
25
+ @color = color
26
+ @hyperlinks = hyperlinks
27
+ @now = now
28
+ @task = task
29
+ end
30
+
31
+ def render
32
+ lines = ["", " #{code(:purple)}#{@platform_label} #{code(:bold)}#{@app}#{code(:reset)}", ""]
33
+ lines.concat(pending_lines)
34
+ lines.concat(deployed_lines)
35
+ lines << ""
36
+ "#{lines.join("\n")}\n"
37
+ end
38
+
39
+ private
40
+
41
+ def snapshot
42
+ @report.snapshot
43
+ end
44
+
45
+ def pending_lines
46
+ pending = snapshot.pending
47
+ return [] if pending.nil?
48
+
49
+ row_lines("Pending: ", :yellow, pending.sha, age(pending.started_at)) + [""]
50
+ end
51
+
52
+ def deployed_lines
53
+ deployed = snapshot.deployed
54
+ if deployed&.sha
55
+ lines = row_lines("Deployed:", :green, deployed.sha, age(deployed.deployed_at), extra: delta_text)
56
+ lines << pinned_line if @report.pinned
57
+ lines.concat(yours_lines)
58
+ lines
59
+ else
60
+ [" #{paint("Could not resolve the running commit for v#{deployed&.version} " \
61
+ "(#{deployed&.description}).", :yellow)}"]
62
+ end
63
+ end
64
+
65
+ def yours_lines
66
+ mine = @report.mine
67
+ return [] if mine.nil?
68
+
69
+ note = mine.had_own_build ? "✓ released, then absorbed by current" : "✓ absorbed by current deploy"
70
+ committed_at = @report.commits[mine.sha]&.committed_at
71
+ [""] + row_lines("Yours: ", :cyan, mine.sha, age(committed_at), extra: paint(note, :cyan))
72
+ end
73
+
74
+ def row_lines(label, color_key, sha, age, extra: nil)
75
+ info = @report.commits[sha]
76
+ subject = info&.subject || NOT_FOUND_SUBJECT
77
+ author = info&.author_name || "?"
78
+ age_blurb = age ? "(#{age}) " : ""
79
+ first = " #{paint(label, color_key)} #{paint(sha[0, 9], :bold)} #{paint("#{age_blurb}by #{author}", :dim)}"
80
+ first = "#{first} #{extra}" if extra
81
+ [first, " #{paint('→', :dim)} #{linkify(subject, info ? sha : nil)}"]
82
+ end
83
+
84
+ def delta_text
85
+ case @report.delta
86
+ when :current
87
+ paint("★ current", :green)
88
+ when Integer
89
+ count = @report.delta
90
+ paint("↓ #{count} #{count == 1 ? 'commit' : 'commits'} behind origin/#{@branch}", :yellow)
91
+ end
92
+ end
93
+
94
+ def pinned_line
95
+ deployed = snapshot.deployed
96
+ " #{paint("⏸ v#{deployed.version} (#{deployed.description}) — newer build " \
97
+ "#{snapshot.latest_built_sha[0, 9]} succeeded but is not running", :yellow)}"
98
+ end
99
+
100
+ # Subjects ending in " (#1234)" (merge convention) strip the suffix and
101
+ # link to the PR; otherwise link the commit. Commits absent from the
102
+ # local repo (sha == nil here) skip linking entirely.
103
+ def linkify(subject, sha)
104
+ if @repo && (match = subject.match(/\A(?<base>.*) \(#(?<pr>\d+)\)\z/))
105
+ "#{tasked(match[:base])} • #{link("https://github.com/#{@repo}/pull/#{match[:pr]}", "↗ ##{match[:pr]}")}"
106
+ elsif @repo && sha
107
+ "#{tasked(subject)} • #{link("https://github.com/#{@repo}/commit/#{sha}", "↗ #{sha[0, 9]}")}"
108
+ else
109
+ tasked(subject)
110
+ end
111
+ end
112
+
113
+ # Configured task ids stay visually identical — they just become clickable.
114
+ def tasked(text)
115
+ return text unless @task && @hyperlinks
116
+
117
+ text.gsub(@task.pattern) { |task_id| link(@task.url_for(task_id), task_id) }
118
+ end
119
+
120
+ def link(url, text)
121
+ @hyperlinks ? "\e]8;;#{url}\a#{text}\e]8;;\a" : text
122
+ end
123
+
124
+ def age(time)
125
+ return nil if time.nil?
126
+
127
+ seconds = (@now - time).to_i.clamp(0..)
128
+ if seconds < 60 then "#{seconds}s ago"
129
+ elsif seconds < 3600 then "#{seconds / 60}m ago"
130
+ elsif seconds < 86_400 then "#{seconds / 3600}h ago"
131
+ else "#{seconds / 86_400}d ago"
132
+ end
133
+ end
134
+
135
+ def paint(text, key)
136
+ @color ? "#{COLORS[key]}#{text}#{COLORS[:reset]}" : text
137
+ end
138
+
139
+ def code(key)
140
+ @color ? COLORS[key] : ""
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onair
4
+ # What renderers consume. Pure domain logic — all IO goes through the
5
+ # injected git wrapper; render output must be derivable from this alone.
6
+ #
7
+ # delta: :current, Integer (commits behind), or nil (diverged/unknown —
8
+ # silence over speculation).
9
+ # pinned: a newer build succeeded but is not what's running.
10
+ # mine: the local user's commit just below someone else's deployed head.
11
+ # commits: sha => CommitInfo (or nil when absent locally) for every sha a
12
+ # renderer may need to describe.
13
+ Report = Data.define(:snapshot, :remote_head, :delta, :pinned, :mine, :commits) do
14
+ def self.build(snapshot:, remote_head:, git:)
15
+ # Pinned is judged before the stale pending is dropped: during the
16
+ # stale window the newest *succeeded* build is still the previous
17
+ # deploy, which must not read as a rollback.
18
+ pinned = pinned?(snapshot)
19
+ snapshot = drop_stale_pending(snapshot)
20
+ deployed_sha = snapshot.deployed&.sha
21
+ mine = compute_mine(snapshot, git)
22
+ commits = [deployed_sha, snapshot.pending&.sha, mine&.sha].compact.uniq
23
+ .to_h { |sha| [sha, git.commit_info(sha)] }
24
+ new(
25
+ snapshot: snapshot,
26
+ remote_head: remote_head,
27
+ delta: compute_delta(deployed_sha, remote_head, git),
28
+ pinned: pinned,
29
+ mine: mine,
30
+ commits: commits
31
+ )
32
+ end
33
+
34
+ # Right after a deploy finishes, the platform's builds list can still
35
+ # report the just-released build as pending while the releases endpoint
36
+ # already shows it running — the same commit would render as both
37
+ # Pending and Deployed. A build that is already on air isn't pending.
38
+ def self.drop_stale_pending(snapshot)
39
+ return snapshot unless snapshot.pending && snapshot.pending.sha == snapshot.deployed&.sha
40
+
41
+ snapshot.with(pending: nil)
42
+ end
43
+
44
+ def self.compute_delta(sha, head, git)
45
+ return nil if sha.nil? || head.nil?
46
+ return :current if sha == head
47
+ return nil unless git.has_commit?(sha) && git.has_commit?(head)
48
+ return nil unless git.ancestor?(sha, head)
49
+
50
+ count = git.count_between(sha, head)
51
+ count&.positive? ? count : nil
52
+ end
53
+
54
+ def self.pinned?(snapshot)
55
+ sha = snapshot.deployed&.sha
56
+ latest = snapshot.latest_built_sha
57
+ !sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil?
58
+ end
59
+
60
+ # "Did my merge just ship?" — deliberately a 2-commit first-parent window
61
+ # below the deployed head, not a history search.
62
+ def self.compute_mine(snapshot, git)
63
+ sha = snapshot.deployed&.sha
64
+ return nil if sha.nil? || !git.has_commit?(sha)
65
+
66
+ identity = git.identity
67
+ info = git.commit_info(sha)
68
+ return nil if info.nil? || identity_match?(identity, info.author_name, info.author_email)
69
+
70
+ mine_sha, = git.first_parent_below(sha, 2).find { |_, name, email| identity_match?(identity, name, email) }
71
+ return nil if mine_sha.nil?
72
+
73
+ Mine.new(sha: mine_sha, had_own_build: snapshot.succeeded_shas.include?(mine_sha))
74
+ end
75
+
76
+ # Squash merges keep the author's name but may swap the email — match either.
77
+ def self.identity_match?(identity, name, email)
78
+ (!identity.email.to_s.empty? && email == identity.email) ||
79
+ (!identity.name.to_s.empty? && name == identity.name)
80
+ end
81
+
82
+ private_class_method :drop_stale_pending, :compute_delta, :pinned?, :compute_mine, :identity_match?
83
+ end
84
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onair
4
+ # Optional, tracker-agnostic task linking. Configured entirely by the user:
5
+ #
6
+ # task:
7
+ # pattern: 'ABC-\d+'
8
+ # url: 'https://acme.atlassian.net/browse/{task}'
9
+ #
10
+ # Any regex match in a commit subject becomes a link to the templated URL
11
+ # ({task} is replaced with the matched text). No config — no behavior.
12
+ TaskLink = Data.define(:pattern, :url_template) do
13
+ def self.from_config(section)
14
+ return nil if section.nil?
15
+
16
+ pattern = section["pattern"].to_s
17
+ url = section["url"].to_s
18
+ raise Error, "task config needs both `pattern` and `url`" if pattern.empty? || url.empty?
19
+ raise Error, "task.url must contain the {task} placeholder" unless url.include?("{task}")
20
+
21
+ new(pattern: Regexp.new(pattern), url_template: url)
22
+ rescue RegexpError => e
23
+ raise Error, "invalid task.pattern: #{e.message}"
24
+ end
25
+
26
+ def find(subject)
27
+ subject&.[](pattern)
28
+ end
29
+
30
+ def url_for(task_id)
31
+ url_template.gsub("{task}", task_id)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onair
4
+ VERSION = "0.1.0"
5
+ end
data/lib/onair.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "onair/version"
4
+
5
+ module Onair
6
+ class Error < StandardError
7
+ end
8
+
9
+ CommitInfo = Data.define(:subject, :author_name, :author_email, :committed_at)
10
+
11
+ # The release currently running in production. `sha` may be nil when the
12
+ # platform can't resolve the running commit; version/description still render.
13
+ Deployed = Data.define(:sha, :version, :description, :deployed_at)
14
+
15
+ Pending = Data.define(:sha, :started_at)
16
+
17
+ # What a platform adapter returns. `latest_built_sha` is the newest
18
+ # successfully built sha (rollback detection); `succeeded_shas` lists all
19
+ # recent succeeded build shas, newest first ("yours had its own deploy").
20
+ Snapshot = Data.define(:deployed, :pending, :latest_built_sha, :succeeded_shas)
21
+
22
+ Mine = Data.define(:sha, :had_own_build)
23
+ end
24
+
25
+ require_relative "onair/task_link"
26
+ require_relative "onair/config"
27
+ require_relative "onair/git"
28
+ require_relative "onair/report"
29
+ require_relative "onair/orchestrator"
30
+ require_relative "onair/auth/netrc"
31
+ require_relative "onair/auth/heroku_cli"
32
+ require_relative "onair/auth/github_token"
33
+ require_relative "onair/remote_head"
34
+ require_relative "onair/platform/base"
35
+ require_relative "onair/platform/heroku"
36
+ require_relative "onair/renderer/tty"
37
+ require_relative "onair/renderer/json"
38
+ require_relative "onair/cli"
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: onair-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eugene
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Reports which commit is actually running in production and where it sits
13
+ relative to origin/main. Truthful after rollbacks, fast, zero runtime dependencies.
14
+ email:
15
+ - amber.pixels.io@gmail.com
16
+ executables:
17
+ - onair
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - exe/onair
25
+ - lib/onair.rb
26
+ - lib/onair/auth/github_token.rb
27
+ - lib/onair/auth/heroku_cli.rb
28
+ - lib/onair/auth/netrc.rb
29
+ - lib/onair/cli.rb
30
+ - lib/onair/config.rb
31
+ - lib/onair/git.rb
32
+ - lib/onair/orchestrator.rb
33
+ - lib/onair/platform/base.rb
34
+ - lib/onair/platform/heroku.rb
35
+ - lib/onair/remote_head.rb
36
+ - lib/onair/renderer/json.rb
37
+ - lib/onair/renderer/tty.rb
38
+ - lib/onair/report.rb
39
+ - lib/onair/task_link.rb
40
+ - lib/onair/version.rb
41
+ homepage: https://github.com/amberpixels/onair-cli
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ source_code_uri: https://github.com/amberpixels/onair-cli
46
+ changelog_uri: https://github.com/amberpixels/onair-cli/blob/main/CHANGELOG.md
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.2'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 4.0.15
63
+ specification_version: 4
64
+ summary: See what's on air in production.
65
+ test_files: []