iemdpk commited on
Commit
8362cf4
Β·
verified Β·
1 Parent(s): 1ef18dd

Upload folder using huggingface_hub

Browse files
.DS_Store ADDED
Binary file (6.15 kB). View file
 
.github/workflows/npm-publish.yml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: deploy it to server
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ jobs:
7
+ build:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+
12
+ - name: "First Command"
13
+ run: "pip3 install huggingface_hub"
14
+
15
+ - name: "Upload to server"
16
+ run : "python3 uploadToHf.py"
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ _testo/
src/.DS_Store ADDED
Binary file (6.15 kB). View file
 
src/demo_workitems.csv ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Title,Description,WorkItemType,Priority,AssignedTo,Tags,Iteration,Area
2
+ "Login page validation test","Test the login functionality with valid credentials","Test Case","2","","authentication;login","Sprint 1","Frontend"
3
+ "User registration flow","Verify user can successfully register with valid data","Test Case","1","","registration;user-management","Sprint 1","Frontend"
4
+ "Password reset functionality","Test password reset email and token validation","Test Case","1","","authentication;password","Sprint 2","Backend"
5
+ "API rate limiting test","Verify API endpoints respect rate limits","Test Case","3","","api;performance","Sprint 2","Backend"
6
+ "Database connection resilience","Test database connection retry mechanisms","Test Case","2","","database;resilience","Sprint 3","Database"
7
+ "Payment gateway integration","Test payment processing with test cards","Test Case","1","","payment;integration","Sprint 3","Payment"
8
+ "Email notification system","Verify email notifications are sent correctly","Test Case","2","","email;notifications","Sprint 1","Backend"
9
+ "File upload validation","Test file upload with various file types and sizes","Test Case","2","","file-upload;validation","Sprint 2","Frontend"
10
+ "Search functionality","Test search with various queries and filters","Test Case","3","","search;feature","Sprint 3","Frontend"
11
+ "Export to CSV feature","Verify data export functionality works correctly","Test Case","2","","export;reporting","Sprint 1","Reporting"
src/streamlit_app.py CHANGED
@@ -1,40 +1,1177 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
 
 
4
  import streamlit as st
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
 
 
 
 
 
8
 
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
 
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
1
+ """
2
+ Azure DevOps Test Management Tool
3
+ A Streamlit application for managing test work items in Azure DevOps.
4
+ """
5
+
6
  import streamlit as st
7
+ import requests
8
+ import base64
9
+ import json
10
+ import pandas as pd
11
+ import io
12
+ from typing import List, Dict, Optional, Any
13
 
14
+ # Page configuration
15
+ st.set_page_config(
16
+ page_title="Azure DevOps Test Manager",
17
+ page_icon="πŸ§ͺ",
18
+ layout="wide",
19
+ initial_sidebar_state="expanded"
20
+ )
21
 
22
+ # Custom CSS for dark theme styling
23
+ st.markdown("""
24
+ <style>
25
+ /* Dark background for main content */
26
+ .main .block-container {
27
+ background-color: #1a1d29;
28
+ color: #ffffff;
29
+ }
30
+
31
+ /* Sidebar dark theme */
32
+ [data-testid="stSidebar"] {
33
+ background-color: #1a1d29;
34
+ }
35
+
36
+ [data-testid="stSidebar"] .stMarkdown {
37
+ color: #ffffff;
38
+ }
39
+
40
+ /* Headers */
41
+ .main-header {
42
+ font-size: 2.5rem;
43
+ font-weight: bold;
44
+ color: #ffa800;
45
+ margin-bottom: 1rem;
46
+ }
47
+
48
+ h1, h2, h3, h4, h5, h6 {
49
+ color: #ffa800 !important;
50
+ }
51
+
52
+ /* Labels and text */
53
+ label, .stMarkdown, p, span {
54
+ color: #ffffff !important;
55
+ }
56
+
57
+ /* Work item cards with dark theme */
58
+ .work-item-card {
59
+ background-color: #252a3a;
60
+ border-radius: 10px;
61
+ padding: 20px;
62
+ margin-bottom: 15px;
63
+ border-left: 4px solid #ffa800;
64
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
65
+ color: #ffffff;
66
+ }
67
+ .work-item-id {
68
+ font-size: 0.9rem;
69
+ color: #ffa800;
70
+ font-weight: bold;
71
+ }
72
+ .work-item-title {
73
+ font-size: 1.2rem;
74
+ font-weight: bold;
75
+ color: #ffffff;
76
+ margin: 10px 0;
77
+ }
78
+ .work-item-status {
79
+ display: inline-block;
80
+ padding: 4px 12px;
81
+ border-radius: 12px;
82
+ font-size: 0.8rem;
83
+ font-weight: bold;
84
+ }
85
+ .status-active {
86
+ background-color: #1e3a5f;
87
+ color: #4fc3f7;
88
+ }
89
+ .status-closed {
90
+ background-color: #1b5e20;
91
+ color: #4CAF50;
92
+ }
93
+ .status-new {
94
+ background-color: #e65100;
95
+ color: #ffffff;
96
+ }
97
+
98
+ /* Messages */
99
+ .success-message {
100
+ background-color: #1b5e20;
101
+ color: #4CAF50;
102
+ padding: 10px;
103
+ border-radius: 5px;
104
+ margin: 10px 0;
105
+ border: 1px solid #4CAF50;
106
+ }
107
+ .error-message {
108
+ background-color: #5c1c1c;
109
+ color: #FF6B6B;
110
+ padding: 10px;
111
+ border-radius: 5px;
112
+ margin: 10px 0;
113
+ border: 1px solid #FF6B6B;
114
+ }
115
+
116
+ /* Comment section */
117
+ .comment-section {
118
+ background-color: #2d1b00;
119
+ border: 1px solid #ffa800;
120
+ border-radius: 8px;
121
+ padding: 15px;
122
+ margin-top: 10px;
123
+ }
124
+
125
+ /* Project cards */
126
+ .project-card {
127
+ background-color: #252a3a;
128
+ border: 2px solid #ffa800;
129
+ border-radius: 10px;
130
+ padding: 15px;
131
+ margin-bottom: 10px;
132
+ cursor: pointer;
133
+ transition: all 0.3s;
134
+ color: #ffffff;
135
+ }
136
+ .project-card:hover {
137
+ background-color: #31384a;
138
+ transform: translateY(-2px);
139
+ box-shadow: 0 4px 12px rgba(255,168,0,0.2);
140
+ }
141
+
142
+ /* Connection status */
143
+ .connection-status {
144
+ padding: 10px;
145
+ border-radius: 5px;
146
+ margin: 10px 0;
147
+ }
148
+ .connected {
149
+ background-color: #1b5e20;
150
+ color: #4CAF50;
151
+ border: 1px solid #4CAF50;
152
+ }
153
+ .disconnected {
154
+ background-color: #5c4a00;
155
+ color: #ffa800;
156
+ border: 1px solid #ffa800;
157
+ }
158
+
159
+ /* Debug section */
160
+ .debug-section {
161
+ background-color: #1a1a2e;
162
+ border: 1px solid #666;
163
+ border-radius: 5px;
164
+ padding: 10px;
165
+ margin: 10px 0;
166
+ font-family: monospace;
167
+ font-size: 0.85rem;
168
+ color: #00ff00;
169
+ }
170
+
171
+ /* Info boxes */
172
+ .stAlert {
173
+ background-color: #252a3a !important;
174
+ color: #ffffff !important;
175
+ border: 1px solid #ffa800 !important;
176
+ }
177
+
178
+ /* Text inputs */
179
+ [data-testid="stTextInput"] input {
180
+ background-color: #252a3a !important;
181
+ color: #ffffff !important;
182
+ border: 1px solid #ffa800 !important;
183
+ }
184
+
185
+ /* Select boxes */
186
+ [data-testid="stSelectbox"] > div > div {
187
+ background-color: #252a3a !important;
188
+ color: #ffffff !important;
189
+ border: 1px solid #ffa800 !important;
190
+ }
191
+
192
+ /* Text area */
193
+ [data-testid="stTextArea"] textarea {
194
+ background-color: #252a3a !important;
195
+ color: #ffffff !important;
196
+ border: 1px solid #ffa800 !important;
197
+ }
198
+
199
+ /* Multiselect */
200
+ [data-testid="stMultiSelect"] > div {
201
+ background-color: #252a3a !important;
202
+ }
203
+
204
+ /* Tabs */
205
+ button[data-baseweb="tab"] {
206
+ background-color: #252a3a !important;
207
+ color: #ffffff !important;
208
+ }
209
+ button[data-baseweb="tab"][aria-selected="true"] {
210
+ background-color: #ffa800 !important;
211
+ color: #1a1d29 !important;
212
+ }
213
+
214
+ /* Divider */
215
+ hr {
216
+ border-color: #ffa800 !important;
217
+ opacity: 0.3;
218
+ }
219
+ </style>
220
+ """, unsafe_allow_html=True)
221
+
222
+
223
+ class AzureDevOpsClient:
224
+ """Client for interacting with Azure DevOps API."""
225
+
226
+ def __init__(self, organization: str, pat: str, project: Optional[str] = None):
227
+ self.organization = organization
228
+ self.project = project
229
+ self.pat = pat
230
+ self.base_url = f"https://dev.azure.com/{organization}"
231
+ self.auth_header = self._get_auth_header()
232
+ self.debug_info = [] # Store debug information
233
+
234
+ def _get_auth_header(self) -> Dict[str, str]:
235
+ """Create authorization header with PAT."""
236
+ credentials = base64.b64encode(f":{self.pat}".encode()).decode()
237
+ return {
238
+ "Authorization": f"Basic {credentials}",
239
+ "Content-Type": "application/json"
240
+ }
241
+
242
+ def test_connection(self) -> bool:
243
+ """Test if the connection to Azure DevOps is valid."""
244
+ url = f"{self.base_url}/_apis/projects?top=1&api-version=7.0"
245
+ try:
246
+ response = requests.get(url, headers=self.auth_header)
247
+ response.raise_for_status()
248
+ return True
249
+ except requests.exceptions.RequestException:
250
+ return False
251
+
252
+ def get_projects(self) -> List[Dict]:
253
+ """Fetch all projects from the organization."""
254
+ url = f"{self.base_url}/_apis/projects?api-version=7.0"
255
+ try:
256
+ response = requests.get(url, headers=self.auth_header)
257
+ response.raise_for_status()
258
+ return response.json().get("value", [])
259
+ except requests.exceptions.RequestException as e:
260
+ st.error(f"Error fetching projects: {str(e)}")
261
+ return []
262
+
263
+ def get_work_items(self, wiql_query: Optional[str] = None, work_item_type: Optional[str] = None,
264
+ iteration_path: Optional[str] = None, area_path: Optional[str] = None,
265
+ debug: bool = False) -> List[Dict]:
266
+ """Fetch work items using WIQL query or get all work items."""
267
+ self.debug_info = [] # Reset debug info
268
+
269
+ if not self.project:
270
+ st.error("No project selected!")
271
+ return []
272
+
273
+ # Build query - simplified to get all work items first
274
+ if wiql_query is None:
275
+ # Build WHERE clause conditions
276
+ conditions = ["[System.TeamProject] = @project"]
277
+
278
+ if work_item_type and work_item_type != "All Types":
279
+ conditions.append(f"[System.WorkItemType] = '{work_item_type}'")
280
+
281
+ if iteration_path and iteration_path != "All Iterations":
282
+ conditions.append(f"[System.IterationPath] = '{iteration_path}'")
283
+
284
+ if area_path and area_path != "All Areas":
285
+ conditions.append(f"[System.AreaPath] = '{area_path}'")
286
+
287
+ where_clause = " AND ".join(conditions)
288
+
289
+ wiql_query = f"""SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType], [System.IterationPath]
290
+ FROM workitems
291
+ WHERE {where_clause}
292
+ ORDER BY [System.ChangedDate] DESC"""
293
+
294
+ url = f"{self.base_url}/{self.project}/_apis/wit/wiql?api-version=7.0"
295
+
296
+ if debug:
297
+ self.debug_info.append(f"URL: {url}")
298
+ self.debug_info.append(f"Query: {wiql_query}")
299
+ self.debug_info.append(f"Project: {self.project}")
300
+
301
+ try:
302
+ response = requests.post(
303
+ url,
304
+ headers=self.auth_header,
305
+ json={"query": wiql_query}
306
+ )
307
+
308
+ if debug:
309
+ self.debug_info.append(f"Status Code: {response.status_code}")
310
+
311
+ response.raise_for_status()
312
+
313
+ result = response.json()
314
+
315
+ if debug:
316
+ self.debug_info.append(f"Query returned {len(result.get('workItems', []))} work items")
317
+
318
+ work_item_ids = [item["id"] for item in result.get("workItems", [])]
319
+
320
+ if not work_item_ids:
321
+ if debug:
322
+ self.debug_info.append("No work item IDs returned from query")
323
+ return []
324
+
325
+ # Fetch detailed work item information
326
+ return self._get_work_item_details(work_item_ids, debug)
327
+
328
+ except requests.exceptions.RequestException as e:
329
+ error_msg = f"Error fetching work items: {str(e)}"
330
+
331
+ # Check for 401 error specifically
332
+ if hasattr(e, 'response') and e.response is not None and e.response.status_code == 401:
333
+ st.error("πŸ”’ **Authentication Error (401)**")
334
+ st.markdown("""
335
+ Your PAT (Personal Access Token) doesn't have the required permissions.
336
+
337
+ **Required PAT Scopes:**
338
+ - βœ… Work Items: **Read & Write**
339
+ - βœ… Project and Team: **Read**
340
+
341
+ **How to create a new PAT:**
342
+ 1. Go to: https://dev.azure.com/{org}/_usersSettings/tokens
343
+ 2. Click **"New Token"**
344
+ 3. Give it a name (e.g., "Test Manager")
345
+ 4. Set expiration
346
+ 5. Under **Scopes**, select:
347
+ - **Work Items**: Read & Write
348
+ - **Project and Team**: Read
349
+ 6. Click **Create** and copy the token
350
+ 7. Paste it in the sidebar and reconnect
351
+ """)
352
+ else:
353
+ st.error(error_msg)
354
+
355
+ if debug:
356
+ self.debug_info.append(error_msg)
357
+ if hasattr(e, 'response') and e.response is not None:
358
+ self.debug_info.append(f"Status Code: {e.response.status_code}")
359
+ self.debug_info.append(f"Response: {e.response.text[:500]}")
360
+
361
+ return []
362
+
363
+ def get_work_item_types(self) -> List[str]:
364
+ """Get available work item types for the project."""
365
+ if not self.project:
366
+ return []
367
+
368
+ url = f"{self.base_url}/{self.project}/_apis/wit/workitemtypes?api-version=7.0"
369
+
370
+ try:
371
+ response = requests.get(url, headers=self.auth_header)
372
+ response.raise_for_status()
373
+ types = response.json().get("value", [])
374
+ type_names = [t.get("name", "") for t in types if t.get("name")]
375
+ return sorted(type_names)
376
+ except requests.exceptions.RequestException as e:
377
+ st.error(f"Error fetching work item types: {str(e)}")
378
+ return []
379
+
380
+ def _get_work_item_details(self, work_item_ids: List[int], debug: bool = False) -> List[Dict]:
381
+ """Get detailed information for work items."""
382
+ if not work_item_ids:
383
+ return []
384
+
385
+ # Azure DevOps has a limit on URL length, so batch if needed
386
+ batch_size = 200
387
+ all_items = []
388
+
389
+ for i in range(0, len(work_item_ids), batch_size):
390
+ batch_ids = work_item_ids[i:i + batch_size]
391
+ ids_str = ",".join(map(str, batch_ids))
392
+ url = f"{self.base_url}/_apis/wit/workitems?ids={ids_str}&$expand=all&api-version=7.0"
393
+
394
+ try:
395
+ response = requests.get(url, headers=self.auth_header)
396
+ response.raise_for_status()
397
+ batch_items = response.json().get("value", [])
398
+ all_items.extend(batch_items)
399
+
400
+ if debug:
401
+ self.debug_info.append(f"Fetched {len(batch_items)} work items in batch {i//batch_size + 1}")
402
+
403
+ except requests.exceptions.RequestException as e:
404
+ error_msg = f"Error fetching work item details for batch: {str(e)}"
405
+ st.error(error_msg)
406
+ if debug:
407
+ self.debug_info.append(error_msg)
408
+
409
+ return all_items
410
+
411
+ def update_work_item_status(self, work_item_id: int, new_state: str, comment: Optional[str] = None) -> bool:
412
+ """Update work item state and optionally add a comment."""
413
+ url = f"{self.base_url}/_apis/wit/workitems/{work_item_id}?api-version=7.0"
414
+
415
+ # Prepare the patch document
416
+ patch_document = [
417
+ {
418
+ "op": "add",
419
+ "path": "/fields/System.State",
420
+ "value": new_state
421
+ }
422
+ ]
423
+
424
+ try:
425
+ response = requests.patch(
426
+ url,
427
+ headers={**self.auth_header, "Content-Type": "application/json-patch+json"},
428
+ json=patch_document
429
+ )
430
+ response.raise_for_status()
431
+
432
+ # Add comment if provided
433
+ if comment:
434
+ self._add_comment(work_item_id, comment)
435
+
436
+ return True
437
+
438
+ except requests.exceptions.RequestException as e:
439
+ st.error(f"Error updating work item {work_item_id}: {str(e)}")
440
+ if hasattr(e, 'response') and e.response is not None:
441
+ st.error(f"Response: {e.response.text}")
442
+ return False
443
+
444
+ def _add_comment(self, work_item_id: int, comment: str) -> bool:
445
+ """Add a comment to a work item."""
446
+ url = f"{self.base_url}/_apis/wit/workitems/{work_item_id}/comments?api-version=7.0"
447
+
448
+ try:
449
+ response = requests.post(
450
+ url,
451
+ headers=self.auth_header,
452
+ json={"text": comment}
453
+ )
454
+ response.raise_for_status()
455
+ return True
456
+ except requests.exceptions.RequestException as e:
457
+ st.error(f"Error adding comment to work item {work_item_id}: {str(e)}")
458
+ return False
459
+
460
+ def create_work_item(self, title: str, description: str = "", work_item_type: str = "Task",
461
+ priority: int = 2, assigned_to: str = "", tags: str = "",
462
+ iteration: str = "", area: str = "") -> Optional[Dict]:
463
+ """Create a new work item."""
464
+ if not self.project:
465
+ st.error("No project selected!")
466
+ return None
467
+
468
+ url = f"{self.base_url}/{self.project}/_apis/wit/workitems/${work_item_type}?api-version=7.0"
469
+
470
+ # Prepare the patch document
471
+ patch_document = [
472
+ {
473
+ "op": "add",
474
+ "path": "/fields/System.Title",
475
+ "value": title
476
+ }
477
+ ]
478
+
479
+ if description:
480
+ patch_document.append({
481
+ "op": "add",
482
+ "path": "/fields/System.Description",
483
+ "value": description
484
+ })
485
+
486
+ if priority:
487
+ patch_document.append({
488
+ "op": "add",
489
+ "path": "/fields/Microsoft.VSTS.Common.Priority",
490
+ "value": priority
491
+ })
492
+
493
+ if assigned_to:
494
+ patch_document.append({
495
+ "op": "add",
496
+ "path": "/fields/System.AssignedTo",
497
+ "value": assigned_to
498
+ })
499
+
500
+ if tags:
501
+ patch_document.append({
502
+ "op": "add",
503
+ "path": "/fields/System.Tags",
504
+ "value": tags
505
+ })
506
+
507
+ if iteration:
508
+ patch_document.append({
509
+ "op": "add",
510
+ "path": "/fields/System.IterationPath",
511
+ "value": iteration if iteration.startswith(self.project) else f"{self.project}\\{iteration}"
512
+ })
513
+
514
+ if area:
515
+ patch_document.append({
516
+ "op": "add",
517
+ "path": "/fields/System.AreaPath",
518
+ "value": area if area.startswith(self.project) else f"{self.project}\\{area}"
519
+ })
520
+
521
+ try:
522
+ response = requests.post(
523
+ url,
524
+ headers={**self.auth_header, "Content-Type": "application/json-patch+json"},
525
+ json=patch_document
526
+ )
527
+ response.raise_for_status()
528
+ return response.json()
529
+ except requests.exceptions.RequestException as e:
530
+ st.error(f"Error creating work item: {str(e)}")
531
+ if hasattr(e, 'response') and e.response is not None:
532
+ st.error(f"Response: {e.response.text[:500]}")
533
+ return None
534
+
535
+ def bulk_create_work_items(self, df: pd.DataFrame) -> tuple[int, List[str]]:
536
+ """Create multiple work items from a DataFrame."""
537
+ created_count = 0
538
+ errors = []
539
+
540
+ required_columns = ['Title']
541
+ for col in required_columns:
542
+ if col not in df.columns:
543
+ return 0, [f"Required column '{col}' not found in CSV"]
544
+
545
+ for index, row in df.iterrows():
546
+ try:
547
+ title = str(row.get('Title', '')).strip()
548
+ if not title:
549
+ errors.append(f"Row {index + 1}: Title is empty")
550
+ continue
551
+
552
+ description = str(row.get('Description', '')).strip()
553
+ work_item_type = str(row.get('WorkItemType', 'Task')).strip()
554
+ priority = int(row.get('Priority', 2)) if pd.notna(row.get('Priority')) else 2
555
+ assigned_to = str(row.get('AssignedTo', '')).strip()
556
+ tags = str(row.get('Tags', '')).strip()
557
+ iteration = str(row.get('Iteration', '')).strip()
558
+ area = str(row.get('Area', '')).strip()
559
+
560
+ result = self.create_work_item(
561
+ title=title,
562
+ description=description,
563
+ work_item_type=work_item_type,
564
+ priority=priority,
565
+ assigned_to=assigned_to,
566
+ tags=tags,
567
+ iteration=iteration,
568
+ area=area
569
+ )
570
+
571
+ if result:
572
+ created_count += 1
573
+ else:
574
+ errors.append(f"Row {index + 1}: Failed to create '{title}'")
575
+
576
+ except Exception as e:
577
+ errors.append(f"Row {index + 1}: {str(e)}")
578
+
579
+ return created_count, errors
580
+
581
+ def get_test_plans(self) -> List[Dict]:
582
+ """Fetch test plans from the project."""
583
+ if not self.project:
584
+ st.error("No project selected!")
585
+ return []
586
+
587
+ url = f"{self.base_url}/{self.project}/_apis/testplan/plans?api-version=7.0"
588
+
589
+ try:
590
+ response = requests.get(url, headers=self.auth_header)
591
+ response.raise_for_status()
592
+ return response.json().get("value", [])
593
+ except requests.exceptions.RequestException as e:
594
+ st.error(f"Error fetching test plans: {str(e)}")
595
+ return []
596
+
597
+ def set_project(self, project: str):
598
+ """Set the current project."""
599
+ self.project = project
600
+
601
+ def get_iterations(self) -> List[Dict]:
602
+ """Fetch all iterations (sprints) for the project."""
603
+ if not self.project:
604
+ return []
605
+
606
+ url = f"{self.base_url}/{self.project}/_apis/work/teamsettings/iterations?api-version=7.0"
607
+
608
+ try:
609
+ response = requests.get(url, headers=self.auth_header)
610
+ response.raise_for_status()
611
+ return response.json().get("value", [])
612
+ except requests.exceptions.RequestException as e:
613
+ st.error(f"Error fetching iterations: {str(e)}")
614
+ return []
615
+
616
+ def get_areas(self) -> List[Dict]:
617
+ """Fetch all areas for the project."""
618
+ if not self.project:
619
+ return []
620
+
621
+ url = f"{self.base_url}/{self.project}/_apis/work/teamsettings/areas?api-version=7.0"
622
+
623
+ try:
624
+ response = requests.get(url, headers=self.auth_header)
625
+ response.raise_for_status()
626
+ return response.json().get("value", [])
627
+ except requests.exceptions.RequestException as e:
628
+ st.error(f"Error fetching areas: {str(e)}")
629
+ return []
630
+
631
+
632
+ def initialize_session_state():
633
+ """Initialize Streamlit session state variables."""
634
+ if "client" not in st.session_state:
635
+ st.session_state.client = None
636
+ if "projects" not in st.session_state:
637
+ st.session_state.projects = []
638
+ if "selected_project" not in st.session_state:
639
+ st.session_state.selected_project = None
640
+ if "work_items" not in st.session_state:
641
+ st.session_state.work_items = []
642
+ if "test_plans" not in st.session_state:
643
+ st.session_state.test_plans = []
644
+ if "comment_work_item_id" not in st.session_state:
645
+ st.session_state.comment_work_item_id = None
646
+ if "success_message" not in st.session_state:
647
+ st.session_state.success_message = None
648
+ if "connection_step" not in st.session_state:
649
+ st.session_state.connection_step = "connect" # connect, select_project, connected
650
+ if "work_item_types" not in st.session_state:
651
+ st.session_state.work_item_types = []
652
+ if "debug_mode" not in st.session_state:
653
+ st.session_state.debug_mode = False
654
+ if "iterations" not in st.session_state:
655
+ st.session_state.iterations = []
656
+ if "selected_iteration" not in st.session_state:
657
+ st.session_state.selected_iteration = "All Iterations"
658
+ if "areas" not in st.session_state:
659
+ st.session_state.areas = []
660
+ if "selected_area" not in st.session_state:
661
+ st.session_state.selected_area = "All Areas"
662
+
663
+
664
+ def reset_connection():
665
+ """Reset connection state."""
666
+ st.session_state.client = None
667
+ st.session_state.projects = []
668
+ st.session_state.selected_project = None
669
+ st.session_state.work_items = []
670
+ st.session_state.test_plans = []
671
+ st.session_state.work_item_types = []
672
+ st.session_state.iterations = []
673
+ st.session_state.selected_iteration = "All Iterations"
674
+ st.session_state.areas = []
675
+ st.session_state.selected_area = "All Areas"
676
+ st.session_state.connection_step = "connect"
677
+ st.session_state.success_message = "Disconnected successfully"
678
+ st.rerun()
679
+
680
+
681
+ def render_sidebar():
682
+ """Render the sidebar with connection settings."""
683
+ with st.sidebar:
684
+ st.header("πŸ”§ Connection Settings")
685
+
686
+ # Step 1: Connect to Organization
687
+ if st.session_state.connection_step == "connect":
688
+ st.subheader("Step 1: Connect to Organization")
689
+
690
+ # Organization input
691
+ org = st.text_input(
692
+ "Organization",
693
+ placeholder="your-organization",
694
+ help="Your Azure DevOps organization name (from dev.azure.com/{organization})"
695
+ )
696
+
697
+ # PAT input
698
+ pat = st.text_input(
699
+ "Personal Access Token (PAT)",
700
+ type="password",
701
+ placeholder="Enter your PAT",
702
+ help="Create a PAT at: https://dev.azure.com/{org}/_usersSettings/tokens"
703
+ )
704
+
705
+ # Connect button
706
+ if st.button("πŸ”— Connect to Organization", use_container_width=True, type="primary"):
707
+ if org and pat:
708
+ with st.spinner("Connecting..."):
709
+ client = AzureDevOpsClient(org, pat)
710
+ if client.test_connection():
711
+ st.session_state.client = client
712
+ # Fetch projects
713
+ st.session_state.projects = client.get_projects()
714
+ if st.session_state.projects:
715
+ st.session_state.connection_step = "select_project"
716
+ st.session_state.success_message = f"βœ… Connected! Found {len(st.session_state.projects)} projects"
717
+ else:
718
+ st.session_state.success_message = "βœ… Connected! No projects found"
719
+ st.rerun()
720
+ else:
721
+ st.error("❌ Connection failed. Check your organization and PAT.")
722
+ else:
723
+ st.error("⚠️ Please fill in all fields")
724
+
725
+ # Step 2: Select Project
726
+ elif st.session_state.connection_step == "select_project":
727
+ st.subheader("Step 2: Select Project")
728
+
729
+ if st.session_state.projects:
730
+ project_names = [p.get("name", "") for p in st.session_state.projects]
731
+ selected = st.selectbox(
732
+ "Choose a Project",
733
+ options=project_names,
734
+ index=0 if project_names else None
735
+ )
736
+
737
+ col1, col2 = st.columns(2)
738
+ with col1:
739
+ if st.button("βœ… Select Project", use_container_width=True, type="primary"):
740
+ if selected:
741
+ st.session_state.selected_project = selected
742
+ st.session_state.client.set_project(selected)
743
+ st.session_state.connection_step = "connected"
744
+
745
+ # Fetch available work item types, iterations, and areas
746
+ with st.spinner("Loading work item types, iterations, and areas..."):
747
+ st.session_state.work_item_types = st.session_state.client.get_work_item_types()
748
+ st.session_state.iterations = st.session_state.client.get_iterations()
749
+ st.session_state.areas = st.session_state.client.get_areas()
750
+
751
+ st.session_state.success_message = f"βœ… Project '{selected}' selected!"
752
+ st.rerun()
753
+
754
+ with col2:
755
+ if st.button("πŸ”™ Back", use_container_width=True):
756
+ st.session_state.connection_step = "connect"
757
+ st.session_state.projects = []
758
+ st.rerun()
759
+ else:
760
+ st.warning("No projects found")
761
+ if st.button("πŸ”™ Back", use_container_width=True):
762
+ st.session_state.connection_step = "connect"
763
+ st.rerun()
764
+
765
+ # Connected State
766
+ elif st.session_state.connection_step == "connected" and st.session_state.client:
767
+ st.markdown(f"""
768
+ <div class="connection-status connected">
769
+ βœ… <strong>Connected</strong><br>
770
+ Org: {st.session_state.client.organization}<br>
771
+ Project: {st.session_state.selected_project}
772
+ </div>
773
+ """, unsafe_allow_html=True)
774
+
775
+ st.divider()
776
+
777
+ # Work item type filter
778
+ type_options = ["All Types"] + st.session_state.work_item_types
779
+ selected_type = st.selectbox(
780
+ "Filter by Type",
781
+ options=type_options,
782
+ index=0,
783
+ help="Select a specific work item type or 'All Types' to see everything"
784
+ )
785
+
786
+ # Iteration filter
787
+ iteration_options = ["All Iterations"]
788
+ if st.session_state.iterations:
789
+ iteration_options.extend([iter.get("path", iter.get("name", "")) for iter in st.session_state.iterations])
790
+
791
+ selected_iteration = st.selectbox(
792
+ "Filter by Iteration",
793
+ options=iteration_options,
794
+ index=0,
795
+ help="Select a specific iteration/sprint or 'All Iterations' to see everything"
796
+ )
797
+ st.session_state.selected_iteration = selected_iteration
798
+
799
+ # Area filter
800
+ area_options = ["All Areas"]
801
+ if st.session_state.areas:
802
+ area_options.extend([area.get("path", area.get("name", "")) for area in st.session_state.areas])
803
+
804
+ selected_area = st.selectbox(
805
+ "Filter by Area",
806
+ options=area_options,
807
+ index=0,
808
+ help="Select a specific area or 'All Areas' to see everything"
809
+ )
810
+ st.session_state.selected_area = selected_area
811
+
812
+ # Debug mode toggle
813
+ st.session_state.debug_mode = st.checkbox("πŸ” Debug Mode", value=st.session_state.debug_mode)
814
+
815
+ # Fetch button
816
+ if st.button("πŸ“₯ Load Work Items", use_container_width=True, type="primary"):
817
+ with st.spinner("Fetching work items..."):
818
+ filter_type = None if selected_type == "All Types" else selected_type
819
+ filter_iteration = None if selected_iteration == "All Iterations" else selected_iteration
820
+ filter_area = None if selected_area == "All Areas" else selected_area
821
+ st.session_state.work_items = st.session_state.client.get_work_items(
822
+ work_item_type=filter_type,
823
+ iteration_path=filter_iteration,
824
+ area_path=filter_area,
825
+ debug=st.session_state.debug_mode
826
+ )
827
+
828
+ if st.session_state.work_items:
829
+ st.session_state.success_message = f"πŸ“‹ Loaded {len(st.session_state.work_items)} work items"
830
+ else:
831
+ if st.session_state.work_item_types:
832
+ st.session_state.success_message = f"πŸ“‹ No work items found. Available types in your project: {', '.join(st.session_state.work_item_types[:5])}"
833
+ else:
834
+ st.session_state.success_message = "πŸ“‹ No work items found. Try selecting 'All Types' or check if work items exist in your project."
835
+ st.rerun()
836
+
837
+ st.divider()
838
+
839
+ # Change Project button
840
+ if st.button("πŸ”„ Change Project", use_container_width=True):
841
+ st.session_state.connection_step = "select_project"
842
+ st.session_state.selected_project = None
843
+ st.session_state.work_items = []
844
+ st.session_state.test_plans = []
845
+ st.session_state.work_item_types = []
846
+ st.session_state.iterations = []
847
+ st.session_state.selected_iteration = "All Iterations"
848
+ st.session_state.areas = []
849
+ st.session_state.selected_area = "All Areas"
850
+ st.rerun()
851
+
852
+ # Disconnect button
853
+ if st.button("πŸ”Œ Disconnect", use_container_width=True, type="secondary"):
854
+ reset_connection()
855
+
856
+ st.divider()
857
+
858
+ # Bulk Upload Section
859
+ st.subheader("πŸ“€ Bulk Upload Work Items")
860
+
861
+ # Download demo CSV template
862
+ with open("demo_workitems.csv", "r") as f:
863
+ demo_csv_content = f.read()
864
+
865
+ st.download_button(
866
+ label="⬇️ Download Demo CSV Template",
867
+ data=demo_csv_content,
868
+ file_name="demo_workitems_template.csv",
869
+ mime="text/csv",
870
+ use_container_width=True
871
+ )
872
+
873
+ st.caption("Download the template, modify it with your data, then upload below.")
874
+
875
+ # CSV file uploader
876
+ uploaded_file = st.file_uploader(
877
+ "Upload CSV File",
878
+ type=['csv'],
879
+ help="Upload a CSV file with work items. Required column: Title"
880
+ )
881
+
882
+ if uploaded_file is not None:
883
+ try:
884
+ df = pd.read_csv(uploaded_file)
885
+
886
+ # Preview the data
887
+ with st.expander("πŸ“‹ Preview CSV Data", expanded=True):
888
+ st.dataframe(df, use_container_width=True)
889
+ st.write(f"**Total rows:** {len(df)}")
890
+
891
+ # Validate required columns
892
+ if 'Title' not in df.columns:
893
+ st.error("❌ CSV must contain a 'Title' column!")
894
+ else:
895
+ # Upload button
896
+ if st.button("πŸš€ Create Work Items", use_container_width=True, type="primary"):
897
+ with st.spinner("Creating work items..."):
898
+ created_count, errors = st.session_state.client.bulk_create_work_items(df)
899
+
900
+ if created_count > 0:
901
+ st.success(f"βœ… Successfully created {created_count} work items!")
902
+
903
+ if errors:
904
+ with st.expander(f"⚠️ Errors ({len(errors)})"):
905
+ for error in errors:
906
+ st.error(error)
907
+
908
+ if created_count > 0:
909
+ st.session_state.success_message = f"βœ… Created {created_count} work items from CSV"
910
+ st.rerun()
911
+
912
+ except Exception as e:
913
+ st.error(f"❌ Error reading CSV: {str(e)}")
914
+
915
+
916
+ def get_status_class(state: str) -> str:
917
+ """Get CSS class for work item state."""
918
+ state_lower = state.lower()
919
+ if state_lower in ["closed", "completed", "done", "resolved"]:
920
+ return "status-closed"
921
+ elif state_lower in ["new", "active"]:
922
+ return "status-new"
923
+ else:
924
+ return "status-active"
925
+
926
+
927
+ def render_work_item(work_item: Dict):
928
+ """Render a single work item card."""
929
+ fields = work_item.get("fields", {})
930
+ work_item_id = work_item.get("id", "N/A")
931
+ title = fields.get("System.Title", "No Title")
932
+ state = fields.get("System.State", "Unknown")
933
+ work_item_type = fields.get("System.WorkItemType", "Unknown")
934
+ assigned_to = fields.get("System.AssignedTo", {}).get("displayName", "Unassigned")
935
+ iteration_path = fields.get("System.IterationPath", "Not assigned to iteration")
936
+ area_path = fields.get("System.AreaPath", "Not assigned to area")
937
+
938
+ status_class = get_status_class(state)
939
+
940
+ st.markdown(f"""
941
+ <div class="work-item-card">
942
+ <div class="work-item-id">#{work_item_id} β€’ {work_item_type}</div>
943
+ <div class="work-item-title">{title}</div>
944
+ <div class="work-item-status {status_class}">{state}</div>
945
+ <div style="margin-top: 8px; color: #aaa; font-size: 0.85rem;">
946
+ πŸ‘€ Assigned to: {assigned_to}
947
+ </div>
948
+ <div style="margin-top: 4px; color: #ffa800; font-size: 0.85rem;">
949
+ πŸ“… Iteration: {iteration_path}
950
+ </div>
951
+ <div style="margin-top: 4px; color: #4fc3f7; font-size: 0.85rem;">
952
+ πŸ“ Area: {area_path}
953
+ </div>
954
+ </div>
955
+ """, unsafe_allow_html=True)
956
+
957
+ # Action buttons
958
+ col1, col2, col3 = st.columns([1, 1, 4])
959
+
960
+ with col1:
961
+ if st.button("βœ… Pass", key=f"pass_{work_item_id}", type="primary"):
962
+ if st.session_state.client:
963
+ success = st.session_state.client.update_work_item_status(
964
+ work_item_id, "Resolved", "Test passed - resolving work item"
965
+ )
966
+ if success:
967
+ st.session_state.success_message = f"βœ… Work item #{work_item_id} resolved successfully!"
968
+ st.rerun()
969
+
970
+ with col2:
971
+ if st.button("❌ Fail", key=f"fail_{work_item_id}", type="secondary"):
972
+ st.session_state.comment_work_item_id = work_item_id
973
+ st.rerun()
974
+
975
+ # Comment section for failed test
976
+ if st.session_state.comment_work_item_id == work_item_id:
977
+ st.markdown('<div class="comment-section">', unsafe_allow_html=True)
978
+ st.warning("πŸ“ Please add a comment explaining why this test failed:")
979
+ comment = st.text_area(
980
+ "Comment",
981
+ key=f"comment_text_{work_item_id}",
982
+ placeholder="Enter failure details...",
983
+ height=100
984
+ )
985
+
986
+ col_submit, col_cancel = st.columns([1, 1])
987
+ with col_submit:
988
+ if st.button("πŸ’Ύ Submit & Reopen", key=f"submit_{work_item_id}", type="primary"):
989
+ if comment.strip():
990
+ if st.session_state.client:
991
+ success = st.session_state.client.update_work_item_status(
992
+ work_item_id, "Active", comment
993
+ )
994
+ if success:
995
+ st.session_state.comment_work_item_id = None
996
+ st.session_state.success_message = f"πŸ“ Work item #{work_item_id} reopened with comment!"
997
+ st.rerun()
998
+ else:
999
+ st.error("⚠️ Please enter a comment")
1000
+
1001
+ with col_cancel:
1002
+ if st.button("Cancel", key=f"cancel_{work_item_id}"):
1003
+ st.session_state.comment_work_item_id = None
1004
+ st.rerun()
1005
+
1006
+ st.markdown('</div>', unsafe_allow_html=True)
1007
+
1008
+
1009
+ def main():
1010
+ """Main application function."""
1011
+ initialize_session_state()
1012
+
1013
+ # Header
1014
+ st.markdown('<div class="main-header">πŸ§ͺ Azure DevOps Test Manager</div>', unsafe_allow_html=True)
1015
+
1016
+ # Sidebar
1017
+ render_sidebar()
1018
+
1019
+ # Display success message
1020
+ if st.session_state.success_message:
1021
+ st.markdown(f'<div class="success-message">{st.session_state.success_message}</div>', unsafe_allow_html=True)
1022
+ st.session_state.success_message = None
1023
+
1024
+ # Display debug information
1025
+ if st.session_state.debug_mode and st.session_state.client and hasattr(st.session_state.client, 'debug_info') and st.session_state.client.debug_info:
1026
+ with st.expander("πŸ” Debug Information", expanded=True):
1027
+ st.markdown('<div class="debug-section">', unsafe_allow_html=True)
1028
+ for info in st.session_state.client.debug_info:
1029
+ st.text(info)
1030
+ st.markdown('</div>', unsafe_allow_html=True)
1031
+
1032
+ # Main content based on connection step
1033
+ if st.session_state.connection_step == "connect":
1034
+ st.info("πŸ‘ˆ Please enter your Azure DevOps organization and PAT in the sidebar to connect")
1035
+
1036
+ # Quick start guide
1037
+ st.markdown("""
1038
+ ### πŸš€ Quick Start Guide
1039
+
1040
+ 1. **Enter your Organization** - This is your Azure DevOps organization name (e.g., `mycompany` from `dev.azure.com/mycompany`)
1041
+ 2. **Enter your PAT** - Personal Access Token with Work Items read/write permissions
1042
+ 3. **Click Connect** - Connect to your Azure DevOps organization
1043
+ 4. **Select Project** - Choose from the list of available projects
1044
+ 5. **Fetch Work Items** - Load your test cases and plans
1045
+
1046
+ ### πŸ“ How to create a PAT
1047
+
1048
+ 1. Go to: `https://dev.azure.com/{your-org}/_usersSettings/tokens`
1049
+ 2. Click "New Token"
1050
+ 3. Give it a name and select expiration
1051
+ 4. Scopes needed: **Work Items (Read & Write)**
1052
+ 5. Create and copy the token
1053
+
1054
+ ### πŸ› Troubleshooting
1055
+
1056
+ If you're not seeing work items:
1057
+ - Enable **Debug Mode** in the sidebar to see detailed query information
1058
+ - Make sure your PAT has "Work Items (Read & Write)" permissions
1059
+ - Try selecting "All Types" in the type filter
1060
+ - Check that work items actually exist in your selected project
1061
+ """)
1062
+
1063
+ elif st.session_state.connection_step == "select_project":
1064
+ st.info("πŸ‘ˆ Please select a project from the sidebar")
1065
+
1066
+ # Display available projects
1067
+ if st.session_state.projects:
1068
+ st.subheader("πŸ“ Available Projects")
1069
+ st.write("Select a project from the dropdown in the sidebar")
1070
+
1071
+ for project in st.session_state.projects:
1072
+ name = project.get("name", "")
1073
+ description = project.get("description", "No description")
1074
+ state = project.get("state", "")
1075
+
1076
+ st.markdown(f"""
1077
+ <div class="project-card">
1078
+ <strong>πŸ“ {name}</strong><br>
1079
+ <small>{description}</small><br>
1080
+ <small>State: {state}</small>
1081
+ </div>
1082
+ """, unsafe_allow_html=True)
1083
+
1084
+ elif st.session_state.connection_step == "connected":
1085
+ # Show available work item types
1086
+ if st.session_state.work_item_types:
1087
+ with st.expander("ℹ️ Available Work Item Types in this Project"):
1088
+ st.write(", ".join(st.session_state.work_item_types))
1089
+
1090
+ # Bulk Upload Info
1091
+ with st.expander("πŸ“€ Bulk Upload Work Items from CSV"):
1092
+ st.markdown("""
1093
+ ### How to Bulk Upload Work Items
1094
+
1095
+ 1. **Download the Demo CSV Template** from the sidebar
1096
+ 2. **Modify the CSV** with your work items data
1097
+ 3. **Upload the CSV** using the file uploader in the sidebar
1098
+ 4. **Click 'Create Work Items'** to bulk create all work items
1099
+
1100
+ ### CSV Format
1101
+
1102
+ Required column:
1103
+ - **Title** - The title of the work item (required)
1104
+
1105
+ Optional columns:
1106
+ - **Description** - Detailed description of the work item
1107
+ - **WorkItemType** - Type of work item (e.g., Task, Bug, Test Case, User Story)
1108
+ - **Priority** - Priority level (1=High, 2=Normal, 3=Low)
1109
+ - **AssignedTo** - Email or display name of assignee
1110
+ - **Tags** - Semicolon-separated tags (e.g., "tag1;tag2;tag3")
1111
+ - **Iteration** - Sprint/iteration name (e.g., "Sprint 1", "Iteration 2")
1112
+ - **Area** - Area path for the work item (e.g., "Frontend", "Backend", "Database")
1113
+
1114
+ ### Available Work Item Types
1115
+ """)
1116
+ if st.session_state.work_item_types:
1117
+ st.write(", ".join(st.session_state.work_item_types))
1118
+ else:
1119
+ st.write("Common types: Task, Bug, Test Case, User Story, Feature, Epic")
1120
+
1121
+ if st.session_state.work_items:
1122
+ st.subheader(f"πŸ“‹ Work Items ({len(st.session_state.work_items)} found)")
1123
+
1124
+ # Filter options
1125
+ col1, col2, col3 = st.columns(3)
1126
+ with col1:
1127
+ filter_type = st.multiselect(
1128
+ "Filter by Type",
1129
+ options=list(set(item.get("fields", {}).get("System.WorkItemType", "Unknown")
1130
+ for item in st.session_state.work_items)),
1131
+ default=[]
1132
+ )
1133
+ with col2:
1134
+ filter_state = st.multiselect(
1135
+ "Filter by State",
1136
+ options=list(set(item.get("fields", {}).get("System.State", "Unknown")
1137
+ for item in st.session_state.work_items)),
1138
+ default=[]
1139
+ )
1140
+ with col3:
1141
+ filter_iteration = st.multiselect(
1142
+ "Filter by Iteration",
1143
+ options=list(set(item.get("fields", {}).get("System.IterationPath", "Unassigned")
1144
+ for item in st.session_state.work_items)),
1145
+ default=[]
1146
+ )
1147
+
1148
+ st.markdown("---")
1149
+
1150
+ # Apply filters
1151
+ filtered_items = st.session_state.work_items
1152
+ if filter_type:
1153
+ filtered_items = [item for item in filtered_items
1154
+ if item.get("fields", {}).get("System.WorkItemType") in filter_type]
1155
+ if filter_state:
1156
+ filtered_items = [item for item in filtered_items
1157
+ if item.get("fields", {}).get("System.State") in filter_state]
1158
+ if filter_iteration:
1159
+ filtered_items = [item for item in filtered_items
1160
+ if item.get("fields", {}).get("System.IterationPath") in filter_iteration]
1161
+
1162
+ st.write(f"Showing {len(filtered_items)} of {len(st.session_state.work_items)} work items")
1163
+
1164
+ # Render work items
1165
+ for work_item in filtered_items:
1166
+ render_work_item(work_item)
1167
+ st.markdown("---")
1168
+ else:
1169
+ st.info("πŸ‘ˆ Click 'Load Work Items' button in the sidebar to load work items")
1170
+
1171
+ if st.session_state.work_item_types:
1172
+ st.write("**Available work item types in this project:**")
1173
+ st.write(", ".join(st.session_state.work_item_types))
1174
 
 
 
1175
 
1176
+ if __name__ == "__main__":
1177
+ main()