feat: Add a script for bulk GitHub issue creation and simplify the client settings profile header UI.
This commit is contained in:
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
import '../../blocs/client_settings_bloc.dart';
|
import '../../blocs/client_settings_bloc.dart';
|
||||||
|
|
||||||
/// A widget that displays the primary actions for the settings page.
|
/// A widget that displays the primary actions for the settings page.
|
||||||
@@ -27,10 +28,6 @@ class SettingsActions extends StatelessWidget {
|
|||||||
_QuickLinksCard(labels: labels),
|
_QuickLinksCard(labels: labels),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
// Notifications section
|
|
||||||
_NotificationsSettingsCard(),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
|
|
||||||
// Log Out button (outlined)
|
// Log Out button (outlined)
|
||||||
BlocBuilder<ClientSettingsBloc, ClientSettingsState>(
|
BlocBuilder<ClientSettingsBloc, ClientSettingsState>(
|
||||||
builder: (BuildContext context, ClientSettingsState state) {
|
builder: (BuildContext context, ClientSettingsState state) {
|
||||||
@@ -80,15 +77,14 @@ class SettingsActions extends StatelessWidget {
|
|||||||
|
|
||||||
/// Handles the sign-out button click event.
|
/// Handles the sign-out button click event.
|
||||||
void _onSignoutClicked(BuildContext context) {
|
void _onSignoutClicked(BuildContext context) {
|
||||||
ReadContext(context)
|
ReadContext(
|
||||||
.read<ClientSettingsBloc>()
|
context,
|
||||||
.add(const ClientSettingsSignOutRequested());
|
).read<ClientSettingsBloc>().add(const ClientSettingsSignOutRequested());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quick Links card — inline here since it's always part of SettingsActions ordering.
|
/// Quick Links card — inline here since it's always part of SettingsActions ordering.
|
||||||
class _QuickLinksCard extends StatelessWidget {
|
class _QuickLinksCard extends StatelessWidget {
|
||||||
|
|
||||||
const _QuickLinksCard({required this.labels});
|
const _QuickLinksCard({required this.labels});
|
||||||
final TranslationsClientSettingsProfileEn labels;
|
final TranslationsClientSettingsProfileEn labels;
|
||||||
|
|
||||||
@@ -130,7 +126,6 @@ class _QuickLinksCard extends StatelessWidget {
|
|||||||
|
|
||||||
/// A single quick link row item.
|
/// A single quick link row item.
|
||||||
class _QuickLinkItem extends StatelessWidget {
|
class _QuickLinkItem extends StatelessWidget {
|
||||||
|
|
||||||
const _QuickLinkItem({
|
const _QuickLinkItem({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.title,
|
required this.title,
|
||||||
@@ -198,24 +193,36 @@ class _NotificationsSettingsCard extends StatelessWidget {
|
|||||||
icon: UiIcons.bell,
|
icon: UiIcons.bell,
|
||||||
title: context.t.client_settings.preferences.push,
|
title: context.t.client_settings.preferences.push,
|
||||||
value: state.pushEnabled,
|
value: state.pushEnabled,
|
||||||
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add(
|
onChanged: (val) =>
|
||||||
ClientSettingsNotificationToggled(type: 'push', isEnabled: val),
|
ReadContext(context).read<ClientSettingsBloc>().add(
|
||||||
|
ClientSettingsNotificationToggled(
|
||||||
|
type: 'push',
|
||||||
|
isEnabled: val,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_NotificationToggle(
|
_NotificationToggle(
|
||||||
icon: UiIcons.mail,
|
icon: UiIcons.mail,
|
||||||
title: context.t.client_settings.preferences.email,
|
title: context.t.client_settings.preferences.email,
|
||||||
value: state.emailEnabled,
|
value: state.emailEnabled,
|
||||||
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add(
|
onChanged: (val) =>
|
||||||
ClientSettingsNotificationToggled(type: 'email', isEnabled: val),
|
ReadContext(context).read<ClientSettingsBloc>().add(
|
||||||
|
ClientSettingsNotificationToggled(
|
||||||
|
type: 'email',
|
||||||
|
isEnabled: val,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_NotificationToggle(
|
_NotificationToggle(
|
||||||
icon: UiIcons.phone,
|
icon: UiIcons.phone,
|
||||||
title: context.t.client_settings.preferences.sms,
|
title: context.t.client_settings.preferences.sms,
|
||||||
value: state.smsEnabled,
|
value: state.smsEnabled,
|
||||||
onChanged: (val) => ReadContext(context).read<ClientSettingsBloc>().add(
|
onChanged: (val) =>
|
||||||
ClientSettingsNotificationToggled(type: 'sms', isEnabled: val),
|
ReadContext(context).read<ClientSettingsBloc>().add(
|
||||||
|
ClientSettingsNotificationToggled(
|
||||||
|
type: 'sms',
|
||||||
|
isEnabled: val,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ class SettingsProfileHeader extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TranslationsClientSettingsProfileEn labels = t.client_settings.profile;
|
final TranslationsClientSettingsProfileEn labels =
|
||||||
|
t.client_settings.profile;
|
||||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||||
final String businessName =
|
final String businessName =
|
||||||
session?.business?.businessName ?? 'Your Company';
|
session?.business?.businessName ?? 'Your Company';
|
||||||
@@ -26,9 +27,7 @@ class SettingsProfileHeader extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.only(bottom: 36),
|
padding: const EdgeInsets.only(bottom: 36),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(color: UiColors.primary),
|
||||||
color: UiColors.primary,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
@@ -75,13 +74,6 @@ class SettingsProfileHeader extends StatelessWidget {
|
|||||||
color: UiColors.white.withValues(alpha: 0.6),
|
color: UiColors.white.withValues(alpha: 0.6),
|
||||||
width: 3,
|
width: 3,
|
||||||
),
|
),
|
||||||
boxShadow: <BoxShadow>[
|
|
||||||
BoxShadow(
|
|
||||||
color: UiColors.black.withValues(alpha: 0.15),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: photoUrl != null && photoUrl.isNotEmpty
|
child: photoUrl != null && photoUrl.isNotEmpty
|
||||||
@@ -103,9 +95,7 @@ class SettingsProfileHeader extends StatelessWidget {
|
|||||||
// ── Business Name ─────────────────────────────────
|
// ── Business Name ─────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
businessName,
|
businessName,
|
||||||
style: UiTypography.headline3m.copyWith(
|
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||||
color: UiColors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
@@ -128,21 +118,6 @@ class SettingsProfileHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space5),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 100),
|
|
||||||
child: UiButton.secondary(
|
|
||||||
text: labels.edit_profile,
|
|
||||||
size: UiButtonSize.small,
|
|
||||||
onPressed: () =>
|
|
||||||
Modular.to.pushNamed('${ClientPaths.settings}/edit-profile'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: UiColors.white,
|
|
||||||
side: const BorderSide(color: UiColors.white, width: 1.5),
|
|
||||||
backgroundColor: UiColors.white.withValues(alpha: 0.1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
207
scripts/create_issues.py
Normal file
207
scripts/create_issues.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
INPUT_FILE = "issues-to-create.md"
|
||||||
|
DEFAULT_PROJECT_TITLE = None
|
||||||
|
DEFAULT_MILESTONE = "Milestone 4"
|
||||||
|
# ---
|
||||||
|
|
||||||
|
def parse_issues(content):
|
||||||
|
"""Parse issue blocks from markdown content.
|
||||||
|
|
||||||
|
Each issue block starts with a '# Title' line, followed by an optional
|
||||||
|
'Labels:' metadata line, then the body. Milestone is set globally, not per-issue.
|
||||||
|
"""
|
||||||
|
issue_blocks = re.split(r'\n(?=#\s)', content)
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
for block in issue_blocks:
|
||||||
|
if not block.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = block.strip().split('\n')
|
||||||
|
|
||||||
|
# Title: strip leading '#' characters and whitespace
|
||||||
|
title = re.sub(r'^#+\s*', '', lines[0]).strip()
|
||||||
|
|
||||||
|
labels_line = ""
|
||||||
|
body_start_index = len(lines) # default: no body
|
||||||
|
|
||||||
|
# Only 'Labels:' is parsed from the markdown; milestone is global
|
||||||
|
for i, line in enumerate(lines[1:], start=1):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.lower().startswith('labels:'):
|
||||||
|
labels_line = stripped.split(':', 1)[1].strip()
|
||||||
|
elif stripped == "":
|
||||||
|
continue # skip blank separator lines in the header
|
||||||
|
else:
|
||||||
|
body_start_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
body = "\n".join(lines[body_start_index:]).strip()
|
||||||
|
labels = [label.strip() for label in labels_line.split(',') if label.strip()]
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
print("⚠️ Skipping block with no title.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
issues.append({
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"labels": labels,
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Bulk create GitHub issues from a markdown file.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Input file format (issues-to-create.md):
|
||||||
|
-----------------------------------------
|
||||||
|
# Issue Title One
|
||||||
|
Labels: bug, enhancement
|
||||||
|
|
||||||
|
This is the body of the first issue.
|
||||||
|
It can span multiple lines.
|
||||||
|
|
||||||
|
# Issue Title Two
|
||||||
|
Labels: documentation
|
||||||
|
|
||||||
|
Body of the second issue.
|
||||||
|
-----------------------------------------
|
||||||
|
All issues share the same project and milestone, configured at the top of this script
|
||||||
|
or passed via --project and --milestone flags.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--file", "-f",
|
||||||
|
default=INPUT_FILE,
|
||||||
|
help=f"Path to the markdown input file (default: {INPUT_FILE})"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--project", "-p",
|
||||||
|
default=DEFAULT_PROJECT_TITLE,
|
||||||
|
help=f"GitHub Project title for all issues (default: {DEFAULT_PROJECT_TITLE})"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--milestone", "-m",
|
||||||
|
default=DEFAULT_MILESTONE,
|
||||||
|
help=f"Milestone to assign to all issues (default: {DEFAULT_MILESTONE})"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-project",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not add issues to any project."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-milestone",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not assign a milestone to any issue."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo", "-r",
|
||||||
|
default=None,
|
||||||
|
help="Target GitHub repo in OWNER/REPO format (uses gh default if not set)."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Parse the file and print issues without creating them."
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
input_file = args.file
|
||||||
|
project_title = args.project if not args.no_project else None
|
||||||
|
milestone = args.milestone if not args.no_milestone else None
|
||||||
|
|
||||||
|
print("🚀 Bulk GitHub Issue Creator")
|
||||||
|
print("=" * 40)
|
||||||
|
print(f" Input file: {input_file}")
|
||||||
|
print(f" Project: {project_title or '(none)'}")
|
||||||
|
print(f" Milestone: {milestone or '(none)'}")
|
||||||
|
if args.repo:
|
||||||
|
print(f" Repo: {args.repo}")
|
||||||
|
if args.dry_run:
|
||||||
|
print(" Mode: DRY RUN (no issues will be created)")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
# --- Preflight checks ---
|
||||||
|
if subprocess.run(["which", "gh"], capture_output=True).returncode != 0:
|
||||||
|
print("❌ ERROR: GitHub CLI ('gh') is not installed or not in PATH.")
|
||||||
|
print(" Install it from: https://cli.github.com/")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
if not os.path.exists(input_file):
|
||||||
|
print(f"❌ ERROR: Input file '{input_file}' not found.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print("✅ Preflight checks passed.\n")
|
||||||
|
|
||||||
|
# --- Parse ---
|
||||||
|
print(f"📄 Parsing '{input_file}'...")
|
||||||
|
with open(input_file, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
issues = parse_issues(content)
|
||||||
|
|
||||||
|
if not issues:
|
||||||
|
print("⚠️ No issues found in the input file. Check the format.")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
print(f" Found {len(issues)} issue(s) to create.\n")
|
||||||
|
|
||||||
|
# --- Create ---
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for idx, issue in enumerate(issues, start=1):
|
||||||
|
print(f"[{idx}/{len(issues)}] {issue['title']}")
|
||||||
|
if issue['labels']:
|
||||||
|
print(f" Labels: {', '.join(issue['labels'])}")
|
||||||
|
print(f" Milestone: {milestone or '(none)'}")
|
||||||
|
print(f" Project: {project_title or '(none)'}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(" (dry-run — skipping creation)\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
command = ["gh", "issue", "create"]
|
||||||
|
if args.repo:
|
||||||
|
command.extend(["--repo", args.repo])
|
||||||
|
command.extend(["--title", issue["title"]])
|
||||||
|
command.extend(["--body", issue["body"] or " "]) # gh requires non-empty body
|
||||||
|
|
||||||
|
if project_title:
|
||||||
|
command.extend(["--project", project_title])
|
||||||
|
if milestone:
|
||||||
|
command.extend(["--milestone", milestone])
|
||||||
|
for label in issue["labels"]:
|
||||||
|
command.extend(["--label", label])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, check=True, text=True, capture_output=True)
|
||||||
|
print(f" ✅ Created: {result.stdout.strip()}")
|
||||||
|
success_count += 1
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f" ❌ Failed: {e.stderr.strip()}")
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# --- Summary ---
|
||||||
|
print("=" * 40)
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"🔍 Dry run complete. {len(issues)} issue(s) parsed, none created.")
|
||||||
|
else:
|
||||||
|
print(f"🎉 Done! {success_count} created, {fail_count} failed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
scripts/issues-to-create.md
Normal file
27
scripts/issues-to-create.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# <Sample Title>
|
||||||
|
Labels: <platform:web, platform:infrastructure, feature, priority:high>
|
||||||
|
|
||||||
|
<Sample Description>
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### <Sample Sub-section>
|
||||||
|
- <Sample Description>
|
||||||
|
|
||||||
|
## <Sample Acceptance Criteria>
|
||||||
|
- [ ] <Sample Description>
|
||||||
|
|
||||||
|
-------
|
||||||
|
|
||||||
|
# <Sample Title 2>
|
||||||
|
Labels: <platform:web, platform:infrastructure, feature, priority:high>
|
||||||
|
|
||||||
|
<Sample Description>
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### <Sample Sub-section>
|
||||||
|
- <Sample Description>
|
||||||
|
|
||||||
|
## <Sample Acceptance Criteria>
|
||||||
|
- [ ] <Sample Description>
|
||||||
Reference in New Issue
Block a user