Avery commited on
Commit
f2cc925
Β·
1 Parent(s): 63542f9

dashboard: global filters, activity views, momentum, org/tag explorer

Browse files

- Global sidebar filters (country, org, language, tag, activity window)
propagating across all tabs
- Dual timeline: repos created vs last-pushed per year
- Rising Stars view (active last 12m) alongside Most Starred
- Most Active Organisations chart and table
- Tag adoption over time chart (top 10 tags by creation year)
- Explorer: sortable, tag filter, org filter, last-pushed column
- Links to GitHub source and HF dataset throughout

Files changed (2) hide show
  1. README.md +6 -0
  2. app.py +434 -191
README.md CHANGED
@@ -11,4 +11,10 @@ datasets:
11
  ---
12
 
13
  # GovTech GitHub Explorer
 
14
  Exploring 70k+ government GitHub repositories worldwide.
 
 
 
 
 
 
11
  ---
12
 
13
  # GovTech GitHub Explorer
14
+
15
  Exploring 70k+ government GitHub repositories worldwide.
16
+
17
+ Filter by country, organisation, language, tag, and activity window. See which governments are actively building, what technologies they're using, and how that's changed over time.
18
+
19
+ **Data**: [AndreasThinks/government-github-repos](https://huggingface.co/datasets/AndreasThinks/government-github-repos) β€” 70k+ repos, updated weekly.
20
+ **Source**: [AndreasThinks/open-govtech-report](https://github.com/AndreasThinks/open-govtech-report) on GitHub.
app.py CHANGED
@@ -4,9 +4,10 @@ import plotly.express as px
4
  import plotly.graph_objects as go
5
  import sqlite3
6
  import os
7
- import json
8
  import logging
 
9
 
 
10
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
11
  logger = logging.getLogger("govtech-dashboard")
12
  logger.info("Starting GovTech Dashboard...")
@@ -17,9 +18,6 @@ st.set_page_config(
17
  layout="wide",
18
  )
19
 
20
- st.title("πŸ›οΈ GovTech GitHub Explorer")
21
- st.caption("Exploring 70k+ government GitHub repositories worldwide")
22
-
23
 
24
  def get_db_path():
25
  candidates = [
@@ -29,11 +27,9 @@ def get_db_path():
29
  "../govtech.db",
30
  ]
31
  for p in candidates:
32
- logger.info(f"Checking for DB at: {os.path.abspath(p)}")
33
  if os.path.exists(p):
34
  logger.info(f"Found local DB: {os.path.abspath(p)}")
35
  return os.path.abspath(p)
36
- # Download from HuggingFace
37
  logger.info("No local DB found, downloading from HuggingFace Hub...")
38
  try:
39
  from huggingface_hub import hf_hub_download
@@ -51,14 +47,6 @@ def get_db_path():
51
 
52
 
53
  DB_PATH = get_db_path()
54
- logger.info(f"Using database: {DB_PATH} ({os.path.getsize(DB_PATH) / 1024 / 1024:.1f} MB)")
55
- try:
56
- _conn = sqlite3.connect(DB_PATH)
57
- _tables = [r[0] for r in _conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
58
- _conn.close()
59
- logger.info(f"DB tables: {', '.join(_tables)}")
60
- except Exception as e:
61
- logger.error(f"DB validation failed: {e}")
62
 
63
 
64
  def get_conn():
@@ -74,148 +62,280 @@ def query_df(sql, params=None):
74
 
75
 
76
  @st.cache_data(ttl=300)
77
- def query_one(sql):
78
  conn = get_conn()
79
  cur = conn.cursor()
80
- result = cur.execute(sql).fetchone()[0]
81
  conn.close()
82
  return result
83
 
84
 
85
- # --- TABS ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  tab_overview, tab_explorer, tab_tags, tab_insights = st.tabs(
87
  ["πŸ“Š Overview", "πŸ” Explorer", "🏷️ Tags", "πŸ’‘ Insights"]
88
  )
89
 
 
90
  # ==================== OVERVIEW ====================
91
  with tab_overview:
92
- repo_count = query_one("SELECT COUNT(*) FROM repositories")
 
 
93
  account_count = query_one("SELECT COUNT(*) FROM accounts")
94
- country_count = query_one("SELECT COUNT(DISTINCT country) FROM repositories")
95
- tagged_count = query_one("SELECT COUNT(DISTINCT html_url) FROM repository_tags")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  c1, c2, c3, c4 = st.columns(4)
98
- c1.metric("Repositories", f"{repo_count:,}")
99
  c2.metric("Accounts", f"{account_count:,}")
100
- c3.metric("Countries", country_count)
101
- c4.metric("Tagged Repos", f"{tagged_count:,}")
102
 
103
  st.divider()
104
 
105
  col_left, col_right = st.columns(2)
106
 
107
  with col_left:
108
- st.subheader("Top 20 Countries by Repository Count")
109
  df_countries = query_df(
110
- "SELECT country, COUNT(*) as count FROM repositories GROUP BY country ORDER BY count DESC LIMIT 20"
 
111
  )
112
- fig = px.bar(
113
- df_countries, x="country", y="count",
114
- color="count", color_continuous_scale="Blues",
115
- )
116
- fig.update_layout(showlegend=False, xaxis_title="Country", yaxis_title="Repositories")
117
- st.plotly_chart(fig, use_container_width=True)
118
 
119
  with col_right:
120
- st.subheader("Top 15 Languages")
121
  df_langs = query_df(
122
- "SELECT language, COUNT(*) as count FROM repositories WHERE language IS NOT NULL AND language != '' GROUP BY language ORDER BY count DESC LIMIT 15"
123
- )
124
- fig = px.bar(
125
- df_langs, x="count", y="language", orientation="h",
126
- color="count", color_continuous_scale="Greens",
127
  )
128
- fig.update_layout(showlegend=False, yaxis=dict(autorange="reversed"), xaxis_title="Repositories", yaxis_title="")
129
- st.plotly_chart(fig, use_container_width=True)
 
 
130
 
131
  st.subheader("Repository Creation Timeline")
132
  df_timeline = query_df(
133
- "SELECT SUBSTR(created_at, 1, 4) as year, COUNT(*) as count FROM repositories WHERE created_at IS NOT NULL GROUP BY year ORDER BY year"
 
 
 
 
134
  )
135
  df_timeline = df_timeline[df_timeline["year"].str.match(r"^\d{4}$", na=False)]
136
- fig = px.area(
137
- df_timeline, x="year", y="count",
138
- labels={"year": "Year", "count": "Repos Created"},
 
 
 
 
 
139
  )
140
- fig.update_layout(xaxis_title="Year", yaxis_title="Repositories Created")
141
- st.plotly_chart(fig, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
 
144
  # ==================== EXPLORER ====================
145
  with tab_explorer:
146
- with st.expander("πŸ”§ Filters", expanded=True):
147
- fc1, fc2, fc3 = st.columns(3)
148
-
149
- all_countries = query_df(
150
- "SELECT DISTINCT country FROM repositories ORDER BY country"
151
- )["country"].tolist()
152
- all_languages = query_df(
153
- "SELECT DISTINCT language FROM repositories WHERE language IS NOT NULL AND language != '' ORDER BY language"
154
- )["language"].tolist()
155
-
156
- with fc1:
157
- sel_countries = st.multiselect("Country", all_countries, key="exp_country")
158
- sel_languages = st.multiselect("Language", all_languages, key="exp_lang")
159
-
160
- with fc2:
161
- min_stars = st.slider("Minimum Stars", 0, 1000, 0, key="exp_stars")
162
- search_text = st.text_input("Search name/description", key="exp_search")
163
-
164
- with fc3:
165
- show_archived = st.checkbox("Include archived", value=True, key="exp_arch")
166
- show_forks = st.checkbox("Include forks", value=True, key="exp_forks")
167
-
168
- # Build query
169
- conditions = []
170
- params = []
171
-
172
- if sel_countries:
173
- placeholders = ",".join(["?"] * len(sel_countries))
174
- conditions.append(f"country IN ({placeholders})")
175
- params.extend(sel_countries)
176
-
177
- if sel_languages:
178
- placeholders = ",".join(["?"] * len(sel_languages))
179
- conditions.append(f"language IN ({placeholders})")
180
- params.extend(sel_languages)
181
-
182
- if min_stars > 0:
183
- conditions.append("stars >= ?")
184
- params.append(min_stars)
185
-
186
- if not show_archived:
187
- conditions.append("(archived = 0 OR archived IS NULL)")
188
-
189
- if not show_forks:
190
- conditions.append("(fork = 0 OR fork IS NULL)")
191
 
 
 
192
  if search_text:
193
- conditions.append("(name LIKE ? OR description LIKE ?)")
194
- params.extend([f"%{search_text}%", f"%{search_text}%"])
 
 
 
 
195
 
196
- where = "WHERE " + " AND ".join(conditions) if conditions else ""
197
 
198
- count_sql = f"SELECT COUNT(*) FROM repositories {where}"
199
  conn = get_conn()
200
- total_results = pd.read_sql_query(count_sql, conn, params=params or []).iloc[0, 0]
 
 
 
 
 
201
  conn.close()
202
 
203
- st.write(f"**{total_results:,}** repositories found")
204
 
205
  page_size = 50
206
  total_pages = max(1, (total_results + page_size - 1) // page_size)
207
  page = st.number_input("Page", min_value=1, max_value=total_pages, value=1, key="exp_page")
208
  offset = (page - 1) * page_size
209
 
 
 
 
210
  data_sql = f"""
211
- SELECT html_url, name, owner, country, language, stars, forks, license, created_at
212
- FROM repositories {where}
213
- ORDER BY stars DESC
 
 
214
  LIMIT ? OFFSET ?
215
  """
216
- params_page = params + [page_size, offset]
217
-
218
- df_results = query_df(data_sql, params_page)
219
 
220
  if not df_results.empty:
221
  st.dataframe(
@@ -230,6 +350,9 @@ with tab_explorer:
230
  "forks": st.column_config.NumberColumn("🍴 Forks"),
231
  "license": st.column_config.TextColumn("License"),
232
  "created_at": st.column_config.TextColumn("Created"),
 
 
 
233
  },
234
  use_container_width=True,
235
  hide_index=True,
@@ -241,55 +364,83 @@ with tab_explorer:
241
 
242
  # ==================== TAGS ====================
243
  with tab_tags:
 
 
244
  tagged_count_t = query_one("SELECT COUNT(DISTINCT html_url) FROM repository_tags")
245
  total_repos_t = query_one("SELECT COUNT(*) FROM repositories")
246
 
247
- if tagged_count_t < total_repos_t * 0.5:
248
  pct = tagged_count_t / total_repos_t * 100 if total_repos_t > 0 else 0
249
  st.info(
250
  f"πŸ—οΈ **Tagging in progress** β€” {tagged_count_t:,} of {total_repos_t:,} repositories tagged ({pct:.1f}%). "
251
- f"Results below reflect partially tagged data."
252
  )
253
 
254
- st.subheader("Top 30 Tags")
255
- df_top_tags = query_df(
256
- "SELECT tag, COUNT(*) as count FROM repository_tags GROUP BY tag ORDER BY count DESC LIMIT 30"
257
- )
258
- if not df_top_tags.empty:
259
- fig = px.bar(
260
- df_top_tags, x="count", y="tag", orientation="h",
261
- color="count", color_continuous_scale="Purples",
 
 
 
 
 
262
  )
263
- fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False, height=600, xaxis_title="Repositories", yaxis_title="")
264
- st.plotly_chart(fig, use_container_width=True)
265
- else:
266
- st.warning("No tags found yet.")
 
 
 
 
267
 
268
- st.subheader("Tag Groups")
269
- df_groups = query_df("SELECT id, name, description FROM tag_groups ORDER BY name")
270
- if not df_groups.empty:
271
- for _, grp in df_groups.iterrows():
272
- with st.expander(f"πŸ“ {grp['name']}" + (f" β€” {grp['description']}" if grp['description'] else "")):
273
- df_members = query_df(
274
- "SELECT tag FROM tag_group_members WHERE group_id = ? ORDER BY tag",
275
- [int(grp["id"])]
276
- )
277
- if not df_members.empty:
278
- st.write(", ".join(df_members["tag"].tolist()))
279
- else:
280
- st.write("No tags in this group yet.")
281
- else:
282
- st.info("No tag groups defined yet.")
 
 
 
 
 
 
 
 
 
 
 
283
 
 
284
  st.subheader("Browse Repos by Tag")
285
- all_tags = query_df("SELECT DISTINCT tag FROM repository_tags ORDER BY tag")["tag"].tolist()
286
- if all_tags:
287
- sel_tag = st.selectbox("Select a tag", all_tags, key="tag_browse")
 
 
 
 
 
288
  df_tag_repos = query_df(
289
- """SELECT r.name, r.owner, r.country, r.language, r.stars, rt.confidence, r.html_url
290
- FROM repository_tags rt JOIN repositories r ON rt.html_url = r.html_url
291
- WHERE rt.tag = ? ORDER BY r.stars DESC LIMIT 100""",
292
- [sel_tag]
293
  )
294
  st.write(f"**{len(df_tag_repos)}** repos tagged with **{sel_tag}**")
295
  if not df_tag_repos.empty:
@@ -298,37 +449,132 @@ with tab_tags:
298
  column_config={
299
  "html_url": st.column_config.LinkColumn("URL", display_text="Open"),
300
  "confidence": st.column_config.ProgressColumn("Confidence", min_value=0, max_value=1),
 
 
301
  },
302
  use_container_width=True,
303
  hide_index=True,
304
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  else:
306
- st.info("No tagged repositories available yet.")
307
 
308
 
309
  # ==================== INSIGHTS ====================
310
  with tab_insights:
311
- st.subheader("⭐ Top 50 Most Starred Repositories")
312
- df_top = query_df(
313
- "SELECT name, owner, country, stars, language, html_url FROM repositories ORDER BY stars DESC LIMIT 50"
314
- )
315
- st.dataframe(
316
- df_top,
317
- column_config={
318
- "html_url": st.column_config.LinkColumn("URL", display_text="Open"),
319
- "stars": st.column_config.NumberColumn("⭐ Stars"),
320
- },
321
- use_container_width=True,
322
- hide_index=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
 
325
  st.divider()
326
- col_a, col_b = st.columns(2)
327
 
328
- with col_a:
 
 
329
  st.subheader("πŸ“œ License Breakdown")
330
  df_lic = query_df(
331
- "SELECT license, COUNT(*) as count FROM repositories WHERE license IS NOT NULL AND license != '' GROUP BY license ORDER BY count DESC"
 
 
 
 
332
  )
333
  if not df_lic.empty:
334
  top_n = 10
@@ -342,58 +588,55 @@ with tab_insights:
342
  fig.update_traces(textposition="inside", textinfo="percent+label")
343
  st.plotly_chart(fig, use_container_width=True)
344
 
345
- with col_b:
346
  st.subheader("🍴 Fork vs Original")
347
- fork_count = query_one("SELECT COUNT(*) FROM repositories WHERE fork = 1")
348
- original_count = query_one("SELECT COUNT(*) FROM repositories WHERE fork = 0 OR fork IS NULL")
 
 
 
 
 
 
349
  m1, m2 = st.columns(2)
350
- m1.metric("Original Repos", f"{original_count:,}")
351
- m2.metric("Forked Repos", f"{fork_count:,}")
352
  fig = px.pie(
353
  pd.DataFrame({"type": ["Original", "Fork"], "count": [original_count, fork_count]}),
354
  names="type", values="count", hole=0.4,
355
- color_discrete_sequence=["#2ecc71", "#e74c3c"]
356
  )
357
  st.plotly_chart(fig, use_container_width=True)
358
 
359
- st.divider()
360
-
361
- st.subheader("πŸš€ Most Recently Active Repos (by last push)")
362
- df_active = query_df(
363
- "SELECT name, owner, country, language, stars, pushed_at, html_url FROM repositories WHERE pushed_at IS NOT NULL ORDER BY pushed_at DESC LIMIT 20"
364
- )
365
- st.dataframe(
366
- df_active,
367
- column_config={
368
- "html_url": st.column_config.LinkColumn("URL", display_text="Open"),
369
- },
370
- use_container_width=True,
371
- hide_index=True,
372
- )
373
-
374
  st.divider()
375
  st.subheader("🌍 Language Γ— Country Heatmap")
376
-
377
- df_heat = query_df("""
378
- SELECT country, language, COUNT(*) as count
379
- FROM repositories
380
- WHERE language IS NOT NULL AND language != ''
381
- AND country IN (SELECT country FROM repositories GROUP BY country ORDER BY COUNT(*) DESC LIMIT 10)
382
- AND language IN (SELECT language FROM repositories WHERE language IS NOT NULL AND language != '' GROUP BY language ORDER BY COUNT(*) DESC LIMIT 10)
383
- GROUP BY country, language
384
- """)
 
 
 
 
 
385
  if not df_heat.empty:
386
  pivot = df_heat.pivot_table(index="country", columns="language", values="count", fill_value=0)
387
- fig = px.imshow(
388
- pivot, text_auto=True,
389
- color_continuous_scale="YlOrRd",
390
- labels=dict(x="Language", y="Country", color="Repos"),
391
- aspect="auto",
392
- )
393
  fig.update_layout(height=500)
394
  st.plotly_chart(fig, use_container_width=True)
395
  else:
396
  st.info("Not enough data for heatmap.")
397
 
 
398
  st.divider()
399
- st.caption("Data sourced from government GitHub accounts worldwide. Built with Streamlit.")
 
 
 
 
 
4
  import plotly.graph_objects as go
5
  import sqlite3
6
  import os
 
7
  import logging
8
+ from datetime import datetime, timedelta, timezone
9
 
10
+ # Configure logging
11
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
12
  logger = logging.getLogger("govtech-dashboard")
13
  logger.info("Starting GovTech Dashboard...")
 
18
  layout="wide",
19
  )
20
 
 
 
 
21
 
22
  def get_db_path():
23
  candidates = [
 
27
  "../govtech.db",
28
  ]
29
  for p in candidates:
 
30
  if os.path.exists(p):
31
  logger.info(f"Found local DB: {os.path.abspath(p)}")
32
  return os.path.abspath(p)
 
33
  logger.info("No local DB found, downloading from HuggingFace Hub...")
34
  try:
35
  from huggingface_hub import hf_hub_download
 
47
 
48
 
49
  DB_PATH = get_db_path()
 
 
 
 
 
 
 
 
50
 
51
 
52
  def get_conn():
 
62
 
63
 
64
  @st.cache_data(ttl=300)
65
+ def query_one(sql, params=None):
66
  conn = get_conn()
67
  cur = conn.cursor()
68
+ result = cur.execute(sql, params or []).fetchone()[0]
69
  conn.close()
70
  return result
71
 
72
 
73
+ @st.cache_data(ttl=600)
74
+ def load_filter_options():
75
+ conn = get_conn()
76
+ countries = pd.read_sql_query(
77
+ "SELECT DISTINCT country FROM repositories WHERE country IS NOT NULL AND country != '' ORDER BY country", conn
78
+ )["country"].tolist()
79
+ languages = pd.read_sql_query(
80
+ "SELECT DISTINCT language FROM repositories WHERE language IS NOT NULL AND language != '' ORDER BY language", conn
81
+ )["language"].tolist()
82
+ tags = pd.read_sql_query(
83
+ "SELECT tag, COUNT(*) as c FROM repository_tags GROUP BY tag ORDER BY c DESC", conn
84
+ )["tag"].tolist()
85
+ orgs = pd.read_sql_query(
86
+ "SELECT owner, COUNT(*) as c FROM repositories GROUP BY owner ORDER BY c DESC LIMIT 300", conn
87
+ )["owner"].tolist()
88
+ conn.close()
89
+ return countries, languages, tags, orgs
90
+
91
+
92
+ # ==================== SIDEBAR FILTERS ====================
93
+ st.sidebar.title("πŸ”­ Filters")
94
+ st.sidebar.caption("Applied across all tabs")
95
+
96
+ countries, languages, tags, orgs = load_filter_options()
97
+
98
+ sel_countries = st.sidebar.multiselect("🌍 Country", countries, key="g_country")
99
+ sel_orgs = st.sidebar.multiselect("🏒 Organisation", orgs, key="g_org")
100
+ sel_languages = st.sidebar.multiselect("πŸ’» Language", languages, key="g_lang")
101
+ sel_tags = st.sidebar.multiselect("🏷️ Tag", tags, key="g_tag")
102
+
103
+ st.sidebar.divider()
104
+ st.sidebar.subheader("πŸ“… Activity")
105
+ activity_options = {
106
+ "All time": None,
107
+ "Active last 3 months": 90,
108
+ "Active last 6 months": 180,
109
+ "Active last 12 months": 365,
110
+ "Active last 2 years": 730,
111
+ }
112
+ activity_label = st.sidebar.selectbox("Last pushed", list(activity_options.keys()), index=0, key="g_activity")
113
+ activity_days = activity_options[activity_label]
114
+
115
+ st.sidebar.divider()
116
+ show_archived = st.sidebar.checkbox("Include archived", value=False, key="g_arch")
117
+ show_forks = st.sidebar.checkbox("Include forks", value=True, key="g_forks")
118
+ min_stars = st.sidebar.slider("Min stars", 0, 500, 0, key="g_stars")
119
+
120
+ st.sidebar.divider()
121
+ st.sidebar.markdown(
122
+ "πŸ”— [GitHub Repo](https://github.com/AndreasThinks/open-govtech-report) &nbsp;|&nbsp; "
123
+ "[Dataset on HF](https://huggingface.co/datasets/AndreasThinks/government-github-repos)",
124
+ unsafe_allow_html=True,
125
+ )
126
+
127
+
128
+ def build_where(extra_conditions=None, base_table="r", tag_table="rt"):
129
+ """Build a WHERE clause and params list from global sidebar filters."""
130
+ conditions = list(extra_conditions or [])
131
+ params = []
132
+
133
+ if sel_countries:
134
+ ph = ",".join(["?"] * len(sel_countries))
135
+ conditions.append(f"{base_table}.country IN ({ph})")
136
+ params.extend(sel_countries)
137
+
138
+ if sel_orgs:
139
+ ph = ",".join(["?"] * len(sel_orgs))
140
+ conditions.append(f"{base_table}.owner IN ({ph})")
141
+ params.extend(sel_orgs)
142
+
143
+ if sel_languages:
144
+ ph = ",".join(["?"] * len(sel_languages))
145
+ conditions.append(f"{base_table}.language IN ({ph})")
146
+ params.extend(sel_languages)
147
+
148
+ if activity_days:
149
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=activity_days)).strftime("%Y-%m-%dT%H:%M:%SZ")
150
+ conditions.append(f"{base_table}.pushed_at >= ?")
151
+ params.append(cutoff)
152
+
153
+ if not show_archived:
154
+ conditions.append(f"({base_table}.archived = 0 OR {base_table}.archived IS NULL)")
155
+
156
+ if not show_forks:
157
+ conditions.append(f"({base_table}.fork = 0 OR {base_table}.fork IS NULL)")
158
+
159
+ if min_stars > 0:
160
+ conditions.append(f"{base_table}.stars >= ?")
161
+ params.append(min_stars)
162
+
163
+ return conditions, params
164
+
165
+
166
+ def build_tag_join_where(extra_conditions=None):
167
+ """Build WHERE for queries that need to join repository_tags for tag filter."""
168
+ conditions, params = build_where(extra_conditions)
169
+ tag_join = ""
170
+ if sel_tags:
171
+ tag_join = "JOIN repository_tags rt ON r.html_url = rt.html_url"
172
+ ph = ",".join(["?"] * len(sel_tags))
173
+ conditions.append(f"rt.tag IN ({ph})")
174
+ params.extend(sel_tags)
175
+ where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
176
+ return where, params, tag_join
177
+
178
+
179
+ # ==================== HEADER ====================
180
+ st.title("πŸ›οΈ GovTech GitHub Explorer")
181
+ st.caption("Exploring 70k+ government GitHub repositories worldwide")
182
+
183
+ # ==================== TABS ====================
184
  tab_overview, tab_explorer, tab_tags, tab_insights = st.tabs(
185
  ["πŸ“Š Overview", "πŸ” Explorer", "🏷️ Tags", "πŸ’‘ Insights"]
186
  )
187
 
188
+
189
  # ==================== OVERVIEW ====================
190
  with tab_overview:
191
+ where, params, tag_join = build_tag_join_where()
192
+
193
+ total_filtered = query_one(f"SELECT COUNT(DISTINCT r.html_url) FROM repositories r {tag_join} {where}", params)
194
  account_count = query_one("SELECT COUNT(*) FROM accounts")
195
+ country_count_val = query_one(
196
+ f"SELECT COUNT(DISTINCT r.country) FROM repositories r {tag_join} {where}", params
197
+ )
198
+
199
+ # Active in last 12m within filtered set
200
+ active_cutoff = (datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")
201
+ active_conditions, active_params = build_where([f"r.pushed_at >= ?"])
202
+ active_params_full = active_params.copy()
203
+ active_params_full.insert(
204
+ len(active_params) - 1 if active_params else 0, active_cutoff
205
+ )
206
+ # Simpler: just count directly
207
+ conn = get_conn()
208
+ conds_12m, p_12m = build_where()
209
+ conds_12m.append("r.pushed_at >= ?")
210
+ p_12m.append(active_cutoff)
211
+ tj2 = "JOIN repository_tags rt ON r.html_url = rt.html_url" if sel_tags else ""
212
+ if sel_tags:
213
+ ph = ",".join(["?"] * len(sel_tags))
214
+ conds_12m.append(f"rt.tag IN ({ph})")
215
+ p_12m.extend(sel_tags)
216
+ w12 = ("WHERE " + " AND ".join(conds_12m)) if conds_12m else ""
217
+ active_12m = conn.execute(
218
+ f"SELECT COUNT(DISTINCT r.html_url) FROM repositories r {tj2} {w12}", p_12m
219
+ ).fetchone()[0]
220
+ conn.close()
221
 
222
  c1, c2, c3, c4 = st.columns(4)
223
+ c1.metric("Repositories", f"{total_filtered:,}")
224
  c2.metric("Accounts", f"{account_count:,}")
225
+ c3.metric("Countries", country_count_val)
226
+ c4.metric("Active last 12m", f"{active_12m:,}", help="Repos with a push in the last 12 months")
227
 
228
  st.divider()
229
 
230
  col_left, col_right = st.columns(2)
231
 
232
  with col_left:
233
+ st.subheader("Top Countries by Repositories")
234
  df_countries = query_df(
235
+ f"SELECT r.country, COUNT(DISTINCT r.html_url) as count FROM repositories r {tag_join} {where} GROUP BY r.country ORDER BY count DESC LIMIT 20",
236
+ params,
237
  )
238
+ if not df_countries.empty:
239
+ fig = px.bar(df_countries, x="country", y="count", color="count", color_continuous_scale="Blues")
240
+ fig.update_layout(showlegend=False, xaxis_title="Country", yaxis_title="Repositories", coloraxis_showscale=False)
241
+ st.plotly_chart(fig, use_container_width=True)
 
 
242
 
243
  with col_right:
244
+ st.subheader("Top Languages")
245
  df_langs = query_df(
246
+ f"""SELECT r.language, COUNT(DISTINCT r.html_url) as count
247
+ FROM repositories r {tag_join} {where}
248
+ {"AND" if where else "WHERE"} r.language IS NOT NULL AND r.language != ''
249
+ GROUP BY r.language ORDER BY count DESC LIMIT 15""",
250
+ params,
251
  )
252
+ if not df_langs.empty:
253
+ fig = px.bar(df_langs, x="count", y="language", orientation="h", color="count", color_continuous_scale="Greens")
254
+ fig.update_layout(showlegend=False, yaxis=dict(autorange="reversed"), xaxis_title="Repositories", yaxis_title="", coloraxis_showscale=False)
255
+ st.plotly_chart(fig, use_container_width=True)
256
 
257
  st.subheader("Repository Creation Timeline")
258
  df_timeline = query_df(
259
+ f"""SELECT SUBSTR(r.created_at, 1, 4) as year, COUNT(DISTINCT r.html_url) as count
260
+ FROM repositories r {tag_join} {where}
261
+ {"AND" if where else "WHERE"} r.created_at IS NOT NULL
262
+ GROUP BY year ORDER BY year""",
263
+ params,
264
  )
265
  df_timeline = df_timeline[df_timeline["year"].str.match(r"^\d{4}$", na=False)]
266
+
267
+ # Also pull active repos per year (pushed_at within 12m of each year-end β€” proxy: pushed in that year or later)
268
+ df_pushed = query_df(
269
+ f"""SELECT SUBSTR(r.pushed_at, 1, 4) as year, COUNT(DISTINCT r.html_url) as active
270
+ FROM repositories r {tag_join} {where}
271
+ {"AND" if where else "WHERE"} r.pushed_at IS NOT NULL
272
+ GROUP BY year ORDER BY year""",
273
+ params,
274
  )
275
+ df_pushed = df_pushed[df_pushed["year"].str.match(r"^\d{4}$", na=False)]
276
+
277
+ if not df_timeline.empty:
278
+ fig = go.Figure()
279
+ fig.add_trace(go.Scatter(
280
+ x=df_timeline["year"], y=df_timeline["count"],
281
+ name="Created", fill="tozeroy", mode="lines",
282
+ line=dict(color="#3b82f6"), fillcolor="rgba(59,130,246,0.2)"
283
+ ))
284
+ if not df_pushed.empty:
285
+ fig.add_trace(go.Scatter(
286
+ x=df_pushed["year"], y=df_pushed["active"],
287
+ name="Last pushed", fill="tozeroy", mode="lines",
288
+ line=dict(color="#10b981"), fillcolor="rgba(16,185,129,0.15)"
289
+ ))
290
+ fig.update_layout(xaxis_title="Year", yaxis_title="Repositories", legend=dict(orientation="h"))
291
+ st.plotly_chart(fig, use_container_width=True)
292
+ st.caption("'Last pushed' shows when repositories last received a commit β€” a proxy for active maintenance.")
293
 
294
 
295
  # ==================== EXPLORER ====================
296
  with tab_explorer:
297
+ where_e, params_e, tag_join_e = build_tag_join_where()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
+ # Extra local search
300
+ search_text = st.text_input("Search name / description", key="exp_search")
301
  if search_text:
302
+ where_e_conds, _ = build_where()
303
+ where_e_conds.append("(r.name LIKE ? OR r.description LIKE ?)")
304
+ params_e_local = params_e + [f"%{search_text}%", f"%{search_text}%"]
305
+ where_e_local = ("WHERE " + " AND ".join(where_e_conds + (["(r.name LIKE ? OR r.description LIKE ?)"] if search_text else []))) if where_e_conds else ""
306
+ else:
307
+ params_e_local = params_e
308
 
309
+ sort_col = st.selectbox("Sort by", ["stars", "forks", "pushed_at", "created_at"], key="exp_sort")
310
 
 
311
  conn = get_conn()
312
+ count_sql = f"SELECT COUNT(DISTINCT r.html_url) FROM repositories r {tag_join_e} {where_e}"
313
+ if search_text:
314
+ extra = " AND (r.name LIKE ? OR r.description LIKE ?)"
315
+ total_results = conn.execute(count_sql + extra, params_e + [f"%{search_text}%", f"%{search_text}%"]).fetchone()[0]
316
+ else:
317
+ total_results = conn.execute(count_sql, params_e).fetchone()[0]
318
  conn.close()
319
 
320
+ st.write(f"**{total_results:,}** repositories match current filters")
321
 
322
  page_size = 50
323
  total_pages = max(1, (total_results + page_size - 1) // page_size)
324
  page = st.number_input("Page", min_value=1, max_value=total_pages, value=1, key="exp_page")
325
  offset = (page - 1) * page_size
326
 
327
+ search_clause = " AND (r.name LIKE ? OR r.description LIKE ?)" if search_text else ""
328
+ search_params = [f"%{search_text}%", f"%{search_text}%"] if search_text else []
329
+
330
  data_sql = f"""
331
+ SELECT r.html_url, r.name, r.owner, r.country, r.language, r.stars, r.forks,
332
+ r.license, r.created_at, r.pushed_at, r.archived, r.fork
333
+ FROM repositories r {tag_join_e} {where_e} {search_clause}
334
+ GROUP BY r.html_url
335
+ ORDER BY r.{sort_col} DESC
336
  LIMIT ? OFFSET ?
337
  """
338
+ df_results = query_df(data_sql, params_e + search_params + [page_size, offset])
 
 
339
 
340
  if not df_results.empty:
341
  st.dataframe(
 
350
  "forks": st.column_config.NumberColumn("🍴 Forks"),
351
  "license": st.column_config.TextColumn("License"),
352
  "created_at": st.column_config.TextColumn("Created"),
353
+ "pushed_at": st.column_config.TextColumn("Last pushed"),
354
+ "archived": st.column_config.CheckboxColumn("Archived"),
355
+ "fork": st.column_config.CheckboxColumn("Fork"),
356
  },
357
  use_container_width=True,
358
  hide_index=True,
 
364
 
365
  # ==================== TAGS ====================
366
  with tab_tags:
367
+ where_t, params_t, tag_join_t = build_tag_join_where()
368
+
369
  tagged_count_t = query_one("SELECT COUNT(DISTINCT html_url) FROM repository_tags")
370
  total_repos_t = query_one("SELECT COUNT(*) FROM repositories")
371
 
372
+ if tagged_count_t < total_repos_t * 0.99:
373
  pct = tagged_count_t / total_repos_t * 100 if total_repos_t > 0 else 0
374
  st.info(
375
  f"πŸ—οΈ **Tagging in progress** β€” {tagged_count_t:,} of {total_repos_t:,} repositories tagged ({pct:.1f}%). "
376
+ "Results below reflect partially tagged data."
377
  )
378
 
379
+ col_tl, col_tr = st.columns(2)
380
+
381
+ with col_tl:
382
+ st.subheader("Top Tags")
383
+ df_top_tags = query_df(
384
+ f"""SELECT rt2.tag, COUNT(DISTINCT r.html_url) as count
385
+ FROM repositories r
386
+ JOIN repository_tags rt2 ON r.html_url = rt2.html_url
387
+ {tag_join_t.replace("rt", "rt_f") if sel_tags else ""}
388
+ {where_t.replace("rt.", "rt2.") if where_t else ""}
389
+ {"AND" if where_t else "WHERE"} rt2.tag IS NOT NULL
390
+ GROUP BY rt2.tag ORDER BY count DESC LIMIT 30""",
391
+ params_t,
392
  )
393
+ if not df_top_tags.empty:
394
+ fig = px.bar(
395
+ df_top_tags, x="count", y="tag", orientation="h",
396
+ color="count", color_continuous_scale="Purples",
397
+ )
398
+ fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False, height=600,
399
+ xaxis_title="Repositories", yaxis_title="", coloraxis_showscale=False)
400
+ st.plotly_chart(fig, use_container_width=True)
401
 
402
+ with col_tr:
403
+ st.subheader("Tags by Year Created")
404
+ st.caption("Repos tagged with each technology, by creation year β€” shows technology adoption over time.")
405
+
406
+ # Pick top 10 tags for the chart
407
+ if not df_top_tags.empty:
408
+ top10_tags = df_top_tags.head(10)["tag"].tolist()
409
+ ph = ",".join(["?"] * len(top10_tags))
410
+ conds_ty, params_ty = build_where(base_table="r")
411
+ conds_ty.append(f"rt3.tag IN ({ph})")
412
+ params_ty.extend(top10_tags)
413
+ conds_ty.append("r.created_at IS NOT NULL")
414
+ w_ty = ("WHERE " + " AND ".join(conds_ty)) if conds_ty else ""
415
+ df_tag_time = query_df(
416
+ f"""SELECT SUBSTR(r.created_at,1,4) as year, rt3.tag, COUNT(DISTINCT r.html_url) as count
417
+ FROM repositories r JOIN repository_tags rt3 ON r.html_url = rt3.html_url
418
+ {w_ty}
419
+ GROUP BY year, rt3.tag ORDER BY year""",
420
+ params_ty,
421
+ )
422
+ df_tag_time = df_tag_time[df_tag_time["year"].str.match(r"^\d{4}$", na=False)]
423
+ if not df_tag_time.empty:
424
+ fig = px.line(df_tag_time, x="year", y="count", color="tag",
425
+ labels={"year": "Year", "count": "Repos created", "tag": "Tag"})
426
+ fig.update_layout(legend=dict(orientation="h", y=-0.3))
427
+ st.plotly_chart(fig, use_container_width=True)
428
 
429
+ st.divider()
430
  st.subheader("Browse Repos by Tag")
431
+ browse_tags = df_top_tags["tag"].tolist() if not df_top_tags.empty else tags
432
+ if browse_tags:
433
+ sel_tag = st.selectbox("Select a tag", browse_tags, key="tag_browse")
434
+ sort_tag = st.selectbox("Sort by", ["stars", "pushed_at", "created_at"], key="tag_sort")
435
+ conds_br, params_br = build_where(base_table="r")
436
+ conds_br.append("rt_b.tag = ?")
437
+ params_br.append(sel_tag)
438
+ w_br = ("WHERE " + " AND ".join(conds_br)) if conds_br else ""
439
  df_tag_repos = query_df(
440
+ f"""SELECT r.name, r.owner, r.country, r.language, r.stars, r.pushed_at, rt_b.confidence, r.html_url
441
+ FROM repository_tags rt_b JOIN repositories r ON rt_b.html_url = r.html_url
442
+ {w_br} ORDER BY r.{sort_tag} DESC LIMIT 200""",
443
+ params_br,
444
  )
445
  st.write(f"**{len(df_tag_repos)}** repos tagged with **{sel_tag}**")
446
  if not df_tag_repos.empty:
 
449
  column_config={
450
  "html_url": st.column_config.LinkColumn("URL", display_text="Open"),
451
  "confidence": st.column_config.ProgressColumn("Confidence", min_value=0, max_value=1),
452
+ "stars": st.column_config.NumberColumn("⭐ Stars"),
453
+ "pushed_at": st.column_config.TextColumn("Last pushed"),
454
  },
455
  use_container_width=True,
456
  hide_index=True,
457
  )
458
+
459
+ st.divider()
460
+ st.subheader("Tag Groups")
461
+ df_groups = query_df("SELECT id, name, description FROM tag_groups ORDER BY name")
462
+ if not df_groups.empty:
463
+ for _, grp in df_groups.iterrows():
464
+ with st.expander(f"πŸ“ {grp['name']}" + (f" β€” {grp['description']}" if grp["description"] else "")):
465
+ df_members = query_df(
466
+ "SELECT tag FROM tag_group_members WHERE group_id = ? ORDER BY tag",
467
+ [int(grp["id"])]
468
+ )
469
+ if not df_members.empty:
470
+ st.write(", ".join(df_members["tag"].tolist()))
471
+ else:
472
+ st.write("No tags in this group yet.")
473
  else:
474
+ st.info("No tag groups defined yet.")
475
 
476
 
477
  # ==================== INSIGHTS ====================
478
  with tab_insights:
479
+ where_i, params_i, tag_join_i = build_tag_join_where()
480
+
481
+ col_ia, col_ib = st.columns(2)
482
+
483
+ with col_ia:
484
+ st.subheader("⭐ Most Starred")
485
+ df_top = query_df(
486
+ f"""SELECT r.name, r.owner, r.country, r.stars, r.language, r.pushed_at, r.html_url
487
+ FROM repositories r {tag_join_i} {where_i}
488
+ GROUP BY r.html_url ORDER BY r.stars DESC LIMIT 25""",
489
+ params_i,
490
+ )
491
+ st.dataframe(
492
+ df_top,
493
+ column_config={
494
+ "html_url": st.column_config.LinkColumn("URL", display_text="Open"),
495
+ "stars": st.column_config.NumberColumn("⭐ Stars"),
496
+ "pushed_at": st.column_config.TextColumn("Last pushed"),
497
+ },
498
+ use_container_width=True,
499
+ hide_index=True,
500
+ )
501
+
502
+ with col_ib:
503
+ st.subheader("πŸš€ Rising Stars (active last 12m, sorted by stars)")
504
+ rising_cutoff = (datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")
505
+ conds_r, params_r = build_where(base_table="r")
506
+ conds_r.append("r.pushed_at >= ?")
507
+ params_r.append(rising_cutoff)
508
+ tj_r = "JOIN repository_tags rt ON r.html_url = rt.html_url" if sel_tags else ""
509
+ if sel_tags:
510
+ ph = ",".join(["?"] * len(sel_tags))
511
+ conds_r.append(f"rt.tag IN ({ph})")
512
+ params_r.extend(sel_tags)
513
+ w_r = ("WHERE " + " AND ".join(conds_r)) if conds_r else ""
514
+ df_rising = query_df(
515
+ f"""SELECT r.name, r.owner, r.country, r.stars, r.language, r.pushed_at, r.html_url
516
+ FROM repositories r {tj_r} {w_r}
517
+ GROUP BY r.html_url ORDER BY r.stars DESC LIMIT 25""",
518
+ params_r,
519
+ )
520
+ st.dataframe(
521
+ df_rising,
522
+ column_config={
523
+ "html_url": st.column_config.LinkColumn("URL", display_text="Open"),
524
+ "stars": st.column_config.NumberColumn("⭐ Stars"),
525
+ "pushed_at": st.column_config.TextColumn("Last pushed"),
526
+ },
527
+ use_container_width=True,
528
+ hide_index=True,
529
+ )
530
+
531
+ st.divider()
532
+
533
+ st.subheader("πŸ“ˆ Most Active Organisations")
534
+ st.caption("Organisations ranked by number of repos with a push in the last 12 months.")
535
+ conds_ao, params_ao = build_where(base_table="r")
536
+ conds_ao.append("r.pushed_at >= ?")
537
+ params_ao.append((datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ"))
538
+ tj_ao = "JOIN repository_tags rt ON r.html_url = rt.html_url" if sel_tags else ""
539
+ if sel_tags:
540
+ ph = ",".join(["?"] * len(sel_tags))
541
+ conds_ao.append(f"rt.tag IN ({ph})")
542
+ params_ao.extend(sel_tags)
543
+ w_ao = ("WHERE " + " AND ".join(conds_ao)) if conds_ao else ""
544
+ df_active_orgs = query_df(
545
+ f"""SELECT r.owner, r.country, COUNT(DISTINCT r.html_url) as active_repos,
546
+ SUM(r.stars) as total_stars
547
+ FROM repositories r {tj_ao} {w_ao}
548
+ GROUP BY r.owner ORDER BY active_repos DESC LIMIT 20""",
549
+ params_ao,
550
  )
551
+ col_org1, col_org2 = st.columns(2)
552
+ with col_org1:
553
+ if not df_active_orgs.empty:
554
+ fig = px.bar(df_active_orgs, x="active_repos", y="owner", orientation="h",
555
+ color="active_repos", color_continuous_scale="Oranges",
556
+ labels={"active_repos": "Active repos (12m)", "owner": ""})
557
+ fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False,
558
+ height=500, coloraxis_showscale=False)
559
+ st.plotly_chart(fig, use_container_width=True)
560
+ with col_org2:
561
+ if not df_active_orgs.empty:
562
+ st.dataframe(df_active_orgs, use_container_width=True, hide_index=True,
563
+ column_config={"total_stars": st.column_config.NumberColumn("⭐ Total stars"),
564
+ "active_repos": st.column_config.NumberColumn("Active repos (12m)")})
565
 
566
  st.divider()
 
567
 
568
+ col_ic, col_id = st.columns(2)
569
+
570
+ with col_ic:
571
  st.subheader("πŸ“œ License Breakdown")
572
  df_lic = query_df(
573
+ f"""SELECT r.license, COUNT(DISTINCT r.html_url) as count
574
+ FROM repositories r {tag_join_i} {where_i}
575
+ {"AND" if where_i else "WHERE"} r.license IS NOT NULL AND r.license != ''
576
+ GROUP BY r.license ORDER BY count DESC""",
577
+ params_i,
578
  )
579
  if not df_lic.empty:
580
  top_n = 10
 
588
  fig.update_traces(textposition="inside", textinfo="percent+label")
589
  st.plotly_chart(fig, use_container_width=True)
590
 
591
+ with col_id:
592
  st.subheader("🍴 Fork vs Original")
593
+ fork_count = query_one(
594
+ f"SELECT COUNT(DISTINCT r.html_url) FROM repositories r {tag_join_i} {where_i} {'AND' if where_i else 'WHERE'} r.fork = 1",
595
+ params_i,
596
+ )
597
+ original_count = query_one(
598
+ f"SELECT COUNT(DISTINCT r.html_url) FROM repositories r {tag_join_i} {where_i} {'AND' if where_i else 'WHERE'} (r.fork = 0 OR r.fork IS NULL)",
599
+ params_i,
600
+ )
601
  m1, m2 = st.columns(2)
602
+ m1.metric("Original", f"{original_count:,}")
603
+ m2.metric("Forked", f"{fork_count:,}")
604
  fig = px.pie(
605
  pd.DataFrame({"type": ["Original", "Fork"], "count": [original_count, fork_count]}),
606
  names="type", values="count", hole=0.4,
607
+ color_discrete_sequence=["#2ecc71", "#e74c3c"],
608
  )
609
  st.plotly_chart(fig, use_container_width=True)
610
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
611
  st.divider()
612
  st.subheader("🌍 Language Γ— Country Heatmap")
613
+ df_heat = query_df(
614
+ f"""SELECT r.country, r.language, COUNT(DISTINCT r.html_url) as count
615
+ FROM repositories r {tag_join_i} {where_i}
616
+ {"AND" if where_i else "WHERE"} r.language IS NOT NULL AND r.language != ''
617
+ AND r.country IN (
618
+ SELECT country FROM repositories GROUP BY country ORDER BY COUNT(*) DESC LIMIT 15
619
+ )
620
+ AND r.language IN (
621
+ SELECT language FROM repositories WHERE language IS NOT NULL AND language != ''
622
+ GROUP BY language ORDER BY COUNT(*) DESC LIMIT 12
623
+ )
624
+ GROUP BY r.country, r.language""",
625
+ params_i,
626
+ )
627
  if not df_heat.empty:
628
  pivot = df_heat.pivot_table(index="country", columns="language", values="count", fill_value=0)
629
+ fig = px.imshow(pivot, text_auto=True, color_continuous_scale="YlOrRd",
630
+ labels=dict(x="Language", y="Country", color="Repos"), aspect="auto")
 
 
 
 
631
  fig.update_layout(height=500)
632
  st.plotly_chart(fig, use_container_width=True)
633
  else:
634
  st.info("Not enough data for heatmap.")
635
 
636
+
637
  st.divider()
638
+ st.caption(
639
+ "Data sourced from government GitHub accounts worldwide. Built with Streamlit. "
640
+ "| [GitHub](https://github.com/AndreasThinks/open-govtech-report) "
641
+ "| [Dataset](https://huggingface.co/datasets/AndreasThinks/government-github-repos)"
642
+ )