好的,使用 VB.NET 來實現這個自動化需求完全沒有問題。核心邏輯與 PowerShell 版本相同,但我們會使用 .NET 的 HttpClient 來呼叫 REST API,並用 Newtonsoft.Json 來解析回傳的 JSON 資料。 這將是一個主控台應用程式 (Console Application),編譯後產生的 .exe 檔案可以被 Windows 工作排程器每日呼叫。 核心步驟 * 專案設定:建立一個 VB.NET 主控台應用程式,並安裝 Newtonsoft.Json 套件。 * 程式碼實作:編寫程式碼來執行查詢、獲取資料、分組並發送郵件。 * 編譯與排程:將專案編譯成 .exe,並設定工作排程器。 1. 專案設定 * 建立專案: * 打開 Visual Studio。 * 建立一個新的專案 (Create a new project)。 * 選擇 Console App (.NET Framework) 並確定語言是 Visual Basic。 * 為專案命名,例如 DevOpsNotifier。 * 安裝 Newtonsoft.Json 套件: * 在方案總管 (Solution Explorer) 中,對您的專案按右鍵,選擇 管理 NuGet 套件 (Manage NuGet Packages...)。 * 在 瀏覽 (Browse) 標籤頁中,搜尋 Newtonsoft.Json。 * 找到 Newtonsoft.Json 並點擊 安裝 (Install)。 2. 程式碼 將以下所有程式碼複製並貼到您的 Module1.vb 檔案中,完全覆蓋原有內容。 Imports System.Net.Http Imports System.Net.Http.Headers Imports System.Text Imports Newtonsoft.Json Imports System.Net.Mail Module Module1 ' ========================【請修改以下設定】======================== ' 你的 Azure DevOps Server URL 和專案名稱 Private Const organizationUrl As String = "https://tfs.yourcompany.com/DefaultCollection" Private Const projectName As String = "YourProjectName" ' 你的個人存取權杖 (PAT) Private Const pat As String = "YOUR_PERSONAL_ACCESS_TOKEN" ' 你已經建立好的查詢 ID Private Const queryId As String = "YOUR_QUERY_ID" ' SMTP 伺服器設定 (用於發送郵件) Private Const smtpServer As String = "smtp.yourcompany.com" Private Const smtpPort As Integer = 25 ' 或 587 等 Private Const emailFrom As String = "devops-no-reply@yourcompany.com" ' ================================================================= ' 建立一個靜態的 HttpClient 實例以供重複使用 Private ReadOnly httpClient As New HttpClient() Async Function Main() As Task ' 設定 HttpClient 的基礎驗證標頭 httpClient.DefaultRequestHeaders.Authorization = New AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($":{pat}"))) Console.WriteLine("開始執行 Azure DevOps 工作項目到期通知...") Try ' 步驟 1: 執行查詢以獲取 ID Console.WriteLine("正在執行查詢...") Dim workItemRefs = Await GetWorkItemIdsAsync() If workItemRefs Is Nothing OrElse workItemRefs.Count = 0 Then Console.WriteLine("沒有找到 30 天內即將到期的工作項目。") Return End If Console.WriteLine($"找到 {workItemRefs.Count} 個工作項目。") ' 步驟 2: 根據 ID 獲取詳細資訊 Console.WriteLine("正在批次獲取工作項目詳細資訊...") Dim workItemDetails = Await GetWorkItemDetailsAsync(workItemRefs.Select(Function(wi) wi.id).ToList()) ' 步驟 3: 按負責人分組 Console.WriteLine("正在按負責人分組...") Dim notifications = GroupItemsByUser(workItemDetails) ' 步驟 4: 發送通知郵件 Console.WriteLine("正在發送通知郵件...") For Each entry In notifications SendNotificationEmail(entry.Key, entry.Value) Next Console.WriteLine("所有通知已處理完畢。") Catch ex As Exception Console.ForegroundColor = ConsoleColor.Red Console.WriteLine($"發生錯誤: {ex.Message}") Console.ResetColor() End Try End Function ' 執行查詢並返回工作項目 ID 列表 Private Async Function GetWorkItemIdsAsync() As Task(Of List(Of WorkItemReference)) Dim queryUrl = $"{organizationUrl}/{projectName}/_apis/wit/wiql/{queryId}?api-version=7.0" Using response = Await httpClient.PostAsync(queryUrl, Nothing) response.EnsureSuccessStatusCode() Dim jsonString = Await response.Content.ReadAsStringAsync() Dim result = JsonConvert.DeserializeObject(Of QueryResult)(jsonString) Return result.workItems End Using End Function ' 批次獲取工作項目的詳細資訊 Private Async Function GetWorkItemDetailsAsync(ids As List(Of Integer)) As Task(Of List(Of WorkItemDetail)) Dim idsString = String.Join(",", ids) Dim fields = "System.Id,System.Title,System.State,System.AssignedTo,Microsoft.VSTS.Scheduling.TargetDate" Dim detailsUrl = $"{organizationUrl}/_apis/wit/workitems?ids={idsString}&fields={fields}&$expand=all&api-version=7.0" Using response = Await httpClient.GetAsync(detailsUrl) response.EnsureSuccessStatusCode() Dim jsonString = Await response.Content.ReadAsStringAsync() Dim result = JsonConvert.DeserializeObject(Of WorkItemDetailList)(jsonString) Return result.value End Using End Function ' 將工作項目按負責人 Email 分組 Private Function GroupItemsByUser(details As List(Of WorkItemDetail)) As Dictionary(Of String, List(Of NotificationItem)) Dim groupedItems = New Dictionary(Of String, List(Of NotificationItem))() For Each item In details ' 檢查是否有指派負責人 If item.fields.AssignedTo IsNot Nothing AndAlso Not String.IsNullOrEmpty(item.fields.AssignedTo.uniqueName) Then Dim userEmail = item.fields.AssignedTo.uniqueName Dim dueDate = item.fields.TargetDate Dim notificationItem = New NotificationItem With { .Id = item.id, .Title = item.fields.Title, .Url = item.url.Replace("_apis/wit/workItems", "_workitems/edit"), ' 轉換為網頁連結 .DueDate = dueDate.ToString("yyyy-MM-dd") } If Not groupedItems.ContainsKey(userEmail) Then groupedItems(userEmail) = New List(Of NotificationItem)() End If groupedItems(userEmail).Add(notificationItem) End If Next Return groupedItems End Function ' 發送單一郵件給指定使用者 Private Sub SendNotificationEmail(emailTo As String, items As List(Of NotificationItem)) Try Dim mailMessage As New MailMessage(emailFrom, emailTo) mailMessage.Subject = $"【Azure DevOps 通知】您有 {items.Count} 個工作項目即將到期" mailMessage.IsBodyHtml = True Dim bodyBuilder As New StringBuilder() bodyBuilder.AppendLine("<html><head><style>body { font-family: 'Segoe UI', sans-serif; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #dddddd; text-align: left; padding: 8px; } th { background-color: #f2f2f2; }</style></head><body>") bodyBuilder.AppendLine("<h2>您好,</h2>") bodyBuilder.AppendLine("<p>系統提醒您,以下工作項目將在 30 天內到期,請及時處理:</p>") bodyBuilder.AppendLine("<table><tr><th>ID</th><th>標題</th><th>到期日</th></tr>") For Each item In items bodyBuilder.AppendLine($"<tr><td><a href='{item.Url}'>{item.Id}</a></td><td>{item.Title}</td><td>{item.DueDate}</td></tr>") Next bodyBuilder.AppendLine("</table><br><p>此為系統自動發送的郵件,請勿直接回覆。</p></body></html>") mailMessage.Body = bodyBuilder.ToString() Dim smtpClient As New SmtpClient(smtpServer, smtpPort) ' 如果你的 SMTP 伺服器需要帳號密碼驗證,請取消註解並填入以下程式碼 ' smtpClient.Credentials = New System.Net.NetworkCredential("username", "password") ' smtpClient.EnableSsl = True smtpClient.Send(mailMessage) Console.WriteLine($"已成功發送通知郵件至: {emailTo}") Catch ex As Exception Console.ForegroundColor = ConsoleColor.Yellow Console.WriteLine($"發送郵件至 {emailTo} 失敗: {ex.Message}") Console.ResetColor() End Try End Sub End Module ' --- 以下是用於解析 JSON 回應的資料模型 --- Public Class QueryResult Public Property workItems As List(Of WorkItemReference) End Class Public Class WorkItemReference Public Property id As Integer End Class Public Class WorkItemDetailList Public Property value As List(Of WorkItemDetail) End Class Public Class WorkItemDetail Public Property id As Integer Public Property url As String Public Property fields As WorkItemFields End Class Public Class WorkItemFields <JsonProperty("System.Title")> Public Property Title As String <JsonProperty("System.AssignedTo")> Public Property AssignedTo As AssignedToUser <JsonProperty("Microsoft.VSTS.Scheduling.TargetDate")> Public Property TargetDate As DateTime End Class Public Class AssignedToUser Public Property uniqueName As String End Class ' --- 用於組織通知內容的輔助類別 --- Public Class NotificationItem Public Property Id As Integer Public Property Title As String Public Property Url As String Public Property DueDate As String End Class 3. 編譯與排程 * 修改設定: * 在程式碼頂端的設定區塊,填入您自己的 organizationUrl, projectName, pat, queryId, 和 SMTP 伺服器資訊。 * 編譯專案: * 在 Visual Studio 的頂端選單,選擇 建置 (Build) -> 建置方案 (Build Solution)。 * 建置成功後,到您的專案資料夾底下 bin/Debug (或 bin/Release) 中,找到產生的 .exe 檔案 (例如 DevOpsNotifier.exe)。 * 設定工作排程器: * 開啟 Windows 工作排程器 (Task Scheduler)。 * 建立工作 (Create Task)... * 一般 (General):填寫名稱,例如 "Azure DevOps Due Date Notifier"。 * 觸發程序 (Triggers):新增一個觸發程序,設定為 每日 (Daily),並選擇一個執行時間。 * 動作 (Actions):新增一個動作。 * 動作:啟動程式 (Start a program)。 * 程式/指令碼:點擊 瀏覽 (Browse...) 並選擇您剛剛編譯好的 .exe 檔案路徑。 * 儲存工作排程。 現在,這個 VB.NET 程式就會在您指定的時間自動運行,完成通知任務。
好的,收到。既然 TLS 1.2 也不是問題所在,代表我們已經排除了最常見的環境設定錯誤。現在我們需要進入更深層的偵錯。 400 錯誤依然指向我們的程式送出的請求「格式有問題」。既然 URL 沒錯、PAT 權限也夠,問題很可能出在 HTTP 標頭 (Headers) 或 請求主體 (Body) 的細微之處,這些是瀏覽器會自動處理,但 HttpClient 需要我們明確設定的地方。 核心診斷:捕獲伺服器真正的錯誤訊息 目前我們的程式碼只知道「失敗了 (400)」,但不知道伺服器為什麼認為這個請求是錯的。400 錯誤的回應中,通常會包含一個 JSON 物件,裡面有更詳細的錯誤說明。 我們現在修改程式碼,目標是無論成功或失敗,都把伺服器回傳的內容印出來,這樣就能看到最關鍵的線索。 同時,我們會模擬瀏覽器的行為,加上一些 HttpClient 預設不會加上的標頭。 解決方案:升級偵錯程式碼 請用以下完整更新的 Module1.vb 程式碼替換掉您現有的版本。這個版本做了幾項關鍵修改: * 新增 User-Agent 標頭:讓請求看起來更像一個合法的用戶端。 * 明確指定 Content-Type:在 POST 請求中,明確告知伺服器我們發送的是 JSON 格式。 * 捕獲並印出詳細錯誤:這是最重要的,在請求失敗時,讀取並印出伺服器的錯誤訊息內文。 <!-- end list --> Imports System.Net.Http Imports System.Net.Http.Headers Imports System.Text Imports Newtonsoft.Json Imports System.Net.Mail Module Module1 ' ========================【請修改以下設定】======================== Private Const organizationUrl As String = "https://tfs.yourcompany.com/DefaultCollection" Private Const projectName As String = "YourProjectName" Private Const pat As String = "YOUR_PERSONAL_ACCESS_TOKEN" Private Const queryId As String = "YOUR_QUERY_ID" Private Const smtpServer As String = "smtp.yourcompany.com" Private Const smtpPort As Integer = 25 Private Const emailFrom As String = "devops-no-reply@yourcompany.com" ' ================================================================= Private ReadOnly httpClient As New HttpClient() Sub Main() ' 強制使用 TLS 1.2 System.Net.ServicePointManager.SecurityProtocol = System.Net.SecurityProtocolType.Tls12 ' 設定基礎驗證標頭 httpClient.DefaultRequestHeaders.Authorization = New AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($":{pat}"))) ' 【修改】新增通用標頭,模擬瀏覽器行為 httpClient.DefaultRequestHeaders.Accept.Add(New MediaTypeWithQualityHeaderValue("application/json")) httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36") RunAsync().Wait() End Sub Async Function RunAsync() As Task Console.WriteLine("開始執行 Azure DevOps 工作項目到期通知...") Try Dim workItemRefs = Await GetWorkItemIdsAsync() If workItemRefs Is Nothing OrElse workItemRefs.Count = 0 Then Console.WriteLine("沒有找到 30 天內即將到期的工作項目。") Return End If Console.WriteLine($"找到 {workItemRefs.Count} 個工作項目。") Dim workItemDetails = Await GetWorkItemDetailsAsync(workItemRefs.Select(Function(wi) wi.id).ToList()) Dim notifications = GroupItemsByUser(workItemDetails) ' ... (發送郵件的邏輯不變) Catch ex As Exception Console.ForegroundColor = ConsoleColor.Red Console.WriteLine($"[執行階段捕捉到例外] 錯誤: {ex.Message}") Console.ResetColor() End Try Console.WriteLine("按任意鍵結束...") Console.ReadKey() End Function ' 【修改】徹底改寫此函式以進行深度偵錯 Private Async Function GetWorkItemIdsAsync() As Task(Of List(Of WorkItemReference)) Dim queryUrl = $"{organizationUrl}/{projectName}/_apis/wit/wiql/{queryId}?api-version=7.0" Console.WriteLine($"[DEBUG] 正在 POST 請求 Query URL: {queryUrl}") ' 【修改】明確設定 POST 請求的內容和標頭 Dim request As New HttpRequestMessage(HttpMethod.Post, queryUrl) request.Content = New StringContent("", Encoding.UTF8, "application/json") ' Body 為空,但明確指定 Content-Type Using response = Await httpClient.SendAsync(request) Dim responseBody = Await response.Content.ReadAsStringAsync() If Not response.IsSuccessStatusCode Then Console.ForegroundColor = ConsoleColor.Red Console.WriteLine($"請求失敗! 狀態碼: {response.StatusCode} ({(Int32)response.StatusCode})") Console.WriteLine("==== 伺服器回應內容 ====") Console.WriteLine(responseBody) Console.WriteLine("========================") Throw New Exception("查詢工作項目ID失敗。") End If Console.WriteLine("查詢 ID 成功。") Dim result = JsonConvert.DeserializeObject(Of QueryResult)(responseBody) Return result.workItems End Using End Function ' 【修改】徹底改寫此函式以進行深度偵錯 Private Async Function GetWorkItemDetailsAsync(ids As List(Of Integer)) As Task(Of List(Of WorkItemDetail)) Dim idsString = String.Join(",", ids) Dim fields = "System.Id,System.Title,System.State,System.AssignedTo,Microsoft.VSTS.Scheduling.TargetDate" Dim detailsUrl = $"{organizationUrl}/_apis/wit/workitems?ids={idsString}&fields={fields}&$expand=all&api-version=7.0" Console.WriteLine($"[DEBUG] 正在 GET 請求 Details URL: {detailsUrl}") Using response = Await httpClient.GetAsync(detailsUrl) Dim responseBody = Await response.Content.ReadAsStringAsync() If Not response.IsSuccessStatusCode Then Console.ForegroundColor = ConsoleColor.Red Console.WriteLine($"請求失敗! 狀態碼: {response.StatusCode} ({(Int32)response.StatusCode})") Console.WriteLine("==== 伺服器回應內容 ====") Console.WriteLine(responseBody) Console.WriteLine("========================") Throw New Exception("獲取工作項目詳情失敗。") End If Console.WriteLine("獲取詳情成功。") Dim result = JsonConvert.DeserializeObject(Of WorkItemDetailList)(responseBody) Return result.value End Using End Function ' (GroupItemsByUser 和 SendNotificationEmail 函式保持不變) '... End Module 執行步驟 * 用上面的程式碼完整替換您的 Module1.vb。 * 重新建置並執行。 * 現在,當 400 錯誤發生時,程式不會立刻崩潰,而是會印出一個區塊,標題是 ==== 伺服器回應內容 ====。 * 請將這個區塊裡的完整內容貼給我。這就是伺服器告訴我們的、最直接的錯誤原因,通常會包含像 "Invalid query id" 或 "VS402327: The value for parameter '...' is not supported." 這樣的關鍵訊息。 有了這個訊息,我們就能 100% 確定問題的根源並解決它。
太好了!這是最有價值的一條線索!這則錯誤訊息非常精準。



















