Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 170 additions & 109 deletions tools/feedback/discord-feedback
Original file line number Diff line number Diff line change
Expand Up @@ -3213,6 +3213,145 @@ def latest_run_id(artifact_root_dir: pathlib.Path) -> Optional[str]:
return runs[-1].name


def scrape_feedback_sources(
repo,
artifact_dir,
token,
token_env,
guild_id,
channel_ids,
include_threads,
include_authors,
max_messages,
hours,
pr_feedback,
pr_feedback_hours,
directions,
watch_pr,
watch_pr_interval,
max_watch_fix_attempts,
):
"""Scrape Discord + recent maintainer PR feedback into the run's artifact dir.

Returns (transcript_path, jsonl_path, pr_feedback_path). Only the analysis agent
consumes these, so a resume whose analysis is already checkpointed skips this entirely
(see the caller) -- no Discord token, no minutes-long re-scrape on every resume/retry.
"""
env_channel_ids = os.environ.get("PWN_FEEDBACK_DISCORD_CHANNEL_IDS")
selected_channel_ids = parse_channel_ids(channel_ids, env_channel_ids)
until = utc_now()
since = resolve_scrape_since(repo, until, hours, DEFAULT_FALLBACK_HOURS)

if not token:
raise click.ClickException(
f"{token_env} is not set. Create a Discord bot token and export it first."
)
api = DiscordAPI(token)
click.echo(f"Scraping Discord messages since {since.isoformat()} into {artifact_dir}")
channels, channels_by_id = discover_channels(
api, guild_id, selected_channel_ids, since, include_threads
)
click.echo(f"Discovered {len(channels)} readable channel(s)/thread(s)")
messages_by_channel: list[tuple[dict[str, Any], list[dict[str, Any]]]] = []
remaining = max_messages
for channel in channels:
if remaining <= 0:
break
messages = fetch_messages_since(api, channel, since, remaining)
remaining -= len(messages)
if messages:
messages_by_channel.append((channel, messages))
click.echo(
"Fetched "
f"{len(messages)} message(s) from "
f"{channel_display_name(channel, channels_by_id)}"
)
logger.info(
"fetched %d message(s) from %s",
len(messages),
channel_display_name(channel, channels_by_id),
)

messages = sanitize_messages(
messages_by_channel,
channels_by_id,
guild_id,
include_authors,
attachment_dir=artifact_dir / "attachments",
)
image_count = sum(
1
for message in messages
for attachment in message["attachments"]
if attachment.get("path")
)
if image_count:
click.echo(
f"Downloaded {image_count} image attachment(s) into "
f"{artifact_dir / 'attachments'}"
)
jsonl_path, transcript_path = write_transcript(artifact_dir, messages, since, until)

pr_feedback_path: Optional[pathlib.Path] = None
pr_feedback_prs = 0
pr_feedback_events = 0
if pr_feedback:
pr_since = until - datetime.timedelta(hours=pr_feedback_hours)
try:
api = GitHubAPI.from_environment(repo)
owner, repo_name = github_repo_slug(repo)
recent_pulls = fetch_recent_pull_requests(
api, owner, repo_name, pr_since.isoformat()
)
maintainer_feedback = collect_maintainer_pr_feedback(
api, owner, repo_name, recent_pulls, {}
)
pr_feedback_path = write_pr_feedback(
artifact_dir, maintainer_feedback, pr_since, until
)
pr_feedback_prs = len(maintainer_feedback)
pr_feedback_events = sum(
len(pull["events"]) for pull in maintainer_feedback
)
click.echo(
"Collected maintainer PR feedback: "
f"{pr_feedback_events} comment(s)/review(s) across "
f"{pr_feedback_prs} recent PR(s)"
)
except (click.ClickException, GitHubAPIError, OSError) as error:
click.echo(f"Skipping PR feedback collection: {error}")
pr_feedback_path = None

(artifact_dir / "run.json").write_text(
json.dumps(
{
"guild_id": guild_id,
"hours": hours,
"since": since.isoformat(),
"until": until.isoformat(),
"messages": len(messages),
"image_attachments": image_count,
"channels": len(channels),
"include_threads": include_threads,
"include_authors": include_authors,
"directions": directions,
"pr_feedback": pr_feedback,
"pr_feedback_hours": pr_feedback_hours,
"pr_feedback_prs": pr_feedback_prs,
"pr_feedback_events": pr_feedback_events,
"watch_pr": watch_pr,
"watch_pr_interval": watch_pr_interval,
"max_watch_fix_attempts": max_watch_fix_attempts,
},
indent=2,
sort_keys=True,
)
+ "\n"
)
click.echo(f"Wrote {len(messages)} sanitized message(s) to {transcript_path}")
return transcript_path, jsonl_path, pr_feedback_path


@click.command("discord-feedback")
@click.option(
"--guild-id",
Expand Down Expand Up @@ -3480,118 +3619,40 @@ def feedback_command(
"falling through to the normal flow."
)

env_channel_ids = os.environ.get("PWN_FEEDBACK_DISCORD_CHANNEL_IDS")
selected_channel_ids = parse_channel_ids(channel_ids, env_channel_ids)
until = utc_now()
since = resolve_scrape_since(repo, until, hours, DEFAULT_FALLBACK_HOURS)

if not token:
raise click.ClickException(
f"{token_env} is not set. Create a Discord bot token and export it first."
# The scrape only feeds the analysis agent. On resume with analysis already
# checkpointed, skip it entirely -- no Discord token, no minutes-long re-scrape on every
# resume/retry, no misleading "scraping from PR #N" line -- and reuse the PR feedback
# already recorded for this run.
if phase_done("analysis"):
click.echo("Resume: analysis already complete; skipping Discord scrape.")
recorded_pr_feedback = artifact_dir / "recent-pr-feedback.md"
pr_feedback_path = (
recorded_pr_feedback
if pr_feedback and recorded_pr_feedback.exists()
else None
)
api = DiscordAPI(token)
click.echo(f"Scraping Discord messages since {since.isoformat()} into {artifact_dir}")
channels, channels_by_id = discover_channels(
api, guild_id, selected_channel_ids, since, include_threads
)
click.echo(f"Discovered {len(channels)} readable channel(s)/thread(s)")
messages_by_channel: list[tuple[dict[str, Any], list[dict[str, Any]]]] = []
remaining = max_messages
for channel in channels:
if remaining <= 0:
break
messages = fetch_messages_since(api, channel, since, remaining)
remaining -= len(messages)
if messages:
messages_by_channel.append((channel, messages))
click.echo(
"Fetched "
f"{len(messages)} message(s) from "
f"{channel_display_name(channel, channels_by_id)}"
)
logger.info(
"fetched %d message(s) from %s",
len(messages),
channel_display_name(channel, channels_by_id),
)

messages = sanitize_messages(
messages_by_channel,
channels_by_id,
guild_id,
include_authors,
attachment_dir=artifact_dir / "attachments",
)
image_count = sum(
1
for message in messages
for attachment in message["attachments"]
if attachment.get("path")
)
if image_count:
click.echo(
f"Downloaded {image_count} image attachment(s) into "
f"{artifact_dir / 'attachments'}"
transcript_path = artifact_dir / "transcript.md"
jsonl_path = artifact_dir / "messages.jsonl"
else:
transcript_path, jsonl_path, pr_feedback_path = scrape_feedback_sources(
repo,
artifact_dir,
token,
token_env,
guild_id,
channel_ids,
include_threads,
include_authors,
max_messages,
hours,
pr_feedback,
pr_feedback_hours,
directions,
watch_pr,
watch_pr_interval,
max_watch_fix_attempts,
)
jsonl_path, transcript_path = write_transcript(artifact_dir, messages, since, until)

pr_feedback_path: Optional[pathlib.Path] = None
pr_feedback_prs = 0
pr_feedback_events = 0
if pr_feedback:
pr_since = until - datetime.timedelta(hours=pr_feedback_hours)
try:
api = GitHubAPI.from_environment(repo)
owner, repo_name = github_repo_slug(repo)
recent_pulls = fetch_recent_pull_requests(
api, owner, repo_name, pr_since.isoformat()
)
maintainer_feedback = collect_maintainer_pr_feedback(
api, owner, repo_name, recent_pulls, {}
)
pr_feedback_path = write_pr_feedback(
artifact_dir, maintainer_feedback, pr_since, until
)
pr_feedback_prs = len(maintainer_feedback)
pr_feedback_events = sum(
len(pull["events"]) for pull in maintainer_feedback
)
click.echo(
"Collected maintainer PR feedback: "
f"{pr_feedback_events} comment(s)/review(s) across "
f"{pr_feedback_prs} recent PR(s)"
)
except (click.ClickException, GitHubAPIError, OSError) as error:
click.echo(f"Skipping PR feedback collection: {error}")
pr_feedback_path = None

(artifact_dir / "run.json").write_text(
json.dumps(
{
"guild_id": guild_id,
"hours": hours,
"since": since.isoformat(),
"until": until.isoformat(),
"messages": len(messages),
"image_attachments": image_count,
"channels": len(channels),
"include_threads": include_threads,
"include_authors": include_authors,
"directions": directions,
"pr_feedback": pr_feedback,
"pr_feedback_hours": pr_feedback_hours,
"pr_feedback_prs": pr_feedback_prs,
"pr_feedback_events": pr_feedback_events,
"watch_pr": watch_pr,
"watch_pr_interval": watch_pr_interval,
"max_watch_fix_attempts": max_watch_fix_attempts,
},
indent=2,
sort_keys=True,
)
+ "\n"
)
click.echo(f"Wrote {len(messages)} sanitized message(s) to {transcript_path}")
if fetch_only:
return

Expand Down
102 changes: 102 additions & 0 deletions tools/feedback/run-feedback
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
#
# Supervisor for the discord-feedback bot.
#
# Two failure modes have repeatedly left the bot's screen window dead at a shell prompt:
#
# 1. It was launched from a plain shell (not `nix develop`), so DOCKER_HOST was unset and
# pwnshop fell through to the host docker -- whose docker0 bridge is broken -- making
# every challenge "fail" to build with "adding interface veth... to bridge docker0
# failed: Device does not exist". Nothing is actually wrong with the challenges.
# 2. A transient hiccup (docker, a GitHub blip, an OOM) killed the bot mid-run, and it
# stayed dead until someone noticed and hand-typed the resume command.
#
# This wrapper makes both impossible:
#
# * It runs the bot inside ONE persistent `nix develop` session. The dev shell's shellHook
# starts the project-local dockerd and exports DOCKER_HOST at it exactly once, and it
# stays up for the whole run -- so the bot always has a correct, stable docker and can
# never fall through to the broken host daemon. (It re-execs itself into `nix develop`
# if it isn't already inside one.)
# * It runs the bot in a loop: on any nonzero exit it waits (capped backoff) and resumes
# from the last checkpoint with --resume-latest. A transient failure self-heals on the
# next iteration instead of leaving the window dead. It stops only on a clean exit
# (the PR merged / work done) or Ctrl-C.
#
# Usage:
# tools/feedback/run-feedback [bot args...]
#
# The bot args are passed verbatim to the FIRST iteration (use --resume <id> to pick up a
# specific run, or pass fresh-run flags to start one). Every retry afterwards forces
# --resume-latest so it continues whatever run the first iteration advanced. With no args it
# defaults to the standard apply/create-pr/watch invocation and resumes the latest run.

set -uo pipefail

REPO="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$REPO"

# Re-exec inside a single persistent dev shell if we aren't already in one. `nix develop
# --command` runs the shellHook first (starting dockerd + exporting DOCKER_HOST), then this
# same script with IN_NIX_SHELL set, so the branch below runs the supervised loop with a
# stable docker for its entire lifetime.
if [ -z "${IN_NIX_SHELL:-}" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the project Docker env before skipping nix develop

If this wrapper is launched from another Nix shell, or from a shell where DOCKER_HOST was unset, IN_NIX_SHELL is still nonempty and this check skips the repository nix develop; the bot then runs with DOCKER_HOST=<unset> and pwnshop can fall back to the host Docker daemon, which is the failure mode this supervisor is meant to prevent. Gate on the repo dev-shell Docker environment (for example DOCKER_HOST/PWN_WORKSPACE) rather than IN_NIX_SHELL alone.

Useful? React with 👍 / 👎.

echo "run-feedback: entering nix develop (starts project dockerd, sets DOCKER_HOST)..."
exec nix develop --command "$0" "$@"
fi

BOT="${FEEDBACK_BOT:-$REPO/.discord-feedback/discord-feedback}"
if [ ! -x "$BOT" ]; then
BOT="$REPO/tools/feedback/discord-feedback"
fi

# Default first-iteration args if the caller passed none.
DEFAULT_ARGS=(--apply --create-pr --resume-latest)
if [ "$#" -gt 0 ]; then
first_args=("$@")
else
first_args=("${DEFAULT_ARGS[@]}")
fi

# Retry args: strip any caller-supplied --resume <id> / --resume-latest, then force
# --resume-latest so every retry continues the run the first iteration advanced.
retry_args=()
skip_next=0
for arg in "${first_args[@]}"; do
if [ "$skip_next" = 1 ]; then skip_next=0; continue; fi
case "$arg" in
--resume) skip_next=1; continue ;; # drop "--resume <id>"
--resume=*) continue ;;
--resume-latest) continue ;;
*) retry_args+=("$arg") ;;
esac
done
retry_args+=(--resume-latest)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the selected run id on retries

When the first iteration was started with --resume <old-id> (or a fresh run dies before it writes any checkpoint), forcing the retry to --resume-latest can attach the next loop to a different artifact directory. The bot's latest_run_id() prefers the newest run with progress (tools/feedback/discord-feedback:3208-3212), so a retry can start watching/fixing an unrelated PR instead of the run that just failed; keep the explicit run id once known rather than converting every retry to latest.

Useful? React with 👍 / 👎.


echo "run-feedback: DOCKER_HOST=${DOCKER_HOST:-<unset>}"
echo "run-feedback: bot=$BOT"

attempt=0
args=("${first_args[@]}")
while true; do
attempt=$((attempt + 1))
echo "==================== run-feedback iteration $attempt ===================="
echo "run-feedback: $BOT ${args[*]}"
"$BOT" "${args[@]}"
rc=$?
if [ "$rc" -eq 0 ]; then
echo "run-feedback: bot exited cleanly (work done); stopping."
break
fi
if [ "$rc" -eq 130 ]; then
echo "run-feedback: interrupted (Ctrl-C); stopping."
break
fi
# Capped linear backoff: 30s, 60s, ... up to 300s. A transient docker/API failure clears
# within this window; a persistent one keeps retrying (loudly) rather than dying.
delay=$((attempt * 30))
[ "$delay" -gt 300 ] && delay=300
echo "run-feedback: bot exited $rc; resuming from checkpoint in ${delay}s (next: --resume-latest)."
sleep "$delay"
args=("${retry_args[@]}")
done