Outlookで届いた問い合わせをRedmineに転記する作業を減らしたくて、PowerShellで小さなGUIツールを作りました。
メールの件名と本文を取り込むだけなら、それほど難しくありません。手間がかかるのは、起票先プロジェクトによってトラッカーやカスタムフィールドが違うことです。担当者が変わったり、カスタムフィールドのIDが変わったりするたびに、スクリプトを修正するのは避けたいところです。
そこで、Redmine APIからプロジェクトの定義をJSONに保存し、GUIでその定義を読み込んでチケットを組み立てる方式にしました。登録前にJSONを確認でき、起票ボタンを押さない限りRedmineには送信しません。
この記事では、環境準備から起票までの手順と、GUI・CLI・設定ファイルのソースコードを掲載します。
大きく分けると2段階です。最初にRedmine側の情報を取得し、そのあとMSGを読み取って起票します。
Redmine API
└─ プロジェクト情報を取得
└─ project-metadata.json
│
▼
GUIにトラッカーなどを反映
│
Outlook .msg ──┤
▼
ルールに沿って項目を抽出
│
▼
起票JSONをプレビュー
│
起票ボタンを押す
▼
POST /issues.json
ツールの機能を整理すると、こんな構成です。
| 機能 | 内容 |
|---|---|
| プロジェクト情報の取得 | トラッカー、優先度、カテゴリ、バージョンなどをAPIから取得してJSONに保存 |
| GUIでの項目選択 | 取得した候補をドロップダウンへ反映 |
| カスタムフィールド | フィールド名から出力先を選択し、MSGの抽出ルールへ割り当て |
| MSG解析 | Classic Outlookを使って件名・本文・送信者を読み込む |
| 抽出ルール | Regex、Keywords、Literal、Constantを設定JSONで管理 |
| プレビュー・起票 | JSONを確認してからRedmineへ登録 |
| CLI | GUIを起動せずにJSON生成または明示的な起票が可能 |
| 重複チェック | 成功した起票をローカル履歴に記録し、同じMSGの再登録を防止 |
v1.3では、MSG内の添付ファイル本体をRedmineへアップロードする処理はありません。登録するのは件名、本文、指定した抽出結果です。
想定環境はWindows 11、Windows PowerShell 5.1、Classic Outlook、REST APIを有効化したRedmineです。新しいOutlookだけでは、ここで使うCOM経由のMSG読み取りはできません。
以下の10ファイルを同じフォルダに置きます。コードは後半にすべて掲載しています。
Redmine-MSG-Ticket-Studio/
├─ Start-RedmineGUI.bat
├─ Start-RedmineGUI.ps1
├─ Redmine.Core.ps1
├─ Fetch-ProjectInfo.bat
├─ Fetch-ProjectInfo.ps1
├─ Run-Issue.ps1
├─ rules.json
├─ sample-project-metadata.json
├─ test-mail.json
└─ tool-settings.json
文字コードは重要です。.ps1 はShift-JIS(CP932)、JSONはUTF-8 で保存してください。Windows PowerShell 5.1ではUTF-8 BOMなしの日本語スクリプトが文字化けする場合があります。
記事のコードブロックからコピーした場合も、ファイル保存時のエンコーディングを合わせます。
Redmineで個人用APIキーを取得し、PowerShellからユーザー環境変数に保存します。
[Environment]::SetEnvironmentVariable(
'REDMINE_API_KEY',
'YOUR_REDMINE_API_KEY',
'User'
)
YOUR_REDMINE_API_KEY は自分のキーに置き換えます。設定後にPowerShellを開き直してください。APIキーを記事・JSON・リポジトリに書き込まないようにします。
最初にプロジェクトの識別子を確認します。URLが https://redmine.example.com/projects/sample-project なら、識別子は sample-project です。
GUIを起動する場合は Start-RedmineGUI.bat を実行します。「プロジェクト設定」タブでURLと識別子を入力し、「APIから取得」を押してください。
コマンドから取得する場合は、次のように実行します。
.\Fetch-ProjectInfo.ps1 `
-BaseUrl 'https://redmine.example.com' `
-Project 'sample-project' `
-OutputPath '.\project-metadata.json'
取得結果は project-metadata.json に保存されます。次回からは、GUIの「JSONを読み込む」でそのファイルを選べます。APIへ接続できない環境で画面の配置を確認するだけなら、同梱の sample-project-metadata.json も利用できます。
内部では主に以下のAPIを呼び出します。
| API | 取得するもの |
|---|---|
/projects/{identifier}.json |
プロジェクト基本情報、トラッカー、カテゴリなど |
/trackers.json |
トラッカーの情報 |
/issue_statuses.json |
ステータス一覧 |
/enumerations/issue_priorities.json |
優先度一覧 |
/projects/{identifier}/memberships.json |
参照可能なメンバー |
/projects/{identifier}/versions.json |
バージョン |
/custom_fields.json |
カスタムフィールドの詳細定義 |
プロジェクトAPIの呼び出しでは include=trackers,issue_categories,issue_custom_fields を使います。
注意したいのが /custom_fields.json です。カスタムフィールド全体の定義取得には管理者権限が必要です。権限が足りなければ取得可能なプロジェクト情報を利用しますが、必須条件や選択肢が一部不足する場合があります。
また、ステータス一覧に表示される値が、そのまま新規起票で指定できるとは限りません。実際のワークフローと権限によって制限されます。
初期版では、Windowsの表示倍率によってヘッダーとタブの位置が重なり、入力欄が一部隠れることがありました。また、抽出ルールの出力先が cf:12 のようなIDだけでは、対象項目を見分けづらい状態でした。
v1.1からヘッダーとタブの配置を調整し、カスタムフィールドを名前で選んでマッピングルールを追加できるようにしました。フィールド名の選択は操作を助けるための表示で、実際にルールへ保存される値はRedmine API用のフィールドIDです。
Name エラーを修正v1.1で「+ ルール追加」や「選択フィールドをルールへ追加」を押すと、次のエラーが出る場合がありました。
Add 呼び出し中に例外が発生しました。フィールド
Nameが見つかりません。
原因は、抽出ルール表の「出力先」を表示する DataGridViewComboBoxColumn に、PowerShellのオブジェクト配列を直接バインドしていた点です。Windows Forms側で表示名の Name プロパティを解決できず、行追加のタイミングで例外になっていました。
v1.2では、表示用 DisplayText と保存用 TargetKey を持つ System.Data.DataTable に変更しました。GUIには「カスタム: 問い合わせ者 (ID:12)」と表示し、rules.json には従来どおり cf:12 を保存します。プロジェクトJSONの読み込み前でも、既存の cf:ID のルールを維持するようにしています。
旧版から更新する場合は、rules.json や取得済みの project-metadata.json を残し、Start-RedmineGUI.ps1 だけをv1.3へ差し替えます。フォルダを丸ごと上書きすると既存設定が消えることがあるので、スクリプトを別フォルダで確認してから差し替えてください。
更新後の確認手順は以下のとおりです。
※ Windows実機でのGUI操作とRedmineへの起票は、環境ごとの確認が必要です。
MSGを解析した後、チケットを登録するボタンを探してタブを移動するのが面倒だったので、画面右上に「Redmineへ起票」ボタンを固定表示する配置に変更しました。「プロジェクト設定」「抽出ルール」「MSG解析・起票」のどのタブでも同じボタンが見えます。
起票の操作は次の順番です。
.msg を選び、トラッカーや優先度などを設定します。プレビューしていない状態で上部ボタンを押したときは、「MSG解析・起票」タブへ案内する仕組みです。ボタンが常に表示されていても、プレビューなしで起票されるわけではありません。 従来の重複登録チェックも同じ処理を使用します。
v1.2以前から差し替える場合、GUIを閉じ、元の Start-RedmineGUI.ps1 を退避してからv1.3のファイルを上書きしてください。抽出ルールや起票履歴はそのまま残せます。フォルダ全体を上書きすると設定ファイルを失う可能性があるため、GUIスクリプトのみ差し替える方法を勧めます。
Start-RedmineGUI.bat でGUIを起動し、「プロジェクト設定」タブでRedmine URLとプロジェクト識別子を指定します。「APIから取得」を押すか、手元にある project-metadata.json を「JSONを読み込む」から指定してください。
情報の取得後は、トラッカーや担当者候補に加え、カスタムフィールド一覧にID・名称・形式・必須かどうかが表示されます。表示されないフィールドがある場合は、APIキーの権限やRedmine側のトラッカーとの関連付けを確認します。
たとえば本文に 問い合わせ者:山田太郎 と記載されており、それをRedmineの「問い合わせ者」カスタムフィールドへ登録する場合、次のように操作します。
Body、方式を Regex にします。(?m)^問い合わせ者[::]\s*(.+?)\s*$、グループ番号に 1 を指定します。12 は例です。RedmineごとにIDは異なります。ルール設定JSONでは "target": "cf:12" のように保存されます。IDを手入力して管理する代わりに、GUIの項目名から選ぶことができます。
| ルールの項目 | 設定例 | 意味 |
|---|---|---|
| 名前 | 問い合わせ者 | ルールの管理名 |
| 抽出元 | Body |
MSG本文を検索 |
| 方式 | Regex |
正規表現で文字列を取り出す |
| パターン | (?m)^問い合わせ者[::]\s*(.+?)\s*$ |
ラベル行を探す |
| グループ | 1 |
(.+?) の中身を取得 |
| 出力先 | カスタム: 問い合わせ者 (ID:12) | Redmineへ渡すフィールド |
抽出方式は Regex 以外に Keywords、Literal、Constant も選択できます。たとえば「ログイン」「障害」のような語句に応じた判定は Keywords、文字列を固定で設定したいときは Constant を使えます。
「MSG解析・起票」タブで .msg ファイルを選び、解析・JSONプレビューで件名・本文から生成された値を確認します。抽出ルールとRedmineフィールドが正しく紐付いていれば、送信用JSONの custom_fields に次のような値が入ります。
{
"issue": {
"project_id": 246,
"tracker_id": 2,
"subject": "ログインについての問い合わせ",
"description": "問い合わせ内容の本文",
"custom_fields": [
{ "id": 12, "value": "山田太郎" }
]
}
}
この段階ではまだチケットは作成されません。トラッカー・優先度・担当者・カスタムフィールドの値を確認したうえで、画面右上の「Redmineへ起票」を押します。必須項目の不足や形式エラーがあれば、Redmine側からエラーが返る場合があります。
GUIはWindows Formsを使っているため、環境の表示倍率によって見た目が変わる可能性があります。v1.3ではルール追加時のエラーも修正していますが、Windows実機での操作とRedmineへのPOSTは未検証です。最初は検証用プロジェクトとダミーメールで確認してください。
「MSG解析・起票」タブでトラッカーと優先度などを選び、対象の .msg を読み込みます。
ここで重要なのは、すぐに登録しないことです。まず「解析プレビュー」で、送信予定のJSONを確認します。例えば次のような形です。
{
"issue": {
"project_id": 246,
"tracker_id": 2,
"subject": "【障害】ログインできない INC-2026-001",
"description": "ログイン時にエラーになります。",
"custom_fields": [
{"id": 12, "value": "山田 太郎"},
{"id": 13, "value": "INC-2026-001"}
]
}
}
ここに出てくるプロジェクトID・トラッカーID・カスタムフィールドIDは例です。実際の登録先に合わせてください。
内容が正しければ画面右上の「Redmineへ起票」を押します。確認ダイアログの後に POST /issues.json が呼ばれ、正常に登録されるとチケットIDとURLが表示されます。プレビュー操作だけではチケットは作成されません。
CLIでも同じルールを使います。Jenkinsなどから処理する場合は、まずJSON出力だけを行うと確認しやすくなります。
.\Run-Issue.ps1 `
-MsgPath 'C:\Mail\request.msg' `
-MetadataPath '.\project-metadata.json' `
-RulesPath '.\rules.json' `
-OutputPath '.\issue-preview.json'
この段階ではRedmineへPOSTされません。
.\Run-Issue.ps1 `
-MsgPath 'C:\Mail\request.msg' `
-CreateIssue `
-TrackerId 2
-CreateIssue を付けたときだけ起票します。APIへの接続先は取得済みプロジェクト定義にあるURLと照合されます。
.\Run-Issue.ps1 `
-MailJson '.\test-mail.json' `
-MetadataPath '.\sample-project-metadata.json'
-MailJson はルールを試すための入力です。このモードで実際のチケットを起票することはできません。
成功した起票は posted-history.json に記録します。MSGのSHA-256、RedmineのURL、プロジェクトIDを組み合わせて、同じファイルを同じ場所へ再登録しないようにしています。
ただし、Redmineでの作成が成功した直後に通信が切れた場合などは、ツールに履歴が残らず再登録される可能性があります。起票結果が不明なときは、再実行より先にRedmine側を確認してください。
また、プロジェクトのメタデータやメール本文は業務情報です。保存したJSON、ログ、登録履歴には適切なアクセス権を設定します。
PowerShell 4ファイル・起動用BAT 2ファイル・設定JSON 4ファイルの内容をそのまま掲載します。各コードブロックをファイル名どおりに保存し、同じフォルダへ配置してください。.ps1 は Shift-JIS(CP932)、JSONは UTF-8 で保存します。
Start-RedmineGUI.bat(全文)@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File "%~dp0Start-RedmineGUI.ps1"
pause
Start-RedmineGUI.ps1(全文)#Requires -Version 5.1
param([string]$Workspace=$PSScriptRoot)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
[System.Windows.Forms.Application]::EnableVisualStyles()
. (Join-Path $PSScriptRoot 'Redmine.Core.ps1')
$ErrorActionPreference='Stop'
$workspace=[IO.Path]::GetFullPath($Workspace)
$metaFile=Join-Path $workspace 'project-metadata.json'
$rulesFile=Join-Path $workspace 'rules.json'
$settingsFile=Join-Path $workspace 'tool-settings.json'
$script:metadata=$null
$script:currentMail=$null
$script:payload=$null
$script:created=$false
$script:previewMsgHash=''
$script:loadedRuleTargets=@()
$script:targetOptionsTable=$null
$colors=@{bg='#111827'; panel='#1f2937'; input='#263548'; border='#435266'; text='#f3f4f6'; muted='#b5c2d1'; accent='#3b82f6'; danger='#b94755'; good='#168366'}
function C([string]$Hex) { return [System.Drawing.ColorTranslator]::FromHtml($Hex) }
function Label($text,$x,$y,$width=180,$height=23) {
$o=New-Object System.Windows.Forms.Label
$o.Text=$text;$o.Left=$x;$o.Top=$y;$o.Width=$width;$o.Height=$height
$o.ForeColor=(C $colors.text);$o.BackColor=(C $colors.panel)
$o.Font=New-Object System.Drawing.Font('Yu Gothic UI',9)
return $o
}
function TextBox($x,$y,$w=200,$h=30,$multi=$false) {
$o=New-Object System.Windows.Forms.TextBox
$o.Left=$x;$o.Top=$y;$o.Width=$w;$o.Height=$h
$o.BackColor=(C $colors.input);$o.ForeColor=(C $colors.text)
$o.BorderStyle='FixedSingle';$o.Font=New-Object System.Drawing.Font('Consolas',10)
$o.Multiline=$multi
if($multi){$o.ScrollBars='Both';$o.WordWrap=$false}
return $o
}
function Button($text,$x,$y,$w=140,$tone='normal') {
$o=New-Object System.Windows.Forms.Button
$o.Text=$text;$o.Left=$x;$o.Top=$y;$o.Width=$w;$o.Height=34
$o.FlatStyle='Flat';$o.FlatAppearance.BorderSize=0
$o.Font=New-Object System.Drawing.Font('Yu Gothic UI',9,[System.Drawing.FontStyle]::Bold)
$o.ForeColor=(C '#ffffff')
$o.BackColor = switch ($tone) { 'accent' { C $colors.accent } 'danger' { C $colors.danger } 'good' { C $colors.good } default { C $colors.input } }
$o.Cursor=[System.Windows.Forms.Cursors]::Hand
return $o
}
function Combo($x,$y,$w=200) {
$o=New-Object System.Windows.Forms.ComboBox
$o.Left=$x;$o.Top=$y;$o.Width=$w;$o.Height=30
$o.DropDownStyle='DropDownList';$o.BackColor=(C $colors.input);$o.ForeColor=(C $colors.text)
$o.Font=New-Object System.Drawing.Font('Yu Gothic UI',9)
return $o
}
function Style-Grid($g) {
$g.BackgroundColor=(C $colors.panel);$g.BorderStyle='None'
$g.GridColor=(C $colors.border);$g.EnableHeadersVisualStyles=$false
$g.ColumnHeadersDefaultCellStyle.BackColor=(C $colors.input)
$g.ColumnHeadersDefaultCellStyle.ForeColor=(C $colors.text)
$g.DefaultCellStyle.BackColor=(C $colors.panel)
$g.DefaultCellStyle.ForeColor=(C $colors.text)
$g.DefaultCellStyle.SelectionBackColor=(C '#245aa4')
$g.DefaultCellStyle.SelectionForeColor=(C '#ffffff')
$g.RowHeadersVisible=$false;$g.ColumnHeadersHeight=38;$g.RowTemplate.Height=31
$g.Font=New-Object System.Drawing.Font('Yu Gothic UI',9)
}
function Fail($e) {
$message=if($e -is [Management.Automation.ErrorRecord]){$e.Exception.Message}else{[string]$e}
if($e -is [Management.Automation.ErrorRecord]) {
if($e.Exception.InnerException) {$message+="`n詳細: "+$e.Exception.InnerException.Message}
if($e.InvocationInfo -and $e.InvocationInfo.ScriptLineNumber -gt 0) {
$message+="`n行番号: "+$e.InvocationInfo.ScriptLineNumber
}
}
[System.Windows.Forms.MessageBox]::Show($message,'処理エラー','OK','Error')|Out-Null
}
function Log([string]$text) {
$txtLog.AppendText((Get-Date -Format 'HH:mm:ss')+' '+$text+"`r`n")
}
function Combo-Id($cb) {
if ($cb.SelectedItem -and $cb.SelectedItem.PSObject.Properties['Id']) { return [int]$cb.SelectedItem.Id }
return 0
}
function Set-Options($cb,$values,[string]$first='既定(指定しない)') {
$cb.Items.Clear()
[void]$cb.Items.Add([pscustomobject]@{Name=$first;Id=0})
foreach ($v in @($values)) {
if ($null -ne $v -and $v.id) { [void]$cb.Items.Add([pscustomobject]@{Name=[string]$v.name;Id=[int]$v.id}) }
}
$cb.DisplayMember='Name';$cb.ValueMember='Id';$cb.SelectedIndex=0
}
function Make-TextColumn($name,$header,$width) {
$col=New-Object System.Windows.Forms.DataGridViewTextBoxColumn
$col.Name=$name;$col.HeaderText=$header;$col.Width=$width
return $col
}
function Make-ComboColumn($name,$header,$width,$items) {
$col=New-Object System.Windows.Forms.DataGridViewComboBoxColumn
$col.Name=$name;$col.HeaderText=$header;$col.Width=$width
$col.DisplayStyle=[System.Windows.Forms.DataGridViewComboBoxDisplayStyle]::DropDownButton
foreach($value in $items){[void]$col.Items.Add($value)}
return $col
}
function Get-RulesFromGrid {
$gridRules.EndEdit() | Out-Null
$list=New-Object System.Collections.Generic.List[object]
foreach($row in $gridRules.Rows) {
if ($row.IsNewRow) {continue}
$obj=[ordered]@{}
foreach($key in @('enabled','name','source','mode','pattern','group','target','fallback')) {
$value=$row.Cells[$key].Value
if($key -eq 'enabled'){$obj[$key]=([string]$value -eq 'True')}
elseif($key -eq 'group'){$obj[$key]=if([string]$value -match '^\d+$'){[int]$value}else{0}}
else{$obj[$key]=[string]$value}
}
if(-not $obj.name){throw 'ルール名を入力してください。'}
if(-not $obj.target){throw "ルール $($obj.name) の出力先が未設定です。"}
$list.Add([pscustomobject]$obj)
}
return [pscustomobject]@{version=1;rules=@($list.ToArray())}
}
# DataGridViewComboBoxColumn cannot reliably discover the dynamic Name/Value
# properties of PSCustomObject[] on Windows PowerShell 5.1.
# Use a typed DataTable for stable WinForms DisplayMember/ValueMember binding.
function Refresh-TargetList {
$gridRules.EndEdit() | Out-Null
$targetCol=[System.Windows.Forms.DataGridViewComboBoxColumn]$gridRules.Columns['target']
$table=New-Object System.Data.DataTable
[void]$table.Columns.Add('DisplayText',[string])
[void]$table.Columns.Add('TargetKey',[string])
$seen=@{}
$labels=[ordered]@{
subject='件名 / subject'
description='説明 / description'
tracker_id='トラッカーID'
priority_id='優先度ID'
category_id='カテゴリID'
assigned_to_id='担当者ID'
fixed_version_id='対象バージョンID'
parent_issue_id='親チケットID'
due_date='期限 / due_date'
start_date='開始日 / start_date'
status_id='ステータスID'
Ignore='除外 / Ignore'
}
foreach($key in $labels.Keys) {
if(-not $seen.ContainsKey($key)) {
$record=$table.NewRow()
$record['DisplayText']=[string]$labels[$key]
$record['TargetKey']=[string]$key
[void]$table.Rows.Add($record)
$seen[$key]=$true
}
}
if($script:metadata) {
foreach($f in @($script:metadata.customFields)) {
if($null -eq $f -or -not $f.id) {continue}
$key='cf:'+ [string]$f.id
if(-not $seen.ContainsKey($key)) {
$record=$table.NewRow()
$record['DisplayText']=('カスタム: {0} (ID:{1})' -f $f.name,$f.id)
$record['TargetKey']=$key
[void]$table.Rows.Add($record)
$seen[$key]=$true
}
}
}
# Keep previously loaded mappings even if the project JSON has not been read.
$keep=New-Object 'System.Collections.Generic.List[string]'
foreach($key in @($script:loadedRuleTargets)) {
if(-not [string]::IsNullOrWhiteSpace([string]$key)) { $keep.Add([string]$key) }
}
foreach($row in $gridRules.Rows) {
if($row.IsNewRow) {continue}
$key=[string]$row.Cells['target'].Value
if(-not [string]::IsNullOrWhiteSpace($key)) { $keep.Add($key) }
}
foreach($key in $keep) {
if(-not $seen.ContainsKey($key)) {
$record=$table.NewRow()
$record['DisplayText']='未取得の項目: '+$key
$record['TargetKey']=$key
[void]$table.Rows.Add($record)
$seen[$key]=$true
}
}
$targetCol.DataSource=$null
$targetCol.Items.Clear()
$targetCol.DisplayMember='DisplayText'
$targetCol.ValueMember='TargetKey'
$targetCol.DataSource=$table
$script:targetOptionsTable=$table
}
function Ensure-TargetOption([string]$key) {
if([string]::IsNullOrWhiteSpace($key)) {return}
if($null -eq $script:targetOptionsTable) {Refresh-TargetList}
foreach($item in $script:targetOptionsTable.Rows) {
if([string]::Equals([string]$item['TargetKey'],$key,[System.StringComparison]::OrdinalIgnoreCase)) {return}
}
$record=$script:targetOptionsTable.NewRow()
$record['DisplayText']='未取得の項目: '+$key
$record['TargetKey']=$key
[void]$script:targetOptionsTable.Rows.Add($record)
}
function Add-RuleToGrid($r) {
# ComboBox values must be present before Rows.Add()/cell assignment.
Ensure-TargetOption ([string]$r.target)
$idx=$gridRules.Rows.Add()
$row=$gridRules.Rows[$idx]
foreach($key in @('enabled','name','source','mode','pattern','group','target','fallback')) {
$row.Cells[$key].Value=$r.$key
}
}
function Load-RulesGrid($data) {
$gridRules.Rows.Clear()
$script:loadedRuleTargets=@($data.rules | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_.target })
Refresh-TargetList
foreach($r in @($data.rules)){if($r){Add-RuleToGrid $r}}
}
function Add-FieldMapping {
if(-not $script:metadata) {throw '先にプロジェクトJSONを読み込んでください。'}
if($gridFields.SelectedRows.Count -lt 1) {throw 'カスタムフィールド一覧の行を選択してください。'}
$selected=$gridFields.SelectedRows[0]
$id=[int]$selected.Cells['id'].Value
$name=[string]$selected.Cells['name'].Value
if($id -le 0 -or [string]::IsNullOrWhiteSpace($name)) {throw 'フィールドID・名称を確認してください。'}
$def=@($script:metadata.customFields | Where-Object { [int]$_.id -eq $id } | Select-Object -First 1)
if(-not $def.Count){throw '選択したフィールドがプロジェクト定義にありません。'}
$possible=@($def[0].possible_values | Where-Object { $null -ne $_ })
$isSelect=($possible.Count -gt 0)
$mode=if($isSelect){'Constant'}else{'Regex'}
$pattern=if($isSelect){[string]$(if($possible[0] -is [string]){$possible[0]}else{$possible[0].value})}else{'(?m)^'+[regex]::Escape($name)+'[::]\s*(.+?)\s*$'}
$rule=[pscustomobject]@{enabled=$true;name=$name;source='Body';mode=$mode;pattern=$pattern;group=1;target=('cf:'+ $id);fallback=''}
Add-RuleToGrid $rule
Refresh-TargetList
$tabs.SelectedTab=$tRules
$gridRules.ClearSelection()
$last=$gridRules.Rows[$gridRules.Rows.Count-1]
$last.Selected=$true
$gridRules.FirstDisplayedScrollingRowIndex=$last.Index
[System.Windows.Forms.MessageBox]::Show(('「'+$name+'」へのマッピングルールを追加しました。抽出方法とパターンを確認し、「ルールを保存」を押してください。'),'マッピング追加')|Out-Null
}
function Apply-Metadata($meta) {
if(-not $meta.project -or -not $meta.project.id){throw 'プロジェクトJSONに project.id がありません。'}
$script:metadata=$meta
$txtUrl.Text=[string]$meta.baseUrl
$txtProject.Text=[string]$meta.project.identifier
if(-not $txtProject.Text){$txtProject.Text=[string]$meta.project.id}
$lblProject.Text="PROJECT $($meta.project.name) (#$($meta.project.id))"
Set-Options $cbTracker $meta.trackers 'トラッカーを選択'
Set-Options $cbPriority $meta.priorities
Set-Options $cbCategory $meta.categories
Set-Options $cbStatus $meta.statuses 'Redmineの既定(推奨)'
Set-Options $cbAssignee $meta.members
Set-Options $cbVersion $meta.versions
if($cbTracker.Items.Count -gt 1){$cbTracker.SelectedIndex=1}
$gridFields.Rows.Clear()
foreach ($f in @($meta.customFields)) {
if(-not $f){continue}
$possible=@()
foreach($v in @($f.possible_values)){
if($v -is [string]){$possible+= $v}
elseif($v -and $v.value){$possible += [string]$v.value}
}
$opts=$possible -join ', '
if(-not $opts){$opts='(未取得または自由入力)'}
[void]$gridFields.Rows.Add($f.id,$f.name,$f.field_format,$f.is_required,$f.multiple,$opts)
}
Refresh-TargetList
Log "プロジェクト情報を反映: $($meta.project.name) / トラッカー $(@($meta.trackers).Count) / カスタムフィールド $(@($meta.customFields).Count)"
foreach($note in @($meta.warnings)){if($note){Log "注意: $note"}}
}
function Prepare-Payload {
if(-not $script:metadata){throw '先にプロジェクト情報を取得/読込してください。'}
if(-not $script:currentMail){
if(-not $txtMsg.Text){throw 'MSGファイルを選択してください。'}
$script:currentMail=Get-MailFromMsg $txtMsg.Text
}
$rules=Get-RulesFromGrid
return New-RedmineIssueBody -Mail $script:currentMail -Metadata $script:metadata -Rules $rules `
-TrackerId (Combo-Id $cbTracker) -PriorityId (Combo-Id $cbPriority) -CategoryId (Combo-Id $cbCategory) `
-StatusId (Combo-Id $cbStatus) -AssignedToId (Combo-Id $cbAssignee) -VersionId (Combo-Id $cbVersion)
}
$form=New-Object System.Windows.Forms.Form
$form.Text='Redmine MSG Ticket Studio | GUI / API'
$form.Width=1250;$form.Height=900;$form.MinimumSize=New-Object System.Drawing.Size(1130,795)
$form.AutoScaleMode=[System.Windows.Forms.AutoScaleMode]::Dpi
$form.StartPosition='CenterScreen';$form.BackColor=(C $colors.bg)
$form.ForeColor=(C $colors.text);$form.Font=New-Object System.Drawing.Font('Yu Gothic UI',9)
$header=New-Object System.Windows.Forms.Panel;$header.Dock='Top';$header.Height=90;$header.BackColor=(C $colors.panel)
$layout=New-Object System.Windows.Forms.TableLayoutPanel
$layout.Dock='Fill';$layout.ColumnCount=1;$layout.RowCount=2
[void]$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute,95)))
[void]$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent,100)))
$form.Controls.Add($layout)
[void]$layout.Controls.Add($header,0,0)
$title=Label 'Redmine MSG Ticket Studio' 26 15 550 35
$title.Font=New-Object System.Drawing.Font('Yu Gothic UI',18,[System.Drawing.FontStyle]::Bold)
$header.Controls.Add($title)
$sub=Label 'プロジェクト定義 → 抽出ルール → JSONプレビュー → チケット起票' 27 52 800 27
$sub.ForeColor=(C $colors.muted);$header.Controls.Add($sub)
$tabs=New-Object System.Windows.Forms.TabControl;$tabs.Dock='Fill'
$tabs.Font=New-Object System.Drawing.Font('Yu Gothic UI',10)
$tabs.SizeMode='Fixed';$tabs.ItemSize=New-Object System.Drawing.Size(210,34)
[void]$layout.Controls.Add($tabs,0,1)
$tabs.Dock='Fill'
$header.Dock='Fill'
# 全タブからアクセスできる、上部固定のRedmine起票ボタン。
# 既存の$btnPost.Add_Clickハンドラーを共有し、重複起票チェックも維持する。
$btnPost=Button 'Redmineへ起票' 945 16 230 'good'
$btnPost.Height=45
$btnPost.Font=New-Object System.Drawing.Font('Yu Gothic UI',11,[System.Drawing.FontStyle]::Bold)
$header.Controls.Add($btnPost)
$lblTopHint=Label 'MSG解析・JSONプレビュー確認後に起票' 936 65 270 20
$lblTopHint.Font=New-Object System.Drawing.Font('Yu Gothic UI',8)
$lblTopHint.ForeColor=(C $colors.muted)
$header.Controls.Add($lblTopHint)
# リサイズ・Windows表示倍率に応じて右上へ追従させる。
$header.Add_Resize({
$btnPost.Left=[Math]::Max(842,$header.ClientSize.Width-$btnPost.Width-24)
$lblTopHint.Left=$btnPost.Left-8
})
function Tab($name){$t=New-Object System.Windows.Forms.TabPage;$t.Text=$name;$t.BackColor=(C $colors.panel);$tabs.TabPages.Add($t);return $t}
$tConnection=Tab '① プロジェクト設定'
$tRules=Tab '② 抽出ルール'
$tPost=Tab '③ MSG解析・起票'
$tConnection.Controls.Add((Label 'Redmine URL' 20 25 150))
$txtUrl=TextBox 20 53 370;$txtUrl.Text='https://redmine.example.com';$tConnection.Controls.Add($txtUrl)
$tConnection.Controls.Add((Label 'プロジェクトID / 識別子' 410 25 190))
$txtProject=TextBox 410 53 210;$txtProject.Text='sample-project';$tConnection.Controls.Add($txtProject)
$tConnection.Controls.Add((Label 'APIキー(一時入力・保存しない)' 640 25 270))
$txtKey=TextBox 640 53 220;$txtKey.UseSystemPasswordChar=$true;$tConnection.Controls.Add($txtKey)
$chkHttp=New-Object System.Windows.Forms.CheckBox;$chkHttp.Text='HTTPを許可(非推奨)';$chkHttp.Left=875;$chkHttp.Top=58;$chkHttp.Width=190;$chkHttp.ForeColor=(C $colors.muted);$tConnection.Controls.Add($chkHttp)
$btnFetch=Button 'APIから取得' 20 105 160 'accent';$tConnection.Controls.Add($btnFetch)
$btnOpenMeta=Button 'JSONを読み込む' 190 105 170;$tConnection.Controls.Add($btnOpenMeta)
$btnSaveMeta=Button 'JSONを保存' 370 105 150;$tConnection.Controls.Add($btnSaveMeta)
$lblProject=Label 'PROJECT 未読込' 25 158 1000 30
$lblProject.Font=New-Object System.Drawing.Font('Yu Gothic UI',12,[System.Drawing.FontStyle]::Bold)
$tConnection.Controls.Add($lblProject)
$tConnection.Controls.Add((Label 'カスタムフィールド一覧(行を選択し、右のボタンでマッピングを作成)' 25 198 680))
$btnMapField=Button '選択フィールドをルールへ追加 →' 733 195 305 'accent';$tConnection.Controls.Add($btnMapField)
$gridFields=New-Object System.Windows.Forms.DataGridView
$gridFields.Location=New-Object System.Drawing.Point(25,232);$gridFields.Size=New-Object System.Drawing.Size(1080,325)
$gridFields.Anchor='Top,Left,Right,Bottom';$gridFields.AllowUserToAddRows=$false;$gridFields.ReadOnly=$true
$gridFields.MultiSelect=$false
$gridFields.AutoSizeColumnsMode='Fill';$gridFields.SelectionMode='FullRowSelect'
Style-Grid $gridFields
foreach($item in @(@('id','ID'),@('name','名前'),@('format','形式'),@('required','必須'),@('multiple','複数'),@('choices','候補値'))) { [void]$gridFields.Columns.Add($item[0],$item[1]) }
$tConnection.Controls.Add($gridFields)
$txtLog=TextBox 25 582 1080 78 $true;$txtLog.ReadOnly=$true;$txtLog.Anchor='Left,Right,Bottom';$tConnection.Controls.Add($txtLog)
$tRules.Controls.Add((Label 'ルールは上から順に適用。同じ項目には後のルールの値が優先されます。' 20 18 980 26))
$btnAdd=Button '+ ルール追加' 20 55 150 'accent';$tRules.Controls.Add($btnAdd)
$btnDelete=Button '選択行を削除' 180 55 150 'danger';$tRules.Controls.Add($btnDelete)
$btnSaveRules=Button 'ルールを保存' 340 55 150 'good';$tRules.Controls.Add($btnSaveRules)
$btnReloadRules=Button '再読み込み' 500 55 150;$tRules.Controls.Add($btnReloadRules)
$gridRules=New-Object System.Windows.Forms.DataGridView
$gridRules.Location=New-Object System.Drawing.Point(20,108);$gridRules.Size=New-Object System.Drawing.Size(1100,480)
$gridRules.Anchor='Top,Left,Right,Bottom';$gridRules.AllowUserToAddRows=$false
$gridRules.EditMode='EditOnEnter'
$gridRules.Add_DataError({param($sender,$e) $e.ThrowException=$false})
$gridRules.SelectionMode='FullRowSelect';$gridRules.MultiSelect=$true
$gridRules.AutoSizeColumnsMode='None';$gridRules.RowHeadersVisible=$false;Style-Grid $gridRules
$enableCol=New-Object System.Windows.Forms.DataGridViewCheckBoxColumn;$enableCol.Name='enabled';$enableCol.HeaderText='有効';$enableCol.Width=53;[void]$gridRules.Columns.Add($enableCol)
[void]$gridRules.Columns.Add((Make-TextColumn 'name' 'ルール名' 124))
[void]$gridRules.Columns.Add((Make-ComboColumn 'source' '抽出元' 150 @('Subject','Body','Subject+Body','SenderName','SenderEmailAddress','To','ReceivedTime')))
[void]$gridRules.Columns.Add((Make-ComboColumn 'mode' '抽出方式' 104 @('Regex','Keywords','Literal','Constant')))
[void]$gridRules.Columns.Add((Make-TextColumn 'pattern' 'パターン(Keywords は | 区切り)' 250))
[void]$gridRules.Columns.Add((Make-TextColumn 'group' 'Grp' 42))
[void]$gridRules.Columns.Add((Make-ComboColumn 'target' '出力先(名前で選択)' 255 @('subject','description','Ignore')))
[void]$gridRules.Columns.Add((Make-TextColumn 'fallback' '不一致時' 154))
$tRules.Controls.Add($gridRules)
$help=Label '出力先はRedmine項目名で選択できます。カスタムフィールドのIDは自動設定(cf:ID)。未一致時は $BODY / $SUBJECT が利用できます。' 20 610 1090 45
$help.Anchor='Left,Right,Bottom';$help.ForeColor=(C $colors.muted);$tRules.Controls.Add($help)
$tPost.Controls.Add((Label 'MSGファイル(Classic Outlookが必要です)' 20 21 450))
$txtMsg=TextBox 20 51 660;$txtMsg.ReadOnly=$true;$tPost.Controls.Add($txtMsg)
$btnBrowse=Button 'ファイルを選択' 698 49 150;$tPost.Controls.Add($btnBrowse)
$btnPreview=Button '解析プレビュー' 865 49 170 'accent';$tPost.Controls.Add($btnPreview)
$y=105
foreach($spec in @(@('トラッカー',20),@('優先度',213),@('カテゴリ',406),@('ステータス',599),@('担当者',792))) {
$tPost.Controls.Add((Label $spec[0] $spec[1] $y 170))
}
$cbTracker=Combo 20 132 180;$cbPriority=Combo 213 132 180;$cbCategory=Combo 406 132 180
$cbStatus=Combo 599 132 180;$cbAssignee=Combo 792 132 180
$tPost.Controls.AddRange(@($cbTracker,$cbPriority,$cbCategory,$cbStatus,$cbAssignee))
$tPost.Controls.Add((Label '対象バージョン' 20 178 165));$cbVersion=Combo 20 208 250;$tPost.Controls.Add($cbVersion)
$tPost.Controls.Add((Label '登録予定JSON(APIキーは含みません)' 20 250 600))
$txtPreview=TextBox 20 280 1090 310 $true;$txtPreview.ReadOnly=$true;$txtPreview.Anchor='Left,Top,Right,Bottom';$tPost.Controls.Add($txtPreview)
$btnSavePreview=Button 'JSONを保存' 20 610 165;$btnSavePreview.Anchor='Bottom,Left';$tPost.Controls.Add($btnSavePreview)
# 起票操作はタブに閉じ込めず、画面上部の共通ヘッダーへ配置する。
$lblPostResult=Label '確認後に「Redmineへ起票」を押してください。' 210 616 650 33
$lblPostResult.Anchor='Bottom,Left,Right';$lblPostResult.ForeColor=(C $colors.muted);$tPost.Controls.Add($lblPostResult)
foreach($cb in @($cbTracker,$cbPriority,$cbCategory,$cbStatus,$cbAssignee,$cbVersion)){Set-Options $cb @()}
$btnFetch.Add_Click({
try {
if($txtKey.Text){[Environment]::SetEnvironmentVariable('REDMINE_API_KEY',$txtKey.Text,'Process')}
$url=Assert-RedmineConnection $txtUrl.Text $chkHttp.Checked
$m=Get-RedmineProjectMetadata -BaseUrl $url -Project $txtProject.Text -AllowHttp:$chkHttp.Checked
Write-JsonUtf8 $settingsFile @{baseUrl=$txtUrl.Text;project=$txtProject.Text;allowHttp=$chkHttp.Checked}
Apply-Metadata $m
Write-JsonUtf8 $metaFile $m
Log 'プロジェクト情報をJSONに保存しました。'
}catch{Fail $_}
})
$btnOpenMeta.Add_Click({
try {
$dlg=New-Object System.Windows.Forms.OpenFileDialog;$dlg.Filter='JSON (*.json)|*.json';$dlg.FileName='project-metadata.json'
if($dlg.ShowDialog() -eq 'OK') { Apply-Metadata (Read-JsonUtf8 $dlg.FileName) }
}catch{Fail $_}
})
$btnSaveMeta.Add_Click({
try {
if(-not $script:metadata){throw '保存する情報がありません。'}
$dlg=New-Object System.Windows.Forms.SaveFileDialog;$dlg.Filter='JSON (*.json)|*.json';$dlg.FileName='project-metadata.json'
if($dlg.ShowDialog() -eq 'OK'){Write-JsonUtf8 $dlg.FileName $script:metadata;Log 'プロジェクトJSONを保存しました。'}
}catch{Fail $_}
})
$btnMapField.Add_Click({try{Add-FieldMapping}catch{Fail $_}})
$gridFields.Add_CellDoubleClick({param($sender,$e) if($e.RowIndex -ge 0){try{Add-FieldMapping}catch{Fail $_}}})
$btnAdd.Add_Click({
try {
Add-RuleToGrid ([pscustomobject]@{enabled=$true;name='新規ルール';source='Body';mode='Regex';pattern='';group=1;target='description';fallback=''})
$gridRules.FirstDisplayedScrollingRowIndex=$gridRules.Rows.Count-1
}catch{Fail $_}
})
$btnDelete.Add_Click({
try {
$indexes=@($gridRules.SelectedRows | ForEach-Object { $_.Index } | Sort-Object -Descending)
if(-not $indexes.Count){throw '削除する行を選択してください。'}
if([System.Windows.Forms.MessageBox]::Show("選択した $($indexes.Count) 行を削除しますか?",'確認','YesNo','Question') -ne 'Yes'){return}
foreach($index in $indexes){$gridRules.Rows.RemoveAt([int]$index)}
}catch{Fail $_}
})
$btnSaveRules.Add_Click({
try {Write-JsonUtf8 $rulesFile (Get-RulesFromGrid);[System.Windows.Forms.MessageBox]::Show("ルールを保存しました: $rulesFile")|Out-Null}
catch{Fail $_}
})
$btnReloadRules.Add_Click({try{Load-RulesGrid (Read-JsonUtf8 $rulesFile);Refresh-TargetList}catch{Fail $_}})
$btnBrowse.Add_Click({
$dlg=New-Object System.Windows.Forms.OpenFileDialog;$dlg.Filter='Outlook MSG (*.msg)|*.msg'
if($dlg.ShowDialog() -eq 'OK'){$txtMsg.Text=$dlg.FileName;$script:currentMail=$null;$script:payload=$null;$script:created=$false;$script:previewMsgHash='';$txtPreview.Clear();$lblPostResult.Text='MSGを選択しました。解析プレビューを実行してください。'}
})
$btnPreview.Add_Click({
try {
$script:currentMail=Get-MailFromMsg $txtMsg.Text
$script:previewMsgHash=(Get-FileHash -LiteralPath $txtMsg.Text -Algorithm SHA256).Hash
$script:payload=Prepare-Payload
$txtPreview.Text=ConvertTo-Json -InputObject $script:payload -Depth 40
$script:created=$false
$lblPostResult.Text='プレビュー完了。内容を確認してから起票してください。'
$tabs.SelectedTab=$tPost
}catch{Fail $_}
})
$btnSavePreview.Add_Click({
try {
if(-not $script:payload){throw '先にプレビューしてください。'}
$dlg=New-Object System.Windows.Forms.SaveFileDialog;$dlg.Filter='JSON (*.json)|*.json';$dlg.FileName='issue-preview.json'
if($dlg.ShowDialog() -eq 'OK'){Write-JsonUtf8 $dlg.FileName $script:payload}
}catch{Fail $_}
})
$btnPost.Add_Click({
try {
if($script:created){throw 'このMSGは既に起票しました。続けて起票する場合は別のMSGを選んでください。'}
if(-not $script:payload){$tabs.SelectedTab=$tPost;throw '先に「③ MSG解析・起票」でMSGを選び、解析プレビューを実行してください。'}
if((Get-FileHash -LiteralPath $txtMsg.Text -Algorithm SHA256).Hash -ne $script:previewMsgHash){throw 'プレビュー後にMSGファイルが変更されました。再解析してください。'}
$sig=Get-PostSignature $txtUrl.Text ([int]$script:metadata.project.id) $txtMsg.Text
$oldId=Find-PostedIssue $workspace $sig
if($oldId){throw "同じMSGは起票済みです: #$oldId"}
$base=Assert-RedmineConnection $txtUrl.Text $chkHttp.Checked
if ($base -ne ([string]$script:metadata.baseUrl).TrimEnd('/')) {
throw '現在のRedmine URLが読み込んだプロジェクトJSONの接続先と違います。正しいプロジェクト情報を再取得してください。'
}
$now=Prepare-Payload
$jsonNow=ConvertTo-Json -InputObject $now -Depth 40 -Compress
$jsonPrev=ConvertTo-Json -InputObject $script:payload -Depth 40 -Compress
if($jsonNow -cne $jsonPrev){throw '入力がプレビュー後に変更されました。もう一度解析プレビューを実行してください。'}
if($txtKey.Text){[Environment]::SetEnvironmentVariable('REDMINE_API_KEY',$txtKey.Text,'Process')}
if([System.Windows.Forms.MessageBox]::Show("Redmine「$($script:metadata.project.name)」へ新規チケットを登録します。よろしいですか?",'起票確認','YesNo','Warning') -ne 'Yes'){return}
$issue=Publish-RedmineIssue $txtUrl.Text $script:payload $chkHttp.Checked
$script:created=$true
try {Save-PostReceipt $workspace $sig $issue.id}
catch {[System.Windows.Forms.MessageBox]::Show("チケット#$($issue.id)は作成されましたが履歴保存に失敗しました。次回起票前にRedmine側を確認してください。`n$($_.Exception.Message)",'履歴保存エラー')|Out-Null}
$link=($txtUrl.Text.TrimEnd('/')+'/issues/'+$issue.id)
$lblPostResult.Text="起票成功 #$($issue.id) $link"
[System.Windows.Forms.MessageBox]::Show("チケットを起票しました。`n$link",'起票完了')|Out-Null
}catch{Fail $_}
})
try {
if(Test-Path -LiteralPath $settingsFile){$prefs=Read-JsonUtf8 $settingsFile;$txtUrl.Text=[string]$prefs.baseUrl;$txtProject.Text=[string]$prefs.project;$chkHttp.Checked=[bool]$prefs.allowHttp}
if(Test-Path -LiteralPath $rulesFile){Load-RulesGrid (Read-JsonUtf8 $rulesFile)}
else {Refresh-TargetList}
if(Test-Path -LiteralPath $metaFile){Apply-Metadata (Read-JsonUtf8 $metaFile)}
} catch {Fail $_}
[void]$form.ShowDialog()
Redmine.Core.ps1(全文)#Requires -Version 5.1
function Read-JsonUtf8([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "File not found: $Path" }
return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json)
}
function Write-JsonUtf8([string]$Path, $Object) {
$full = [System.IO.Path]::GetFullPath($Path)
$folder = [System.IO.Path]::GetDirectoryName($full)
if (-not [System.IO.Directory]::Exists($folder)) { [void][System.IO.Directory]::CreateDirectory($folder) }
$json = ConvertTo-Json -InputObject $Object -Depth 40
[System.IO.File]::WriteAllText($full, $json + "`r`n", (New-Object System.Text.UTF8Encoding($false)))
}
function Get-RedmineKey {
$key = [Environment]::GetEnvironmentVariable('REDMINE_API_KEY', 'Process')
if (-not $key) { $key = [Environment]::GetEnvironmentVariable('REDMINE_API_KEY','User') }
if (-not $key) { throw 'REDMINE_API_KEY が未設定です。環境変数にAPIキーを登録してください。' }
return $key
}
function Assert-RedmineConnection([string]$BaseUrl, [bool]$AllowHttp=$false) {
$uri = $null
if (-not [Uri]::TryCreate($BaseUrl, [UriKind]::Absolute, [ref]$uri)) { throw 'Redmine URLが不正です。' }
if ($uri.Scheme -ne 'https' -and -not ($AllowHttp -and $uri.Scheme -eq 'http')) {
throw 'APIキーを保護するためHTTPSのみ許可しています。HTTP環境では「HTTPを許可」を明示的に選択してください。'
}
if ($uri.UserInfo -or $uri.Query -or $uri.Fragment) { throw 'Redmine URLにログイン情報・クエリ・フラグメントを含めないでください。' }
return $BaseUrl.TrimEnd('/')
}
function Invoke-RedmineApi {
param([string]$BaseUrl,[string]$Path,[ValidateSet('GET','POST')]$Method='GET',$Body=$null,[bool]$AllowHttp=$false)
$base = Assert-RedmineConnection $BaseUrl $AllowHttp
$headers = @{'X-Redmine-API-Key' = (Get-RedmineKey); 'Accept' = 'application/json'}
$uri = $base + '/' + $Path.TrimStart('/')
try {
if ($Method -eq 'POST') {
$json = ConvertTo-Json -InputObject $Body -Depth 40 -Compress
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
return Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 50 -ErrorAction Stop
}
return Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -TimeoutSec 35 -ErrorAction Stop
}
catch {
$detail = $_.Exception.Message
try {
$response = $_.Exception.Response
if ($null -ne $response) {
$stream = $response.GetResponseStream()
if ($stream) {
$reader = New-Object System.IO.StreamReader($stream)
$bodyText = $reader.ReadToEnd()
$reader.Dispose()
if ($bodyText) { $detail += ' / ' + $bodyText.Substring(0, [Math]::Min(1600,$bodyText.Length)) }
}
}
} catch { }
throw "Redmine API $Method $Path : $detail"
}
}
function Get-RedmineProjectMetadata {
param([string]$BaseUrl, [string]$Project, [bool]$AllowHttp=$false)
if (-not $Project.Trim()) { throw 'プロジェクトIDまたは識別子を入力してください。' }
$escaped = [Uri]::EscapeDataString($Project.Trim())
$projectResult = Invoke-RedmineApi $BaseUrl "projects/$escaped.json?include=trackers,issue_categories,issue_custom_fields" -AllowHttp:$AllowHttp
$p = $projectResult.project
if (-not $p -or -not $p.id) { throw 'プロジェクトが見つかりません。' }
$notes = New-Object System.Collections.Generic.List[string]
$priorities=@(); $statuses=@(); $trackers=@(); $categories=@(); $custom=@(); $versions=@(); $members=@()
if ($p.trackers) { $trackers = @($p.trackers) }
if ($p.issue_categories) { $categories = @($p.issue_categories) }
try { $priorities = @( (Invoke-RedmineApi $BaseUrl 'enumerations/issue_priorities.json' -AllowHttp:$AllowHttp).issue_priorities ) }
catch { $notes.Add('優先度の取得に失敗: ' + $_.Exception.Message) }
try { $statuses = @( (Invoke-RedmineApi $BaseUrl 'issue_statuses.json' -AllowHttp:$AllowHttp).issue_statuses ) }
catch { $notes.Add('ステータスの取得に失敗: ' + $_.Exception.Message) }
try {
$allTrackers = @((Invoke-RedmineApi $BaseUrl 'trackers.json' -AllowHttp:$AllowHttp).trackers)
if (-not $trackers.Count) { $trackers=$allTrackers }
elseif ($allTrackers.Count) {
$ids = @($trackers | ForEach-Object { [int]$_.id })
$trackers = @($allTrackers | Where-Object { $ids -contains [int]$_.id })
}
} catch { $notes.Add('トラッカーの詳細取得に失敗: ' + $_.Exception.Message) }
try { $versions = @((Invoke-RedmineApi $BaseUrl "projects/$escaped/versions.json" -AllowHttp:$AllowHttp).versions) }
catch { $notes.Add('対象バージョンの取得を省略: ' + $_.Exception.Message) }
try {
$offset=0
do {
$m=(Invoke-RedmineApi $BaseUrl "projects/$escaped/memberships.json?offset=$offset&limit=100" -AllowHttp:$AllowHttp)
foreach ($entry in @($m.memberships)) {
if ($entry.user -and $entry.user.id) { $members += [pscustomobject]@{id=$entry.user.id;name=$entry.user.name} }
}
$offset += @($m.memberships).Count
} while ($offset -lt [int]$m.total_count -and @($m.memberships).Count -gt 0)
$members = @($members | Sort-Object id -Unique)
} catch { $notes.Add('担当者候補の取得を省略: ' + $_.Exception.Message) }
try {
$allCustom = @((Invoke-RedmineApi $BaseUrl 'custom_fields.json' -AllowHttp:$AllowHttp).custom_fields |
Where-Object { $_.customized_type -eq 'issue' })
$projFieldIds = @($p.issue_custom_fields | ForEach-Object { [int]$_.id })
# プロジェクトが返す一覧にない「全プロジェクト共通」の項目は管理者APIの定義から補う。
$custom = @($allCustom | Where-Object {
($projFieldIds -contains [int]$_.id) -or $_.is_for_all -eq $true -or $_.is_for_all -eq 1
})
# 全プロジェクト共通のフラグを返さない構成では、プロジェクトから見える項目を優先。
if (-not $custom.Count -and $projFieldIds.Count) {
$custom = @($allCustom | Where-Object { $projFieldIds -contains [int]$_.id })
}
if (-not $custom.Count -and -not $projFieldIds.Count) {
$notes.Add('カスタムフィールドが取得できませんでした。全プロジェクト共通フィールドがある場合は対象プロジェクトで確認してください。')
}
} catch {
$custom = @($p.issue_custom_fields)
$notes.Add('管理者権限が必要な /custom_fields.json にアクセスできないため、プロジェクトの公開範囲のみ使用しています。形式や候補値は取得できない場合があります。')
}
return [pscustomobject]@{
schemaVersion=1
exportedAt=(Get-Date).ToString('o')
baseUrl=$BaseUrl.TrimEnd('/')
project=[pscustomobject]@{id=[int]$p.id;identifier=[string]$p.identifier;name=[string]$p.name}
trackers=@($trackers); priorities=@($priorities); statuses=@($statuses)
categories=@($categories); versions=@($versions); members=@($members)
customFields=@($custom); warnings=@($notes.ToArray())
}
}
function Get-MailFromMsg([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "MSGが見つかりません: $Path" }
$outlook=$null; $item=$null
try {
$outlook = New-Object -ComObject Outlook.Application
$item = $outlook.Session.OpenSharedItem([System.IO.Path]::GetFullPath($Path))
if ([int]$item.Class -ne 43) { throw 'MailItem形式の.msgではありません。' }
$received=''
try { $received=([datetime]$item.ReceivedTime).ToString('yyyy-MM-dd HH:mm:ss') } catch { }
return [pscustomobject]@{
Subject=[string]$item.Subject
Body=[string]$item.Body
SenderName=[string]$item.SenderName
SenderEmailAddress=[string]$item.SenderEmailAddress
To=[string]$item.To
ReceivedTime=$received
}
} finally {
if ($item) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($item) }
if ($outlook) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($outlook) }
}
}
function Get-MailSource($Mail,[string]$Source) {
switch ($Source) {
'Subject' { return [string]$Mail.Subject }
'Body' { return [string]$Mail.Body }
'Subject+Body' { return ([string]$Mail.Subject + "`n" + [string]$Mail.Body) }
'SenderName' { return [string]$Mail.SenderName }
'SenderEmailAddress' { return [string]$Mail.SenderEmailAddress }
'To' { return [string]$Mail.To }
'ReceivedTime' { return [string]$Mail.ReceivedTime }
default { throw "未知の抽出元: $Source" }
}
}
function Get-RuleValue($Mail,$Rule) {
$src = [string](Get-MailSource $Mail ([string]$Rule.source))
$pattern=[string]$Rule.pattern
$value=$null
switch ([string]$Rule.mode) {
'Regex' {
if (-not $pattern) { throw "ルール「$($Rule.name)」に正規表現が設定されていません" }
$rx=[System.Text.RegularExpressions.Regex]::new($pattern,([System.Text.RegularExpressions.RegexOptions]::Multiline -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase),[TimeSpan]::FromSeconds(2))
$m=$rx.Match($src)
$group=0; if ($null -ne $Rule.group -and "$($Rule.group)" -match '^\d+$') { $group=[int]$Rule.group }
if ($m.Success -and $group -lt $m.Groups.Count) { $value=$m.Groups[$group].Value }
}
'Keywords' {
$found=@($pattern.Split([char]'|') | Where-Object { $_.Trim() -ne '' -and $src.IndexOf($_.Trim(),[System.StringComparison]::OrdinalIgnoreCase) -ge 0 } | ForEach-Object { $_.Trim() })
if ($found.Count) { $value=($found -join '、') }
}
'Literal' {
if (-not $pattern) { $value=$src }
elseif ($src.IndexOf($pattern,[System.StringComparison]::OrdinalIgnoreCase) -ge 0) { $value=$pattern }
}
'Constant' { $value=$pattern }
default { throw "未知の抽出方式: $($Rule.mode)" }
}
if ([string]::IsNullOrWhiteSpace([string]$value)) {
switch ([string]$Rule.fallback) {
'$BODY' { $value=[string]$Mail.Body }
'$SUBJECT' { $value=[string]$Mail.Subject }
'$SENDER' { $value=[string]$Mail.SenderName }
default { $value=[string]$Rule.fallback }
}
}
return [string]$value
}
function New-RedmineIssueBody {
param($Mail,$Metadata,$Rules,[int]$TrackerId=0,[int]$PriorityId=0,[int]$CategoryId=0,[int]$StatusId=0,[int]$AssignedToId=0,[int]$VersionId=0)
$issue=[ordered]@{ project_id=[int]$Metadata.project.id; subject=[string]$Mail.Subject; description=[string]$Mail.Body }
foreach ($pair in @(@('tracker_id',$TrackerId),@('priority_id',$PriorityId),@('category_id',$CategoryId),@('status_id',$StatusId),@('assigned_to_id',$AssignedToId),@('fixed_version_id',$VersionId))) {
if ([int]$pair[1] -gt 0) { $issue[[string]$pair[0]]=[int]$pair[1] }
}
$cfs=@{}
foreach ($rule in @($Rules.rules)) {
if ($null -eq $rule -or $rule.enabled -eq $false) { continue }
$target=[string]$rule.target
if ([string]::IsNullOrWhiteSpace($target) -or $target -eq 'Ignore') { continue }
$value=[string](Get-RuleValue $Mail $rule)
if (-not $value.Trim()) { continue }
if ($target -match '^cf:(\d+)$') {
$id=[int]$Matches[1]
$def=@($Metadata.customFields | Where-Object { [int]$_.id -eq $id } | Select-Object -First 1)
if (-not $def.Count) { throw "カスタムフィールドID=$id はプロジェクト定義にありません" }
if ($def[0].multiple -eq $true) {
$cfs[$id]=@($value -split '\|' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
} else { $cfs[$id]=$value }
}
elseif ($target -in @('subject','description')) { $issue[$target]=$value }
elseif ($target -in @('tracker_id','priority_id','category_id','status_id','assigned_to_id','fixed_version_id','parent_issue_id')) {
if ($value -notmatch '^\d+$') { throw "「$target」の値は数値IDが必要です: $value" }
$issue[$target]=[int]$value
}
elseif ($target -in @('due_date','start_date')) {
$date=[datetime]::MinValue
if (-not [datetime]::TryParse($value,[ref]$date)) { throw "日付形式が不正です: $value" }
$issue[$target]=$date.ToString('yyyy-MM-dd')
}
else { throw "未知の出力先: $target" }
}
if ([string]::IsNullOrWhiteSpace($issue.subject)) { throw 'チケット件名が空です。' }
if (-not $issue.Contains('tracker_id') -and @($Metadata.trackers).Count) { $issue['tracker_id']=[int]$Metadata.trackers[0].id }
if ($cfs.Count) { $issue['custom_fields']=@($cfs.Keys | Sort-Object | ForEach-Object { @{id=[int]$_;value=$cfs[$_]} }) }
foreach ($def in @($Metadata.customFields)) {
if ($def.is_required -eq $true) {
$allowedTrackers=@($def.trackers | ForEach-Object { [int]$_.id })
if ($allowedTrackers.Count -and $issue['tracker_id'] -notin $allowedTrackers) { continue }
$id=[int]$def.id
if (-not $cfs.ContainsKey($id)) { throw "必須カスタムフィールド「$($def.name)」(ID=$id)が未設定です。抽出ルールを追加してください。" }
}
}
return @{issue=$issue}
}
function Publish-RedmineIssue($BaseUrl,$Payload,[bool]$AllowHttp=$false) {
$created=Invoke-RedmineApi -BaseUrl $BaseUrl -Path 'issues.json' -Method POST -Body $Payload -AllowHttp:$AllowHttp
if (-not $created.issue -or -not $created.issue.id) { throw 'APIは応答しましたがチケットIDを取得できませんでした。重複登録防止のため、Redmine側の登録状況を確認してください。' }
return $created.issue
}
function Get-PostSignature([string]$BaseUrl,[int]$ProjectId,[string]$MsgPath) {
$hash=(Get-FileHash -LiteralPath $MsgPath -Algorithm SHA256).Hash
return ($BaseUrl.TrimEnd('/').ToLowerInvariant() + '|' + $ProjectId + '|' + $hash)
}
function Find-PostedIssue([string]$Workspace,[string]$Signature) {
$path=Join-Path $Workspace 'posted-history.json'
if (-not (Test-Path -LiteralPath $path)) {return $null}
$history=Read-JsonUtf8 $path
$item=@($history.entries | Where-Object { $_.signature -ceq $Signature } | Select-Object -First 1)
if ($item.Count) {return $item[0].issueId}
return $null
}
function Save-PostReceipt([string]$Workspace,[string]$Signature,$IssueId) {
$path=Join-Path $Workspace 'posted-history.json'
$history=[ordered]@{schemaVersion=1;entries=@()}
if (Test-Path -LiteralPath $path) {$history=Read-JsonUtf8 $path}
$existing=@($history.entries | Where-Object {$_.signature -ceq $Signature})
if (-not $existing.Count) {
$entries=@($history.entries) + @([pscustomobject]@{signature=$Signature;issueId=[int]$IssueId;createdAt=(Get-Date).ToString('o')})
Write-JsonUtf8 $path ([pscustomobject]@{schemaVersion=1;entries=$entries})
}
}
Fetch-ProjectInfo.bat(全文)@echo off
cd /d "%~dp0"
echo Run this in PowerShell with Redmine URL and Project identifier:
echo .\Fetch-ProjectInfo.ps1 -BaseUrl https://redmine.example.com -Project sample-project
pause
Fetch-ProjectInfo.ps1(全文)#Requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$BaseUrl,
[Parameter(Mandatory=$true)][string]$Project,
[string]$OutputPath=(Join-Path $PSScriptRoot 'project-metadata.json'),
[switch]$AllowHttp
)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'Redmine.Core.ps1')
try {
$meta=Get-RedmineProjectMetadata -BaseUrl $BaseUrl -Project $Project -AllowHttp:$AllowHttp.IsPresent
Write-JsonUtf8 $OutputPath $meta
Write-Host "プロジェクト情報の取得に成功: $($meta.project.name)" -ForegroundColor Green
Write-Host "保存先: $OutputPath"
Write-Host "トラッカー $(@($meta.trackers).Count) 件、カスタムフィールド $(@($meta.customFields).Count) 件"
foreach($warn in @($meta.warnings)){if($warn){Write-Warning $warn}}
exit 0
} catch {
Write-Error $_.Exception.Message
exit 2
}
Run-Issue.ps1(全文)#Requires -Version 5.1
[CmdletBinding()]
param(
[string]$MsgPath,
[string]$MailJson,
[string]$MetadataPath=(Join-Path $PSScriptRoot 'project-metadata.json'),
[string]$RulesPath=(Join-Path $PSScriptRoot 'rules.json'),
[string]$BaseUrl,
[int]$TrackerId=0,
[int]$PriorityId=0,
[int]$StatusId=0,
[string]$OutputPath,
[switch]$CreateIssue,
[switch]$AllowHttp
)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'Redmine.Core.ps1')
try {
if ([bool]$MsgPath -eq [bool]$MailJson) { throw '-MsgPath または -MailJson のどちらか1つを指定してください。' }
$meta=Read-JsonUtf8 $MetadataPath
$rules=Read-JsonUtf8 $RulesPath
if($MsgPath){$mail=Get-MailFromMsg $MsgPath}else{$mail=Read-JsonUtf8 $MailJson}
$payload=New-RedmineIssueBody -Mail $mail -Metadata $meta -Rules $rules -TrackerId $TrackerId -PriorityId $PriorityId -StatusId $StatusId
$json=ConvertTo-Json -InputObject $payload -Depth 40
Write-Host $json
if($OutputPath){Write-JsonUtf8 $OutputPath $payload;Write-Host "JSON保存: $OutputPath"}
if(-not $CreateIssue){Write-Host 'プレビューモードです。チケットは登録していません。起票には -CreateIssue を付けてください。';exit 0}
if($MailJson){throw '安全のためMailJsonテスト入力からの本番起票は無効です。MSGファイルを指定してください。'}
if(-not $BaseUrl){$BaseUrl=[string]$meta.baseUrl}
$base=Assert-RedmineConnection $BaseUrl $AllowHttp.IsPresent
if($base.TrimEnd('/') -ne ([string]$meta.baseUrl).TrimEnd('/')) { throw 'メタデータの接続先と指定BaseUrlが一致しません。プロジェクト情報を更新してください。' }
$signature=Get-PostSignature $base ([int]$meta.project.id) $MsgPath
$oldId=Find-PostedIssue $PSScriptRoot $signature
if($oldId){throw "同じMSGは既に起票済みです: #$oldId"}
$issue=Publish-RedmineIssue $base $payload $AllowHttp.IsPresent
try { Save-PostReceipt $PSScriptRoot $signature $issue.id }
catch { Write-Warning "チケット#$($issue.id)は起票済みですが履歴保存に失敗しました。再起票しないでください: $($_.Exception.Message)" }
Write-Host "起票成功: $base/issues/$($issue.id)" -ForegroundColor Green
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
rules.json(全文){
"version": 1,
"rules": [
{
"enabled": true,
"name": "問い合わせ者",
"source": "Body",
"mode": "Regex",
"pattern": "(?m)^問い合わせ者[::]\\s*(.+)\\s*$",
"group": 1,
"target": "cf:12",
"fallback": "$SENDER"
},
{
"enabled": true,
"name": "外部チケット番号",
"source": "Subject+Body",
"mode": "Regex",
"pattern": "\\b(?:INC|REQ|TCK)-\\d{4}-\\d+\\b",
"group": 0,
"target": "cf:13",
"fallback": ""
},
{
"enabled": true,
"name": "問い合わせ分類",
"source": "Body",
"mode": "Regex",
"pattern": "(?m)^分類[::]\\s*(障害|質問|依頼)\\s*$",
"group": 1,
"target": "cf:15",
"fallback": "質問"
}
]
}
sample-project-metadata.json(全文){
"schemaVersion": 1,
"exportedAt": "2026-10-14T00:00:00+09:00",
"baseUrl": "https://redmine.example.com",
"project": {
"id": 246,
"identifier": "sample-project",
"name": "サンプルプロジェクト(オフライン確認用)"
},
"trackers": [
{
"id": 1,
"name": "バグ",
"default_status": {
"id": 1,
"name": "新規"
}
},
{
"id": 2,
"name": "問い合わせ",
"default_status": {
"id": 1,
"name": "新規"
}
}
],
"priorities": [
{
"id": 2,
"name": "通常"
},
{
"id": 3,
"name": "高"
}
],
"statuses": [
{
"id": 1,
"name": "新規"
},
{
"id": 2,
"name": "進行中"
},
{
"id": 3,
"name": "解決"
}
],
"categories": [
{
"id": 7,
"name": "アプリケーション"
},
{
"id": 8,
"name": "ネットワーク"
}
],
"versions": [],
"members": [
{
"id": 5,
"name": "山田 太郎"
},
{
"id": 6,
"name": "佐藤 花子"
}
],
"customFields": [
{
"id": 12,
"name": "問い合わせ者",
"customized_type": "issue",
"field_format": "string",
"is_required": true,
"multiple": false,
"possible_values": []
},
{
"id": 13,
"name": "外部チケット番号",
"customized_type": "issue",
"field_format": "string",
"is_required": false,
"multiple": false,
"possible_values": []
},
{
"id": 15,
"name": "問い合わせ分類",
"customized_type": "issue",
"field_format": "list",
"is_required": false,
"multiple": false,
"possible_values": [
{
"value": "障害"
},
{
"value": "質問"
},
{
"value": "依頼"
}
]
}
],
"warnings": [
"これはオフライン確認用の例です。実際のチケット登録前にRedmineからプロジェクト定義を取得してください。"
]
}
test-mail.json(全文){
"Subject": "【障害】ログインできない INC-2026-001",
"Body": "問い合わせ者: 山田 太郎\n分類: 障害\n\n業務システムへログインできず、エラーが発生します。",
"SenderName": "山田 太郎",
"SenderEmailAddress": "[email protected]",
"To": "[email protected]",
"ReceivedTime": "2026-10-14 09:30:00"
}
tool-settings.json(全文){
"baseUrl": "https://redmine.example.com",
"project": "sample-project",
"allowHttp": false
}
| 症状 | 確認すること |
|---|---|
| 401 / 403 | APIキー、REST APIの有効化、プロジェクトの権限 |
| 404 | Redmine URLとプロジェクト識別子 |
| 422 | 必須項目、カスタムフィールドの値、トラッカーの指定 |
| カスタムフィールドが一部しか表示されない | APIキーの権限、管理者向けAPIの応答 |
| MSGが読み込めない | Classic Outlook、メールプロファイル、ファイル形式 |
| 二重登録と判定される | posted-history.json とRedmine側の登録済みチケット |
| 日本語が文字化け | .ps1 のCP932保存、JSONのUTF-8保存 |
ルール追加で Name フィールドがない |
v1.3の Start-RedmineGUI.ps1 に差し替え済みか、古いプロセスが残っていないか確認 |
実際のRedmine環境によって、APIで取得できる内容や入力必須項目は変わります。まずはテストプロジェクトで一度、プロジェクト情報取得→JSONプレビュー→起票まで確認するのがよいと思います。