Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 83e1446

Browse files
bobmatnycclaude
andcommitted
fix: wire ticketing reports into gfa analyze, fix Confluence 401, quiet constraint noise (#32 #33 #34)
#32 — generate_all_reports() in analyze_pipeline_reports.py now calls TicketingActivityReport.write_reports() so github_issues_summary.json, confluence_activity_summary.json, and ticketing_activity_summary.json are produced by gfa analyze without a separate gfa report run. #33 — _resolve_env_var in loader_sections.py now warns on unset/empty env vars (${VAR} syntax) and ConfluenceIntegration.verify_credentials() runs a pre-flight GET on init; 401/403 disables the integration with a clear warning rather than silently emitting empty reports. #34 — metrics_storage.py UNIQUE constraint violations now log at DEBUG instead of ERROR; other unexpected DB errors downgraded to WARNING. 4 new tests confirm correct log levels. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 9f2f880 commit 83e1446

7 files changed

Lines changed: 548 additions & 31 deletions

File tree

src/gitflow_analytics/config/loader_sections.py

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import logging
99
import os
10+
import re
1011
from pathlib import Path
1112
from typing import Any, Optional
1213

@@ -34,6 +35,14 @@
3435
)
3536
from .validator import ConfigValidator
3637

38+
# Regex for embedded env-var references in config strings.
39+
# Matches both ``${VAR}`` and ``$VAR`` (latter only for ASCII identifiers).
40+
# WHY: Some users write ``api_token: "${CONFLUENCE_API_TOKEN}"`` but others may
41+
# inadvertently write ``$CONFLUENCE_API_TOKEN`` or embed the reference in a
42+
# larger string (e.g. ``"Bearer ${TOKEN}"``); handling all three keeps behaviour
43+
# predictable and backward compatible.
44+
_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
45+
3746
logger = logging.getLogger(__name__)
3847

3948

@@ -512,7 +521,7 @@ def _process_pm_config(cls, pm_data: dict[str, Any]) -> Optional[Any]:
512521
),
513522
},
514523
)()
515-
pm_config.jira = jira_sub_config
524+
pm_config.jira = jira_sub_config # type: ignore[misc]
516525

517526
return pm_config
518527

@@ -563,30 +572,74 @@ def _process_pm_integration_config(
563572

564573
@staticmethod
565574
def _resolve_env_var(value: Optional[str]) -> Optional[str]:
566-
"""Resolve environment variable references.
575+
"""Resolve environment variable references in a string.
576+
577+
Supports three syntaxes (all backward-compatible with prior
578+
exact-match behaviour):
579+
580+
* ``${VAR}`` — braced reference (preferred, works anywhere in the
581+
string).
582+
* ``$VAR`` — unbraced reference (must be a valid identifier).
583+
* Embedded references (e.g. ``"Bearer ${TOKEN}"``).
584+
585+
If a referenced variable is not set in ``os.environ``, the reference
586+
is replaced with an empty string and a warning is logged — this
587+
mirrors shell behaviour and lets callers decide whether the resulting
588+
value is acceptable. For the legacy whole-string ``${VAR}`` pattern
589+
the function preserves the prior contract of returning ``None`` when
590+
the variable is unset, so downstream ``or ""`` guards continue to
591+
work.
567592
568593
Args:
569-
value: Value that may contain environment variable reference
594+
value: Value that may contain environment variable reference(s).
570595
571596
Returns:
572-
Resolved value or None
573-
574-
Raises:
575-
EnvironmentVariableError: If environment variable is not set
597+
Resolved string, or ``None`` if the input was empty / the legacy
598+
whole-string reference resolved to an unset variable.
576599
"""
600+
if value is None:
601+
return None
602+
if not isinstance(value, str):
603+
return value # type: ignore[return-value]
577604
if not value:
578605
return None
579606

580-
if value.startswith("${") and value.endswith("}"):
607+
# Legacy whole-string ``${VAR}`` path — keep exact prior semantics so
608+
# callers that rely on ``None`` (rather than "") continue to work.
609+
if value.startswith("${") and value.endswith("}") and value.count("${") == 1:
581610
env_var = value[2:-1]
582611
resolved = os.environ.get(env_var)
583612
if not resolved:
584-
# Note: We don't raise here directly, let the caller handle it
585-
# based on whether the field is required
613+
logger.warning(
614+
"Environment variable %r referenced in config is not set or empty; "
615+
"the resulting credential/value will be blank. Check your .env / "
616+
".env.local files and shell environment.",
617+
env_var,
618+
)
586619
return None
587620
return resolved
588621

589-
return value
622+
# Otherwise, perform substitution for every ``${VAR}`` / ``$VAR`` match.
623+
if "$" not in value:
624+
return value
625+
626+
missing: list[str] = []
627+
628+
def _sub(match: "re.Match[str]") -> str:
629+
name = match.group(1) or match.group(2)
630+
resolved = os.environ.get(name, "")
631+
if not resolved:
632+
missing.append(name)
633+
return resolved
634+
635+
substituted = _ENV_VAR_PATTERN.sub(_sub, value)
636+
if missing:
637+
logger.warning(
638+
"Environment variable(s) %s referenced in config are not set or empty; "
639+
"substituted with empty strings. Check your .env / .env.local files.",
640+
", ".join(sorted(set(missing))),
641+
)
642+
return substituted
590643

591644
@classmethod
592645
def _resolve_config_dict(cls, config_dict: dict[str, Any]) -> dict[str, Any]:

src/gitflow_analytics/core/analyze_pipeline_reports.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ def run_qualitative_analysis(
2525
all_commits: list[dict[str, Any]],
2626
enable_qualitative: bool,
2727
qualitative_only: bool,
28-
display: Any,
28+
_display: Any,
2929
) -> QualitativeResult:
3030
"""Run qualitative NLP/LLM analysis if enabled.
3131
3232
Returns empty result when qualitative analysis is not configured or
3333
disabled. Raises ImportError / Exception when qualitative_only=True
3434
and analysis cannot proceed.
3535
"""
36+
_ = _display # passed by callers for progress output; not used in this layer
3637
from ..core.analyze_pipeline_helpers import (
3738
get_qualitative_config,
3839
is_qualitative_enabled,
@@ -390,7 +391,7 @@ def generate_all_reports(
390391
# ---- Narrative markdown ----
391392
if "markdown" in cfg.output.formats and generate_csv:
392393
try:
393-
import pandas as pd
394+
import pandas as pd # type: ignore[import-not-found]
394395

395396
activity_df = pd.read_csv(activity_report)
396397
focus_df = pd.read_csv(focus_report)
@@ -412,8 +413,8 @@ def generate_all_reports(
412413
pr_metrics,
413414
narrative_report,
414415
weeks,
415-
aggregated_pm_data,
416-
chatgpt_summary,
416+
aggregated_pm_data or {},
417+
chatgpt_summary or "",
417418
branch_health_metrics,
418419
cfg.analysis.exclude_authors,
419420
analysis_start_date=start_date,
@@ -446,6 +447,31 @@ def generate_all_reports(
446447
except Exception as e:
447448
logger.error("Database qualitative report failed: %s", e)
448449

450+
# ---- Ticketing activity report (GitHub Issues + Confluence) ----
451+
# Mirrors the guarded block in ``pipeline_report.py`` so that `gfa analyze`
452+
# emits the same three JSON summaries (github_issues_summary.json,
453+
# confluence_activity_summary.json, ticketing_activity_summary.json) that
454+
# `gfa report` produces. Only runs when at least one upstream source is
455+
# enabled in configuration. See GitHub issue #32.
456+
gh_issues_cfg = getattr(cfg, "github_issues", None)
457+
confluence_cfg = getattr(cfg, "confluence", None)
458+
if (gh_issues_cfg and getattr(gh_issues_cfg, "enabled", False)) or (
459+
confluence_cfg and getattr(confluence_cfg, "enabled", False)
460+
):
461+
try:
462+
from ..reports.ticketing_activity_report import TicketingActivityReport
463+
464+
ticket_gen = TicketingActivityReport(
465+
cache=analyzer.cache, identity_resolver=identity_resolver
466+
)
467+
written = ticket_gen.write_reports(output, start_date, end_date)
468+
for path_str in written:
469+
name = Path(path_str).name
470+
if name not in generated_reports:
471+
generated_reports.append(name)
472+
except Exception as e:
473+
logger.error("Ticketing activity report failed: %s", e)
474+
449475
# ---- Comprehensive JSON export ----
450476
if "json" in cfg.output.formats:
451477
try:
@@ -494,7 +520,7 @@ def generate_all_reports(
494520

495521
def _gen_csv_report(
496522
label: str,
497-
fn: Callable[[Path], None],
523+
fn: Callable[[Path], Any],
498524
path: Path,
499525
report_list: list[str],
500526
reraise: bool = False,

src/gitflow_analytics/core/metrics_storage.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88
import logging
99
from collections import defaultdict
1010
from contextlib import contextmanager
11-
from datetime import date, datetime
11+
from datetime import date, datetime, timezone
1212
from pathlib import Path
1313
from typing import Any, Optional, TypedDict, cast
1414

15-
from sqlalchemy import and_, func
16-
from sqlalchemy.orm import Session
15+
from sqlalchemy import and_, func # type: ignore[import-not-found]
16+
from sqlalchemy.orm import Session # type: ignore[import-not-found]
1717

1818
from ..models.database import DailyMetrics, Database
1919
from ..utils.ai_detection import detect_ai_tool, is_ai_generated
@@ -152,7 +152,7 @@ def store_daily_metrics(
152152
if existing:
153153
# Update existing record
154154
self._update_metrics_record(existing, metrics)
155-
existing.updated_at = datetime.utcnow()
155+
existing.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
156156
logger.debug(
157157
f"Updated existing daily metrics for {dev_id} in {project_key} on {analysis_date}"
158158
)
@@ -180,9 +180,15 @@ def store_daily_metrics(
180180
records_processed += 1
181181

182182
except Exception as e:
183-
logger.warning(
184-
f"Failed to store/update daily metrics for {dev_id} in {project_key} on {analysis_date}: {e}"
185-
)
183+
is_unique_violation = "UNIQUE constraint failed" in str(e)
184+
if is_unique_violation:
185+
logger.debug(
186+
f"UNIQUE constraint conflict for {dev_id} in {project_key} on {analysis_date}: {e}"
187+
)
188+
else:
189+
logger.warning(
190+
f"Failed to store/update daily metrics for {dev_id} in {project_key} on {analysis_date}: {e}"
191+
)
186192
session.rollback()
187193
# Try to handle UNIQUE constraint violations by doing another lookup
188194
try:
@@ -200,18 +206,19 @@ def store_daily_metrics(
200206
if existing:
201207
# Record was created by another process, just update it
202208
self._update_metrics_record(existing, metrics)
203-
existing.updated_at = datetime.utcnow()
209+
existing.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
204210
session.commit()
205211
records_processed += 1
206212
logger.info(
207213
f"Updated metrics after constraint violation for {dev_id} in {project_key} on {analysis_date}"
208214
)
209215
else:
210-
logger.error(
216+
# UNIQUE violation but record gone — expected during re-classification
217+
logger.debug(
211218
f"Could not resolve constraint violation for {dev_id} in {project_key} on {analysis_date}"
212219
)
213220
except Exception as retry_e:
214-
logger.error(
221+
logger.warning(
215222
f"Retry failed for {dev_id} in {project_key} on {analysis_date}: {retry_e}"
216223
)
217224
session.rollback()

src/gitflow_analytics/integrations/confluence_integration.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,63 @@ def _build_session(self) -> requests.Session:
101101
session.headers.update(self.headers)
102102
return session
103103

104+
# ------------------------------------------------------------------
105+
# Pre-flight credential check
106+
# ------------------------------------------------------------------
107+
def verify_credentials(self) -> None:
108+
"""Perform a lightweight authenticated probe against Confluence.
109+
110+
Issues a single ``GET {base_url}/rest/api/space?limit=1`` request and
111+
raises :class:`RuntimeError` with a descriptive message on any
112+
authentication failure. This surfaces credential problems during
113+
orchestrator initialization instead of during the first
114+
``fetch_all_spaces`` call, where failures would previously be
115+
swallowed and silently produce an empty report (see issue #33).
116+
117+
Raises:
118+
RuntimeError: If credentials appear invalid (missing creds, HTTP
119+
401/403, or other request-level failure).
120+
"""
121+
# Fail fast on obviously-missing credentials rather than issuing a
122+
# guaranteed-401 request.
123+
if not self.base_url:
124+
raise RuntimeError(
125+
"Confluence authentication failed: base_url is empty. "
126+
"Check your config's 'confluence.base_url'."
127+
)
128+
if not self.username or not self.api_token:
129+
raise RuntimeError(
130+
"Confluence authentication failed: check base_url, username, "
131+
"and api_token. The username or api_token resolved to an empty "
132+
"string — most likely the ${CONFLUENCE_API_TOKEN} environment "
133+
"variable is not set. Verify your .env / .env.local file is "
134+
"loaded before `gfa analyze` runs."
135+
)
136+
137+
url = f"{self.base_url}/rest/api/space"
138+
try:
139+
response = self._session.get(
140+
url,
141+
params={"limit": 1},
142+
timeout=self.connection_timeout,
143+
)
144+
except requests.RequestException as exc:
145+
raise RuntimeError(
146+
f"Confluence authentication failed: request error contacting "
147+
f"{url}: {exc}. Check base_url and network connectivity."
148+
) from exc
149+
150+
if response.status_code in (401, 403):
151+
raise RuntimeError(
152+
"Confluence authentication failed: check base_url, username, "
153+
f"and api_token (HTTP {response.status_code} from {url})."
154+
)
155+
if response.status_code >= 400:
156+
raise RuntimeError(
157+
f"Confluence pre-flight check failed with HTTP "
158+
f"{response.status_code} from {url}: {response.text[:200]}"
159+
)
160+
104161
# ------------------------------------------------------------------
105162
# Public API
106163
# ------------------------------------------------------------------

src/gitflow_analytics/integrations/orchestrator.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
"""Integration orchestrator for multiple platforms."""
22

33
import json
4+
import logging
45
from datetime import datetime
56
from typing import Any, Union
67

78
from ..core.cache import GitAnalysisCache
89
from ..pm_framework.orchestrator import PMFrameworkOrchestrator
910
from ..utils.debug import is_debug_mode
1011
from .cicd.github_actions import GitHubActionsIntegration
11-
from .confluence_integration import ConfluenceIntegration
12+
from .confluence_integration import ConfluenceIntegration # type: ignore[import-untyped]
1213
from .github_integration import GitHubIntegration
1314
from .github_issues_integration import GitHubIssuesIntegration
1415
from .jira_integration import JIRAIntegration
1516

17+
logger = logging.getLogger(__name__)
18+
1619

1720
class IntegrationOrchestrator:
1821
"""Orchestrate integrations with multiple platforms."""
@@ -106,7 +109,7 @@ def __init__(self, config: Any, cache: GitAnalysisCache):
106109
and getattr(confluence_cfg, "base_url", "")
107110
):
108111
try:
109-
self.integrations["confluence"] = ConfluenceIntegration(
112+
confluence_integration = ConfluenceIntegration(
110113
base_url=confluence_cfg.base_url,
111114
username=confluence_cfg.username,
112115
api_token=confluence_cfg.api_token,
@@ -118,9 +121,23 @@ def __init__(self, config: Any, cache: GitAnalysisCache):
118121
max_retries=confluence_cfg.max_retries,
119122
backoff_factor=confluence_cfg.backoff_factor,
120123
)
121-
if self.debug_mode:
122-
print(" ✅ Confluence integration initialized")
124+
125+
# Pre-flight credential verification (issue #33): catch 401s
126+
# at init time so we can surface a clear error and disable
127+
# the integration rather than silently producing empty
128+
# reports later.
129+
try:
130+
confluence_integration.verify_credentials()
131+
except RuntimeError as auth_err:
132+
logger.warning("Disabling Confluence integration: %s", auth_err)
133+
if self.debug_mode:
134+
print(f" ⚠️ Confluence disabled: {auth_err}")
135+
else:
136+
self.integrations["confluence"] = confluence_integration
137+
if self.debug_mode:
138+
print(" ✅ Confluence integration initialized")
123139
except Exception as e:
140+
logger.warning("Failed to initialize Confluence: %s", e)
124141
if self.debug_mode:
125142
print(f" ⚠️ Failed to initialize Confluence: {e}")
126143

@@ -302,7 +319,7 @@ def enrich_repository_data(
302319
conf = self.integrations["confluence"]
303320
if isinstance(conf, ConfluenceIntegration):
304321
try:
305-
conf.fetch_all_spaces(since)
322+
conf.fetch_all_spaces(since) # type: ignore[union-attr]
306323
self._confluence_fetched = True
307324
except Exception as e:
308325
if self.debug_mode:

0 commit comments

Comments
 (0)