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 +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/exe/onair +6 -0
- data/lib/onair/auth/github_token.rb +29 -0
- data/lib/onair/auth/heroku_cli.rb +21 -0
- data/lib/onair/auth/netrc.rb +29 -0
- data/lib/onair/cli.rb +120 -0
- data/lib/onair/config.rb +51 -0
- data/lib/onair/git.rb +97 -0
- data/lib/onair/orchestrator.rb +37 -0
- data/lib/onair/platform/base.rb +48 -0
- data/lib/onair/platform/heroku.rb +130 -0
- data/lib/onair/remote_head.rb +46 -0
- data/lib/onair/renderer/json.rb +107 -0
- data/lib/onair/renderer/tty.rb +144 -0
- data/lib/onair/report.rb +84 -0
- data/lib/onair/task_link.rb +34 -0
- data/lib/onair/version.rb +5 -0
- data/lib/onair.rb +38 -0
- metadata +65 -0
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
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
|
+
[](https://rubygems.org/gems/onair-cli)
|
|
10
|
+
[](https://github.com/amberpixels/onair-cli/actions/workflows/ci.yml)
|
|
11
|
+
[](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,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
|
data/lib/onair/config.rb
ADDED
|
@@ -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
|
data/lib/onair/report.rb
ADDED
|
@@ -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
|
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: []
|