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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Integrations
|
|
5
|
+
module Shortcut
|
|
6
|
+
# Liquid Drop for Shortcut member data. Wraps the symbol-keyed
|
|
7
|
+
# hash returned by ShortcutMonitor#resolve_member /
|
|
8
|
+
# ShortcutSpawner#resolve_member so Liquid templates can access
|
|
9
|
+
# properties via dot notation (e.g. {{ author.display_name }}).
|
|
10
|
+
class MemberDrop < Superkick::Drop
|
|
11
|
+
def self.drop_type = "shortcut_member"
|
|
12
|
+
|
|
13
|
+
def display_name = @data[:display_name]
|
|
14
|
+
|
|
15
|
+
def mention_name = @data[:mention_name]
|
|
16
|
+
|
|
17
|
+
# "Display Name (@mention)" — used in member_list filter and
|
|
18
|
+
# directly in templates via {{ member.tag }}.
|
|
19
|
+
def tag = "#{display_name} (@#{mention_name})"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Liquid Drop for a Shortcut story. Wraps the common story fields
|
|
23
|
+
# (id, name, url) that appear in every Shortcut event, plus optional
|
|
24
|
+
# extended fields from spawner events (description, type, etc.).
|
|
25
|
+
#
|
|
26
|
+
# The `ref` method replaces the `story_ref` filter — it formats
|
|
27
|
+
# the story reference as `sc-{id} "truncated name"`.
|
|
28
|
+
class StoryDrop < Superkick::Drop
|
|
29
|
+
def self.drop_type = "shortcut_story"
|
|
30
|
+
|
|
31
|
+
def id = @data[:id]
|
|
32
|
+
|
|
33
|
+
def name = @data[:name]
|
|
34
|
+
|
|
35
|
+
def url = @data[:url]
|
|
36
|
+
|
|
37
|
+
def description = @data[:description]
|
|
38
|
+
|
|
39
|
+
def type = @data[:type]
|
|
40
|
+
|
|
41
|
+
def workflow_state = @data[:workflow_state]
|
|
42
|
+
|
|
43
|
+
def labels = @data[:labels]
|
|
44
|
+
|
|
45
|
+
def owners = @data[:owners]
|
|
46
|
+
|
|
47
|
+
def epic_name = @data[:epic_name]
|
|
48
|
+
|
|
49
|
+
def tasks = @data[:tasks]
|
|
50
|
+
|
|
51
|
+
def comments = @data[:comments]
|
|
52
|
+
|
|
53
|
+
# Formatted reference: sc-123 "Story title" — replaces story_ref filter.
|
|
54
|
+
def ref
|
|
55
|
+
truncated = name.to_s
|
|
56
|
+
truncated = "#{truncated[0, 47]}..." if truncated.length > 50
|
|
57
|
+
name_part = truncated.empty? ? "" : " \"#{truncated}\""
|
|
58
|
+
"sc-#{id}#{name_part}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Liquid Drop for Shortcut story tasks.
|
|
63
|
+
class TaskDrop < Superkick::Drop
|
|
64
|
+
def self.drop_type = "shortcut_task"
|
|
65
|
+
|
|
66
|
+
def description = @data[:description]
|
|
67
|
+
|
|
68
|
+
def complete = @data[:complete]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Liquid Drop for Shortcut story comments.
|
|
72
|
+
class CommentDrop < Superkick::Drop
|
|
73
|
+
def self.drop_type = "shortcut_comment"
|
|
74
|
+
|
|
75
|
+
def author
|
|
76
|
+
raw = @data[:author]
|
|
77
|
+
raw.is_a?(MemberDrop) ? raw : MemberDrop.new(raw)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def body = @data[:body]
|
|
81
|
+
|
|
82
|
+
def created_at = @data[:created_at]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Superkick::Drop.register(Superkick::Integrations::Shortcut::MemberDrop)
|
|
89
|
+
Superkick::Drop.register(Superkick::Integrations::Shortcut::StoryDrop)
|
|
90
|
+
Superkick::Drop.register(Superkick::Integrations::Shortcut::TaskDrop)
|
|
91
|
+
Superkick::Drop.register(Superkick::Integrations::Shortcut::CommentDrop)
|
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Superkick
|
|
8
|
+
module Integrations
|
|
9
|
+
module Shortcut
|
|
10
|
+
# Polls Shortcut (formerly Clubhouse) for story-level changes.
|
|
11
|
+
#
|
|
12
|
+
# Config keys provided by probe: story_id (auto-detected from sc-XXXXX branch)
|
|
13
|
+
# Optional config keys: token (or SHORTCUT_API_TOKEN env var),
|
|
14
|
+
# workspace_slug (for building web URLs),
|
|
15
|
+
# member (UUID, email, or mention name to identify current user),
|
|
16
|
+
# ignore_self (default true — skip self-authored changes)
|
|
17
|
+
#
|
|
18
|
+
# Events emitted: story_state_changed, story_comment, story_blocker,
|
|
19
|
+
# story_unblocked, story_owner_changed,
|
|
20
|
+
# story_description_changed, related_story_changed
|
|
21
|
+
#
|
|
22
|
+
# Watermarks persisted: last_state_id, last_comment_id, last_blocker,
|
|
23
|
+
# last_owner_ids, last_description_hash
|
|
24
|
+
class Monitor < Superkick::Monitor
|
|
25
|
+
API_BASE = "https://api.app.shortcut.com"
|
|
26
|
+
BRANCH_STORY_RE = /sc-(\d+)/
|
|
27
|
+
|
|
28
|
+
attr_reader :conn
|
|
29
|
+
|
|
30
|
+
def self.type = :shortcut
|
|
31
|
+
|
|
32
|
+
def self.description
|
|
33
|
+
"Monitors a Shortcut story for state changes, new comments, blockers, " \
|
|
34
|
+
"owner changes, and description updates. Also watches linked and sibling stories " \
|
|
35
|
+
"under the same epic. story_id is auto-detected from sc-XXXXX branch pattern. " \
|
|
36
|
+
"Optionally accepts a token (or set SHORTCUT_API_TOKEN env var) and workspace_slug. " \
|
|
37
|
+
"Self-authored changes are ignored by default (set ignore_self: false to disable)."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.required_config = %i[story_id]
|
|
41
|
+
|
|
42
|
+
def self.setup_label = "Shortcut"
|
|
43
|
+
|
|
44
|
+
def self.setup_config
|
|
45
|
+
<<~YAML
|
|
46
|
+
shortcut:
|
|
47
|
+
token: <%= env("SHORTCUT_API_TOKEN") %>
|
|
48
|
+
# story_id is auto-detected from sc-XXXXX branch pattern.
|
|
49
|
+
# Uncomment to set explicitly:
|
|
50
|
+
# story_id: 12345
|
|
51
|
+
# workspace_slug: my-workspace # enables story URL generation
|
|
52
|
+
# ignore_self: true # skip self-authored changes (default)
|
|
53
|
+
YAML
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fill in missing story_id from the git branch name (sc-XXXXX pattern).
|
|
57
|
+
def self.resolve_config(config, environment: {})
|
|
58
|
+
unless config[:story_id]
|
|
59
|
+
branch = environment[:git_branch].to_s
|
|
60
|
+
match = BRANCH_STORY_RE.match(branch)
|
|
61
|
+
config[:story_id] = match[1] if match
|
|
62
|
+
end
|
|
63
|
+
config
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.templates_dir
|
|
67
|
+
File.join(__dir__, "templates")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(name:, config:, handler:, agent: nil, server_context: {}, connection: nil)
|
|
71
|
+
super(name:, config:, handler:, agent:, server_context:)
|
|
72
|
+
@conn = connection
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def tick
|
|
76
|
+
story_id = self[:story_id].to_i
|
|
77
|
+
story = fetch_story(story_id)
|
|
78
|
+
return unless story
|
|
79
|
+
|
|
80
|
+
check_state_change(story)
|
|
81
|
+
check_comments(story)
|
|
82
|
+
check_blocker(story)
|
|
83
|
+
check_owners(story)
|
|
84
|
+
check_description(story)
|
|
85
|
+
check_related_stories(story)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def on_start
|
|
89
|
+
@conn ||= build_connection
|
|
90
|
+
@workflow_states = fetch_workflow_states
|
|
91
|
+
@members = fetch_members
|
|
92
|
+
@current_member_id = detect_current_member
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# ── Self-action filtering ────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def ignore_self?
|
|
100
|
+
self[:ignore_self].nil? || self[:ignore_self]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
104
|
+
private_constant :UUID_PATTERN
|
|
105
|
+
|
|
106
|
+
def detect_current_member
|
|
107
|
+
member = self[:member]
|
|
108
|
+
if member
|
|
109
|
+
resolved = resolve_member_config(member)
|
|
110
|
+
if resolved
|
|
111
|
+
Superkick.logger.info(log_tag) { "Resolved member #{member.inspect} to #{resolved}" }
|
|
112
|
+
return resolved
|
|
113
|
+
end
|
|
114
|
+
Superkick.logger.warn(log_tag) { "Could not resolve member #{member.inspect}" }
|
|
115
|
+
return nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Auto-detect from API token
|
|
119
|
+
resp = get("/api/v3/member")
|
|
120
|
+
return nil unless resp
|
|
121
|
+
|
|
122
|
+
data = resp.body
|
|
123
|
+
member_id = data["id"]
|
|
124
|
+
mention = data["mention_name"] || data.dig("profile", "mention_name") || member_id
|
|
125
|
+
Superkick.logger.info(log_tag) { "Detected current member: #{mention} (#{member_id})" }
|
|
126
|
+
member_id
|
|
127
|
+
rescue => e
|
|
128
|
+
Superkick.logger.warn(log_tag) { "Could not detect current member: #{e.message}" }
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolve_member_config(value)
|
|
133
|
+
# UUID — use directly
|
|
134
|
+
return value if value.match?(UUID_PATTERN)
|
|
135
|
+
|
|
136
|
+
# Email — lookup by email_address
|
|
137
|
+
# Mention name — lookup by mention_name
|
|
138
|
+
resp = get("/api/v3/members")
|
|
139
|
+
return nil unless resp
|
|
140
|
+
|
|
141
|
+
members = resp.body
|
|
142
|
+
match = if value.include?("@")
|
|
143
|
+
members.find { |m| (m.dig("profile", "email_address") || "").casecmp?(value) }
|
|
144
|
+
else
|
|
145
|
+
members.find { |m| (m.dig("profile", "mention_name") || "") == value }
|
|
146
|
+
end
|
|
147
|
+
match&.dig("id")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self_action?(actor_id)
|
|
151
|
+
ignore_self? && @current_member_id && actor_id == @current_member_id
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def latest_history_actor(story_id)
|
|
155
|
+
resp = get("/api/v3/stories/#{story_id}/history")
|
|
156
|
+
return nil unless resp
|
|
157
|
+
|
|
158
|
+
entries = resp.body
|
|
159
|
+
return nil if entries.empty?
|
|
160
|
+
|
|
161
|
+
# History entries are chronological; last entry is most recent
|
|
162
|
+
latest = entries.last
|
|
163
|
+
latest["member_id"]
|
|
164
|
+
rescue => e
|
|
165
|
+
Superkick.logger.debug(log_tag) { "Could not fetch story history: #{e.message}" }
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# ── Story fetch ─────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def fetch_story(story_id)
|
|
172
|
+
resp = get("/api/v3/stories/#{story_id}")
|
|
173
|
+
return nil unless resp
|
|
174
|
+
|
|
175
|
+
resp.body
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# ── State changes ───────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
def check_state_change(story)
|
|
181
|
+
current_state_id = story["workflow_state_id"]
|
|
182
|
+
last_state_id = self[:last_state_id]
|
|
183
|
+
|
|
184
|
+
# Seed watermark on first tick
|
|
185
|
+
if last_state_id.nil?
|
|
186
|
+
@agent.set_monitor_field(@name, :last_state_id, current_state_id)
|
|
187
|
+
@config[:last_state_id] = current_state_id
|
|
188
|
+
return
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return if current_state_id == last_state_id
|
|
192
|
+
|
|
193
|
+
old_state = resolve_state(last_state_id)
|
|
194
|
+
new_state = resolve_state(current_state_id)
|
|
195
|
+
new_state_type = resolve_state_type(current_state_id)
|
|
196
|
+
|
|
197
|
+
@agent.set_monitor_field(@name, :last_state_id, current_state_id)
|
|
198
|
+
@config[:last_state_id] = current_state_id
|
|
199
|
+
|
|
200
|
+
actor_id = latest_history_actor(story["id"])
|
|
201
|
+
return if self_action?(actor_id)
|
|
202
|
+
|
|
203
|
+
dispatch(
|
|
204
|
+
event_type: :story_state_changed,
|
|
205
|
+
story: story_drop(story),
|
|
206
|
+
old_state:,
|
|
207
|
+
new_state:,
|
|
208
|
+
new_state_type:,
|
|
209
|
+
actor: actor_id ? member_drop(actor_id) : nil
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ── Comments ────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
def check_comments(story)
|
|
216
|
+
comments = story["comments"] || []
|
|
217
|
+
last_id = self[:last_comment_id]&.to_i || 0
|
|
218
|
+
|
|
219
|
+
# Seed watermark on first tick
|
|
220
|
+
if self[:last_comment_id].nil? && comments.any?
|
|
221
|
+
max_id = comments.map { |c| c["id"] }.max
|
|
222
|
+
@agent.set_monitor_field(@name, :last_comment_id, max_id)
|
|
223
|
+
@config[:last_comment_id] = max_id
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
new_comments = comments.select { |c| c["id"] > last_id }
|
|
228
|
+
return if new_comments.empty?
|
|
229
|
+
|
|
230
|
+
# Always advance the watermark (even for self-authored comments)
|
|
231
|
+
max_id = new_comments.map { |c| c["id"] }.max
|
|
232
|
+
@agent.set_monitor_field(@name, :last_comment_id, max_id)
|
|
233
|
+
@config[:last_comment_id] = max_id
|
|
234
|
+
|
|
235
|
+
# Filter out self-authored comments
|
|
236
|
+
if ignore_self? && @current_member_id
|
|
237
|
+
new_comments = new_comments.reject { |c| c["author_id"] == @current_member_id }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
new_comments.each do |comment|
|
|
241
|
+
dispatch(
|
|
242
|
+
event_type: :story_comment,
|
|
243
|
+
story: story_drop(story),
|
|
244
|
+
author: member_drop(comment["author_id"]),
|
|
245
|
+
body: comment["text"].to_s.strip
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ── Blocker status ──────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def check_blocker(story)
|
|
253
|
+
current_blocker = story["blocker"] || false
|
|
254
|
+
last_blocker = self[:last_blocker]
|
|
255
|
+
|
|
256
|
+
# Seed watermark on first tick
|
|
257
|
+
if last_blocker.nil?
|
|
258
|
+
@agent.set_monitor_field(@name, :last_blocker, current_blocker)
|
|
259
|
+
@config[:last_blocker] = current_blocker
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
return if current_blocker == last_blocker
|
|
264
|
+
|
|
265
|
+
@agent.set_monitor_field(@name, :last_blocker, current_blocker)
|
|
266
|
+
@config[:last_blocker] = current_blocker
|
|
267
|
+
|
|
268
|
+
actor_id = latest_history_actor(story["id"])
|
|
269
|
+
return if self_action?(actor_id)
|
|
270
|
+
|
|
271
|
+
if current_blocker
|
|
272
|
+
# Look for blocker comments
|
|
273
|
+
comments = story["comments"] || []
|
|
274
|
+
blocker_comments = comments.select { |c| c["blocker"] }
|
|
275
|
+
blocker_reason = blocker_comments.last&.dig("text")
|
|
276
|
+
|
|
277
|
+
dispatch(
|
|
278
|
+
event_type: :story_blocker,
|
|
279
|
+
story: story_drop(story),
|
|
280
|
+
blocker_reason: blocker_reason,
|
|
281
|
+
actor: actor_id ? member_drop(actor_id) : nil,
|
|
282
|
+
injection_priority: :high,
|
|
283
|
+
injection_supersede_key: "story_blocker_status"
|
|
284
|
+
)
|
|
285
|
+
else
|
|
286
|
+
dispatch(
|
|
287
|
+
event_type: :story_unblocked,
|
|
288
|
+
story: story_drop(story),
|
|
289
|
+
actor: actor_id ? member_drop(actor_id) : nil,
|
|
290
|
+
injection_ttl: 120,
|
|
291
|
+
injection_supersede_key: "story_blocker_status"
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# ── Owner changes ───────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
def check_owners(story)
|
|
299
|
+
owner_ids = story["owner_ids"] || []
|
|
300
|
+
current_ids = owner_ids.sort
|
|
301
|
+
last_ids_json = self[:last_owner_ids]
|
|
302
|
+
|
|
303
|
+
# Seed watermark on first tick
|
|
304
|
+
if last_ids_json.nil?
|
|
305
|
+
@agent.set_monitor_field(@name, :last_owner_ids, JSON.generate(current_ids))
|
|
306
|
+
@config[:last_owner_ids] = JSON.generate(current_ids)
|
|
307
|
+
return
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
last_ids = JSON.parse(last_ids_json).sort
|
|
311
|
+
return if current_ids == last_ids
|
|
312
|
+
|
|
313
|
+
@agent.set_monitor_field(@name, :last_owner_ids, JSON.generate(current_ids))
|
|
314
|
+
@config[:last_owner_ids] = JSON.generate(current_ids)
|
|
315
|
+
|
|
316
|
+
actor_id = latest_history_actor(story["id"])
|
|
317
|
+
return if self_action?(actor_id)
|
|
318
|
+
|
|
319
|
+
added = current_ids - last_ids
|
|
320
|
+
removed = last_ids - current_ids
|
|
321
|
+
|
|
322
|
+
dispatch(
|
|
323
|
+
event_type: :story_owner_changed,
|
|
324
|
+
story: story_drop(story),
|
|
325
|
+
added_owners: added.map { member_drop(it) },
|
|
326
|
+
removed_owners: removed.map { member_drop(it) },
|
|
327
|
+
actor: actor_id ? member_drop(actor_id) : nil
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# ── Description changes ─────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
def check_description(story)
|
|
334
|
+
current_name = story["name"].to_s
|
|
335
|
+
current_description = story["description"].to_s
|
|
336
|
+
content = "#{current_name}\n#{current_description}"
|
|
337
|
+
current_hash = Digest::SHA256.hexdigest(content)
|
|
338
|
+
last_hash = self[:last_description_hash]
|
|
339
|
+
|
|
340
|
+
# Seed watermark on first tick
|
|
341
|
+
if last_hash.nil?
|
|
342
|
+
@last_name = current_name
|
|
343
|
+
@last_description = current_description
|
|
344
|
+
@agent.set_monitor_field(@name, :last_description_hash, current_hash)
|
|
345
|
+
@config[:last_description_hash] = current_hash
|
|
346
|
+
return
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
return if current_hash == last_hash
|
|
350
|
+
|
|
351
|
+
old_name = @last_name || current_name
|
|
352
|
+
old_description = @last_description || current_description
|
|
353
|
+
|
|
354
|
+
@last_name = current_name
|
|
355
|
+
@last_description = current_description
|
|
356
|
+
@agent.set_monitor_field(@name, :last_description_hash, current_hash)
|
|
357
|
+
@config[:last_description_hash] = current_hash
|
|
358
|
+
|
|
359
|
+
actor_id = latest_history_actor(story["id"])
|
|
360
|
+
return if self_action?(actor_id)
|
|
361
|
+
|
|
362
|
+
dispatch(
|
|
363
|
+
event_type: :story_description_changed,
|
|
364
|
+
story: story_drop(story),
|
|
365
|
+
old_name:,
|
|
366
|
+
new_name: current_name,
|
|
367
|
+
old_description:,
|
|
368
|
+
new_description: current_description,
|
|
369
|
+
actor: actor_id ? member_drop(actor_id) : nil
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# ── Related stories (linked + epic siblings) ────────────────────────────
|
|
374
|
+
|
|
375
|
+
def check_related_stories(story)
|
|
376
|
+
@related_states ||= {}
|
|
377
|
+
|
|
378
|
+
related_ids = collect_related_ids(story)
|
|
379
|
+
return if related_ids.empty?
|
|
380
|
+
|
|
381
|
+
related_ids.each do |rid|
|
|
382
|
+
related = fetch_story(rid)
|
|
383
|
+
next unless related
|
|
384
|
+
|
|
385
|
+
current_state_id = related["workflow_state_id"]
|
|
386
|
+
last_state_id = @related_states[rid]
|
|
387
|
+
|
|
388
|
+
if last_state_id.nil?
|
|
389
|
+
@related_states[rid] = current_state_id
|
|
390
|
+
next
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
next if current_state_id == last_state_id
|
|
394
|
+
|
|
395
|
+
@related_states[rid] = current_state_id
|
|
396
|
+
|
|
397
|
+
dispatch(
|
|
398
|
+
event_type: :related_story_changed,
|
|
399
|
+
story: story_drop(related),
|
|
400
|
+
old_state: resolve_state(last_state_id),
|
|
401
|
+
new_state: resolve_state(current_state_id),
|
|
402
|
+
primary_story: story_drop(story),
|
|
403
|
+
relationship: directed_verbs(story, related),
|
|
404
|
+
epic_sibling: epic_sibling?(story, related)
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def collect_related_ids(story)
|
|
410
|
+
ids = Set.new
|
|
411
|
+
|
|
412
|
+
# Linked stories
|
|
413
|
+
story_links = story["story_links"] || []
|
|
414
|
+
story_links.each do |link|
|
|
415
|
+
other_id = (link["subject_id"] == story["id"]) ? link["object_id"] : link["subject_id"]
|
|
416
|
+
ids.add(other_id)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Epic siblings
|
|
420
|
+
epic_id = story["epic_id"]
|
|
421
|
+
if epic_id && !@epic_stories_fetched
|
|
422
|
+
@epic_stories_fetched = true
|
|
423
|
+
@epic_story_ids = fetch_epic_story_ids(epic_id) - [story["id"]]
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
ids.merge(@epic_story_ids || [])
|
|
427
|
+
ids.to_a.first(10) # Cap to avoid too many API calls
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def fetch_epic_story_ids(epic_id)
|
|
431
|
+
resp = get("/api/v3/epics/#{epic_id}/stories")
|
|
432
|
+
return [] unless resp
|
|
433
|
+
|
|
434
|
+
stories = resp.body
|
|
435
|
+
stories.map { |s| s["id"] }
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
INVERSE_VERBS = {
|
|
439
|
+
"blocks" => "is blocked by",
|
|
440
|
+
"duplicates" => "is duplicated by",
|
|
441
|
+
"relates to" => "relates to"
|
|
442
|
+
}.freeze
|
|
443
|
+
|
|
444
|
+
def directed_verbs(primary, related)
|
|
445
|
+
primary_links = primary["story_links"] || []
|
|
446
|
+
links = primary_links.select do |l|
|
|
447
|
+
l["subject_id"] == related["id"] || l["object_id"] == related["id"]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
links.map do |l|
|
|
451
|
+
verb = l["verb"]
|
|
452
|
+
if l["subject_id"] == primary["id"]
|
|
453
|
+
verb
|
|
454
|
+
else
|
|
455
|
+
INVERSE_VERBS[verb] || verb
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def epic_sibling?(primary, related)
|
|
461
|
+
primary["epic_id"] && primary["epic_id"] == related["epic_id"]
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# ── Workflow state resolution ───────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
def fetch_workflow_states
|
|
467
|
+
resp = get("/api/v3/workflows")
|
|
468
|
+
return {} unless resp
|
|
469
|
+
|
|
470
|
+
states = {}
|
|
471
|
+
workflows = resp.body
|
|
472
|
+
workflows.each do |wf|
|
|
473
|
+
wf_states = wf["states"] || []
|
|
474
|
+
wf_states.each do |s|
|
|
475
|
+
states[s["id"]] = {name: s["name"], type: s["type"]}
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
states
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def resolve_state(state_id)
|
|
482
|
+
entry = @workflow_states[state_id]
|
|
483
|
+
return entry[:name] if entry
|
|
484
|
+
|
|
485
|
+
# Cache miss — refetch
|
|
486
|
+
@workflow_states = fetch_workflow_states
|
|
487
|
+
@workflow_states.dig(state_id, :name) || "Unknown (#{state_id})"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def resolve_state_type(state_id)
|
|
491
|
+
entry = @workflow_states[state_id]
|
|
492
|
+
return entry[:type] if entry
|
|
493
|
+
|
|
494
|
+
@workflow_states = fetch_workflow_states
|
|
495
|
+
@workflow_states.dig(state_id, :type) || "unknown"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# ── Member resolution ───────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
def fetch_members
|
|
501
|
+
resp = get("/api/v3/members")
|
|
502
|
+
return {} unless resp
|
|
503
|
+
|
|
504
|
+
members = resp.body
|
|
505
|
+
members.each_with_object({}) do |m, h|
|
|
506
|
+
profile = m["profile"] || {}
|
|
507
|
+
h[m["id"]] = {
|
|
508
|
+
mention_name: profile["mention_name"] || m["id"],
|
|
509
|
+
display_name: profile["name"] || profile["mention_name"] || m["id"]
|
|
510
|
+
}
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def resolve_member(member_id)
|
|
515
|
+
return {mention_name: "unknown", display_name: "unknown"} unless member_id
|
|
516
|
+
|
|
517
|
+
info = @members[member_id]
|
|
518
|
+
return info if info
|
|
519
|
+
|
|
520
|
+
# Cache miss — refetch
|
|
521
|
+
@members = fetch_members
|
|
522
|
+
@members[member_id] || {mention_name: member_id.to_s, display_name: member_id.to_s}
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# ── Drop helpers ──────────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
def member_drop(member_id)
|
|
528
|
+
MemberDrop.new(resolve_member(member_id))
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def story_drop(story, **extra)
|
|
532
|
+
StoryDrop.new({
|
|
533
|
+
id: story["id"],
|
|
534
|
+
name: story["name"],
|
|
535
|
+
url: story_url(story["id"]),
|
|
536
|
+
**extra
|
|
537
|
+
})
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# ── URL helpers ─────────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
def story_url(story_id)
|
|
543
|
+
slug = self[:workspace_slug]
|
|
544
|
+
return nil unless slug
|
|
545
|
+
|
|
546
|
+
"https://app.shortcut.com/#{slug}/story/#{story_id}"
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# ── HTTP helpers ────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
def get(path)
|
|
552
|
+
resp = @conn.get(path)
|
|
553
|
+
return nil unless handle_response!(resp)
|
|
554
|
+
resp
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Returns true on success, raises on auth/rate-limit, returns false on 404.
|
|
558
|
+
def handle_response!(resp)
|
|
559
|
+
case resp.status
|
|
560
|
+
when 200..299 then true
|
|
561
|
+
when 401, 403 then raise FatalError, "Shortcut auth failed (HTTP #{resp.status})"
|
|
562
|
+
when 429 then raise RateLimited, "Shortcut rate limited (HTTP 429)"
|
|
563
|
+
when 404
|
|
564
|
+
Superkick.logger.warn(log_tag) { "Shortcut resource not found (HTTP 404)" }
|
|
565
|
+
false
|
|
566
|
+
else
|
|
567
|
+
false
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def build_connection
|
|
572
|
+
token = self[:token] || ENV["SHORTCUT_API_TOKEN"]
|
|
573
|
+
|
|
574
|
+
Faraday.new(url: API_BASE) do |f|
|
|
575
|
+
f.headers["Shortcut-Token"] = token if token
|
|
576
|
+
f.response :json
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Integrations
|
|
5
|
+
module Shortcut
|
|
6
|
+
# Detects a Shortcut story ID from the agent's git branch name.
|
|
7
|
+
#
|
|
8
|
+
# Shortcut's Git integration creates branches with patterns like:
|
|
9
|
+
# user/sc-12345/my-feature
|
|
10
|
+
# sc-12345/my-feature
|
|
11
|
+
# sc-12345
|
|
12
|
+
class Monitor::Probe < Superkick::Monitor::Probe
|
|
13
|
+
def self.type = :shortcut
|
|
14
|
+
|
|
15
|
+
def self.description
|
|
16
|
+
"Detects Shortcut story ID from git branch name (sc-XXXXX pattern)."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.environment_actions
|
|
20
|
+
[{action: :git_branch}]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param environment [Hash] environment data from the agent
|
|
24
|
+
# @return [Hash] { shortcut: { type: "shortcut", story_id: "12345" } } or {}
|
|
25
|
+
def self.detect(environment:)
|
|
26
|
+
config = Monitor.resolve_config({}, environment:)
|
|
27
|
+
return {} unless config[:story_id]
|
|
28
|
+
|
|
29
|
+
{shortcut: config.merge(type: "shortcut")}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|