superkick 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/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- metadata +404 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "octokit"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Integrations
|
|
7
|
+
module GitHub
|
|
8
|
+
# Goal that checks whether a GitHub PR has been merged.
|
|
9
|
+
#
|
|
10
|
+
# All config keys are optional — repo and branch are auto-detected from the
|
|
11
|
+
# git working directory when not provided. The working_dir is injected by
|
|
12
|
+
# AgentSpawner at spawn time.
|
|
13
|
+
#
|
|
14
|
+
# Configuration:
|
|
15
|
+
# goal:
|
|
16
|
+
# type: github_pr_merged
|
|
17
|
+
# repo: org/my-repo # optional, inferred from git remote
|
|
18
|
+
# token: ghp_xxx # optional, falls back to GITHUB_TOKEN
|
|
19
|
+
# check_interval: 120
|
|
20
|
+
class PrMergedGoal < Superkick::Goal
|
|
21
|
+
GITHUB_SSH_RE = %r{github\.com[:/](?<repo>[^/]+/[^\s.]+)}
|
|
22
|
+
GITHUB_HTTPS_RE = %r{github\.com/(?<repo>[^/\s]+/[^\s.]+)}
|
|
23
|
+
|
|
24
|
+
def self.type = :github_pr_merged
|
|
25
|
+
|
|
26
|
+
def self.description
|
|
27
|
+
"Polls GitHub to check whether a PR has been merged. Repo and branch " \
|
|
28
|
+
"are auto-detected from the git working directory when not provided."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(config:, agent_id:)
|
|
32
|
+
super
|
|
33
|
+
@cached_repo = nil
|
|
34
|
+
@cached_pr_number = config[:pr_number]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check
|
|
38
|
+
repo = resolve_repo
|
|
39
|
+
return :pending unless repo
|
|
40
|
+
|
|
41
|
+
if @cached_pr_number
|
|
42
|
+
check_pr(repo, @cached_pr_number)
|
|
43
|
+
else
|
|
44
|
+
branch = resolve_branch
|
|
45
|
+
return :pending unless branch
|
|
46
|
+
|
|
47
|
+
find_and_check_pr(repo, branch)
|
|
48
|
+
end
|
|
49
|
+
rescue Octokit::TooManyRequests
|
|
50
|
+
Superkick.logger.warn("goal:github_pr_merged") { "Rate limited for #{agent_id}" }
|
|
51
|
+
:errored
|
|
52
|
+
rescue Octokit::Unauthorized => e
|
|
53
|
+
Superkick.logger.error("goal:github_pr_merged") { "Auth failed for #{agent_id}: #{e.message}" }
|
|
54
|
+
:errored
|
|
55
|
+
rescue => e
|
|
56
|
+
Superkick.logger.error("goal:github_pr_merged") { "Check failed for #{agent_id}: #{e.message}" }
|
|
57
|
+
:errored
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def teardown
|
|
61
|
+
@client = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def check_pr(repo, number)
|
|
67
|
+
pr = client.pull_request(repo, number)
|
|
68
|
+
pr_state(pr)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def find_and_check_pr(repo, branch)
|
|
72
|
+
owner = repo.split("/").first
|
|
73
|
+
prs = client.pull_requests(repo, state: "all", head: "#{owner}:#{branch}")
|
|
74
|
+
pr = prs.first
|
|
75
|
+
return :pending unless pr
|
|
76
|
+
|
|
77
|
+
@cached_pr_number = pr.number
|
|
78
|
+
pr_state(pr)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def pr_state(pr)
|
|
82
|
+
if pr.merged_at
|
|
83
|
+
:completed
|
|
84
|
+
elsif pr.state == "closed"
|
|
85
|
+
:failed
|
|
86
|
+
else
|
|
87
|
+
:in_progress
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def resolve_repo
|
|
92
|
+
@cached_repo ||= config[:repo] || detect_repo
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_branch
|
|
96
|
+
config[:branch] || detect_branch
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def detect_repo
|
|
100
|
+
out = run_git("remote", "-v")
|
|
101
|
+
return nil unless out
|
|
102
|
+
|
|
103
|
+
out.each_line do |line|
|
|
104
|
+
m = GITHUB_SSH_RE.match(line) || GITHUB_HTTPS_RE.match(line)
|
|
105
|
+
return m[:repo].delete_suffix(".git") if m
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def detect_branch
|
|
112
|
+
out = run_git("symbolic-ref", "--short", "HEAD")&.strip
|
|
113
|
+
out unless out&.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def run_git(*args)
|
|
117
|
+
dir = config[:working_dir] || Dir.pwd
|
|
118
|
+
IO.popen(["git", "-C", dir, *args], err: File::NULL) { |io| io.read }
|
|
119
|
+
rescue SystemCallError, IOError => e
|
|
120
|
+
Superkick.logger.debug("goal:github_pr_merged") { "git failed: #{e.message}" }
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def client
|
|
125
|
+
@client ||= begin
|
|
126
|
+
token = config[:token] || ENV["GITHUB_TOKEN"]
|
|
127
|
+
Octokit::Client.new(access_token: token)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Superkick::Goal.register(Superkick::Integrations::GitHub::PrMergedGoal)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "octokit"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Integrations
|
|
7
|
+
module GitHub
|
|
8
|
+
# Goal that checks whether a GitHub issue has been closed.
|
|
9
|
+
#
|
|
10
|
+
# Any close reason (completed, not_planned) counts as success — the
|
|
11
|
+
# requirement is simply that the issue is resolved.
|
|
12
|
+
#
|
|
13
|
+
# Configuration:
|
|
14
|
+
# goal:
|
|
15
|
+
# type: github_issue_resolved
|
|
16
|
+
# repo: org/my-repo # optional, inferred from git remote
|
|
17
|
+
# token: ghp_xxx # optional, falls back to GITHUB_TOKEN
|
|
18
|
+
#
|
|
19
|
+
# The issue is injected from the event context via AgentSpawner.
|
|
20
|
+
# AgentSpawner rehydrates the serialized context and merges it into goal
|
|
21
|
+
# config, so config[:issue] is an IssueDrop with .number, .title, etc.
|
|
22
|
+
class IssueResolvedGoal < Superkick::Goal
|
|
23
|
+
GITHUB_SSH_RE = PrMergedGoal::GITHUB_SSH_RE
|
|
24
|
+
GITHUB_HTTPS_RE = PrMergedGoal::GITHUB_HTTPS_RE
|
|
25
|
+
|
|
26
|
+
def self.type = :github_issue_resolved
|
|
27
|
+
|
|
28
|
+
def self.description
|
|
29
|
+
"Polls GitHub to check whether an issue has been closed. " \
|
|
30
|
+
"Any close reason counts as success."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.required_config = %i[]
|
|
34
|
+
|
|
35
|
+
def check
|
|
36
|
+
repo = resolve_repo
|
|
37
|
+
return :pending unless repo
|
|
38
|
+
|
|
39
|
+
issue_number = resolve_issue_number
|
|
40
|
+
return :pending unless issue_number
|
|
41
|
+
|
|
42
|
+
issue = client.issue(repo, issue_number)
|
|
43
|
+
|
|
44
|
+
case issue.state
|
|
45
|
+
when "closed" then :completed
|
|
46
|
+
else :in_progress
|
|
47
|
+
end
|
|
48
|
+
rescue Octokit::TooManyRequests
|
|
49
|
+
Superkick.logger.warn("goal:github_issue_resolved") { "Rate limited for #{agent_id}" }
|
|
50
|
+
:errored
|
|
51
|
+
rescue Octokit::Unauthorized => e
|
|
52
|
+
Superkick.logger.error("goal:github_issue_resolved") { "Auth failed for #{agent_id}: #{e.message}" }
|
|
53
|
+
:errored
|
|
54
|
+
rescue => e
|
|
55
|
+
Superkick.logger.error("goal:github_issue_resolved") { "Check failed for #{agent_id}: #{e.message}" }
|
|
56
|
+
:errored
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def teardown
|
|
60
|
+
@client = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def resolve_issue_number
|
|
66
|
+
@cached_issue_number ||= config[:issue]&.number
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_repo
|
|
70
|
+
@cached_repo ||= config[:repo] || detect_repo
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def detect_repo
|
|
74
|
+
out = run_git("remote", "-v")
|
|
75
|
+
return nil unless out
|
|
76
|
+
|
|
77
|
+
out.each_line do |line|
|
|
78
|
+
m = GITHUB_SSH_RE.match(line) || GITHUB_HTTPS_RE.match(line)
|
|
79
|
+
return m[:repo].delete_suffix(".git") if m
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def run_git(*args)
|
|
86
|
+
dir = config[:working_dir] || Dir.pwd
|
|
87
|
+
IO.popen(["git", "-C", dir, *args], err: File::NULL) { |io| io.read }
|
|
88
|
+
rescue SystemCallError, IOError => e
|
|
89
|
+
Superkick.logger.debug("goal:github_issue_resolved") { "git failed: #{e.message}" }
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def client
|
|
94
|
+
@client ||= begin
|
|
95
|
+
token = config[:token] || ENV["GITHUB_TOKEN"]
|
|
96
|
+
Octokit::Client.new(access_token: token)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
Superkick::Goal.register(Superkick::Integrations::GitHub::IssueResolvedGoal)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "octokit"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Integrations
|
|
7
|
+
module GitHub
|
|
8
|
+
# Watches GitHub for newly created or reopened issues and spawns agents.
|
|
9
|
+
#
|
|
10
|
+
# Polls the GitHub Issues API with configurable filters (labels, assignee,
|
|
11
|
+
# milestone, creator). Uses an updated_at watermark so reopened issues are
|
|
12
|
+
# caught automatically.
|
|
13
|
+
#
|
|
14
|
+
# Config keys:
|
|
15
|
+
# repo (required) — owner/name
|
|
16
|
+
# token (optional) — falls back to GITHUB_TOKEN
|
|
17
|
+
# labels (optional) — array of label names (AND filter)
|
|
18
|
+
# assignee (optional) — username, "*" (any), or "none"
|
|
19
|
+
# milestone (optional) — milestone number, "*", or "none"
|
|
20
|
+
# creator (optional) — filter by issue creator
|
|
21
|
+
# exclude_pull_requests (optional, default true) — filter out PRs
|
|
22
|
+
class IssueSpawner < Superkick::Spawner
|
|
23
|
+
attr_reader :seen_issue_ids, :client
|
|
24
|
+
|
|
25
|
+
def initialize(name:, config:, handler:, client: nil)
|
|
26
|
+
super(name:, config:, handler:)
|
|
27
|
+
@client = client
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.type = :github_issues
|
|
31
|
+
|
|
32
|
+
def self.description
|
|
33
|
+
"Watches GitHub for newly created or reopened issues and spawns " \
|
|
34
|
+
"AI coding agents to work on them. Supports filtering by labels, " \
|
|
35
|
+
"assignee, milestone, and creator."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.required_config = %i[repo]
|
|
39
|
+
|
|
40
|
+
def self.spawn_templates_dir
|
|
41
|
+
File.join(__dir__, "templates")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.agent_id(event)
|
|
45
|
+
"github-issue-#{event[:repo].tr("/", "-")}-#{event[:issue].number}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.setup_label = "GitHub Issues"
|
|
49
|
+
|
|
50
|
+
def self.setup_config
|
|
51
|
+
<<~YAML
|
|
52
|
+
github_issues:
|
|
53
|
+
type: github_issues
|
|
54
|
+
repo: org/repo
|
|
55
|
+
token: <%= env("GITHUB_TOKEN") %>
|
|
56
|
+
# labels: # only issues with ALL of these labels
|
|
57
|
+
# - bug
|
|
58
|
+
# assignee: "*" # "*" = assigned, "none" = unassigned, or a username
|
|
59
|
+
# milestone: v1.0
|
|
60
|
+
# max_duration: 3600 # hard timeout in seconds
|
|
61
|
+
# cooldown: 300 # minimum seconds between spawns
|
|
62
|
+
# on_complete:
|
|
63
|
+
# spawner: review_pr # chain to another spawner on success
|
|
64
|
+
YAML
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tick
|
|
68
|
+
issues = fetch_issues
|
|
69
|
+
issues.each do |issue|
|
|
70
|
+
dispatch(
|
|
71
|
+
event_type: :issue_opened,
|
|
72
|
+
repo: self[:repo],
|
|
73
|
+
issue: IssueDrop.new({
|
|
74
|
+
number: issue.number,
|
|
75
|
+
title: issue.title,
|
|
76
|
+
body: issue.body.to_s,
|
|
77
|
+
url: issue.html_url,
|
|
78
|
+
author: issue.user.login,
|
|
79
|
+
labels: issue.labels.map(&:name),
|
|
80
|
+
assignees: issue.assignees.map(&:login),
|
|
81
|
+
milestone: issue.milestone&.title,
|
|
82
|
+
created_at: issue.created_at.to_s,
|
|
83
|
+
updated_at: issue.updated_at.to_s
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
rescue Octokit::TooManyRequests => e
|
|
88
|
+
raise RateLimited, e.message
|
|
89
|
+
rescue Octokit::Unauthorized => e
|
|
90
|
+
raise FatalError, "GitHub auth failed: #{e.message}"
|
|
91
|
+
rescue Octokit::NotFound => e
|
|
92
|
+
Superkick.logger.warn(log_tag) { "GitHub resource not found: #{e.message}" }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def on_start
|
|
96
|
+
@client ||= build_client
|
|
97
|
+
@seen_issue_ids = Set.new
|
|
98
|
+
@since = Time.now.utc
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def fetch_issues
|
|
104
|
+
params = {
|
|
105
|
+
state: "open",
|
|
106
|
+
sort: "updated",
|
|
107
|
+
direction: "asc",
|
|
108
|
+
since: @since.iso8601
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
params[:labels] = self[:labels].join(",") if self[:labels]&.any?
|
|
112
|
+
params[:assignee] = self[:assignee] if self[:assignee]
|
|
113
|
+
params[:milestone] = self[:milestone] if self[:milestone]
|
|
114
|
+
params[:creator] = self[:creator] if self[:creator]
|
|
115
|
+
|
|
116
|
+
all_issues = @client.list_issues(self[:repo], **params)
|
|
117
|
+
|
|
118
|
+
# GitHub's issues endpoint includes PRs; filter them out by default
|
|
119
|
+
exclude_prs = self[:exclude_pull_requests] != false
|
|
120
|
+
if exclude_prs
|
|
121
|
+
all_issues = all_issues.reject { it.respond_to?(:pull_request) && it.pull_request }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Within-session dedup
|
|
125
|
+
new_issues = all_issues.reject { @seen_issue_ids.include?(it.number) }
|
|
126
|
+
new_issues.each { @seen_issue_ids.add(it.number) }
|
|
127
|
+
|
|
128
|
+
# Advance watermark
|
|
129
|
+
if all_issues.any?
|
|
130
|
+
latest = all_issues.map(&:updated_at).compact.max
|
|
131
|
+
@since = latest if latest && latest > @since
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
Superkick.logger.info(log_tag) { "Found #{all_issues.size} issues, #{new_issues.size} new" }
|
|
135
|
+
new_issues
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_client
|
|
139
|
+
token = self[:token] || ENV["GITHUB_TOKEN"]
|
|
140
|
+
|
|
141
|
+
Octokit::Client.new(
|
|
142
|
+
access_token: token,
|
|
143
|
+
middleware: faraday_stack
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def faraday_stack
|
|
148
|
+
Faraday::RackBuilder.new do |builder|
|
|
149
|
+
builder.request :retry,
|
|
150
|
+
max: 3,
|
|
151
|
+
interval: 0.5,
|
|
152
|
+
backoff_factor: 2,
|
|
153
|
+
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed]
|
|
154
|
+
builder.adapter Faraday.default_adapter
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "octokit"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Superkick
|
|
7
|
+
module Integrations
|
|
8
|
+
module GitHub
|
|
9
|
+
# Polls GitHub CI check runs and PR activity for a registered agent.
|
|
10
|
+
#
|
|
11
|
+
# Required per-agent config keys: branch, repo
|
|
12
|
+
# Optional config key: token (or GITHUB_TOKEN env var)
|
|
13
|
+
#
|
|
14
|
+
# Events emitted: ci_failure, ci_success, pr_comment, pr_review
|
|
15
|
+
# Watermarks persisted to registry: last_comment_id, last_review_id,
|
|
16
|
+
# last_ci_sha, last_ci_status
|
|
17
|
+
class Monitor < Superkick::Monitor
|
|
18
|
+
GITHUB_SSH_RE = %r{github\.com[:/](?<repo>[^/]+/[^\s.]+)}
|
|
19
|
+
GITHUB_HTTPS_RE = %r{github\.com/(?<repo>[^/\s]+/[^\s.]+)}
|
|
20
|
+
|
|
21
|
+
attr_reader :client
|
|
22
|
+
|
|
23
|
+
def initialize(name:, config:, handler:, agent: nil, server_context: {}, client: nil)
|
|
24
|
+
super(name:, config:, handler:, agent:, server_context:)
|
|
25
|
+
@client = client
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.type = :github
|
|
29
|
+
|
|
30
|
+
def self.description
|
|
31
|
+
"Monitors GitHub pull requests for CI status changes, new comments, and code reviews. " \
|
|
32
|
+
"Injects context when CI fails, new PR comments arrive, or reviews are submitted. " \
|
|
33
|
+
"Requires a repo (org/name) and branch. Optionally accepts a token for private repos " \
|
|
34
|
+
"(or set GITHUB_TOKEN env var)."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.required_config = %i[branch repo]
|
|
38
|
+
|
|
39
|
+
def self.setup_label = "GitHub"
|
|
40
|
+
|
|
41
|
+
def self.setup_config
|
|
42
|
+
<<~YAML
|
|
43
|
+
github:
|
|
44
|
+
token: <%= env("GITHUB_TOKEN") %>
|
|
45
|
+
# repo and branch are auto-detected from git remote.
|
|
46
|
+
# Uncomment to override:
|
|
47
|
+
# repo: org/repo
|
|
48
|
+
# branch: main
|
|
49
|
+
YAML
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.templates_dir
|
|
53
|
+
File.join(__dir__, "templates")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fill in missing branch and repo from the agent's environment snapshot.
|
|
57
|
+
def self.resolve_config(config, environment: {})
|
|
58
|
+
config[:branch] ||= environment[:git_branch]
|
|
59
|
+
config[:repo] ||= parse_github_repo(environment[:git_remotes])
|
|
60
|
+
config
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Extract org/repo from git remote URLs.
|
|
64
|
+
def self.parse_github_repo(remotes)
|
|
65
|
+
return nil unless remotes
|
|
66
|
+
|
|
67
|
+
remotes.each do |remote|
|
|
68
|
+
url = remote[:url].to_s
|
|
69
|
+
m = GITHUB_SSH_RE.match(url) || GITHUB_HTTPS_RE.match(url)
|
|
70
|
+
return m[:repo].delete_suffix(".git") if m
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tick
|
|
77
|
+
branch = self[:branch]
|
|
78
|
+
repo = self[:repo]
|
|
79
|
+
|
|
80
|
+
check_ci(repo, branch)
|
|
81
|
+
check_pr_comments(repo, branch)
|
|
82
|
+
check_pr_reviews(repo, branch)
|
|
83
|
+
rescue Octokit::TooManyRequests => e
|
|
84
|
+
raise RateLimited, e.message
|
|
85
|
+
rescue Octokit::Unauthorized => e
|
|
86
|
+
raise FatalError, "GitHub auth failed: #{e.message}"
|
|
87
|
+
rescue Octokit::NotFound => e
|
|
88
|
+
Superkick.logger.warn(log_tag) { "GitHub resource not found: #{e.message}" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def on_start
|
|
92
|
+
@client ||= build_client
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# ── CI checks ──────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def check_ci(repo, branch)
|
|
100
|
+
sha = head_sha(repo, branch)
|
|
101
|
+
return unless sha
|
|
102
|
+
|
|
103
|
+
# Avoid re-reporting the same SHA
|
|
104
|
+
last_sha = self[:last_ci_sha]
|
|
105
|
+
last_status = self[:last_ci_status]
|
|
106
|
+
|
|
107
|
+
runs = @client.check_runs_for_ref(repo, sha, accept: "application/vnd.github+json")
|
|
108
|
+
checks = runs.check_runs
|
|
109
|
+
|
|
110
|
+
return if checks.empty?
|
|
111
|
+
|
|
112
|
+
# Consider all completed
|
|
113
|
+
completed = checks.select { |c| c.status == "completed" }
|
|
114
|
+
return unless completed.size == checks.size
|
|
115
|
+
|
|
116
|
+
current_status = (completed.all? { |c| c.conclusion == "success" }) ? "success" : "failure"
|
|
117
|
+
|
|
118
|
+
# Only fire if status changed or it's a new SHA
|
|
119
|
+
return if sha == last_sha && current_status == last_status
|
|
120
|
+
|
|
121
|
+
@agent.set_monitor_field(@name, :last_ci_sha, sha)
|
|
122
|
+
@agent.set_monitor_field(@name, :last_ci_status, current_status)
|
|
123
|
+
|
|
124
|
+
# Reload config to pick up persisted watermarks
|
|
125
|
+
@config[:last_ci_sha] = sha
|
|
126
|
+
@config[:last_ci_status] = current_status
|
|
127
|
+
|
|
128
|
+
failed = completed.reject { |c| c.conclusion == "success" }
|
|
129
|
+
event_type = (current_status == "success") ? :ci_success : :ci_failure
|
|
130
|
+
|
|
131
|
+
dispatch(
|
|
132
|
+
event_type:,
|
|
133
|
+
repo:,
|
|
134
|
+
branch:,
|
|
135
|
+
sha:,
|
|
136
|
+
failed_checks: failed.map(&:name),
|
|
137
|
+
check_count: checks.size,
|
|
138
|
+
injection_supersede_key: "ci_status",
|
|
139
|
+
**((event_type == :ci_success) ? {injection_ttl: 60} : {})
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def head_sha(repo, branch)
|
|
144
|
+
@client.branch(repo, branch).commit.sha
|
|
145
|
+
rescue Octokit::NotFound
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── PR comments ────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def check_pr_comments(repo, branch)
|
|
152
|
+
pr = open_pr(repo, branch)
|
|
153
|
+
return unless pr
|
|
154
|
+
|
|
155
|
+
last_id = self[:last_comment_id]&.to_i || 0
|
|
156
|
+
comments = @client.issue_comments(repo, pr.number)
|
|
157
|
+
|
|
158
|
+
new_comments = comments.select { |c| c.id > last_id }
|
|
159
|
+
return if new_comments.empty?
|
|
160
|
+
|
|
161
|
+
# Persist the highest seen comment ID
|
|
162
|
+
max_id = new_comments.map(&:id).max
|
|
163
|
+
@agent.set_monitor_field(@name, :last_comment_id, max_id)
|
|
164
|
+
@config[:last_comment_id] = max_id
|
|
165
|
+
|
|
166
|
+
new_comments.each do |comment|
|
|
167
|
+
dispatch(
|
|
168
|
+
event_type: :pr_comment,
|
|
169
|
+
repo:,
|
|
170
|
+
branch:,
|
|
171
|
+
pull_request: PullRequestDrop.new({
|
|
172
|
+
number: pr.number,
|
|
173
|
+
title: pr.title
|
|
174
|
+
}),
|
|
175
|
+
comment: CommentDrop.new({
|
|
176
|
+
author: comment.user.login,
|
|
177
|
+
body: comment.body.to_s.strip,
|
|
178
|
+
url: comment.html_url
|
|
179
|
+
})
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ── PR reviews ─────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
def check_pr_reviews(repo, branch)
|
|
187
|
+
pr = open_pr(repo, branch)
|
|
188
|
+
return unless pr
|
|
189
|
+
|
|
190
|
+
last_id = self[:last_review_id]&.to_i || 0
|
|
191
|
+
reviews = @client.pull_request_reviews(repo, pr.number)
|
|
192
|
+
|
|
193
|
+
new_reviews = reviews.select { |r| r.id > last_id && r.state != "PENDING" }
|
|
194
|
+
return if new_reviews.empty?
|
|
195
|
+
|
|
196
|
+
max_id = new_reviews.map(&:id).max
|
|
197
|
+
@agent.set_monitor_field(@name, :last_review_id, max_id)
|
|
198
|
+
@config[:last_review_id] = max_id
|
|
199
|
+
|
|
200
|
+
new_reviews.each do |review|
|
|
201
|
+
dispatch(
|
|
202
|
+
event_type: :pr_review,
|
|
203
|
+
repo:,
|
|
204
|
+
branch:,
|
|
205
|
+
pull_request: PullRequestDrop.new({
|
|
206
|
+
number: pr.number,
|
|
207
|
+
title: pr.title
|
|
208
|
+
}),
|
|
209
|
+
review: ReviewDrop.new({
|
|
210
|
+
author: review.user.login,
|
|
211
|
+
state: review.state,
|
|
212
|
+
body: review.body.to_s.strip,
|
|
213
|
+
url: review.html_url
|
|
214
|
+
}),
|
|
215
|
+
**((review.state == "CHANGES_REQUESTED") ? {injection_priority: :high} : {})
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Memoize the open PR for this branch within a single tick.
|
|
221
|
+
def open_pr(repo, branch)
|
|
222
|
+
@open_pr_cache ||= {}
|
|
223
|
+
@open_pr_cache[branch] ||= begin
|
|
224
|
+
prs = @client.pull_requests(repo, state: "open", head: "#{repo.split("/").first}:#{branch}")
|
|
225
|
+
prs.first
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_client
|
|
230
|
+
token = self[:token] || ENV["GITHUB_TOKEN"]
|
|
231
|
+
|
|
232
|
+
Octokit::Client.new(
|
|
233
|
+
access_token: token,
|
|
234
|
+
middleware: faraday_stack
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def faraday_stack
|
|
239
|
+
Faraday::RackBuilder.new do |builder|
|
|
240
|
+
builder.request :retry,
|
|
241
|
+
max: 3,
|
|
242
|
+
interval: 0.5,
|
|
243
|
+
backoff_factor: 2,
|
|
244
|
+
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed]
|
|
245
|
+
builder.adapter Faraday.default_adapter
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Integrations
|
|
5
|
+
module GitHub
|
|
6
|
+
# Detects a GitHub repository from the agent's environment snapshot and
|
|
7
|
+
# returns the monitor config needed to start a Monitor for this agent.
|
|
8
|
+
class Monitor::Probe < Superkick::Monitor::Probe
|
|
9
|
+
def self.type = :github
|
|
10
|
+
|
|
11
|
+
def self.description
|
|
12
|
+
"Detects GitHub repository and current branch from git remote configuration."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.environment_actions
|
|
16
|
+
[{action: :git_branch}, {action: :git_remotes}]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param environment [Hash] environment data from the agent
|
|
20
|
+
# @return [Hash] { github: { type: "github", branch: …, repo: … } } or {}
|
|
21
|
+
def self.detect(environment:)
|
|
22
|
+
config = Monitor.resolve_config({}, environment:)
|
|
23
|
+
return {} unless config[:branch] && config[:repo]
|
|
24
|
+
|
|
25
|
+
{github: config.merge(type: "github")}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|