igel-qe-core 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { setupPlatform } from "./platform.js";
|
|
4
6
|
const program = new Command();
|
|
5
7
|
program
|
|
6
8
|
.name("igel-qe")
|
|
@@ -9,7 +11,10 @@ program
|
|
|
9
11
|
program
|
|
10
12
|
.command("init")
|
|
11
13
|
.description("Bootstrap the Python AI Platform Layer")
|
|
12
|
-
.
|
|
14
|
+
.option("--with-copilot", "Auto-configure for GitHub Copilot in VS Code")
|
|
15
|
+
.option("--with-cline", "Auto-configure for Cline")
|
|
16
|
+
.option("--with-all", "Auto-configure all supported platforms")
|
|
17
|
+
.action(async (options) => {
|
|
13
18
|
console.log(chalk.blue("Initializing IGEL QE AI Platform..."));
|
|
14
19
|
const steps = [
|
|
15
20
|
"1. Checking Python version...",
|
|
@@ -26,13 +31,52 @@ program
|
|
|
26
31
|
console.log(chalk.green(` ✓ Done`));
|
|
27
32
|
}
|
|
28
33
|
console.log(chalk.bold.green("\nIGEL QE AI Platform successfully initialized!"));
|
|
29
|
-
|
|
34
|
+
if (options.withCopilot || options.withAll) {
|
|
35
|
+
await setupPlatform('copilot');
|
|
36
|
+
}
|
|
37
|
+
if (options.withCline || options.withAll) {
|
|
38
|
+
await setupPlatform('cline');
|
|
39
|
+
}
|
|
40
|
+
console.log(`You can now use GitHub Copilot or Cline to invoke MCP tools or run commands via this CLI.`);
|
|
30
41
|
});
|
|
31
42
|
program
|
|
32
43
|
.command("sync")
|
|
33
44
|
.description("Trigger a delta sync of the automation codebase")
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
.option("-c, --commit <hash>", "Bitbucket commit hash to sync", "HEAD")
|
|
46
|
+
.action((options) => {
|
|
47
|
+
console.log(chalk.blue(`Triggering delta sync for commit: ${options.commit}...`));
|
|
48
|
+
import('path').then(path => {
|
|
49
|
+
import('url').then(url => {
|
|
50
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
51
|
+
const projectRoot = path.join(__dirname, '..', '..', '..');
|
|
52
|
+
const pythonProcess = spawn("python", [
|
|
53
|
+
"-m", "knowledge_base.cli.workflow_cli",
|
|
54
|
+
"--action", "sync_bitbucket",
|
|
55
|
+
"--args", JSON.stringify({ commit_hash: options.commit })
|
|
56
|
+
], {
|
|
57
|
+
cwd: projectRoot,
|
|
58
|
+
env: process.env,
|
|
59
|
+
stdio: 'inherit'
|
|
60
|
+
});
|
|
61
|
+
pythonProcess.on("close", (code) => {
|
|
62
|
+
if (code === 0) {
|
|
63
|
+
console.log(chalk.green("Sync completed successfully."));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
console.error(chalk.red(`Sync failed with code ${code}`));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
const platformCmd = program
|
|
73
|
+
.command("platform")
|
|
74
|
+
.description("Manage coding agent platform configurations");
|
|
75
|
+
platformCmd
|
|
76
|
+
.command("setup <platform>")
|
|
77
|
+
.description("Setup an MCP configuration for a specific platform (copilot, cline)")
|
|
78
|
+
.action(async (platform) => {
|
|
79
|
+
console.log(chalk.blue(`Setting up configuration for ${platform}...`));
|
|
80
|
+
await setupPlatform(platform);
|
|
37
81
|
});
|
|
38
82
|
program.parse();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
export async function setupPlatform(platform) {
|
|
6
|
+
const homeDir = os.homedir();
|
|
7
|
+
// The command we want to inject
|
|
8
|
+
const mcpServerConfig = {
|
|
9
|
+
command: process.platform === 'win32' ? 'igel-qe-mcp.cmd' : 'igel-qe-mcp',
|
|
10
|
+
args: []
|
|
11
|
+
};
|
|
12
|
+
try {
|
|
13
|
+
if (platform === 'copilot') {
|
|
14
|
+
// Setup for GitHub Copilot in VS Code
|
|
15
|
+
// VS Code user settings on Windows usually live in AppData/Roaming/Code/User
|
|
16
|
+
let vscodeSettingsPath = path.join(homeDir, 'AppData', 'Roaming', 'Code', 'User', 'settings.json');
|
|
17
|
+
if (process.platform !== 'win32') {
|
|
18
|
+
vscodeSettingsPath = path.join(homeDir, '.config', 'Code', 'User', 'settings.json');
|
|
19
|
+
}
|
|
20
|
+
let settings = {};
|
|
21
|
+
if (fs.existsSync(vscodeSettingsPath)) {
|
|
22
|
+
const raw = fs.readFileSync(vscodeSettingsPath, 'utf8');
|
|
23
|
+
try {
|
|
24
|
+
settings = JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch (e) { /* ignore parse error for now */ }
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
fs.mkdirSync(path.dirname(vscodeSettingsPath), { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
if (!settings['github.copilot.chat.mcp.servers']) {
|
|
32
|
+
settings['github.copilot.chat.mcp.servers'] = {};
|
|
33
|
+
}
|
|
34
|
+
settings['github.copilot.chat.mcp.servers']['igel-qe'] = mcpServerConfig;
|
|
35
|
+
fs.writeFileSync(vscodeSettingsPath, JSON.stringify(settings, null, 2));
|
|
36
|
+
console.log(chalk.green(`✓ Successfully configured GitHub Copilot MCP in ${vscodeSettingsPath}`));
|
|
37
|
+
}
|
|
38
|
+
else if (platform === 'cline') {
|
|
39
|
+
// Setup for Cline (Claude Dev)
|
|
40
|
+
let clineConfigPath = path.join(homeDir, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json');
|
|
41
|
+
if (process.platform !== 'win32') {
|
|
42
|
+
clineConfigPath = path.join(homeDir, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json');
|
|
43
|
+
}
|
|
44
|
+
let settings = { mcpServers: {} };
|
|
45
|
+
if (fs.existsSync(clineConfigPath)) {
|
|
46
|
+
const raw = fs.readFileSync(clineConfigPath, 'utf8');
|
|
47
|
+
try {
|
|
48
|
+
settings = JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch (e) { /* ignore parse error */ }
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
fs.mkdirSync(path.dirname(clineConfigPath), { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
settings.mcpServers['igel-qe'] = mcpServerConfig;
|
|
56
|
+
fs.writeFileSync(clineConfigPath, JSON.stringify(settings, null, 2));
|
|
57
|
+
console.log(chalk.green(`✓ Successfully configured Cline MCP in ${clineConfigPath}`));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.log(chalk.yellow(`Platform '${platform}' is not currently supported for auto-setup.`));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
console.error(chalk.red(`Failed to setup ${platform}: ${err.message}`));
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { spawn } from "child_process";
|
|
4
5
|
import path from "path";
|
|
5
6
|
import { fileURLToPath } from "url";
|
|
6
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -12,16 +13,42 @@ const server = new Server({
|
|
|
12
13
|
tools: {}
|
|
13
14
|
}
|
|
14
15
|
});
|
|
15
|
-
// Helper to bridge to the Python AI Platform Layer
|
|
16
16
|
async function callPythonEngine(workflowName, args) {
|
|
17
17
|
return new Promise((resolve, reject) => {
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
// Run the Python CLI in the project root
|
|
19
|
+
// We assume the environment is set up and `python` refers to the venv
|
|
20
|
+
const projectRoot = path.join(__dirname, '..', '..', '..');
|
|
21
|
+
const pythonProcess = spawn("python", [
|
|
22
|
+
"-m", "knowledge_base.cli.workflow_cli",
|
|
23
|
+
"--action", workflowName,
|
|
24
|
+
"--args", JSON.stringify(args)
|
|
25
|
+
], {
|
|
26
|
+
cwd: projectRoot,
|
|
27
|
+
env: process.env // Inherit the environment (.env vars)
|
|
28
|
+
});
|
|
29
|
+
let stdoutData = "";
|
|
30
|
+
let stderrData = "";
|
|
31
|
+
pythonProcess.stdout.on("data", (data) => {
|
|
32
|
+
stdoutData += data.toString();
|
|
33
|
+
});
|
|
34
|
+
pythonProcess.stderr.on("data", (data) => {
|
|
35
|
+
stderrData += data.toString();
|
|
36
|
+
console.error(`Python stderr: ${data}`); // MCP servers use stderr for logging
|
|
37
|
+
});
|
|
38
|
+
pythonProcess.on("close", (code) => {
|
|
39
|
+
if (code !== 0) {
|
|
40
|
+
// If it crashed entirely, return the stderr as a JSON error
|
|
41
|
+
resolve(JSON.stringify({
|
|
42
|
+
status: "error",
|
|
43
|
+
message: `Python process exited with code ${code}`,
|
|
44
|
+
stderr: stderrData
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Return the exact JSON stdout printed by workflow_cli.py
|
|
49
|
+
resolve(stdoutData.trim());
|
|
50
|
+
}
|
|
51
|
+
});
|
|
25
52
|
});
|
|
26
53
|
}
|
|
27
54
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from knowledge_base.db.client import get_conn
|
|
8
|
+
from knowledge_base.db.igel_schema import ensure_igel_tables
|
|
9
|
+
from knowledge_base.connectors.jira_connector import JiraConnector
|
|
10
|
+
from knowledge_base.agents.workflow_engine import WorkflowEngine
|
|
11
|
+
|
|
12
|
+
logging.basicConfig(level=logging.ERROR) # Only output strict JSON to stdout
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
parser = argparse.ArgumentParser(description="Workflow CLI for Node.js IPC")
|
|
16
|
+
parser.add_argument("--action", required=True, choices=["generate_test_cases", "sync_bitbucket"])
|
|
17
|
+
parser.add_argument("--args", required=True, help="JSON arguments for the action")
|
|
18
|
+
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
payload = json.loads(args.args)
|
|
23
|
+
except json.JSONDecodeError:
|
|
24
|
+
print(json.dumps({"status": "error", "message": "Invalid JSON args"}))
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
ensure_igel_tables()
|
|
29
|
+
except Exception as e:
|
|
30
|
+
print(json.dumps({"status": "error", "message": f"Database Init Failed: {e}"}))
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
if args.action == "generate_test_cases":
|
|
34
|
+
jira_key = payload.get("jira_key")
|
|
35
|
+
if not jira_key:
|
|
36
|
+
print(json.dumps({"status": "error", "message": "Missing jira_key"}))
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
# 1. Fetch from Jira
|
|
41
|
+
connector = JiraConnector()
|
|
42
|
+
req = connector.get_test_requirement(jira_key)
|
|
43
|
+
|
|
44
|
+
# 2. Run Workflow
|
|
45
|
+
repo_path = Path.cwd()
|
|
46
|
+
engine = WorkflowEngine(repo_path)
|
|
47
|
+
|
|
48
|
+
req, scenarios = engine.run_requirement_flow(req)
|
|
49
|
+
test_cases = engine.run_testcase_flow(req, scenarios)
|
|
50
|
+
|
|
51
|
+
# Since we skip Human Review in CLI mode, we auto-approve all for scripting
|
|
52
|
+
for tc in test_cases:
|
|
53
|
+
tc.is_approved = True
|
|
54
|
+
|
|
55
|
+
output_script = repo_path / "tests" / f"test_{jira_key.replace('-', '_').lower()}.py"
|
|
56
|
+
ok, msg = engine.run_script_flow(req, test_cases, output_script)
|
|
57
|
+
|
|
58
|
+
if ok:
|
|
59
|
+
result = {
|
|
60
|
+
"status": "success",
|
|
61
|
+
"jira_key": jira_key,
|
|
62
|
+
"generated_cases": len(test_cases),
|
|
63
|
+
"script_path": str(output_script)
|
|
64
|
+
}
|
|
65
|
+
else:
|
|
66
|
+
result = {
|
|
67
|
+
"status": "error",
|
|
68
|
+
"message": msg
|
|
69
|
+
}
|
|
70
|
+
print(json.dumps(result))
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
print(json.dumps({"status": "error", "message": str(e)}))
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
elif args.action == "sync_bitbucket":
|
|
77
|
+
commit_hash = payload.get("commit_hash")
|
|
78
|
+
if not commit_hash:
|
|
79
|
+
print(json.dumps({"status": "error", "message": "Missing commit_hash"}))
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
from knowledge_base.agents.regression_agent import BitbucketSyncAgent
|
|
84
|
+
repo_path = Path.cwd()
|
|
85
|
+
agent = BitbucketSyncAgent(repo_path)
|
|
86
|
+
agent.sync_changed_files(commit_hash)
|
|
87
|
+
print(json.dumps({"status": "success", "synced_commit": commit_hash}))
|
|
88
|
+
except Exception as e:
|
|
89
|
+
print(json.dumps({"status": "error", "message": str(e)}))
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
|
@@ -219,6 +219,7 @@ class Config:
|
|
|
219
219
|
JIRA_URL: str = os.getenv("JIRA_URL", "")
|
|
220
220
|
JIRA_USERNAME: str = os.getenv("JIRA_USERNAME", "")
|
|
221
221
|
JIRA_API_TOKEN: str = os.getenv("JIRA_API_TOKEN", "")
|
|
222
|
+
JIRA_OAUTH_TOKEN: str = os.getenv("JIRA_OAUTH_TOKEN", "")
|
|
222
223
|
|
|
223
224
|
CONFLUENCE_ENABLED: bool = _truthy(os.getenv("CONFLUENCE_ENABLED"), False)
|
|
224
225
|
CONFLUENCE_URL: str = os.getenv("CONFLUENCE_URL", "")
|
|
@@ -45,11 +45,20 @@ class JiraConnector(BaseConnector):
|
|
|
45
45
|
def connect(self) -> bool:
|
|
46
46
|
if not self.enabled:
|
|
47
47
|
return False
|
|
48
|
-
if not cfg.JIRA_URL
|
|
49
|
-
logger.warning("
|
|
48
|
+
if not cfg.JIRA_URL:
|
|
49
|
+
logger.warning("JIRA_URL missing")
|
|
50
|
+
return False
|
|
51
|
+
if not cfg.JIRA_OAUTH_TOKEN and not (cfg.JIRA_USERNAME and cfg.JIRA_API_TOKEN):
|
|
52
|
+
logger.warning("JIRA credentials missing (no OAuth token or Basic Auth)")
|
|
50
53
|
return False
|
|
51
54
|
return True
|
|
52
55
|
|
|
56
|
+
def _get_auth_kwargs(self) -> dict:
|
|
57
|
+
"""Return httpx kwargs for authentication (Bearer or Basic)."""
|
|
58
|
+
if cfg.JIRA_OAUTH_TOKEN:
|
|
59
|
+
return {"headers": {"Authorization": f"Bearer {cfg.JIRA_OAUTH_TOKEN}"}}
|
|
60
|
+
return {"auth": (cfg.JIRA_USERNAME, cfg.JIRA_API_TOKEN)}
|
|
61
|
+
|
|
53
62
|
def search(self, query: str, limit: int = 5, timeout: float | None = None) -> list[str]:
|
|
54
63
|
if not self.connect():
|
|
55
64
|
return []
|
|
@@ -60,7 +69,7 @@ class JiraConnector(BaseConnector):
|
|
|
60
69
|
resp = client.get(
|
|
61
70
|
f"{cfg.JIRA_URL.rstrip('/')}/rest/api/2/search",
|
|
62
71
|
params={"jql": jql, "maxResults": limit, "fields": "summary"},
|
|
63
|
-
|
|
72
|
+
**self._get_auth_kwargs()
|
|
64
73
|
)
|
|
65
74
|
resp.raise_for_status()
|
|
66
75
|
issues = resp.json().get("issues") or []
|
|
@@ -84,7 +93,7 @@ class JiraConnector(BaseConnector):
|
|
|
84
93
|
resp = client.get(
|
|
85
94
|
f"{cfg.JIRA_URL.rstrip('/')}/rest/api/2/search",
|
|
86
95
|
params={"jql": jql, "maxResults": 50, "fields": "summary,description,status,components,fixVersions,issuetype"},
|
|
87
|
-
|
|
96
|
+
**self._get_auth_kwargs()
|
|
88
97
|
)
|
|
89
98
|
resp.raise_for_status()
|
|
90
99
|
for issue in resp.json().get("issues") or []:
|
|
@@ -98,6 +107,78 @@ class JiraConnector(BaseConnector):
|
|
|
98
107
|
"issue_type": (fields.get("issuetype") or {}).get("name"),
|
|
99
108
|
"source_type": "jira",
|
|
100
109
|
})
|
|
110
|
+
return items
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.warning("JIRA sync failed: %s", exc)
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
def get_test_requirement(self, jira_key: str) -> 'knowledge_base.models.test_requirement.TestRequirement':
|
|
116
|
+
"""Fetch a specific Jira issue and parse its manual Xray test steps."""
|
|
117
|
+
if not self.connect():
|
|
118
|
+
raise Exception("Jira is not configured or disabled.")
|
|
119
|
+
|
|
120
|
+
# We import here to avoid circular imports during init
|
|
121
|
+
from knowledge_base.models.test_requirement import TestRequirement, TestStep
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
with httpx.Client(timeout=30.0) as client:
|
|
125
|
+
# 1. Fetch the Issue
|
|
126
|
+
resp = client.get(
|
|
127
|
+
f"{cfg.JIRA_URL.rstrip('/')}/rest/api/2/issue/{jira_key}",
|
|
128
|
+
**self._get_auth_kwargs()
|
|
129
|
+
)
|
|
130
|
+
resp.raise_for_status()
|
|
131
|
+
issue = resp.json()
|
|
132
|
+
fields = issue.get("fields", {})
|
|
133
|
+
|
|
134
|
+
summary = fields.get("summary", jira_key)
|
|
135
|
+
description = _jira_text(fields.get("description"))
|
|
136
|
+
components = [c.get("name") for c in fields.get("components", []) if c.get("name")]
|
|
137
|
+
labels = fields.get("labels", [])
|
|
138
|
+
|
|
139
|
+
# 2. Extract Test Steps
|
|
140
|
+
# Depending on the Xray configuration, steps might be in a custom field,
|
|
141
|
+
# or require a separate API call to `/rest/raven/1.0/api/test/{issueId}/step`.
|
|
142
|
+
# We will attempt to fetch via Raven API for standard Xray.
|
|
143
|
+
steps = []
|
|
144
|
+
try:
|
|
145
|
+
step_resp = client.get(
|
|
146
|
+
f"{cfg.JIRA_URL.rstrip('/')}/rest/raven/1.0/api/test/{jira_key}/step",
|
|
147
|
+
**self._get_auth_kwargs()
|
|
148
|
+
)
|
|
149
|
+
if step_resp.status_code == 200:
|
|
150
|
+
xray_steps = step_resp.json()
|
|
151
|
+
for i, s in enumerate(xray_steps):
|
|
152
|
+
# The Xray REST API returns raw HTML or text for action/data/result
|
|
153
|
+
steps.append(TestStep(
|
|
154
|
+
step_number=i + 1,
|
|
155
|
+
action=_jira_text(s.get("action")),
|
|
156
|
+
test_data=_jira_text(s.get("data")),
|
|
157
|
+
expected_result=_jira_text(s.get("result"))
|
|
158
|
+
))
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.warning(f"Failed to fetch Xray steps for {jira_key}: {e}")
|
|
161
|
+
|
|
162
|
+
# If no Xray steps found, provide a fallback stub step for the AI to process based on description
|
|
163
|
+
if not steps:
|
|
164
|
+
steps.append(TestStep(
|
|
165
|
+
step_number=1,
|
|
166
|
+
action="Analyze requirement description",
|
|
167
|
+
expected_result="System behaves as specified in description"
|
|
168
|
+
))
|
|
169
|
+
|
|
170
|
+
return TestRequirement(
|
|
171
|
+
jira_key=jira_key,
|
|
172
|
+
summary=summary,
|
|
173
|
+
description=description,
|
|
174
|
+
components=components,
|
|
175
|
+
labels=labels,
|
|
176
|
+
steps=steps
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
logger.error(f"Failed to get test requirement {jira_key}: {exc}")
|
|
181
|
+
raise
|
|
101
182
|
except Exception as exc:
|
|
102
183
|
logger.warning("JIRA sync failed: %s", exc)
|
|
103
184
|
raise
|