Skip to content
Merged

0.9.9 #419

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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
202 changes: 125 additions & 77 deletions .github/workflows/scripts/tweet_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
import requests
import tweepy

REQUEST_TIMEOUT_SECONDS = 30


class ReleaseTweetError(RuntimeError):
"""Stage-specific failure surfaced by the release tweet workflow."""


# ── Extract section headers from release markdown ────────────────────────────

Expand All @@ -37,17 +43,16 @@ def extract_headers(body: str) -> list[str]:

def generate_tweet_and_prompt(tag: str, headers: list[str], url: str) -> dict:
"""Ask Claude to produce a tweet and an image-gen prompt."""
client = anthropic.Anthropic()

headers_text = "\n".join(f"- {h}" for h in headers)

msg = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[
{
"role": "user",
"content": f"""You're writing a tweet and an image prompt for a software release announcement.
try:
client = anthropic.Anthropic()
msg = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[
{
"role": "user",
"content": f"""You're writing a tweet and an image prompt for a software release announcement.

Project: desloppify — a CLI tool that tracks codebase health and technical debt.
Release: {tag}
Expand All @@ -74,15 +79,26 @@ def generate_tweet_and_prompt(tag: str, headers: list[str], url: str) -> dict:
The overall vibe should be "someone explaining the release on a whiteboard with too much enthusiasm".

Return ONLY valid JSON, no markdown fences.""",
}
],
)

text = msg.content[0].text
}
],
)
except Exception as exc:
raise ReleaseTweetError(f"Anthropic request failed: {exc}") from exc

try:
text = msg.content[0].text
except (AttributeError, IndexError, KeyError, TypeError) as exc:
raise ReleaseTweetError("Anthropic response did not include text content") from exc
# Strip markdown fences if Claude adds them anyway
text = re.sub(r"^```json\s*", "", text.strip())
text = re.sub(r"\s*```$", "", text.strip())
return json.loads(text)
try:
result = json.loads(text)
except (TypeError, ValueError) as exc:
raise ReleaseTweetError("Anthropic returned invalid JSON payload") from exc
if not isinstance(result, dict):
raise ReleaseTweetError("Anthropic returned a non-object JSON payload")
return result


# ── Generate image via fal.ai Nano Banana 2 ──────────────────────────────────
Expand All @@ -92,37 +108,56 @@ def generate_tweet_and_prompt(tag: str, headers: list[str], url: str) -> dict:

def generate_image(prompt: str, api_key: str) -> str:
"""Call fal.ai and return the image URL."""
resp = requests.post(
FAL_ENDPOINT,
headers={
"Authorization": f"Key {api_key}",
"Content-Type": "application/json",
},
json={
"prompt": prompt,
"num_images": 1,
"aspect_ratio": "1:1",
"resolution": "1K",
"output_format": "png",
},
)
try:
resp = requests.post(
FAL_ENDPOINT,
headers={
"Authorization": f"Key {api_key}",
"Content-Type": "application/json",
},
json={
"prompt": prompt,
"num_images": 1,
"aspect_ratio": "1:1",
"resolution": "1K",
"output_format": "png",
},
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise ReleaseTweetError(f"fal.ai request failed: {exc}") from exc
if not resp.ok:
raise RuntimeError(f"fal.ai error {resp.status_code}: {resp.text}")
raise ReleaseTweetError(f"fal.ai error {resp.status_code}: {resp.text}")

images = resp.json().get("images", [])
try:
images = resp.json().get("images", [])
except (ValueError, AttributeError) as exc:
raise ReleaseTweetError("fal.ai returned invalid JSON payload") from exc
if not images:
raise RuntimeError("fal.ai returned no images")
return images[0]["url"]
raise ReleaseTweetError("fal.ai returned no images")
image_url = images[0].get("url") if isinstance(images[0], dict) else None
if not isinstance(image_url, str) or not image_url:
raise ReleaseTweetError("fal.ai returned an image without a URL")
return image_url


def download_image(url: str) -> str:
"""Download image to a temp file and return the path."""
resp = requests.get(url, stream=True)
resp.raise_for_status()
try:
resp = requests.get(url, stream=True, timeout=REQUEST_TIMEOUT_SECONDS)
resp.raise_for_status()
except requests.RequestException as exc:
raise ReleaseTweetError(f"image download failed: {exc}") from exc
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
for chunk in resp.iter_content(8192):
tmp.write(chunk)
tmp.close()
try:
for chunk in resp.iter_content(8192):
if chunk:
tmp.write(chunk)
except requests.RequestException as exc:
os.unlink(tmp.name)
raise ReleaseTweetError(f"image download failed while streaming: {exc}") from exc
finally:
tmp.close()
return tmp.name


Expand All @@ -140,7 +175,10 @@ def post_tweet_with_reply(tweet_text: str, image_path: str, reply_text: str):
api_v1 = tweepy.API(auth)

# Upload image
media = api_v1.media_upload(image_path)
try:
media = api_v1.media_upload(image_path)
except Exception as exc:
raise ReleaseTweetError(f"Twitter media upload failed: {exc}") from exc
print(f"Uploaded media: {media.media_id}")

# v2 client for tweeting
Expand All @@ -159,12 +197,14 @@ def post_tweet_with_reply(tweet_text: str, image_path: str, reply_text: str):
media_ids=[media.media_id],
)
break
except tweepy.errors.TwitterServerError:
except tweepy.errors.TwitterServerError as exc:
if attempt < 2:
print(f" Twitter 5xx error, retrying in {5 * (attempt + 1)}s...")
time.sleep(5 * (attempt + 1))
else:
raise
raise ReleaseTweetError("Twitter create_tweet failed after retries") from exc
except Exception as exc:
raise ReleaseTweetError(f"Twitter create_tweet failed: {exc}") from exc

tweet_id = response.data["id"]
print(f"Posted tweet: https://twitter.com/i/web/status/{tweet_id}")
Expand All @@ -177,12 +217,14 @@ def post_tweet_with_reply(tweet_text: str, image_path: str, reply_text: str):
in_reply_to_tweet_id=tweet_id,
)
break
except tweepy.errors.TwitterServerError:
except tweepy.errors.TwitterServerError as exc:
if attempt < 2:
print(f" Twitter 5xx error, retrying in {5 * (attempt + 1)}s...")
time.sleep(5 * (attempt + 1))
else:
raise
raise ReleaseTweetError("Twitter reply failed after retries") from exc
except Exception as exc:
raise ReleaseTweetError(f"Twitter reply failed: {exc}") from exc

reply_id = reply.data["id"]
print(f"Posted reply: https://twitter.com/i/web/status/{reply_id}")
Expand All @@ -203,40 +245,46 @@ def main():
print(f"Release: {tag}")
print(f"Headers: {headers}")

# Step 1: Generate tweet text + image prompt via Claude
print("\nGenerating tweet and image prompt...")
result = generate_tweet_and_prompt(tag, headers, url)
tweet_text = result["tweet"]
image_prompt = result["image_prompt"]

print(f"\nTweet: {tweet_text}")
print(f"\nImage prompt: {image_prompt[:200]}...")

# Step 2: Generate image via fal.ai
print("\nGenerating image...")
fal_key = os.environ["FAL_KEY"]
image_url = generate_image(image_prompt, fal_key)
print(f"Image URL: {image_url}")

image_path = download_image(image_url)
print(f"Downloaded to: {image_path}")

# Step 3: Post main tweet with image, reply with release link
if len(tweet_text) > 280:
lines = tweet_text.strip().splitlines()
while len(chr(10).join(lines)) > 280 and len(lines) > 1:
lines.pop()
tweet_text = chr(10).join(lines)

reply_text = f"Release notes: {url}"

print(f"\nPosting tweet ({len(tweet_text)} chars):")
print(tweet_text)
print(f"\nReply: {reply_text}")
post_tweet_with_reply(tweet_text, image_path, reply_text)
image_path: str | None = None
try:
# Step 1: Generate tweet text + image prompt via Claude
print("\nGenerating tweet and image prompt...")
result = generate_tweet_and_prompt(tag, headers, url)
tweet_text = result["tweet"]
image_prompt = result["image_prompt"]

print(f"\nTweet: {tweet_text}")
print(f"\nImage prompt: {image_prompt[:200]}...")

# Step 2: Generate image via fal.ai
print("\nGenerating image...")
fal_key = os.environ["FAL_KEY"]
image_url = generate_image(image_prompt, fal_key)
print(f"Image URL: {image_url}")

image_path = download_image(image_url)
print(f"Downloaded to: {image_path}")

# Step 3: Post main tweet with image, reply with release link
if len(tweet_text) > 280:
lines = tweet_text.strip().splitlines()
while len(chr(10).join(lines)) > 280 and len(lines) > 1:
lines.pop()
tweet_text = chr(10).join(lines)

reply_text = f"Release notes: {url}"

print(f"\nPosting tweet ({len(tweet_text)} chars):")
print(tweet_text)
print(f"\nReply: {reply_text}")
post_tweet_with_reply(tweet_text, image_path, reply_text)
except ReleaseTweetError as exc:
print(f"Release tweet failed: {exc}", file=sys.stderr)
sys.exit(1)
finally:
if image_path and os.path.exists(image_path):
os.unlink(image_path)

# Cleanup
os.unlink(image_path)
print("\nDone!")


Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: >
about code quality, technical debt, dead code, large files, god classes,
duplicate functions, code smells, naming issues, import cycles, or coupling
problems. Also use when asked for a health score, what to fix next, or to
create a cleanup plan. Supports 28 languages.
create a cleanup plan. Supports 29 languages.
allowed-tools: Bash(desloppify *)
---

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ arch: install-ci-tools

ci-contracts: install-ci-tools
pytest -q desloppify/tests/ci/test_ci_contracts.py
pytest -q desloppify/tests/commands/test_lifecycle_transitions.py -k "subjective_then_score_then_triage"
pytest -q desloppify/tests/commands/test_lifecycle_transitions.py -k "assessment_then_score_when_no_review_followup"

integration-roslyn: install-ci-tools
pytest -q desloppify/tests/lang/csharp/test_csharp_deps.py -k "roslyn"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ That score generates a scorecard badge for your GitHub profile or README:

<img src="assets/scorecard.png" width="100%">

Currently supports 28 languages — full plugin depth for TypeScript, Python, C#, C++, Dart, GDScript, Go, and Rust; generic linter + tree-sitter support for Ruby, Java, Kotlin, and 16 more. For C++ projects, `compile_commands.json` is the primary analysis path and `Makefile` repositories fall back to best-effort local include scanning.
Currently supports 29 languages — full plugin depth for TypeScript, Python, C#, C++, Dart, GDScript, Go, and Rust; generic linter + tree-sitter support for Ruby, Java, Kotlin, and 18 more. For C++ projects, `compile_commands.json` is the primary analysis path and `Makefile` repositories fall back to best-effort local include scanning.

## For your agent's consideration...

Expand All @@ -25,6 +25,8 @@ Run ALL of the following (requires Python 3.11+):
pip install --upgrade "desloppify[full]"
desloppify update-skill claude # installs the full workflow guide — pick yours: claude, cursor, codex, copilot, windsurf, gemini

Add .desloppify/ to your .gitignore — it contains local state that shouldn't be committed.

Before scanning, check for directories that should be excluded (vendor, build output,
generated code, worktrees, etc.) and exclude obvious ones with `desloppify exclude <path>`.
Share any questionable candidates with me before excluding.
Expand Down
Binary file modified assets/scorecard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions desloppify/app/cli_support/parser_groups_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import logging

from desloppify.languages.framework import get_lang
from desloppify.app.commands.helpers.lang import load_lang_config
from .parser_groups_admin_review import _add_review_parser # noqa: F401 (re-export)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -94,7 +94,7 @@ def _fixer_help_lines(langs: list[str]) -> list[str]:
fixer_help_lines: list[str] = []
for lang_name in langs:
try:
fixer_names = sorted(get_lang(lang_name).fixers.keys())
fixer_names = sorted(load_lang_config(lang_name).fixers.keys())
except (ImportError, ValueError, TypeError, AttributeError) as exc:
logger.debug("Failed to load fixer metadata for %s: %s", lang_name, exc)
fixer_help_lines.append(
Expand Down
3 changes: 3 additions & 0 deletions desloppify/app/cli_support/parser_groups_plan_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
)
from .parser_groups_plan_impl_sections_cluster import _add_cluster_subparser
from .parser_groups_plan_impl_sections_queue_reorder import (
_add_promote_subparser,
_add_queue_subparser,
_add_reorder_subparser,
)
Expand Down Expand Up @@ -50,6 +51,7 @@ def add_plan_parser(sub) -> None:
show Show plan metadata summary
queue Compact table of execution queue items
reset Reset plan to empty
promote Promote backlog issues or clusters into the queue
reorder Reposition issues or clusters in the queue
resolve Mark issues as fixed (score movement + next-step)
describe Set augmented description
Expand Down Expand Up @@ -81,6 +83,7 @@ def add_plan_parser(sub) -> None:
# plan reset
plan_sub.add_parser("reset", help="Reset plan to empty")

_add_promote_subparser(plan_sub)
_add_reorder_subparser(plan_sub)
_add_annotation_subparsers(plan_sub)
_add_skip_subparsers(plan_sub)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ def _add_queue_subparser(plan_sub) -> None:
help="Sort order (default: priority)")


def _add_promote_subparser(plan_sub) -> None:
p_promote = plan_sub.add_parser(
"promote",
help="Promote backlog issues or clusters into the queue",
epilog="""\
patterns accept issue IDs, detector names, file paths, globs, or cluster names.
cluster names expand to all member IDs automatically.

examples:
desloppify plan promote security top
desloppify plan promote auto/test_coverage bottom
desloppify plan promote my-cluster before -t workflow::run-scan""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p_promote.add_argument(
"patterns", nargs="+", metavar="PATTERN",
help="Issue ID(s), detector, file path, glob, or cluster name",
)
p_promote.add_argument(
"position", nargs="?", choices=["top", "bottom", "before", "after"], default="bottom",
help="Where to insert in the active queue (default: bottom)",
)
p_promote.add_argument(
"-t", "--target", default=None,
help="Required for before/after (issue ID or cluster name)",
)


def _add_reorder_subparser(plan_sub) -> None:
p_move = plan_sub.add_parser(
"reorder",
Expand Down Expand Up @@ -48,5 +76,4 @@ def _add_reorder_subparser(plan_sub) -> None:
help="Required for before/after (issue ID or cluster name) and up/down (integer offset)",
)


__all__ = ["_add_queue_subparser", "_add_reorder_subparser"]
__all__ = ["_add_promote_subparser", "_add_queue_subparser", "_add_reorder_subparser"]
Loading
Loading