argus-cloud-optimizer 0.6.0__tar.gz → 0.6.1__tar.gz

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 (134) hide show
  1. {argus_cloud_optimizer-0.6.0/argus_cloud_optimizer.egg-info → argus_cloud_optimizer-0.6.1}/PKG-INFO +1 -1
  2. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1/argus_cloud_optimizer.egg-info}/PKG-INFO +1 -1
  3. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/SOURCES.txt +9 -1
  4. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/top_level.txt +1 -0
  5. argus_cloud_optimizer-0.6.1/core/__version__.py +1 -0
  6. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/remediation/policies.md +5 -0
  7. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/roadmap.md +2 -0
  8. argus_cloud_optimizer-0.6.1/integrations/__init__.py +0 -0
  9. argus_cloud_optimizer-0.6.1/integrations/base.py +33 -0
  10. argus_cloud_optimizer-0.6.1/integrations/jira/__init__.py +0 -0
  11. argus_cloud_optimizer-0.6.1/integrations/jira/client.py +116 -0
  12. argus_cloud_optimizer-0.6.1/integrations/jira/formatter.py +281 -0
  13. argus_cloud_optimizer-0.6.1/integrations/jira/tracker.py +265 -0
  14. argus_cloud_optimizer-0.6.1/integrations/jira/webhook.py +32 -0
  15. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/pyproject.toml +1 -1
  16. argus_cloud_optimizer-0.6.1/tests/test_packaging.py +48 -0
  17. argus_cloud_optimizer-0.6.0/core/__version__.py +0 -1
  18. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/.env.example +0 -0
  19. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/LICENSE +0 -0
  20. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/MANIFEST.in +0 -0
  21. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/README.md +0 -0
  22. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/accounts.yaml.example +0 -0
  23. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/__init__.py +0 -0
  24. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/__init__.py +0 -0
  25. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/adapter.py +0 -0
  26. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/auth.py +0 -0
  27. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/cloudtrail.py +0 -0
  28. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/cloudwatch.py +0 -0
  29. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/config.py +0 -0
  30. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/cost_explorer.py +0 -0
  31. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/resource_explorer.py +0 -0
  32. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/retry.py +0 -0
  33. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/__init__.py +0 -0
  34. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/activity_log.py +0 -0
  35. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/adapter.py +0 -0
  36. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/cost_management.py +0 -0
  37. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/monitor.py +0 -0
  38. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/resource_graph.py +0 -0
  39. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/retry.py +0 -0
  40. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/base.py +0 -0
  41. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/__init__.py +0 -0
  42. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/adapter.py +0 -0
  43. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/asset_inventory.py +0 -0
  44. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/billing.py +0 -0
  45. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/cloud_logging.py +0 -0
  46. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/cloud_monitoring.py +0 -0
  47. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/retry.py +0 -0
  48. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/__init__.py +0 -0
  49. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/anthropic.py +0 -0
  50. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/azure_openai.py +0 -0
  51. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/base.py +0 -0
  52. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/bedrock.py +0 -0
  53. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/vertexai.py +0 -0
  54. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/dependency_links.txt +0 -0
  55. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/entry_points.txt +0 -0
  56. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/requires.txt +0 -0
  57. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/__init__.py +0 -0
  58. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/agent/__init__.py +0 -0
  59. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/agent/chat.py +0 -0
  60. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/agent/loop.py +0 -0
  61. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/agent/prompts.py +0 -0
  62. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/config.py +0 -0
  63. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/log.py +0 -0
  64. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/models/__init__.py +0 -0
  65. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/models/finding.py +0 -0
  66. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/py.typed +0 -0
  67. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/__init__.py +0 -0
  68. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/aws.py +0 -0
  69. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/azure.py +0 -0
  70. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/factory.py +0 -0
  71. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/gcp.py +0 -0
  72. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/models.py +0 -0
  73. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/registry.py +0 -0
  74. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/__init__.py +0 -0
  75. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/audit.py +0 -0
  76. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/engine.py +0 -0
  77. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/loader.py +0 -0
  78. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/models.py +0 -0
  79. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/rightsizing.py +0 -0
  80. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/validator.py +0 -0
  81. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/__init__.py +0 -0
  82. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/comparison.py +0 -0
  83. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/delivery.py +0 -0
  84. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/export.py +0 -0
  85. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/generator.py +0 -0
  86. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/html.py +0 -0
  87. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/multi_cloud.py +0 -0
  88. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/secrets.py +0 -0
  89. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/token_tracker.py +0 -0
  90. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/validation.py +0 -0
  91. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/aws/multi-account/hub/template.yaml +0 -0
  92. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/aws/multi-account/spoke-role.yaml +0 -0
  93. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/aws/single-account/template.yaml +0 -0
  94. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/azure/function-app.bicep +0 -0
  95. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/gcp/deploy.sh +0 -0
  96. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/blog/index.md +0 -0
  97. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/blog/posts/react-vs-rule-based.md +0 -0
  98. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/adapters.md +0 -0
  99. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/ai-providers.md +0 -0
  100. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/ai-reasoning.md +0 -0
  101. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/how-it-works.md +0 -0
  102. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/index.md +0 -0
  103. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/contributing/development.md +0 -0
  104. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/contributing/index.md +0 -0
  105. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/contributing/new-adapter.md +0 -0
  106. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/contributing/new-ai-provider.md +0 -0
  107. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/aws.md +0 -0
  108. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/azure.md +0 -0
  109. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/gcp.md +0 -0
  110. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/index.md +0 -0
  111. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/multi-account.md +0 -0
  112. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/getting-started/configuration.md +0 -0
  113. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/getting-started/first-scan.md +0 -0
  114. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/getting-started/index.md +0 -0
  115. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/getting-started/quickstart.md +0 -0
  116. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/index.md +0 -0
  117. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/env-vars.md +0 -0
  118. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/iam-permissions.md +0 -0
  119. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/index.md +0 -0
  120. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/report-schema.md +0 -0
  121. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/resource-types.md +0 -0
  122. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/security.md +0 -0
  123. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/troubleshooting.md +0 -0
  124. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/remediation/cli.md +0 -0
  125. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/remediation/index.md +0 -0
  126. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/remediation/jira.md +0 -0
  127. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/__init__.py +0 -0
  128. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/_remediation.py +0 -0
  129. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/aws_lambda.py +0 -0
  130. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/azure_function.py +0 -0
  131. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/cli.py +0 -0
  132. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/cli_chat.py +0 -0
  133. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/gcp_cloudrun.py +0 -0
  134. {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argus-cloud-optimizer
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Argus — AI Cloud Detective for AWS, GCP, and Azure
5
5
  Author-email: Vamshi Siddarth Gaddam <vamshisiddarth02@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argus-cloud-optimizer
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Argus — AI Cloud Detective for AWS, GCP, and Azure
5
5
  Author-email: Vamshi Siddarth Gaddam <vamshisiddarth02@gmail.com>
6
6
  License-Expression: MIT
@@ -120,4 +120,12 @@ entrypoints/aws_lambda.py
120
120
  entrypoints/azure_function.py
121
121
  entrypoints/cli.py
122
122
  entrypoints/cli_chat.py
123
- entrypoints/gcp_cloudrun.py
123
+ entrypoints/gcp_cloudrun.py
124
+ integrations/__init__.py
125
+ integrations/base.py
126
+ integrations/jira/__init__.py
127
+ integrations/jira/client.py
128
+ integrations/jira/formatter.py
129
+ integrations/jira/tracker.py
130
+ integrations/jira/webhook.py
131
+ tests/test_packaging.py
@@ -0,0 +1 @@
1
+ __version__ = "0.6.1"
@@ -2,6 +2,11 @@
2
2
 
3
3
  Policies are YAML files that tell Argus which findings deserve a Jira ticket and what action to propose. Argus ships 13 bundled policies; you can add your own in `config/policies/`.
4
4
 
5
+ !!! note "Installed from PyPI?"
6
+ The bundled policies live in the repository's `config/policies/` folder and aren't included in the
7
+ `pip` package. Copy that folder (or `config/policies.example/`) into your project and set
8
+ `ARGUS_POLICY_DIR` to it.
9
+
5
10
  !!! tip "Starter templates"
6
11
  `config/policies.example/` contains three annotated example policies (EC2 stop, RDS resize, GCE stop) with inline comments explaining every field and production-safe exclude defaults. Copy them to `config/policies/` and edit to match your environment.
7
12
 
@@ -55,6 +55,8 @@ Small, known issues in what has shipped. Not scheduled; contributions welcome.
55
55
  - **`BEDROCK_MAX_TOKENS` is ignored.** The Bedrock provider always requests up to 4096 output tokens.
56
56
  - **AWS template doesn't expose `RESOURCE_EXPLORER_REGION`.** Deployed Lambdas assume the aggregator index is in `us-east-1` unless you add the variable by hand.
57
57
  - **GCP deploy script doesn't enable the Vertex AI API** even though Vertex AI is the default provider on Cloud Run.
58
+ - **Bundled policies aren't in the PyPI package.** `pip install` users don't get `config/policies/`; copy it from the repository (or write your own) and point `ARGUS_POLICY_DIR` at it.
59
+ - **Docs deploys can race.** The docs workflow has no `concurrency` group, so merges landing seconds apart start parallel `mike deploy --push` runs to `gh-pages` and all but the first fail. Re-running the latest failed run fixes the site; adding a concurrency group to `.github/workflows/docs.yml` would prevent it.
58
60
 
59
61
  ---
60
62
 
File without changes
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from core.remediation.models import ChangeProposal
6
+
7
+
8
+ class ChangeTracker(ABC):
9
+ """
10
+ Integration-agnostic interface for tracking change proposals.
11
+
12
+ v1 implementation: JiraTracker (creates tickets, deduplicates via labels).
13
+ Future: ServiceNow, Linear, GitHub Issues — same interface, swap the class.
14
+ """
15
+
16
+ @abstractmethod
17
+ def create(self, proposal: ChangeProposal) -> str:
18
+ """
19
+ Record a change proposal and return its URL.
20
+
21
+ Implementations must:
22
+ - Be idempotent: same proposal on a second call returns the existing URL
23
+ - Update the existing record if the analysis has meaningfully changed
24
+ - Never raise on transient errors — log and re-raise as TrackerError
25
+ """
26
+
27
+ @abstractmethod
28
+ def close(self, url: str, reason: str) -> None:
29
+ """Mark a tracked item as resolved (used when a resource is gone)."""
30
+
31
+
32
+ class TrackerError(Exception):
33
+ """Raised when a tracker operation fails after retries."""
@@ -0,0 +1,116 @@
1
+ """
2
+ Thin Jira REST API wrapper — no Jira SDK, plain requests.
3
+
4
+ Covers only the operations Argus needs:
5
+ - search issues by JQL
6
+ - create issue
7
+ - add comment
8
+ - get issue (fetch description for snapshot extraction)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any
15
+
16
+ import requests
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _TIMEOUT = 15 # seconds
21
+
22
+
23
+ class JiraClient:
24
+ def __init__(self, base_url: str, email: str, api_token: str) -> None:
25
+ self._base = base_url.rstrip("/")
26
+ self._auth = (email, api_token)
27
+ self._headers = {
28
+ "Accept": "application/json",
29
+ "Content-Type": "application/json",
30
+ }
31
+
32
+ # ------------------------------------------------------------------
33
+ # Public API
34
+ # ------------------------------------------------------------------
35
+
36
+ def search(self, jql: str, max_results: int = 10) -> list[dict[str, Any]]:
37
+ """Return list of issue dicts matching the JQL query."""
38
+ resp = self._get(
39
+ "/rest/api/3/search",
40
+ params={
41
+ "jql": jql,
42
+ "maxResults": max_results,
43
+ "fields": "summary,status,description",
44
+ },
45
+ )
46
+ issues: list[dict[str, Any]] = resp.get("issues", [])
47
+ return issues
48
+
49
+ def create_issue(self, fields: dict[str, Any]) -> dict[str, Any]:
50
+ """Create an issue and return the response dict (contains key + self URL)."""
51
+ result: dict[str, Any] = self._post(
52
+ "/rest/api/3/issue", json={"fields": fields}
53
+ )
54
+ return result
55
+
56
+ def add_comment(self, issue_key: str, body: str | dict[str, Any]) -> None:
57
+ """Add a comment to an issue. body may be plain text or an ADF dict."""
58
+ adf_body = body if isinstance(body, dict) else _adf_paragraph(body)
59
+ self._post(
60
+ f"/rest/api/3/issue/{issue_key}/comment",
61
+ json={"body": adf_body},
62
+ )
63
+
64
+ def get_issue(self, issue_key: str) -> dict[str, Any]:
65
+ """Fetch a single issue by key."""
66
+ return self._get(
67
+ f"/rest/api/3/issue/{issue_key}",
68
+ params={"fields": "summary,status,description"},
69
+ )
70
+
71
+ def issue_url(self, issue_key: str) -> str:
72
+ return f"{self._base}/browse/{issue_key}"
73
+
74
+ # ------------------------------------------------------------------
75
+ # HTTP helpers
76
+ # ------------------------------------------------------------------
77
+
78
+ def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
79
+ resp = requests.get(
80
+ self._base + path,
81
+ auth=self._auth,
82
+ headers=self._headers,
83
+ params=params,
84
+ timeout=_TIMEOUT,
85
+ )
86
+ resp.raise_for_status()
87
+ result: dict[str, Any] = resp.json()
88
+ return result
89
+
90
+ def _post(self, path: str, json: dict | None = None) -> dict[str, Any]:
91
+ resp = requests.post(
92
+ self._base + path,
93
+ auth=self._auth,
94
+ headers=self._headers,
95
+ json=json,
96
+ timeout=_TIMEOUT,
97
+ )
98
+ resp.raise_for_status()
99
+ if resp.content:
100
+ result: dict[str, Any] = resp.json()
101
+ return result
102
+ return {}
103
+
104
+
105
+ def _adf_paragraph(text: str) -> dict[str, Any]:
106
+ """Wrap plain text in Atlassian Document Format (ADF) for API v3."""
107
+ return {
108
+ "version": 1,
109
+ "type": "doc",
110
+ "content": [
111
+ {
112
+ "type": "paragraph",
113
+ "content": [{"type": "text", "text": text}],
114
+ }
115
+ ],
116
+ }
@@ -0,0 +1,281 @@
1
+ """
2
+ Converts a ChangeProposal into Jira issue fields (ADF format).
3
+
4
+ Description structure:
5
+ h2 Finding
6
+ resource details, cost, action
7
+ h2 Key Metrics
8
+ table of metrics_summary values
9
+ h2 Why Argus Flagged This
10
+ AI waste_reason
11
+ h2 Recommendation
12
+ AI recommendation
13
+ h2 Runbook
14
+ code block with CLI commands
15
+ h2 Policy
16
+ policy_id, weight, source file
17
+ h2 Full Report (if report_url given)
18
+ --- snapshot footer (machine-readable fingerprint for diff-on-update)
19
+
20
+ Snapshot fingerprint stored in description footer enables dedup and
21
+ change detection on subsequent scans without a separate DB.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ from datetime import date
29
+ from typing import Any
30
+
31
+ from core.remediation.models import ChangeProposal
32
+
33
+ _SNAPSHOT_MARKER = "<!-- argus-snapshot:"
34
+ _SNAPSHOT_END = "-->"
35
+
36
+
37
+ def build_issue_fields(
38
+ proposal: ChangeProposal,
39
+ *,
40
+ project: str,
41
+ issue_type: str,
42
+ default_labels: list[str],
43
+ priority_map: dict[str, str],
44
+ dedup_label: str,
45
+ report_url: str | None = None,
46
+ ) -> dict[str, Any]:
47
+ """Return the Jira issue fields dict ready to POST to /rest/api/3/issue."""
48
+ f = proposal.finding
49
+ p = proposal.policy
50
+
51
+ summary = (
52
+ f"[Argus] {_action_verb(p.action)} {f.name or f.resource_id} "
53
+ f"(${proposal.estimated_monthly_cost_usd:.0f}/mo · {f.priority} priority)"
54
+ )
55
+
56
+ priority_name = priority_map.get(f.priority, "Medium")
57
+ labels = list(default_labels) + [
58
+ dedup_label,
59
+ f"argus-priority-{f.priority}",
60
+ f"argus-action-{p.action}",
61
+ ]
62
+
63
+ return {
64
+ "project": {"key": project},
65
+ "summary": summary,
66
+ "description": _build_adf_description(proposal, report_url=report_url),
67
+ "issuetype": {"name": issue_type},
68
+ "priority": {"name": priority_name},
69
+ "labels": labels,
70
+ }
71
+
72
+
73
+ def build_update_comment(
74
+ proposal: ChangeProposal,
75
+ stored: dict[str, Any],
76
+ ) -> dict[str, Any]:
77
+ """ADF comment body describing what changed since the last scan."""
78
+ f = proposal.finding
79
+ current = fingerprint(proposal)
80
+ lines = [f"Argus re-scan update — {date.today()}"]
81
+
82
+ if stored.get("cost") != current["cost"]:
83
+ lines.append(f"Cost: ${stored.get('cost', '?')}/mo → ${current['cost']}/mo")
84
+ if stored.get("priority") != current["priority"]:
85
+ lines.append(f"Priority: {stored.get('priority', '?')} → {current['priority']}")
86
+ if stored.get("reason_hash") != current["reason_hash"]:
87
+ lines.append(f'AI reasoning updated: "{f.waste_reason[:160]}"')
88
+ lines.append(f"Proposal ID: {proposal.proposal_id}")
89
+
90
+ return _adf_paragraph("\n".join(lines))
91
+
92
+
93
+ def fingerprint(proposal: ChangeProposal) -> dict[str, Any]:
94
+ """Compact snapshot of fields that trigger a comment on change.
95
+
96
+ Deliberately excludes proposal_id: it is a fresh UUID every scan, so
97
+ including it made every re-scan look like a change.
98
+ """
99
+ return {
100
+ "cost": round(proposal.estimated_monthly_cost_usd, 0),
101
+ "priority": proposal.finding.priority,
102
+ "reason_hash": hashlib.md5(
103
+ proposal.finding.waste_reason[:120].encode()
104
+ ).hexdigest()[:8],
105
+ }
106
+
107
+
108
+ FINGERPRINT_KEYS = ("cost", "priority", "reason_hash")
109
+
110
+
111
+ def snapshot_changed(stored: dict[str, Any], current: dict[str, Any]) -> bool:
112
+ """Compare only the fingerprint keys (ignores legacy keys such as proposal_id)."""
113
+ return any(stored.get(k) != current.get(k) for k in FINGERPRINT_KEYS)
114
+
115
+
116
+ def extract_snapshot(description_text: str) -> dict[str, Any] | None:
117
+ """
118
+ Parse the argus-snapshot JSON from the issue description footer.
119
+ Returns None if not present (ticket created by an older version).
120
+ """
121
+ start = description_text.find(_SNAPSHOT_MARKER)
122
+ if start == -1:
123
+ return None
124
+ end = description_text.find(_SNAPSHOT_END, start)
125
+ if end == -1:
126
+ return None
127
+ raw = description_text[start + len(_SNAPSHOT_MARKER) : end].strip()
128
+ try:
129
+ result: dict[str, Any] = json.loads(raw)
130
+ return result
131
+ except (json.JSONDecodeError, ValueError):
132
+ return None
133
+
134
+
135
+ # ------------------------------------------------------------------
136
+ # ADF builder
137
+ # ------------------------------------------------------------------
138
+
139
+
140
+ def _build_adf_description(
141
+ proposal: ChangeProposal,
142
+ report_url: str | None,
143
+ ) -> dict[str, Any]:
144
+ f = proposal.finding
145
+ p = proposal.policy
146
+ snap = json.dumps(fingerprint(proposal))
147
+
148
+ content: list[dict[str, Any]] = []
149
+
150
+ # h2 Finding
151
+ content.append(_adf_heading("Finding", level=2))
152
+ content.append(
153
+ _adf_paragraph(
154
+ f"Resource: {f.name or f.resource_id}\n"
155
+ f"Type: {f.resource_type}\n"
156
+ f"Cloud: {f.cloud.upper()}\n"
157
+ f"Region: {f.region}\n"
158
+ f"Cost: ${proposal.estimated_monthly_cost_usd:.2f}/mo\n"
159
+ f"Priority: {f.priority.upper()}\n"
160
+ f"Action: {p.action}\n"
161
+ f"Proposal: {proposal.proposal_id}"
162
+ )
163
+ )
164
+
165
+ # h2 Key Metrics (only if present)
166
+ if f.metrics_summary:
167
+ content.append(_adf_heading("Key Metrics", level=2))
168
+ rows = [
169
+ [
170
+ _adf_text(k),
171
+ _adf_text(str(round(v, 4)) if isinstance(v, float) else str(v)),
172
+ ]
173
+ for k, v in sorted(f.metrics_summary.items())
174
+ ]
175
+ content.append(_adf_table(["Metric", "Value"], rows))
176
+
177
+ # h2 Why Argus Flagged This
178
+ content.append(_adf_heading("Why Argus Flagged This", level=2))
179
+ content.append(_adf_paragraph(f.waste_reason))
180
+
181
+ # h2 Recommendation
182
+ content.append(_adf_heading("Recommendation", level=2))
183
+ content.append(_adf_paragraph(f.recommendation))
184
+ if proposal.resize_recommendation:
185
+ content.append(_adf_paragraph(f"Rightsizing: {proposal.resize_recommendation}"))
186
+
187
+ # h2 Runbook
188
+ content.append(_adf_heading("Runbook", level=2))
189
+ content.append(
190
+ _adf_paragraph(
191
+ "⚠ Human approval required. Argus does not execute these commands."
192
+ )
193
+ )
194
+ content.append(_adf_code_block(proposal.runbook))
195
+
196
+ # h2 Policy
197
+ content.append(_adf_heading("Policy", level=2))
198
+ content.append(
199
+ _adf_paragraph(
200
+ f"Policy ID: {p.policy_id}\n"
201
+ f"Weight: {p.weight}\n"
202
+ f"Source: {p.source_file}"
203
+ )
204
+ )
205
+
206
+ # h2 Full Report
207
+ if report_url:
208
+ content.append(_adf_heading("Full Report", level=2))
209
+ content.append(_adf_paragraph(report_url))
210
+
211
+ # Machine-readable snapshot footer
212
+ content.append(_adf_paragraph(f"{_SNAPSHOT_MARKER} {snap} {_SNAPSHOT_END}"))
213
+
214
+ return {"version": 1, "type": "doc", "content": content}
215
+
216
+
217
+ # ------------------------------------------------------------------
218
+ # ADF node helpers
219
+ # ------------------------------------------------------------------
220
+
221
+
222
+ def _adf_heading(text: str, level: int) -> dict[str, Any]:
223
+ return {
224
+ "type": "heading",
225
+ "attrs": {"level": level},
226
+ "content": [{"type": "text", "text": text}],
227
+ }
228
+
229
+
230
+ def _adf_paragraph(text: str) -> dict[str, Any]:
231
+ return {
232
+ "type": "paragraph",
233
+ "content": [{"type": "text", "text": text}],
234
+ }
235
+
236
+
237
+ def _adf_code_block(text: str) -> dict[str, Any]:
238
+ return {
239
+ "type": "codeBlock",
240
+ "attrs": {"language": "bash"},
241
+ "content": [{"type": "text", "text": text}],
242
+ }
243
+
244
+
245
+ def _adf_text(text: str) -> dict[str, Any]:
246
+ return {"type": "tableCell", "content": [_adf_paragraph(text)]}
247
+
248
+
249
+ def _adf_table(
250
+ headers: list[str],
251
+ rows: list[list[dict[str, Any]]],
252
+ ) -> dict[str, Any]:
253
+ header_row = {
254
+ "type": "tableRow",
255
+ "content": [
256
+ {
257
+ "type": "tableHeader",
258
+ "content": [_adf_paragraph(h)],
259
+ }
260
+ for h in headers
261
+ ],
262
+ }
263
+ body_rows = [{"type": "tableRow", "content": row} for row in rows]
264
+ return {
265
+ "type": "table",
266
+ "attrs": {"isNumberColumnEnabled": False, "layout": "default"},
267
+ "content": [header_row, *body_rows],
268
+ }
269
+
270
+
271
+ def _action_verb(action: str) -> str:
272
+ return {
273
+ "delete": "Delete",
274
+ "resize": "Resize",
275
+ "stop": "Stop",
276
+ "snapshot_delete": "Snapshot & delete",
277
+ "reduce_replicas": "Reduce replicas for",
278
+ "reduce_nodes": "Reduce nodes for",
279
+ "archive": "Archive",
280
+ "convert_spot": "Convert to Spot",
281
+ }.get(action, action.capitalize())