Merge pull request #148 from barrett-ruth/feat/interactor
interactive problems
This commit is contained in:
commit
6d4299ec68
11 changed files with 272 additions and 56 deletions
9
.github/workflows/quality.yml
vendored
9
.github/workflows/quality.yml
vendored
|
|
@ -29,8 +29,9 @@ jobs:
|
|||
- '.luarc.json'
|
||||
- '*.toml'
|
||||
python:
|
||||
- 'scrapers/**'
|
||||
- 'tests/scrapers/**'
|
||||
- 'scripts/**/.py'
|
||||
- 'scrapers/**/*.py'
|
||||
- 'tests/**/*.py'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
markdown:
|
||||
|
|
@ -103,7 +104,7 @@ jobs:
|
|||
- name: Install ruff
|
||||
run: uv tool install ruff
|
||||
- name: Lint Python files with ruff
|
||||
run: ruff check scrapers/ tests/scrapers/
|
||||
run: ruff check scripts/ scrapers/ tests/scrapers/
|
||||
|
||||
python-typecheck:
|
||||
name: Python Type Check
|
||||
|
|
@ -117,7 +118,7 @@ jobs:
|
|||
- name: Install dependencies with mypy
|
||||
run: uv sync --dev
|
||||
- name: Type check Python files with mypy
|
||||
run: uv run mypy scrapers/ tests/scrapers/
|
||||
run: uv run mypy scripts/ scrapers/ tests/scrapers/
|
||||
|
||||
markdown-format:
|
||||
name: Markdown Format Check
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ COMMANDS *cp-commands*
|
|||
:CP pick Launch configured picker for interactive
|
||||
platform/contest selection.
|
||||
|
||||
:CP interact [script]
|
||||
Open an interactive terminal for the current problem.
|
||||
If an executable interactor is provided, runs the compiled
|
||||
binary against the source file (see
|
||||
*cp-interact*). Otherwise, runs the source
|
||||
file. Only valid for interactive problems.
|
||||
|
||||
Navigation Commands ~
|
||||
:CP next Navigate to next problem in current contest.
|
||||
Stops at last problem (no wrapping).
|
||||
|
|
@ -78,7 +85,7 @@ COMMANDS *cp-commands*
|
|||
|
||||
Template Variables ~
|
||||
*cp-template-vars*
|
||||
Command templates support variable substitution using `{variable}` syntax:
|
||||
Command templates support variable substitution using {variable} syntax:
|
||||
|
||||
• {source} Source file path (e.g. "abc324a.cpp")
|
||||
• {binary} Output binary path (e.g. "build/abc324a.run")
|
||||
|
|
@ -155,8 +162,8 @@ Here's an example configuration with lazy.nvim:
|
|||
<
|
||||
|
||||
By default, C++ (g++ with ISO C++17) and Python are preconfigured under
|
||||
`languages`. Platforms select which languages are enabled and which one is
|
||||
the default; per-platform overrides can tweak `extension` or `commands`.
|
||||
'languages'. Platforms select which languages are enabled and which one is
|
||||
the default; per-platform overrides can tweak 'extension' or 'commands'.
|
||||
|
||||
For example, to run CodeForces contests with Python by default:
|
||||
>lua
|
||||
|
|
@ -398,6 +405,37 @@ Test cases use competitive programming terminology with color highlighting:
|
|||
NA Any other state
|
||||
<
|
||||
|
||||
==============================================================================
|
||||
INTERACTIVE MODE *cp-interact*
|
||||
|
||||
Run interactive problems manually or with an orchestrator. :CP interact is
|
||||
available for interactive problems. Test cases are ignored in interactive mode
|
||||
(no run panel, no diffs).
|
||||
|
||||
When using :CP interact {interactor}, the interactor must be executable
|
||||
(chmod +x). Completion after :CP interact suggests executables in CWD.
|
||||
|
||||
1) Terminal-only ~
|
||||
:CP interact
|
||||
Execute the current program and open an interactive terminal running
|
||||
it directly. Use this for manual testing.
|
||||
|
||||
2) Orchestrated ~
|
||||
:CP interact {interactor}
|
||||
Execute the current program and open an interactive terminal that runs
|
||||
your interactor script against it.
|
||||
{interactor} is an executable file relative to the CWD.
|
||||
Example:
|
||||
:CP interact my-executable-interactor.py
|
||||
|
||||
|
||||
Keymaps ~
|
||||
<c-q> Close the terminal and restore the previous layout.
|
||||
|
||||
==============================================================================
|
||||
COMMANDS (update) *cp-commands*
|
||||
|
||||
|
||||
==============================================================================
|
||||
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
|
||||
|
||||
|
|
|
|||
|
|
@ -43,18 +43,12 @@ function M.handle_cache_command(cmd)
|
|||
if vim.tbl_contains(platforms, cmd.platform) then
|
||||
cache.clear_platform(cmd.platform)
|
||||
logger.log(
|
||||
('Cache cleared for platform %s'):format(cmd.platform),
|
||||
("Cache cleared for platform '%s'"):format(constants.PLATFORM_DISPLAY_NAMES[cmd.platform]),
|
||||
vim.log.levels.INFO,
|
||||
true
|
||||
)
|
||||
else
|
||||
logger.log(
|
||||
("Unknown platform: '%s'. Available: %s"):format(
|
||||
cmd.platform,
|
||||
table.concat(platforms, ', ')
|
||||
),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR)
|
||||
end
|
||||
else
|
||||
cache.clear_all()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ local actions = constants.ACTIONS
|
|||
---@field contest? string
|
||||
---@field platform? string
|
||||
---@field problem_id? string
|
||||
---@field interactor_cmd? string
|
||||
|
||||
--- Turn raw args into normalized structure to later dispatch
|
||||
---@param args string[] The raw command-line mode args
|
||||
|
|
@ -32,7 +33,7 @@ local function parse_command(args)
|
|||
if first == 'cache' then
|
||||
local subcommand = args[2]
|
||||
if not subcommand then
|
||||
return { type = 'error', message = 'cache command requires subcommand: clear' }
|
||||
return { type = 'error', message = 'cache command requires subcommand' }
|
||||
end
|
||||
if vim.tbl_contains({ 'clear', 'read' }, subcommand) then
|
||||
local platform = args[3]
|
||||
|
|
@ -44,6 +45,13 @@ local function parse_command(args)
|
|||
else
|
||||
return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand }
|
||||
end
|
||||
elseif first == 'interact' then
|
||||
local inter = args[2]
|
||||
if inter and inter ~= '' then
|
||||
return { type = 'action', action = 'interact', interactor_cmd = inter }
|
||||
else
|
||||
return { type = 'action', action = 'interact' }
|
||||
end
|
||||
else
|
||||
return { type = 'action', action = first }
|
||||
end
|
||||
|
|
@ -99,7 +107,7 @@ function M.handle_command(opts)
|
|||
local ui = require('cp.ui.panel')
|
||||
|
||||
if cmd.action == 'interact' then
|
||||
ui.toggle_interactive()
|
||||
ui.toggle_interactive(cmd.interactor_cmd)
|
||||
elseif cmd.action == 'run' then
|
||||
ui.toggle_run_panel()
|
||||
elseif cmd.action == 'debug' then
|
||||
|
|
@ -128,7 +136,11 @@ function M.handle_command(opts)
|
|||
|
||||
if not (contest_data and contest_data.index_map and contest_data.index_map[problem_id]) then
|
||||
logger.log(
|
||||
("%s contest '%s' has no problem '%s'."):format(platform, contest_id, problem_id),
|
||||
("%s contest '%s' has no problem '%s'."):format(
|
||||
constants.PLATFORM_DISPLAY_NAMES[platform],
|
||||
contest_id,
|
||||
problem_id
|
||||
),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ local M = {}
|
|||
|
||||
local function contest_picker(platform, refresh)
|
||||
local constants = require('cp.constants')
|
||||
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
|
||||
local fzf = require('fzf-lua')
|
||||
local contests = picker_utils.get_platform_contests(platform, refresh)
|
||||
|
||||
if vim.tbl_isempty(contests) then
|
||||
vim.notify(
|
||||
('No contests found for platform: %s'):format(platform_display_name),
|
||||
("No contests found for platform '%s'"):format(platform_display_name),
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
local M = {}
|
||||
|
||||
local constants = require('cp.log')
|
||||
local logger = require('cp.log')
|
||||
local utils = require('cp.utils')
|
||||
|
||||
|
|
@ -113,7 +115,10 @@ function M.scrape_contest_metadata(platform, contest_id, callback)
|
|||
on_exit = function(result)
|
||||
if not result or not result.success then
|
||||
logger.log(
|
||||
("Failed to scrape metadata for %s contest '%s'."):format(platform, contest_id),
|
||||
("Failed to scrape metadata for %s contest '%s'."):format(
|
||||
constants.PLATFORM_DISPLAY_NAMES[platform],
|
||||
contest_id
|
||||
),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
return
|
||||
|
|
@ -121,7 +126,10 @@ function M.scrape_contest_metadata(platform, contest_id, callback)
|
|||
local data = result.data or {}
|
||||
if not data.problems or #data.problems == 0 then
|
||||
logger.log(
|
||||
("No problems returned for %s contest '%s'."):format(platform, contest_id),
|
||||
("No problems returned for %s contest '%s'."):format(
|
||||
constants.PLATFORM_DISPLAY_NAMES[platform],
|
||||
contest_id
|
||||
),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@ local platforms = constants.PLATFORMS
|
|||
|
||||
function M.set_platform(platform)
|
||||
if not vim.tbl_contains(platforms, platform) then
|
||||
logger.log(
|
||||
('unknown platform: %s. supported: %s'):format(platform, table.concat(platforms, ', ')),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
logger.log(("Unknown platform '%s'"):format(platform), vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
state.set_platform(platform)
|
||||
|
|
@ -57,7 +54,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
|
|||
logger.log(('Fetching test cases...'):format(cached_len, #problems))
|
||||
scraper.scrape_all_tests(platform, contest_id, function(ev)
|
||||
local cached_tests = {}
|
||||
if vim.tbl_isempty(ev.tests) then
|
||||
if not ev.interactive and vim.tbl_isempty(ev.tests) then
|
||||
logger.log(
|
||||
("No tests found for problem '%s'."):format(ev.problem_id),
|
||||
vim.log.levels.WARN
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ local M = {}
|
|||
---@field debug? boolean
|
||||
|
||||
local config_module = require('cp.config')
|
||||
local constants = require('cp.constants')
|
||||
local layouts = require('cp.ui.layouts')
|
||||
local logger = require('cp.log')
|
||||
local state = require('cp.state')
|
||||
|
|
@ -28,7 +29,8 @@ function M.disable()
|
|||
end
|
||||
end
|
||||
|
||||
function M.toggle_interactive()
|
||||
---@param interactor_cmd? string
|
||||
function M.toggle_interactive(interactor_cmd)
|
||||
if state.get_active_panel() == 'interactive' then
|
||||
if state.interactive_buf and vim.api.nvim_buf_is_valid(state.interactive_buf) then
|
||||
local job = vim.b[state.interactive_buf].terminal_job_id
|
||||
|
|
@ -42,7 +44,6 @@ function M.toggle_interactive()
|
|||
state.saved_interactive_session = nil
|
||||
end
|
||||
state.set_active_panel(nil)
|
||||
logger.log('Interactive panel closed.')
|
||||
return
|
||||
end
|
||||
|
||||
|
|
@ -52,15 +53,16 @@ function M.toggle_interactive()
|
|||
end
|
||||
|
||||
local platform, contest_id = state.get_platform(), state.get_contest_id()
|
||||
|
||||
if not platform then
|
||||
logger.log('No platform configured.', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if not contest_id then
|
||||
logger.log(
|
||||
('No contest %s configured for platform %s.'):format(contest_id, platform),
|
||||
("No contest %s configured for platform '%s'."):format(
|
||||
contest_id,
|
||||
constants.PLATFORM_DISPLAY_NAMES[platform]
|
||||
),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
return
|
||||
|
|
@ -68,7 +70,7 @@ function M.toggle_interactive()
|
|||
|
||||
local problem_id = state.get_problem_id()
|
||||
if not problem_id then
|
||||
logger.log(('No problem found for the current problem id %s'):format(problem_id))
|
||||
logger.log('No problem is active.', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
|
|
@ -76,10 +78,12 @@ function M.toggle_interactive()
|
|||
cache.load()
|
||||
local contest_data = cache.get_contest_data(platform, contest_id)
|
||||
if
|
||||
contest_data
|
||||
and not contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive
|
||||
not contest_data
|
||||
or not contest_data.index_map
|
||||
or not contest_data.problems[contest_data.index_map[problem_id]]
|
||||
or not contest_data.problems[contest_data.index_map[problem_id]].interactive
|
||||
then
|
||||
logger.log('This is NOT an interactive problem. Use :CP run instead.', vim.log.levels.WARN)
|
||||
logger.log('This problem is not interactive. Use :CP run.', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
|
|
@ -95,19 +99,104 @@ function M.toggle_interactive()
|
|||
end
|
||||
|
||||
local binary = state.get_binary_file()
|
||||
if not binary then
|
||||
logger.log('no binary path found', vim.log.levels.ERROR)
|
||||
if not binary or binary == '' then
|
||||
logger.log('No binary produced.', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
vim.cmd('terminal')
|
||||
local cmdline
|
||||
if interactor_cmd and interactor_cmd ~= '' then
|
||||
local interactor = interactor_cmd
|
||||
if not interactor:find('/') then
|
||||
interactor = './' .. interactor
|
||||
end
|
||||
if vim.fn.executable(interactor) ~= 1 then
|
||||
logger.log(
|
||||
("Interactor '%s' is not executable."):format(interactor_cmd),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
if state.saved_interactive_session then
|
||||
vim.cmd(('source %s'):format(state.saved_interactive_session))
|
||||
vim.fn.delete(state.saved_interactive_session)
|
||||
state.saved_interactive_session = nil
|
||||
end
|
||||
return
|
||||
end
|
||||
local orchestrator = vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
|
||||
cmdline = table.concat({
|
||||
'uv',
|
||||
'run',
|
||||
vim.fn.shellescape(orchestrator),
|
||||
vim.fn.shellescape(interactor),
|
||||
vim.fn.shellescape(binary),
|
||||
}, ' ')
|
||||
else
|
||||
cmdline = vim.fn.shellescape(binary)
|
||||
end
|
||||
|
||||
vim.cmd('terminal ' .. cmdline)
|
||||
local term_buf = vim.api.nvim_get_current_buf()
|
||||
local term_win = vim.api.nvim_get_current_win()
|
||||
|
||||
vim.fn.chansend(vim.b.terminal_job_id, binary .. '\n')
|
||||
local cleaned = false
|
||||
local function cleanup()
|
||||
if cleaned then
|
||||
return
|
||||
end
|
||||
cleaned = true
|
||||
if term_buf and vim.api.nvim_buf_is_valid(term_buf) then
|
||||
local job = vim.b[term_buf] and vim.b[term_buf].terminal_job_id or nil
|
||||
if job then
|
||||
pcall(vim.fn.jobstop, job)
|
||||
end
|
||||
end
|
||||
if state.saved_interactive_session then
|
||||
vim.cmd(('source %s'):format(state.saved_interactive_session))
|
||||
vim.fn.delete(state.saved_interactive_session)
|
||||
state.saved_interactive_session = nil
|
||||
end
|
||||
state.interactive_buf = nil
|
||||
state.interactive_win = nil
|
||||
state.set_active_panel(nil)
|
||||
end
|
||||
|
||||
vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, {
|
||||
buffer = term_buf,
|
||||
callback = function()
|
||||
cleanup()
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd('WinClosed', {
|
||||
callback = function()
|
||||
if cleaned then
|
||||
return
|
||||
end
|
||||
local any = false
|
||||
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
||||
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == term_buf then
|
||||
any = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not any then
|
||||
cleanup()
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd('TermClose', {
|
||||
buffer = term_buf,
|
||||
callback = function()
|
||||
vim.b[term_buf].cp_interactive_exited = true
|
||||
end,
|
||||
})
|
||||
|
||||
vim.keymap.set('t', '<c-q>', function()
|
||||
M.toggle_interactive()
|
||||
cleanup()
|
||||
end, { buffer = term_buf, silent = true })
|
||||
vim.keymap.set('n', '<c-q>', function()
|
||||
cleanup()
|
||||
end, { buffer = term_buf, silent = true })
|
||||
|
||||
state.interactive_buf = term_buf
|
||||
|
|
@ -149,18 +238,15 @@ function M.toggle_run_panel(run_opts)
|
|||
|
||||
if not contest_id then
|
||||
logger.log(
|
||||
('No contest %s configured for platform %s.'):format(contest_id, platform),
|
||||
("No contest '%s' configured for platform '%s'."):format(
|
||||
contest_id,
|
||||
constants.PLATFORM_DISPLAY_NAMES[platform]
|
||||
),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local problem_id = state.get_problem_id()
|
||||
if not problem_id then
|
||||
logger.log(('No problem found for the current problem id %s'):format(problem_id))
|
||||
return
|
||||
end
|
||||
|
||||
local cache = require('cp.cache')
|
||||
cache.load()
|
||||
local contest_data = cache.get_contest_data(platform, contest_id)
|
||||
|
|
@ -172,14 +258,6 @@ function M.toggle_run_panel(run_opts)
|
|||
return
|
||||
end
|
||||
|
||||
logger.log(
|
||||
('Run panel: platform=%s, contest=%s, problem=%s'):format(
|
||||
tostring(platform),
|
||||
tostring(contest_id),
|
||||
tostring(problem_id)
|
||||
)
|
||||
)
|
||||
|
||||
local config = config_module.get_config()
|
||||
local run = require('cp.runner.run')
|
||||
local input_file = state.get_input_file()
|
||||
|
|
|
|||
|
|
@ -228,4 +228,27 @@ function M.timeout_capability()
|
|||
return { ok = path ~= nil, path = path, reason = reason }
|
||||
end
|
||||
|
||||
function M.cwd_executables()
|
||||
local uv = vim.uv or vim.loop
|
||||
local req = uv.fs_scandir('.')
|
||||
if not req then
|
||||
return {}
|
||||
end
|
||||
local out = {}
|
||||
while true do
|
||||
local name, t = uv.fs_scandir_next(req)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
if t == 'file' or t == 'link' then
|
||||
local path = './' .. name
|
||||
if vim.fn.executable(path) == 1 then
|
||||
out[#out + 1] = name
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ if vim.g.loaded_cp then
|
|||
end
|
||||
vim.g.loaded_cp = 1
|
||||
|
||||
local utils = require('cp.utils')
|
||||
|
||||
vim.api.nvim_create_user_command('CP', function(opts)
|
||||
local cp = require('cp')
|
||||
cp.handle_command(opts)
|
||||
|
|
@ -51,6 +53,11 @@ end, {
|
|||
return vim.tbl_filter(function(cmd)
|
||||
return cmd:find(ArgLead, 1, true) == 1
|
||||
end, { 'clear', 'read' })
|
||||
elseif args[2] == 'interact' then
|
||||
local cands = utils.cwd_executables()
|
||||
return vim.tbl_filter(function(cmd)
|
||||
return cmd:find(ArgLead, 1, true) == 1
|
||||
end, cands)
|
||||
end
|
||||
elseif num_args == 4 then
|
||||
if args[2] == 'cache' and args[3] == 'clear' then
|
||||
|
|
|
|||
58
scripts/interact.py
Normal file
58
scripts/interact.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
async def pump(
|
||||
reader: asyncio.StreamReader, writer: asyncio.StreamWriter | None
|
||||
) -> None:
|
||||
while True:
|
||||
data = await reader.readline()
|
||||
if not data:
|
||||
break
|
||||
sys.stdout.buffer.write(data)
|
||||
sys.stdout.flush()
|
||||
if writer:
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
|
||||
|
||||
async def main(interactor_cmd: Sequence[str], interactee_cmd: Sequence[str]) -> None:
|
||||
interactor = await asyncio.create_subprocess_exec(
|
||||
*interactor_cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
interactee = await asyncio.create_subprocess_exec(
|
||||
*interactee_cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
assert (
|
||||
interactor.stdout
|
||||
and interactor.stdin
|
||||
and interactee.stdout
|
||||
and interactee.stdin
|
||||
)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(pump(interactor.stdout, interactee.stdin)),
|
||||
asyncio.create_task(pump(interactee.stdout, interactor.stdin)),
|
||||
]
|
||||
await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
|
||||
await interactor.wait()
|
||||
await interactee.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: interact.py <interactor> <interactee>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
interactor_cmd = shlex.split(sys.argv[1])
|
||||
interactee_cmd = shlex.split(sys.argv[2])
|
||||
|
||||
asyncio.run(main(interactor_cmd, interactee_cmd))
|
||||
Loading…
Add table
Add a link
Reference in a new issue