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.
Files changed (199) hide show
  1. checksums.yaml +7 -0
  2. data/CLA.md +91 -0
  3. data/CLAUDE.md +2226 -0
  4. data/CONTRIBUTING.md +104 -0
  5. data/LICENSE +108 -0
  6. data/LICENSE-COMMERCIAL.md +39 -0
  7. data/PLAN.md +161 -0
  8. data/README.md +1155 -0
  9. data/exe/superkick +6 -0
  10. data/lib/superkick/agent/runtime.rb +82 -0
  11. data/lib/superkick/agent/runtimes/local.rb +74 -0
  12. data/lib/superkick/agent/runtimes.rb +4 -0
  13. data/lib/superkick/agent.rb +209 -0
  14. data/lib/superkick/agent_store.rb +85 -0
  15. data/lib/superkick/attach/client.rb +245 -0
  16. data/lib/superkick/attach/protocol.rb +71 -0
  17. data/lib/superkick/attach/server.rb +371 -0
  18. data/lib/superkick/budget_checker.rb +120 -0
  19. data/lib/superkick/buffer/client.rb +91 -0
  20. data/lib/superkick/buffer/server.rb +127 -0
  21. data/lib/superkick/cli/agent.rb +524 -0
  22. data/lib/superkick/cli/completion.rb +591 -0
  23. data/lib/superkick/cli/goal.rb +71 -0
  24. data/lib/superkick/cli/mcp.rb +34 -0
  25. data/lib/superkick/cli/monitor.rb +47 -0
  26. data/lib/superkick/cli/notifier.rb +39 -0
  27. data/lib/superkick/cli/repository.rb +46 -0
  28. data/lib/superkick/cli/server.rb +106 -0
  29. data/lib/superkick/cli/setup.rb +166 -0
  30. data/lib/superkick/cli/spawner.rb +85 -0
  31. data/lib/superkick/cli/team.rb +407 -0
  32. data/lib/superkick/cli.rb +175 -0
  33. data/lib/superkick/client_registry.rb +30 -0
  34. data/lib/superkick/configuration.rb +178 -0
  35. data/lib/superkick/connection.rb +56 -0
  36. data/lib/superkick/control/client.rb +78 -0
  37. data/lib/superkick/control/reply.rb +43 -0
  38. data/lib/superkick/control/server.rb +1271 -0
  39. data/lib/superkick/cost_accumulator.rb +53 -0
  40. data/lib/superkick/cost_extractor.rb +65 -0
  41. data/lib/superkick/cost_poller.rb +70 -0
  42. data/lib/superkick/driver/profile_source.rb +134 -0
  43. data/lib/superkick/driver.rb +179 -0
  44. data/lib/superkick/drivers/claude_code.rb +110 -0
  45. data/lib/superkick/drivers/codex.rb +57 -0
  46. data/lib/superkick/drivers/copilot.rb +75 -0
  47. data/lib/superkick/drivers/gemini.rb +86 -0
  48. data/lib/superkick/drivers/goose.rb +74 -0
  49. data/lib/superkick/drivers.rb +16 -0
  50. data/lib/superkick/drop.rb +80 -0
  51. data/lib/superkick/drops.rb +76 -0
  52. data/lib/superkick/environment_executor.rb +90 -0
  53. data/lib/superkick/goal.rb +95 -0
  54. data/lib/superkick/goals/agent_exit.rb +41 -0
  55. data/lib/superkick/goals/agent_signal.rb +42 -0
  56. data/lib/superkick/goals/command.rb +103 -0
  57. data/lib/superkick/history_buffer.rb +38 -0
  58. data/lib/superkick/hosted/attach/bridge.rb +52 -0
  59. data/lib/superkick/hosted/attach/client.rb +208 -0
  60. data/lib/superkick/hosted/attach/relay.rb +313 -0
  61. data/lib/superkick/hosted/attach/relay_store.rb +48 -0
  62. data/lib/superkick/hosted/bridge.rb +263 -0
  63. data/lib/superkick/hosted/buffer/bridge.rb +42 -0
  64. data/lib/superkick/hosted/buffer/client.rb +63 -0
  65. data/lib/superkick/hosted/buffer/relay.rb +126 -0
  66. data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
  67. data/lib/superkick/hosted/control/client.rb +84 -0
  68. data/lib/superkick/hosted/mcp_proxy.rb +144 -0
  69. data/lib/superkick/inject_handler.rb +24 -0
  70. data/lib/superkick/injection_guard.rb +26 -0
  71. data/lib/superkick/injection_queue.rb +177 -0
  72. data/lib/superkick/injector.rb +65 -0
  73. data/lib/superkick/input_buffer.rb +171 -0
  74. data/lib/superkick/integrations/bugsnag/README.md +98 -0
  75. data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
  76. data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
  77. data/lib/superkick/integrations/bugsnag.rb +7 -0
  78. data/lib/superkick/integrations/circleci/README.md +75 -0
  79. data/lib/superkick/integrations/circleci/monitor.rb +185 -0
  80. data/lib/superkick/integrations/circleci/probe.rb +36 -0
  81. data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
  82. data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
  83. data/lib/superkick/integrations/circleci.rb +8 -0
  84. data/lib/superkick/integrations/datadog/README.md +253 -0
  85. data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
  86. data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
  87. data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
  88. data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
  89. data/lib/superkick/integrations/datadog/notifier.rb +294 -0
  90. data/lib/superkick/integrations/datadog/spawner.rb +201 -0
  91. data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
  92. data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
  93. data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
  94. data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
  95. data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
  96. data/lib/superkick/integrations/datadog.rb +14 -0
  97. data/lib/superkick/integrations/docker/README.md +256 -0
  98. data/lib/superkick/integrations/docker/client.rb +295 -0
  99. data/lib/superkick/integrations/docker/runtime.rb +218 -0
  100. data/lib/superkick/integrations/docker.rb +4 -0
  101. data/lib/superkick/integrations/git/repository_source.rb +66 -0
  102. data/lib/superkick/integrations/git/version_control.rb +119 -0
  103. data/lib/superkick/integrations/git.rb +8 -0
  104. data/lib/superkick/integrations/github/README.md +300 -0
  105. data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
  106. data/lib/superkick/integrations/github/drops.rb +114 -0
  107. data/lib/superkick/integrations/github/goal.rb +135 -0
  108. data/lib/superkick/integrations/github/issue_goal.rb +104 -0
  109. data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
  110. data/lib/superkick/integrations/github/monitor.rb +251 -0
  111. data/lib/superkick/integrations/github/probe.rb +30 -0
  112. data/lib/superkick/integrations/github/repository_source.rb +228 -0
  113. data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
  114. data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
  115. data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
  116. data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
  117. data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
  118. data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
  119. data/lib/superkick/integrations/github.rb +16 -0
  120. data/lib/superkick/integrations/honeybadger/README.md +97 -0
  121. data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
  122. data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
  123. data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
  124. data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
  125. data/lib/superkick/integrations/honeybadger.rb +9 -0
  126. data/lib/superkick/integrations/shell/README.md +83 -0
  127. data/lib/superkick/integrations/shell/monitor.rb +87 -0
  128. data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
  129. data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
  130. data/lib/superkick/integrations/shell.rb +7 -0
  131. data/lib/superkick/integrations/shortcut/README.md +193 -0
  132. data/lib/superkick/integrations/shortcut/drops.rb +91 -0
  133. data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
  134. data/lib/superkick/integrations/shortcut/probe.rb +34 -0
  135. data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
  136. data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
  137. data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
  138. data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
  139. data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
  140. data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
  141. data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
  142. data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
  143. data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
  144. data/lib/superkick/integrations/shortcut.rb +11 -0
  145. data/lib/superkick/integrations/slack/README.md +297 -0
  146. data/lib/superkick/integrations/slack/drops.rb +70 -0
  147. data/lib/superkick/integrations/slack/notifier.rb +426 -0
  148. data/lib/superkick/integrations/slack/spawner.rb +251 -0
  149. data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
  150. data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
  151. data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
  152. data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
  153. data/lib/superkick/integrations/slack.rb +12 -0
  154. data/lib/superkick/liquid.rb +129 -0
  155. data/lib/superkick/local/repository_source.rb +148 -0
  156. data/lib/superkick/mcp_server.rb +596 -0
  157. data/lib/superkick/monitor.rb +215 -0
  158. data/lib/superkick/notification_dispatcher.rb +280 -0
  159. data/lib/superkick/notifier.rb +173 -0
  160. data/lib/superkick/notifier_state_store.rb +55 -0
  161. data/lib/superkick/notifier_template.rb +121 -0
  162. data/lib/superkick/notifiers/command.rb +124 -0
  163. data/lib/superkick/notifiers/terminal_bell.rb +41 -0
  164. data/lib/superkick/output_logger.rb +54 -0
  165. data/lib/superkick/poller.rb +126 -0
  166. data/lib/superkick/process_runner.rb +87 -0
  167. data/lib/superkick/pty_proxy.rb +403 -0
  168. data/lib/superkick/registry.rb +75 -0
  169. data/lib/superkick/repository_source.rb +195 -0
  170. data/lib/superkick/server.rb +211 -0
  171. data/lib/superkick/session_recorder.rb +154 -0
  172. data/lib/superkick/setup.rb +160 -0
  173. data/lib/superkick/spawn/agent_spawner.rb +311 -0
  174. data/lib/superkick/spawn/approval_store.rb +113 -0
  175. data/lib/superkick/spawn/handler.rb +144 -0
  176. data/lib/superkick/spawn/injector.rb +119 -0
  177. data/lib/superkick/spawn/workflow_executor.rb +196 -0
  178. data/lib/superkick/spawn/workflow_validator.rb +77 -0
  179. data/lib/superkick/spawner.rb +67 -0
  180. data/lib/superkick/supervisor.rb +516 -0
  181. data/lib/superkick/team/artifact_store.rb +92 -0
  182. data/lib/superkick/team/log.rb +140 -0
  183. data/lib/superkick/team/log_entry_drop.rb +34 -0
  184. data/lib/superkick/team/log_monitor.rb +84 -0
  185. data/lib/superkick/team/log_notifier.rb +96 -0
  186. data/lib/superkick/team/log_store.rb +40 -0
  187. data/lib/superkick/template_filters.rb +24 -0
  188. data/lib/superkick/template_renderer.rb +223 -0
  189. data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
  190. data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
  191. data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
  192. data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
  193. data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
  194. data/lib/superkick/version.rb +5 -0
  195. data/lib/superkick/version_control.rb +135 -0
  196. data/lib/superkick/yaml_config.rb +302 -0
  197. data/lib/superkick.rb +198 -0
  198. data/plan.md +267 -0
  199. 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