""" JIRA AI Sprint Dashboard — Streamlit App IP (Indirect Procurement) project — Ford Jira Deploy: Streamlit Community Cloud (free) from GitHub """ import os import io import streamlit as st import requests import pandas as pd import plotly.graph_objects as go import plotly.express as px from datetime import datetime, timedelta from base64 import b64encode from functools import reduce from urllib.parse import quote as urlquote # ── Page config ────────────────────────────────────────────── st.set_page_config( page_title="IP Sprint Dashboard", page_icon="📊", layout="wide", initial_sidebar_state="expanded", ) # ── Jira credentials (env vars for Cloud Run, st.secrets for local) ── JIRA_BASE = os.environ.get("JIRA_BASE_URL") or st.secrets["jira"]["base_url"] JIRA_EMAIL = os.environ.get("JIRA_EMAIL") or st.secrets["jira"]["email"] JIRA_TOKEN = os.environ.get("JIRA_API_TOKEN") or st.secrets["jira"]["api_token"] PROJECT_KEY = "IP" SP_FIELD = "customfield_10040" TEAM_LABELS = [ "IndirectMDM", "I2P", "EIIM", "IMEU", "IMNA", "S2R", "SAPIntegration", "SNO-Aurora-Data-and-Env-Mgt", "SNO-Aurora-I2P-IT-L2", "SNO-Global-IERPAP-Support", ] DONE_STATUSES = ["Done", "Closed Complete"] AUTH_HEADER = { "Authorization": f"Basic {b64encode(f'{JIRA_EMAIL}:{JIRA_TOKEN}'.encode()).decode()}", "Content-Type": "application/json", } # ── Jira API helpers ───────────────────────────────────────── def jira_get(endpoint, agile=False): prefix = "rest/agile/1.0" if agile else "rest/api/3" r = requests.get(f"{JIRA_BASE}/{prefix}/{endpoint}", headers=AUTH_HEADER, timeout=30) r.raise_for_status() return r.json() @st.cache_data(ttl=300, show_spinner="Fetching active sprint...") def get_active_sprint(): boards = jira_get(f"board?projectKeyOrId={PROJECT_KEY}&maxResults=50", agile=True) for b in boards.get("values", []): try: sprints = jira_get(f"board/{b['id']}/sprint?state=active&maxResults=5", agile=True) if sprints.get("values"): return sprints["values"][0] except Exception: continue return None @st.cache_data(ttl=300, show_spinner="Fetching sprint issues...") def get_sprint_issues(sprint_id): all_issues = [] start_at = 0 fields = f"summary,status,assignee,priority,issuetype,{SP_FIELD},created,updated,labels,parent" while True: data = jira_get( f"sprint/{sprint_id}/issue?maxResults=100&startAt={start_at}&fields={fields}", agile=True, ) all_issues.extend(data.get("issues", [])) start_at += 100 if start_at >= data.get("total", 0): break return all_issues @st.cache_data(ttl=300, show_spinner="Fetching today's changes...") def get_today_changes(sprint_id): today = datetime.now().strftime("%Y-%m-%d") all_issues = [] start_at = 0 while True: jql = requests.utils.quote( f'project = {PROJECT_KEY} AND sprint = {sprint_id} AND updated >= "{today}" ORDER BY updated DESC' ) data = jira_get( f"search/jql?jql={jql}&maxResults=100&startAt={start_at}" f"&fields=summary,status,assignee,updated,labels&expand=changelog" ) all_issues.extend(data.get("issues", [])) start_at += 100 if data.get("isLast", True): break return all_issues # ── Data processing ────────────────────────────────────────── def issues_to_df(issues): rows = [] for iss in issues: f = iss["fields"] epic_key = "" epic_name = "" if f.get("parent"): epic_key = f["parent"].get("key", "") if f["parent"].get("fields", {}).get("summary"): epic_name = f["parent"]["fields"]["summary"] sp = float(f.get(SP_FIELD) or 0) labels = f.get("labels") or [] team = "No Team" for tl in TEAM_LABELS: if tl in labels: team = tl break status = f["status"]["name"] cat = "Other" if status in DONE_STATUSES: cat = "Done" elif status == "In Progress": cat = "In Progress" elif status == "Ready for acceptance": cat = "Review" elif status in ("To Do", "New"): cat = "To Do" elif status == "Canceled": cat = "Canceled" rows.append({ "Key": iss["key"], "Summary": f.get("summary", ""), "Status": status, "Category": cat, "Assignee": f["assignee"]["displayName"] if f.get("assignee") else "Unassigned", "Priority": f["priority"]["name"] if f.get("priority") else "None", "Type": f["issuetype"]["name"], "SP": sp, "Team": team, "Labels": "; ".join(labels), "Epic": epic_key, "Epic Name": epic_name, "Created": f.get("created", "")[:10], "Updated": f.get("updated", "")[:16].replace("T", " "), }) return pd.DataFrame(rows) def build_burndown(df, sprint): start = pd.to_datetime(sprint["startDate"]).replace(tzinfo=None).normalize() end = pd.to_datetime(sprint["endDate"]).replace(tzinfo=None).normalize() today = pd.Timestamp.now().normalize() last_day = min(today, end) total_sp = df["SP"].sum() total_days = (end - start).days # daily done / planned lookups daily_done = {} daily_planned = {} for _, row in df.iterrows(): sp = row["SP"] if sp > 0: created = pd.to_datetime(row["Created"]).normalize() added = max(created, start) k = added.strftime("%Y-%m-%d") daily_planned[k] = daily_planned.get(k, 0) + sp if row["Category"] == "Done" and sp > 0 and row["Updated"]: done_d = pd.to_datetime(row["Updated"]).normalize() if done_d < start: done_d = start k = done_d.strftime("%Y-%m-%d") daily_done[k] = daily_done.get(k, 0) + sp dates, ideal, actual, planned = [], [], [], [] cum_done = 0 cum_planned = 0 day = start idx = 0 while day <= end: ds = day.strftime("%Y-%m-%d") label = day.strftime("%b %d") dates.append(label) cum_planned += daily_planned.get(ds, 0) ideal_val = round(total_sp - (total_sp * idx / total_days), 1) if total_days > 0 else 0 ideal.append(ideal_val) if day <= last_day: cum_done += daily_done.get(ds, 0) actual.append(round(total_sp - cum_done, 1)) planned.append(cum_planned) else: actual.append(None) planned.append(None) day += timedelta(days=1) idx += 1 return dates, ideal, actual, planned, total_sp # ── Main app ───────────────────────────────────────────────── def main(): # ── Global CSS: pill-shaped tabs ───────────────────────── _pill_css = '' st.markdown(_pill_css, unsafe_allow_html=True) # Fetch data sprint = get_active_sprint() if not sprint: st.error("No active sprint found in IP project.") return issues = get_sprint_issues(sprint["id"]) df = issues_to_df(issues) changes_issues = get_today_changes(sprint["id"]) changed_today_count = len(changes_issues) # Sprint info s_name = sprint["name"] s_start = sprint["startDate"][:10] s_end = sprint["endDate"][:10] days_left = max(0, (pd.to_datetime(s_end).date() - datetime.now().date()).days) # ── Sidebar filters ────────────────────────────────────── st.sidebar.title("Filters") st.sidebar.caption(f"Last updated: {datetime.now().strftime('%b %d, %Y %H:%M')} UTC") st.sidebar.markdown('

', unsafe_allow_html=True) teams = ["All"] + sorted(df["Team"].unique().tolist()) sel_team = st.sidebar.selectbox("Team", teams) assignees = ["All"] + sorted(df["Assignee"].unique().tolist()) sel_assignee = st.sidebar.selectbox("Assignee", assignees) statuses = ["All"] + sorted(df["Category"].unique().tolist()) sel_status = st.sidebar.selectbox("Status Category", statuses) epics = ["All"] + sorted(df[df["Epic Name"] != ""]["Epic Name"].unique().tolist()) sel_epic = st.sidebar.selectbox("Epic", epics) # Apply filters fdf = df.copy() if sel_team != "All": fdf = fdf[fdf["Team"] == sel_team] if sel_assignee != "All": fdf = fdf[fdf["Assignee"] == sel_assignee] if sel_status != "All": fdf = fdf[fdf["Category"] == sel_status] if sel_epic != "All": fdf = fdf[fdf["Epic Name"] == sel_epic] # ── Sidebar: Export & Email ─────────────────────────────── st.sidebar.markdown("---") st.sidebar.markdown("**Export**") # Active filter summary active_filters = [] if sel_team != "All": active_filters.append(f"Team: {sel_team}") if sel_assignee != "All": active_filters.append(f"Assignee: {sel_assignee}") if sel_status != "All": active_filters.append(f"Status: {sel_status}") if sel_epic != "All": active_filters.append(f"Epic: {sel_epic}") filter_desc = ", ".join(active_filters) if active_filters else "All Data (no filters)" # Compute stats for email/export summary exp_total = len(fdf) exp_done = len(fdf[fdf["Category"] == "Done"]) exp_sp = fdf["SP"].sum() exp_done_sp = fdf[fdf["Category"] == "Done"]["SP"].sum() exp_ip_sp = fdf[fdf["Category"] == "In Progress"]["SP"].sum() exp_rem_sp = exp_sp - exp_done_sp - exp_ip_sp exp_comp = round(exp_done_sp / exp_sp * 100, 1) if exp_sp > 0 else 0 # ── Download (single button, format selector) ── dl_format = st.sidebar.selectbox("Download Format", ["CSV", "Excel"], key="dl_fmt") export_cols = ["Key", "Summary", "Status", "Category", "Assignee", "SP", "Priority", "Type", "Team", "Epic", "Epic Name", "Created", "Updated"] if dl_format == "CSV": csv_data = fdf[export_cols].to_csv(index=False).encode("utf-8") st.sidebar.download_button("Download", csv_data, file_name=f"sprint-{s_name}-{datetime.now().strftime('%Y%m%d')}.csv", mime="text/csv", key="dl_btn") else: excel_buf = io.BytesIO() with pd.ExcelWriter(excel_buf, engine="openpyxl") as writer: fdf[export_cols].to_excel(writer, index=False, sheet_name="Sprint Issues") summary_rows = [("Sprint", s_name), ("Period", f"{s_start} to {s_end}"), ("Filters", filter_desc), ("Total Issues", exp_total), ("Done Issues", exp_done), ("Total SP", exp_sp), ("Done SP", exp_done_sp), ("In Progress SP", exp_ip_sp), ("Remaining SP", exp_rem_sp), ("Completion %", f"{exp_comp}%"), ("Dashboard URL", "https://fordai-sprint-dashboard.hf.space")] pd.DataFrame(summary_rows, columns=["Metric", "Value"]).to_excel(writer, index=False, sheet_name="Summary") user_summary = fdf.groupby("Assignee").agg(Planned=("SP", "sum"), Done=("SP", lambda x: x[fdf.loc[x.index, "Category"] == "Done"].sum()), InProgress=("SP", lambda x: x[fdf.loc[x.index, "Category"] == "In Progress"].sum())).reset_index() user_summary["Remaining"] = user_summary["Planned"] - user_summary["Done"] - user_summary["InProgress"] user_summary.sort_values("Planned", ascending=False).to_excel(writer, index=False, sheet_name="SP by User") st.sidebar.download_button("Download", excel_buf.getvalue(), file_name=f"sprint-{s_name}-{datetime.now().strftime('%Y%m%d')}.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", key="dl_btn") # ── Ford header banner with Refresh ───────────────────────── hdr_left, hdr_right = st.columns([6, 1]) with hdr_left: ford_logo = 'Ford' header_html = '
' header_html += ford_logo header_html += '
JIRA Sprint Dashboard
' header_html += '
Indirect Procurement
' header_html += '
' st.markdown(header_html, unsafe_allow_html=True) with hdr_right: st.markdown('
', unsafe_allow_html=True) if st.button("🔄 Refresh", use_container_width=True): st.cache_data.clear() st.rerun() # ── KPI data ────────────────────────────────────────────── total_sp = fdf["SP"].sum() done_sp = fdf[fdf["Category"] == "Done"]["SP"].sum() remaining_sp = total_sp - done_sp done_issues = len(fdf[fdf["Category"] == "Done"]) in_progress = len(fdf[fdf["Category"] == "In Progress"]) completion = round((done_sp / total_sp * 100), 1) if total_sp > 0 else 0 # ── 4 KPI cards in ONE row ─────────────────────────────── kpi_css = '' kpi_html = kpi_css kpi_html += '
' kpi_html += f'
SPRINT
{s_name}
{s_start} → {s_end}
' kpi_html += f'
DAYS REMAINING
{days_left}
Sprint deadline
' kpi_html += f'
TOTAL ISSUES
{len(fdf)}
{done_issues} done • {completion}% complete
' kpi_html += f'
CHANGED TODAY
{changed_today_count}
Issues updated today
' kpi_html += '
' st.markdown(kpi_html, unsafe_allow_html=True) # ── Global search bar ───────────────────────────────────── srch_col1, srch_col2, srch_col3 = st.columns([5, 1, 1]) with srch_col1: search = st.text_input("Search", placeholder="Search by key, summary, or assignee...", label_visibility="collapsed", key="global_search") with srch_col2: st.button("🔍 Search", use_container_width=True, type="primary") with srch_col3: email_subject = f"Sprint Report: {s_name} - Indirect Procurement" mailto_url = f"mailto:?subject={urlquote(email_subject)}" st.markdown(f'📧 Email Results', unsafe_allow_html=True) # Apply search filter sdf = fdf.copy() if search: mask = sdf["Key"].str.contains(search, case=False, na=False) | sdf["Summary"].str.contains(search, case=False, na=False) | sdf["Assignee"].str.contains(search, case=False, na=False) sdf = sdf[mask] # ── Tabs ────────────────────────────────────────────────── tab1, tab2, tab3, tab4 = st.tabs(["📈 Overview", "📊 Story Points", "📋 All Issues", "🔄 Changes Today"]) # ── Tab 1: Overview ─────────────────────────────────────── with tab1: col_left, col_right = st.columns([1, 1]) # ── Left: Status Distribution ──────────────────────── with col_left: status_counts = fdf.groupby("Status").size().reset_index(name="Count").sort_values("Count", ascending=False) total_issues = status_counts["Count"].sum() status_colors = { "Done": "#22c55e", "Closed Complete": "#16a34a", "To Do": "#eab308", "In Progress": "#3b82f6", "Ready for acceptance": "#f59e0b", "New": "#06b6d4", "Closed": "#64748b", "Accepted": "#64748b", "Canceled": "#ef4444", "On Hold": "#64748b", "Work in progress": "#64748b", "Closed Incomplete": "#64748b", "Resolved": "#64748b", } # Build HTML bar chart — no indentation to avoid markdown code blocks bars = [] for _, row in status_counts.iterrows(): s = row["Status"] cnt = int(row["Count"]) pct = round(cnt / total_issues * 100) if total_issues > 0 else 0 bar_w = max(pct, 1) color = status_colors.get(s, "#64748b") bars.append( f'
' f'
{s}
' f'
' f'
' f'
{cnt}
' f'
{pct}%
' ) html = '
' html += '
STATUS DISTRIBUTION
' html += ''.join(bars) html += '
' st.markdown(html, unsafe_allow_html=True) # ── Right: Sprint Completion + Burndown ────────────── with col_right: # Sprint completion donut done_count = len(fdf[fdf["Category"] == "Done"]) comp_pct = round(done_count / len(fdf) * 100) if len(fdf) > 0 else 0 fig_donut = go.Figure(go.Pie( values=[done_count, len(fdf) - done_count], labels=["Done", "Remaining"], hole=0.75, marker=dict(colors=["#f59e0b", "#1e293b"]), textinfo="none", hovertemplate="%{label}: %{value}", )) fig_donut.update_layout( showlegend=False, height=240, margin=dict(t=40, b=10, l=20, r=20), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", annotations=[ dict(text=f"{comp_pct}%", x=0.5, y=0.55, font=dict(size=36, color="#f59e0b"), showarrow=False), dict(text=f"{done_count} of {len(fdf)} issues completed", x=0.5, y=-0.05, font=dict(size=13, color="#94a3b8"), showarrow=False), ], title=dict(text="SPRINT COMPLETION", font=dict(size=13, color="#60a5fa"), x=0.01), ) st.plotly_chart(fig_donut, use_container_width=True) # Burndown chart dates, ideal, actual, planned_vals, total_sp_all = build_burndown(fdf, sprint) # Find today index for vertical marker today_label = pd.Timestamp.now().strftime("%b %d") fig_burn = go.Figure() # Ideal line (gray dashed) fig_burn.add_trace(go.Scatter( x=dates, y=ideal, name="Ideal", line=dict(dash="dash", color="#666", width=2), )) # Planned line (orange, filled) fig_burn.add_trace(go.Scatter( x=dates, y=planned_vals, name="Planned", line=dict(color="#f59e0b", width=3), fill="tozeroy", fillcolor="rgba(245,158,11,0.1)", )) # Actual line (blue, dashed with markers) fig_burn.add_trace(go.Scatter( x=dates, y=actual, name="Actual", line=dict(color="#3b82f6", width=3, dash="dot"), mode="lines+markers", marker=dict(size=5), fill="tozeroy", fillcolor="rgba(59,130,246,0.08)", )) # Add "Today" vertical line (manual shape for categorical x-axis) if today_label in dates: today_idx = dates.index(today_label) fig_burn.add_shape( type="line", x0=today_idx, x1=today_idx, y0=0, y1=1, xref="x", yref="paper", line=dict(color="#f59e0b", width=1, dash="dash"), ) fig_burn.add_annotation( x=today_label, y=0, yref="paper", text="Today", showarrow=False, yshift=-18, font=dict(color="#ef4444", size=12), ) # Annotate latest planned and actual values last_actual = next((v for v in reversed(actual) if v is not None), None) last_planned = next((v for v in reversed(planned_vals) if v is not None), None) last_idx = next((i for i in range(len(actual) - 1, -1, -1) if actual[i] is not None), 0) if last_planned is not None: fig_burn.add_annotation(x=dates[last_idx], y=last_planned, text=f"{last_planned:g} SP", showarrow=False, xshift=50, font=dict(color="#f59e0b", size=12)) if last_actual is not None: fig_burn.add_annotation(x=dates[last_idx], y=last_actual, text=f"{last_actual:g} SP", showarrow=False, xshift=50, font=dict(color="#3b82f6", size=12)) # Compute "behind by X SP vs ideal" behind_sp = 0 if last_actual is not None and ideal: ideal_now = ideal[last_idx] if last_idx < len(ideal) else 0 behind_sp = round(last_actual - ideal_now, 1) fig_burn.update_layout( title=dict(text="BURNDOWN CHART", font=dict(size=13, color="#60a5fa"), x=0.01), height=350, paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", xaxis=dict(showgrid=False, color="#94a3b8"), yaxis=dict(showgrid=True, gridcolor="#1e293b", color="#94a3b8"), legend=dict(orientation="h", yanchor="bottom", y=-0.25, x=0.5, xanchor="center", font=dict(size=12)), margin=dict(t=40, b=60, l=50, r=60), ) st.plotly_chart(fig_burn, use_container_width=True) if behind_sp > 0: st.markdown(f'

Behind by {behind_sp:g} SP vs ideal

', unsafe_allow_html=True) elif behind_sp < 0: st.markdown(f'

Ahead by {abs(behind_sp):g} SP vs ideal

', unsafe_allow_html=True) # ── Tab 2: Story Points ───────────────────────────────────── with tab2: # Compute per-user SP data user_sp = fdf.groupby("Assignee").agg( PlannedSP=("SP", "sum"), DoneSP=("SP", lambda x: x[fdf.loc[x.index, "Category"] == "Done"].sum()), InProgressSP=("SP", lambda x: x[fdf.loc[x.index, "Category"] == "In Progress"].sum()), ).reset_index() user_sp["RemainingSP"] = user_sp["PlannedSP"] - user_sp["DoneSP"] - user_sp["InProgressSP"] user_sp["SPpct"] = (user_sp["DoneSP"] / user_sp["PlannedSP"] * 100).fillna(0).round(0).astype(int) user_sp = user_sp.sort_values("PlannedSP", ascending=False) sp_left, sp_right = st.columns([3, 2]) # ── Left: SP by User table ─────────────────────────── with sp_left: # Build HTML table rows — single-line to avoid Streamlit code-block rendering table_rows = [] for _, row in user_sp.iterrows(): p = row["PlannedSP"] d = row["DoneSP"] ip = row["InProgressSP"] rem = row["RemainingSP"] pct = row["SPpct"] d_w = round(d / p * 100) if p > 0 else 0 ip_w = round(ip / p * 100) if p > 0 else 0 pct_color = "#22c55e" if pct >= 75 else ("#f59e0b" if pct >= 40 else "#7c83ff") tr = ( f'' f'{row["Assignee"]}' f'
' f'{p:g}' f'{d:g}' f'{ip:g}' f'{rem:g}' f'{pct}%' f'' ) table_rows.append(tr) # Build full table as single-line concatenated HTML sp_html = '
' sp_html += '
' sp_html += 'STORY POINTS BY USER
' sp_html += '
' sp_html += '' sp_html += '' sp_html += '' sp_html += '' sp_html += '' sp_html += '' sp_html += '' sp_html += '' sp_html += '' sp_html += '' sp_html += ''.join(table_rows) sp_html += '
USERSP
BAR
PLANNED
SP
DONE
SP
IN PROGRESS
SP
REMAINING
SP
SP %
' st.markdown(sp_html, unsafe_allow_html=True) # ── Right: Story Points Summary ────────────────────── with sp_right: all_total_sp = fdf["SP"].sum() all_done_sp = fdf[fdf["Category"] == "Done"]["SP"].sum() all_ip_sp = fdf[fdf["Category"] == "In Progress"]["SP"].sum() all_rem_sp = all_total_sp - all_done_sp - all_ip_sp sp_comp = round(all_done_sp / all_total_sp * 100) if all_total_sp > 0 else 0 done_pct = round(all_done_sp / all_total_sp * 100) if all_total_sp > 0 else 0 ip_pct = round(all_ip_sp / all_total_sp * 100) if all_total_sp > 0 else 0 # Build SP Summary as single-line concatenated HTML sum_html = '
' sum_html += '
STORY POINTS SUMMARY
' sum_html += f'
{all_total_sp:g}
' sum_html += '
Total Story Points
' sum_html += '
' sum_html += f'
{all_done_sp:g}
Done SP
' sum_html += f'
{all_ip_sp:g}
In Progress SP
' sum_html += f'
{all_rem_sp:g}
Remaining SP
' sum_html += '
' sum_html += f'
' sum_html += f'
0%{sp_comp}% Complete100%
' sum_html += '
' sum_html += 'Done' sum_html += 'In Progress' sum_html += 'Remaining' sum_html += '
' st.markdown(sum_html, unsafe_allow_html=True) # ── Tab 3: All Issues ───────────────────────────────────── with tab3: st.subheader(f"All Issues ({len(sdf)})") st.dataframe( sdf[["Key", "Summary", "Status", "Assignee", "SP", "Priority", "Type", "Team", "Epic Name", "Updated"]], use_container_width=True, hide_index=True, height=600, column_config={ "Key": st.column_config.TextColumn("Key", width="small"), "SP": st.column_config.NumberColumn("SP", format="%.1f"), }, ) # ── Tab 4: Today's Changes ──────────────────────────────── with tab4: change_rows = [] today_date = datetime.now().date() for iss in changes_issues: f = iss["fields"] if iss.get("changelog"): for h in iss["changelog"].get("histories", []): hist_date = datetime.fromisoformat(h["created"].replace("Z", "+00:00")).date() if hist_date != today_date: continue author = h.get("author", {}).get("displayName", "Unknown") time_str = h["created"][11:16] for item in h.get("items", []): from_val = item.get("fromString") or "(empty)" to_val = item.get("toString") or "(empty)" change_rows.append({ "Time": time_str, "Key": iss["key"], "Summary": f.get("summary", "")[:60], "Field": item.get("field", ""), "From": from_val[:80], "To": to_val[:80], "Author": author, }) st.subheader(f"Today's Changes ({len(change_rows)} records)") if change_rows: cdf = pd.DataFrame(change_rows).sort_values("Time", ascending=False) st.dataframe(cdf, use_container_width=True, hide_index=True, height=600) else: st.info("No changes recorded today.") if __name__ == "__main__": main()