Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/commands/issue/issue-query.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Command, EnumType } from "@cliffy/command"
import { unicodeWidth } from "@std/cli"
import { rgb24 } from "@std/fmt/colors"
import { resolveIssueSort } from "../../config.ts"
import { type OptionSource, resolveIssueSort } from "../../config.ts"
import {
colorCycleShort,
type CycleDisplayInfo,
Expand All @@ -17,7 +17,7 @@ import {
getProjectIdByName,
getProjectOptionsByName,
getTeamIdByKey,
getTeamKey,
getTeamKeyWithSource,
isIssueBlocked,
isLinearUuid,
resolveMilestoneId,
Expand Down Expand Up @@ -242,7 +242,7 @@ export const queryCommand = new Command()
resolvedTeamKeys = teamKeys
isMultiTeam = teamKeys.length > 1
} else {
const defaultTeam = getTeamKey()
const defaultTeam = getTeamKeyWithSource()
if (!defaultTeam) {
throw new ValidationError(
"No default team configured and no team scope provided",
Expand All @@ -252,10 +252,12 @@ export const queryCommand = new Command()
},
)
}
console.error(
`Note: using default team ${defaultTeam}. Pass --team <key> or --all-teams to be explicit.`,
)
resolvedTeamKeys = [defaultTeam]
if (shouldShowDefaultTeamNote(defaultTeam.source)) {
console.error(
`Note: using default team ${defaultTeam.key}. Pass --team <key> or --all-teams to be explicit.`,
)
}
resolvedTeamKeys = [defaultTeam.key]
}

// --- Resolve entity IDs ---
Expand Down Expand Up @@ -407,6 +409,24 @@ export const queryCommand = new Command()
}
})

/**
* The default-team note warns that an ambient default (global config file or
* shell-exported env var) silently scoped the query. A team configured by the
* project itself — a linear.toml in the directory/repo, or a project .env —
* is explicit local intent, so the note would be noise.
*/
export function shouldShowDefaultTeamNote(source: OptionSource): boolean {
switch (source) {
case "cli":
case "project-env":
case "project-config":
return false
case "env":
case "global-config":
return true
}
}

async function outputPaged(
outputLines: string[],
usePager: boolean,
Expand Down
114 changes: 86 additions & 28 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { load } from "@std/dotenv"
import * as v from "valibot"
import { ValidationError } from "./utils/errors.ts"

let config: Record<string, unknown> = {}
let globalConfig: Record<string, unknown> = {}
let projectConfig: Record<string, unknown> = {}

// Env keys that loadEnvFiles() actually wrote from a project .env file, as
// opposed to values that were already present in the process environment.
const dotenvAppliedKeys = new Set<string>()

async function loadConfigFromPath(
path: string,
Expand Down Expand Up @@ -56,18 +61,18 @@ async function loadConfig() {

// Load global config first (lowest priority)
for (const path of globalConfigPaths) {
const globalConfig = await loadConfigFromPath(path)
if (globalConfig) {
config = globalConfig
const loaded = await loadConfigFromPath(path)
if (loaded) {
globalConfig = loaded
break
}
}

// Load project config and merge on top (project overrides global)
// Load project config (higher priority; shadows global per option)
for (const path of projectConfigPaths) {
const projectConfig = await loadConfigFromPath(path)
if (projectConfig) {
config = { ...config, ...projectConfig }
const loaded = await loadConfigFromPath(path)
if (loaded) {
projectConfig = loaded
break
}
}
Expand Down Expand Up @@ -106,6 +111,7 @@ async function loadEnvFiles() {
// Use same precedence as dotenv
if (Deno.env.get(key) !== undefined) continue
Deno.env.set(key, value)
dotenvAppliedKeys.add(key)
}
}
}
Expand Down Expand Up @@ -136,8 +142,9 @@ export const ISSUE_SORT_VALUES = ["manual", "priority"] as const
export type IssueSort = (typeof ISSUE_SORT_VALUES)[number]
export const DEFAULT_ISSUE_SORT: IssueSort = "priority"

// Options schema
const OptionsSchema = v.object({
// Per-option schemas, indexable by option name so parsed values keep their
// option-specific output types without casts.
const OptionSchemas = {
team_id: v.optional(v.string()),
api_key: v.optional(v.string()),
workspace: v.optional(v.string()),
Expand All @@ -149,27 +156,78 @@ const OptionsSchema = v.object({
hyperlink_format: v.optional(v.string()),
attachment_dir: v.optional(v.string()),
auto_download_attachments: v.optional(BooleanLike),
})
}

export type OptionName = keyof typeof OptionSchemas
type OptionValue<T extends OptionName> = v.InferOutput<
(typeof OptionSchemas)[T]
>
export type Options = { [K in OptionName]: OptionValue<K> }

export type Options = v.InferOutput<typeof OptionsSchema>
export type OptionName = keyof Options
/** Where a resolved option value came from. */
export type OptionSource =
| "cli"
| "env" // pre-existing process environment variable
| "project-env" // LINEAR_* applied from a project .env file
| "project-config" // linear.toml / .linear.toml in cwd or git root
| "global-config" // XDG / ~/.config / APPDATA linear.toml

function getRawOption(optionName: OptionName, cliValue?: string): unknown {
return cliValue ??
Deno.env.get("LINEAR_" + optionName.toUpperCase()) ??
config[optionName]
export interface ResolvedOption<T> {
value: T
source: OptionSource
}

export function getOption<T extends OptionName>(
function resolveRawOption(
optionName: OptionName,
cliValue?: string,
): { raw: unknown; source: OptionSource } | undefined {
if (cliValue != null) {
return { raw: cliValue, source: "cli" }
}
const envKey = "LINEAR_" + optionName.toUpperCase()
const envValue = Deno.env.get(envKey)
if (envValue != null) {
return {
raw: envValue,
source: dotenvAppliedKeys.has(envKey) ? "project-env" : "env",
}
}
// Check key presence rather than value nullishness so a present-but-invalid
// higher-precedence value still shadows lower-precedence values, matching
// the previous spread-merge behavior.
if (Object.hasOwn(projectConfig, optionName)) {
return { raw: projectConfig[optionName], source: "project-config" }
}
if (Object.hasOwn(globalConfig, optionName)) {
return { raw: globalConfig[optionName], source: "global-config" }
}
return undefined
}

export function getOptionWithSource<T extends OptionName>(
optionName: T,
cliValue?: string,
): Options[T] {
const raw = getRawOption(optionName, cliValue)
const result = v.safeParse(OptionsSchema, { [optionName]: raw })
if (result.success) {
return result.output[optionName] as Options[T]
): ResolvedOption<NonNullable<OptionValue<T>>> | undefined {
const resolved = resolveRawOption(optionName, cliValue)
if (resolved == null) {
return undefined
}
const parsed = v.safeParse(OptionSchemas[optionName], resolved.raw)
if (!parsed.success) {
return undefined
}
const value = parsed.output
if (value == null) {
return undefined
}
return undefined as Options[T]
return { value, source: resolved.source }
}

export function getOption<T extends OptionName>(
optionName: T,
cliValue?: string,
): OptionValue<T> | undefined {
return getOptionWithSource(optionName, cliValue)?.value
}

/**
Expand All @@ -179,12 +237,12 @@ export function getOption<T extends OptionName>(
* silently falling back to the default.
*/
export function resolveIssueSort(cliValue?: string): IssueSort {
const raw = getRawOption("issue_sort", cliValue)
if (raw == null) return DEFAULT_ISSUE_SORT
const parsed = v.safeParse(v.picklist(ISSUE_SORT_VALUES), raw)
const resolved = resolveRawOption("issue_sort", cliValue)
if (resolved == null || resolved.raw == null) return DEFAULT_ISSUE_SORT
const parsed = v.safeParse(v.picklist(ISSUE_SORT_VALUES), resolved.raw)
if (!parsed.success) {
throw new ValidationError(
`Invalid issue sort: ${JSON.stringify(raw)}`,
`Invalid issue sort: ${JSON.stringify(resolved.raw)}`,
{
suggestion: `Use one of: ${
ISSUE_SORT_VALUES.join(", ")
Expand Down
22 changes: 16 additions & 6 deletions src/utils/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import type {
SearchIssuesQuery,
} from "../__codegen__/graphql.ts"
import { Select } from "@cliffy/prompt"
import { getOption, resolveIssueSort } from "../config.ts"
import {
getOptionWithSource,
type OptionSource,
resolveIssueSort,
} from "../config.ts"
import { CliError, NotFoundError, ValidationError } from "./errors.ts"
import { getGraphQLClient } from "./graphql.ts"
import { normalizeIssueIdentifier } from "./issue-identifier.ts"
Expand Down Expand Up @@ -74,12 +78,18 @@ export function formatIssueIdentifier(providedId: string): string {
return normalizeIssueIdentifier(providedId) ?? providedId.toUpperCase()
}

export function getTeamKey(): string | undefined {
const teamId = getOption("team_id")
if (teamId) {
return teamId.toUpperCase()
export function getTeamKeyWithSource():
| { key: string; source: OptionSource }
| undefined {
const resolved = getOptionWithSource("team_id")
if (resolved == null || resolved.value === "") {
return undefined
}
return undefined
return { key: resolved.value.toUpperCase(), source: resolved.source }
}

export function getTeamKey(): string | undefined {
return getTeamKeyWithSource()?.key
}

/**
Expand Down
Loading
Loading