pagure_add_migration_comment: support --skip-close and --skip-comment

Also do fewer API calls.

This is a follow-up to 4303ca8b0 and !2 that enhances it.

Assisted-by: Cursor AI
This commit is contained in:
Kamil Páral 2026-06-01 17:20:23 +02:00
commit 4b723e50ee
5 changed files with 417 additions and 138 deletions

View file

@ -1,4 +1,13 @@
<!-- 504d7c33-2436-4205-884c-2cb2ec52fbaa c2f0ff49-88d0-4799-a32e-d784da88f306 -->
---
name: Forge Migrate Story Points Script
overview: ""
todos:
- id: 64ccca89-b4a6-4dcd-b417-b927fc5e2077
content: Create forge_migrate_story_points.py with URL parsing, story points normalization, and Forge label API integration
status: pending
isProject: false
---
# Forge Migrate Story Points Script
## Overview
@ -87,8 +96,4 @@ python forge_migrate_story_points.py https://pagure.io/fedora-qa/repo \
python forge_migrate_story_points.py https://pagure.io/fedora-qa/repo \
https://forge.fedoraproject.org/quality/repo \
--token YOUR_TOKEN --issues 1 2 3
```
### To-dos
- [ ] Create forge_migrate_story_points.py with URL parsing, story points normalization, and Forge label API integration
```

View file

@ -1,4 +1,13 @@
<!-- 0aaf11c0-c26e-4416-b495-4d751068a277 562bc839-4e10-4657-ad9c-54c111860188 -->
---
name: Pagure Create Issues Script
overview: ""
todos:
- id: 49651eb4-a421-4a21-9111-bac8040bcf4a
content: Create pagure_create_issues.py with argument parsing and issue creation logic
status: pending
isProject: false
---
# Pagure Create Issues Script
## Overview
@ -35,8 +44,4 @@ Where N ranges from 1 to the specified count.
### Output
Similar to existing script - show progress for each issue, summary at end with success/failure counts.
### To-dos
- [ ] Create pagure_create_issues.py with argument parsing and issue creation logic
Similar to existing script - show progress for each issue, summary at end with success/failure counts.

View file

@ -0,0 +1,237 @@
---
name: Refactor migration script
overview: Refactor `pagure_add_migration_comment.py` to make comment/close composable (close by default, opt out with --skip-close), eliminate redundant API calls, fix the "already migrated" skip path, and restore the simpler `(processed, skipped, failed)` summary counters.
todos:
- id: refactor-api-helpers
content: Replace get_issue_comments/issue_is_opened/add_comment/close_issue with get_issue (for --issues only) + update_issue; use full dicts from get_pagure_issues in bulk mode
status: completed
- id: rewrite-process-issues
content: "Rewrite process_issues loop: new skip/close logic, (processed, skipped, failed) counters, pass new flags"
status: completed
- id: add-cli-args
content: Add --issue-status, --skip-close, --skip-comment with validation and Pagure status mapping
status: completed
- id: update-readme
content: Document new flags and Pagure token ACL for closing
status: completed
isProject: false
---
# Refactor pagure migration comment + close behavior
## Assessment: your proposals make sense
All five changes address real problems in commit `fd3e7d6`:
| Proposal | Verdict |
|----------|---------|
| Merge into `update_issue` | Good — same auth/URL pattern, clearer orchestration |
| Drop `issue_is_opened()` | Good — but only if status comes from data you already fetch |
| Restore `(processed, skipped, failed)` | Good — aligns with [`forge_migrate_story_points.py`](forge_migrate_story_points.py) |
| Don't skip when close is still needed | Important bug fix when auto-close is enabled (default) |
| New CLI flags | Good — makes comment/close/status filter explicit; close stays on by default |
The current commit closes **unconditionally** after every successful comment and adds **2 extra GETs per issue** (`issue_is_opened` after comment, and again in dry-run). Your refactor fixes both.
---
## Data fetching strategy (bulk vs `--issues`)
The Pagure issues list API (`GET .../issues`) returns **full issue objects** per page — each entry already includes `status` and `comments` (confirmed on [fedora-qa/oraculum/issues](https://pagure.io/api/0/fedora-qa/oraculum/issues)). No per-issue GET is needed in bulk mode.
| Mode | How issues are loaded | GETs needed |
|------|----------------------|-------------|
| Default (all issues) | Paginated `get_pagure_issues` — keep full dicts | 1 GET per page only |
| `--issues N M ...` | `get_issue` per number — keep full dicts | 1 GET per listed issue |
In both cases, `process_issues` iterates over stored issue dicts and reads `issue["status"]` and `issue.get("comments", [])` directly. Remove `get_issue_comments()` entirely.
After mutations (POST comment / POST close), in-memory dicts are stale, but that does not matter — each issue is processed once and we move on.
---
## Architecture after refactor
```mermaid
flowchart TD
start[For each issue dict] --> filter{Matches --issue-status?}
filter -->|no| skipStatus[Skip silently or log]
filter -->|yes| needComment{Add comment?}
needComment -->|yes, missing| doComment[POST comment]
needComment -->|yes, exists| skipComment[Skip comment]
needComment -->|no --skip-comment| skipComment
doComment --> commentOK{OK?}
commentOK -->|no| failed[failed++]
skipComment --> needClose{"not --skip-close and Open?"}
commentOK -->|yes| needClose
needClose -->|yes| doClose[POST status Closed]
needClose -->|no, nothing done| skipped[skipped++]
needClose -->|no, work done| processed[processed++]
doClose --> closeOK{OK?}
closeOK -->|yes| processed
closeOK -->|no| failed
```
---
## 1. Replace `add_comment_to_issue` + `close_issue` with `update_issue`
New function in [`pagure_add_migration_comment.py`](pagure_add_migration_comment.py):
```python
def update_issue(
api_base, project, issue_id, api_token,
*, comment: str | None = None, close: bool = False, issue_status: str | None = None,
) -> bool:
```
Behavior:
- If `comment` is not `None` → POST to `.../comment`
- If `close` is `True` and `issue_status == "Open"` → POST to `.../status` with `status=Closed`
- If `close` is `True` and already `"Closed"` → no-op (success)
- Return `True` only if **all requested** operations succeed; on first failure, stop and return `False`
Keep error printing inside `update_issue` (same style as today).
---
## 2. Drop `issue_is_opened()` and `get_issue_comments()` — use stored issue dicts
**Remove** both functions. Issue data comes from:
- **Bulk mode:** `get_pagure_issues()` already returns `list[dict]` with full issue JSON — pass these through unchanged (stop stubbing or re-fetching).
- **`--issues` mode:** add a `get_issue()` helper (same pattern as [`forge_migrate_story_points.py`](forge_migrate_story_points.py)) that returns the full issue dict from `GET .../issue/{id}`. Fetch all requested issues upfront into a list before the processing loop.
Inside the loop, always use the stored dict:
```python
issue_id = issue["id"]
status = issue["status"]
comments = issue.get("comments", [])
```
No per-issue GET inside the loop in either mode.
---
## 3. Restore `process_issues` return type and counter semantics
Return `tuple[int, int, int]` again: `(processed, skipped, failed)`.
Per issue, after determining what actions are needed:
| Situation | Counter |
|-----------|---------|
| Nothing to do (comment exists or skipped, and not closing / already closed) | `skipped` |
| All requested actions succeed (comment and/or close) | `processed` |
| Any requested action fails | `failed` |
| Fetch error | `failed` |
**Dry run:** increment `processed` when work *would* be done; `skipped` when nothing would happen.
Remove `closed` / `failed_to_close` from summary output in `main()`.
---
## 4. Fix the `has_migration_comment` branch
Current code (lines 321–327) always `continue`s. New logic:
```python
needs_comment = not skip_comment and not has_migration_comment(comments)
needs_close = not skip_close and status == "Open"
if not needs_comment and not needs_close:
print(" Skipping: nothing to do")
skipped += 1
continue
if has_migration_comment(comments) and not skip_comment:
print(" Migration comment already exists")
# then proceed to update_issue(...)
```
This enables `--skip-comment` reruns on already-commented open tickets (close still runs by default).
---
## 5. New CLI arguments
Add to `argparse` in [`pagure_add_migration_comment.py`](pagure_add_migration_comment.py):
| Flag | Values | Default | Effect |
|------|--------|---------|--------|
| `--issue-status` | `open`, `closed`, `all` | `all` | Filter which issues to process |
| `--skip-close` | flag | off | Do **not** close open issues (by default, open issues **are** closed) |
| `--skip-comment` | flag | off | Do not post migration comment |
**Default behavior:** add migration comment **and** close open issues (same intent as commit `fd3e7d6`, but now explicit and controllable).
**Startup validation:** if `--skip-comment` and `--skip-close`, exit with error — otherwise the script would do nothing useful.
**Pagure API mapping:** user-facing lowercase → Pagure values passed to `get_pagure_issues`:
- `open` → `"Open"`
- `closed` → `"Closed"`
- `all` → `"all"`
Wire `--issue-status` into the existing `get_pagure_issues(..., status=...)` call (currently hardcoded/default only).
Pass `skip_comment`, `skip_close`, and mapped status through to `process_issues`.
---
## Things to consider (not blockers, but worth deciding)
### `--issues` + `--issue-status` interaction
Fetch full issue dicts upfront via `get_issue()`, then apply `--issue-status` filter client-side on `issue["status"]` before entering the mutation logic. Issues not matching the filter are skipped (log optionally, do not count as failed).
Note: when `--issue-status open` is used **without** `--issues`, the Pagure API filter handles it server-side via `get_pagure_issues(status="Open")` — no client-side filter needed in bulk mode.
### Pagure token ACLs
README currently lists only "Comment on a ticket". Closing requires **`issue_change_status`** or **`issue_update`** on the Pagure API token. Update [README.md](README.md) to note that closing is the default and requires the extra ACL; mention `--skip-close` to disable.
### Partial failure policy
If comment succeeds but close fails → count as `failed`, not `processed`. Do **not** attempt close after a comment failure (keeps behavior predictable).
### Idempotent close
Closing an already-closed issue should be a no-op success, not a failure or skip — important for reruns.
### `--issue-status closed` with default close behavior
Harmless but pointless (closed issues won't be closed again). No special case needed; they'll land in `skipped`.
### Rate limiting / `--delay`
Bulk mode drops from **1 GET per issue + POSTs** down to **pagination GETs + POSTs only** — a major improvement for large repos. Keep `--delay` between pagination pages (already in `get_pagure_issues`) and after each issue's mutation phase. For `--issues` mode, apply delay between individual `get_issue` fetches (same as `forge_migrate_story_points.py`).
---
## Files to change
- [`pagure_add_migration_comment.py`](pagure_add_migration_comment.py) — main refactor (API helpers, `process_issues`, CLI, summary)
- [`README.md`](README.md) — document new flags and closing ACL requirement (minimal update)
No tests exist in this repo; manual verification with `--dry-run` on staging is the test plan.
## Suggested manual test matrix
```bash
# Default: comment + close open issues, all statuses
--dry-run
# Comment only, no closing (e.g. token lacks close ACL)
--skip-close --dry-run
# Close-only rerun on already-commented open issues
--skip-comment --issue-status open --dry-run
# Comment + close, open issues only
--issue-status open --dry-run
# Invalid: should error at startup
--skip-comment --skip-close
```

View file

@ -20,7 +20,10 @@ Several scripts require a Pagure API token for authentication. To create one:
1. Log in to [Pagure](https://pagure.io/)
2. Go to your [API settings](https://pagure.io/settings#nav-api-tab)
3. Click "Create new API key"
4. Select the required ACLs (e.g., "Comment on a ticket" for adding migration comments)
4. Select the required ACLs:
- **Comment on a ticket (issue_comment)** — required for posting migration comments
- **Update an issue, status, comments, custom fields... (issue_update)** — required for closing issues
- **Create a new ticket (issue_create)** — required for `pagure_create_issues.py`
5. Save the token to a file (e.g., `~/.pagure-token`) for use with `--token-file`
## Forge (Forgejo) API Token
@ -37,7 +40,7 @@ Several scripts require a Forge API token for authentication. To create one:
### pagure_add_migration_comment.py
Adds a migration notice comment to Pagure tickets, linking to their new Forge location.
Adds a migration notice comment to Pagure tickets, linking to their new Forge location. By default, open issues are also closed after the comment is posted.
**Usage:**
@ -51,8 +54,20 @@ python pagure_add_migration_comment.py https://pagure.io/fedora-qa/some-repo \
python pagure_add_migration_comment.py https://stg.pagure.io/fedora-qa/some-repo \
https://forge.stg.fedoraproject.org/quality/some-repo \
--token-file ~/.pagure-token --issues 1 2 3
# Comment only, without closing
python pagure_add_migration_comment.py https://pagure.io/fedora-qa/some-repo \
https://forge.fedoraproject.org/quality/some-repo \
--token-file ~/.pagure-token --skip-close
# Close open issues that already have a migration comment
python pagure_add_migration_comment.py https://pagure.io/fedora-qa/some-repo \
https://forge.fedoraproject.org/quality/some-repo \
--token-file ~/.pagure-token --skip-comment --issue-status open
```
Notable options: `--issue-status` (`open`, `closed`, `all`), `--skip-close`, `--skip-comment`.
Run with `--help` for all available options.
### pagure_create_issues.py

View file

@ -15,12 +15,15 @@ import time
from urllib.parse import urlparse
import requests
DEFAULT_COMMENT_TEMPLATE = (
"This issue has been migrated to Fedora Forge:\n{forge_url}\n\n"
"Please continue any further discussion there."
)
MIGRATION_MARKER = "This issue has been migrated to Fedora Forge:"
PAGURE_STATUS = {"open": "Open", "closed": "Closed", "all": "all"}
# Ensure the marker is present in the default template to detect already-migrated issues
assert MIGRATION_MARKER in DEFAULT_COMMENT_TEMPLATE, (
"MIGRATION_MARKER must be contained in DEFAULT_COMMENT_TEMPLATE"
@ -124,11 +127,11 @@ def get_pagure_issues(
return all_issues
def get_issue_comments(
def get_issue(
api_base: str, project: str, issue_id: int, api_token: str
) -> list[dict]:
) -> dict:
"""
Fetch comments for a specific issue.
Fetch a single issue from Pagure.
Args:
api_base: Pagure API base URL
@ -137,16 +140,17 @@ def get_issue_comments(
api_token: Pagure API token
Returns:
List of comment dictionaries
Issue dictionary
Raises:
requests.exceptions.HTTPError: If the API call fails
"""
url = f"{api_base}/{project}/issue/{issue_id}"
headers = {"Authorization": f"token {api_token}"}
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
data = response.json()
return data.get("comments", [])
return response.json()
def has_migration_comment(comments: list[dict]) -> bool:
@ -165,87 +169,48 @@ def has_migration_comment(comments: list[dict]) -> bool:
return False
def add_comment_to_issue(
api_base: str, project: str, issue_id: int, comment: str, api_token: str
def update_issue(
api_base: str,
project: str,
issue_id: int,
api_token: str,
*,
comment: str | None = None,
close: bool = False,
issue_status: str | None = None,
) -> bool:
"""
Add a comment to a Pagure issue.
Args:
api_base: Pagure API base URL
project: Pagure project path
issue_id: Issue number
comment: Comment text to add
api_token: Pagure API token
Returns:
True if successful, False otherwise
"""
url = f"{api_base}/{project}/issue/{issue_id}/comment"
headers = {"Authorization": f"token {api_token}"}
data = {"comment": comment}
response = requests.post(url, headers=headers, data=data, timeout=30)
if response.status_code == 200:
return True
else:
print(f" Error adding comment: {response.status_code} - {response.text}")
return False
def close_issue(
api_base: str, project: str, issue_id: int, api_token: str
) -> bool:
"""
Close a Pagure issue.
Update a Pagure issue by adding a comment and/or closing it.
Args:
api_base: Pagure API base URL
project: Pagure project path
issue_id: Issue number
api_token: Pagure API token
comment: Comment text to add, or None to skip
close: If True, close the issue when it is Open
issue_status: Current issue status ('Open' or 'Closed')
Returns:
True if successful, False otherwise
True if all requested operations succeed, False otherwise
"""
url = f"{api_base}/{project}/issue/{issue_id}/status"
headers = {"Authorization": f"token {api_token}"}
data = {"status": "Closed"}
response = requests.post(url, headers=headers, data=data, timeout=30)
if response.status_code == 200:
return True
else:
print(f" Error closing issue: {response.status_code} - {response.text}")
return False
def issue_is_opened(
api_base: str, project: str, issue_id: int, api_token: str
) -> bool:
"""
Check if issue is opened.
Args:
api_base: Pagure API base URL
project: Pagure project path
issue_id: Issue number
api_token: Pagure API token
Returns:
True if issue is opened, False otherwise
"""
url = f"{api_base}/{project}/issue/{issue_id}"
headers = {"Authorization": f"token {api_token}"}
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
data = response.json()
if comment is not None:
url = f"{api_base}/{project}/issue/{issue_id}/comment"
response = requests.post(url, headers=headers, data={"comment": comment}, timeout=30)
if response.status_code != 200:
print(f" Error adding comment: {response.status_code} - {response.text}")
return False
status = data.get("status", [])
return status and status == "Open"
if close and issue_status == "Open":
url = f"{api_base}/{project}/issue/{issue_id}/status"
response = requests.post(url, headers=headers, data={"status": "Closed"}, timeout=30)
if response.status_code != 200:
print(f" Error closing issue: {response.status_code} - {response.text}")
return False
return True
def construct_forge_issue_url(forge_url: str, issue_id: int) -> str:
@ -270,9 +235,12 @@ def process_issues(
forge_url: str,
api_token: str,
issue_numbers: list[int] | None,
issue_status: str,
skip_comment: bool,
skip_close: bool,
dry_run: bool,
delay: float,
) -> tuple[int, int, int, int, int]:
) -> tuple[int, int, int]:
"""
Process issues and add migration comments.
@ -282,83 +250,107 @@ def process_issues(
forge_url: Full URL to the Forge project
api_token: Pagure API token
issue_numbers: Specific issue numbers to process, or None for all
dry_run: If True, don't actually add comments
issue_status: Pagure status filter ('all', 'Open', 'Closed')
skip_comment: If True, do not post migration comments
skip_close: If True, do not close open issues
dry_run: If True, don't actually modify issues
delay: Delay between API calls in seconds
Returns:
Tuple of (processed, skipped, failed) counts
"""
processed = 0
closed = 0
failed_to_close = 0
skipped = 0
failed = 0
if issue_numbers:
# Process specific issues
issues = [{"id": num} for num in issue_numbers]
print(f"Fetching {len(issue_numbers)} specific issue(s) from {project}...")
issues = []
for issue_id in issue_numbers:
try:
issue = get_issue(api_base, project, issue_id, api_token)
except requests.exceptions.HTTPError as e:
print(f" Error fetching issue #{issue_id}: {e}")
failed += 1
if delay > 0:
time.sleep(delay)
continue
if issue_status != "all" and issue["status"] != issue_status:
print(
f" Skipping #{issue_id}: status is {issue['status']} "
f"(filter: {issue_status})"
)
skipped += 1
else:
issues.append(issue)
if delay > 0:
time.sleep(delay)
else:
# Fetch all issues
print(f"Fetching issues from {project}...")
issues = get_pagure_issues(api_base, project, api_token, delay=delay)
issues = get_pagure_issues(
api_base, project, api_token, status=issue_status, delay=delay
)
print(f"Found {len(issues)} issues")
for issue in issues:
issue_id = issue["id"]
status = issue["status"]
comments = issue.get("comments", [])
forge_issue_url = construct_forge_issue_url(forge_url, issue_id)
comment = DEFAULT_COMMENT_TEMPLATE.format(forge_url=forge_issue_url, issue_id=issue_id)
comment_text = DEFAULT_COMMENT_TEMPLATE.format(
forge_url=forge_issue_url, issue_id=issue_id
)
print(f"\nProcessing issue #{issue_id}...")
# Check for existing migration comment
try:
comments = get_issue_comments(api_base, project, issue_id, api_token)
except requests.exceptions.HTTPError as e:
print(f" Error fetching issue: {e}")
failed += 1
continue
requested_comment = not skip_comment
requested_close = not skip_close
needs_comment = requested_comment and not has_migration_comment(comments)
needs_close = not skip_close and status == "Open"
if has_migration_comment(comments):
print(" Skipping: migration comment already exists")
if requested_comment and has_migration_comment(comments):
print(" Migration comment already exists")
if requested_close and status == "Closed":
print(" Issue is already closed")
if not needs_comment and not needs_close:
print(" Skipping: nothing to do")
skipped += 1
# Delay after API call to avoid rate limiting
if delay > 0:
time.sleep(delay)
continue
if dry_run:
print(f" [DRY RUN] Would add comment linking to: {forge_issue_url}")
if needs_comment:
print(f" [DRY RUN] Would add comment linking to: {forge_issue_url}")
if needs_close:
print(" [DRY RUN] Would close issue")
processed += 1
# Delay after API call to avoid rate limiting
if delay > 0:
time.sleep(delay)
if issue_is_opened(api_base, project, issue_id, api_token):
print(f" [DRY RUN] Issue is opened")
print(f" [DRY RUN] Closing the issue")
else:
print(f" [DRY RUN] Issue is closed")
else:
print(f" Adding migration comment linking to: {forge_issue_url}")
if add_comment_to_issue(api_base, project, issue_id, comment, api_token):
print(" Success!")
if issue_is_opened(api_base, project, issue_id, api_token):
if close_issue(api_base, project, issue_id, api_token):
print(" Issue closed!")
closed += 1
else:
failed_to_close += 1
if needs_comment:
print(f" Adding migration comment linking to: {forge_issue_url}")
if needs_close:
print(" Closing issue...")
if update_issue(
api_base,
project,
issue_id,
api_token,
comment=comment_text if needs_comment else None,
close=needs_close,
issue_status=status,
):
print(" Success!")
processed += 1
else:
failed += 1
# Delay after API call to avoid rate limiting
if delay > 0:
time.sleep(delay)
if delay > 0:
time.sleep(delay)
return processed, skipped, failed, closed, failed_to_close
return processed, skipped, failed
def main():
@ -372,6 +364,9 @@ Examples:
# Process specific issues on staging Pagure
%(prog)s https://stg.pagure.io/fedora-qa/some-repo https://forge.fedoraproject.org/quality/some-repo --token YOUR_TOKEN --issues 1 2 3
# Comment only, without closing issues
%(prog)s https://pagure.io/fedora-qa/some-repo https://forge.fedoraproject.org/quality/some-repo --token-file ~/.pagure-token --skip-close
""",
)
@ -400,10 +395,26 @@ Examples:
nargs="+",
help="Specific issue numbers to process (default: all issues)",
)
parser.add_argument(
"--issue-status",
choices=["open", "closed", "all"],
default="all",
help="Only process issues with this status (default: all)",
)
parser.add_argument(
"--skip-close",
action="store_true",
help="Do not close open issues (by default, open issues are closed)",
)
parser.add_argument(
"--skip-comment",
action="store_true",
help="Do not post migration comment (by default, migration comment is posted)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without actually adding comments",
help="Preview changes without actually modifying issues",
)
parser.add_argument(
"--delay",
@ -414,6 +425,10 @@ Examples:
args = parser.parse_args()
if args.skip_comment and args.skip_close:
print("Error: --skip-comment and --skip-close together would do nothing")
sys.exit(1)
# Parse the Pagure URL
try:
api_base, project = parse_pagure_url(args.pagure_url)
@ -434,6 +449,8 @@ Examples:
print(f"Error: {e}")
sys.exit(1)
pagure_status = PAGURE_STATUS[args.issue_status]
print(f"Pagure URL: {args.pagure_url}")
print(f"Forge URL: {args.forge_url}")
if args.dry_run:
@ -441,12 +458,15 @@ Examples:
print()
try:
processed, skipped, failed, closed, failed_to_close = process_issues(
processed, skipped, failed = process_issues(
api_base=api_base,
project=project,
forge_url=args.forge_url,
api_token=api_token,
issue_numbers=args.issues,
issue_status=pagure_status,
skip_comment=args.skip_comment,
skip_close=args.skip_close,
dry_run=args.dry_run,
delay=args.delay,
)
@ -460,10 +480,8 @@ Examples:
print(f"\n{'=' * 40}")
print("Summary:")
print(f" Processed: {processed}")
print(f" Closed: {closed}")
print(f" Skipped (already migrated): {skipped}")
print(f" Skipped: {skipped}")
print(f" Failed: {failed}")
print(f" Failed to close: {failed_to_close}")
if failed > 0:
sys.exit(1)
@ -471,4 +489,3 @@ Examples:
if __name__ == "__main__":
main()